04. Contenuti: Markdown, MDX e content collections
← 03 - Routing, pagine ed endpoint · Indice · successivo → 05 - Interattività - island, script e view transitions
Questo è il modulo che conta di più se scrivi già in Markdown. Tutti gli snippet marcati ✅ sono stati eseguiti in un progetto Astro 7.2.3 con build completata.
4.1 Le due strade per il Markdown
Strada A, .md dentro src/pages/. Il file diventa una pagina, punto.
---
layout: ../../layouts/Articolo.astro
title: Il mio primo post
data: 2026-08-19
---
# CiaoIl layout riceve tutto in Astro.props.frontmatter. Semplice, immediato, e va
benissimo per tre o quattro pagine.
Con
layout:devi mettere tu<meta charset="utf-8">nel layout,altrimenti gli accenti si rompono.
Strada B, content collections. Il contenuto è dato, non pagina: viene validato, tipizzato, interrogato, e tu decidi separatamente come diventa URL.
Perché per una guida serve la strada B
src/pages/*.md | Content collections | |
|---|---|---|
| Frontmatter | libero, non validato | validato con zod: la build fallisce |
| Tipi TypeScript | nessuno | CollectionEntry<'guide'> completo |
| Ordinamento, filtri | a mano con import.meta.glob | getCollection('guide', filtro) |
| Relazioni tra contenuti | non esistono | reference() |
| Immagini ottimizzate nel frontmatter | no | sì, con image() |
| Autocompletamento del frontmatter nell’editor | no | sì |
| URL slegati dalla struttura cartelle | no | sì |
L’argomento decisivo è il primo. Su 40 pagine di guida, se scrivi oder: 3 invece
di order: 3, con la strada A non se ne accorge nessuno finché la navigazione non
si presenta in ordine sbagliato in produzione. Con la strada B la build si ferma e
ti dice file e campo.
📖 Markdown content · Content collections
4.2 Content Layer: come si dichiara una collection
Il file è src/content.config.ts, alla radice di src/.
// src/content.config.ts ✅ verificato
import { defineCollection, reference } from 'astro:content';
import { glob, file } from 'astro/loaders';
import { z } from 'astro/zod';
const guide = defineCollection({
loader: glob({ base: './src/content/guide', pattern: '**/[^_]*.{md,mdx}' }),
schema: z.object({
title: z.string(),
descrizione: z.string().optional(),
order: z.number().default(0),
draft: z.boolean().default(false),
tags: z.array(z.string()).default([]),
aggiornato: z.coerce.date().optional(),
author: reference('autori').optional(),
}),
});
const autori = defineCollection({
loader: file('src/data/autori.json'), // ogni oggetto deve avere una chiave `id`
schema: z.object({
id: z.string(),
nome: z.string(),
}),
});
export const collections = { guide, autori };Punti da fissare:
zsi importa daastro/zod, non dal pacchettozodinstallato a mano (eviti mismatch di versione). Astro 6+ usa Zod 4: si scrivez.email(), nonz.string().email().- Il loader è la novità architetturale:
glob()legge file dal disco,file()legge un singolo JSON/YAML/TOML che contiene molte entry, e puoi scriverne di custom per leggere da un CMS o un’API. pattern: '**/[^_]*.{md,mdx}'esclude i file che iniziano con_. In Astro 4 era una convenzione automatica, oggi devi chiederlo esplicitamente.z.coerce.date()converte la stringa YAML inDate. Conz.date()secco ti arriva un errore di tipo.reference('autori')crea una relazione: nel frontmatter scriviauthor: alee Astro validerà che quell’id esista.- Con
schema: ({ image }) => z.object({ cover: image() })ottieni immagini ottimizzate a partire da un path nel frontmatter. Nota:image().refine()non è supportato.
Astro genera anche uno schema JSON in .astro/collections/ che dà
autocompletamento del frontmatter nell’editor. Da solo vale il passaggio alle collections.
4.3 Leggere e renderizzare il contenuto
---
// src/pages/guida/[...id].astro ✅ verificato
import { getCollection, getEntry, render } from 'astro:content';
import type { GetStaticPaths } from 'astro';
import Base from '../../layouts/Base.astro';
import Toc from '../../components/Toc.astro';
export const getStaticPaths: GetStaticPaths = async () => {
const pagine = await getCollection('guide', ({ data }) => !data.draft);
return pagine.map((p) => ({ params: { id: p.id }, props: { pagina: p } }));
};
const { pagina } = Astro.props;
const { Content, headings } = await render(pagina);
const autore = pagina.data.author ? await getEntry(pagina.data.author) : undefined;
---
<Base titolo={pagina.data.title}>
{autore && <p>di {autore.data.nome}</p>}
<Toc headings={headings} />
<article><Content /></article>
</Base>La differenza che rompe tutti i tutorial vecchi
In Astro 4 si scriveva
const { Content } = await post.render(). Oggi si scriveawait render(post), conrenderimportato daastro:content. Motivo architetturale: le entry sono oggetti serializzabili puri, salvati in un data store tra una build e l’altra: un metodo di istanza non è serializzabile. Allo stesso modoentry.slugnon esiste più: si chiamaentry.id, egetEntryBySlug()è stato sostituito dagetEntry(collection, id).
render(entry) restituisce tre cose:
Content: il componente da renderizzareheadings:{ depth, slug, text }[]remarkPluginFrontmatter: frontmatter arricchito dai plugin (tipoany, non validato)
getEntry() può restituire undefined: il null-check è obbligatorio, non è pignoleria.
4.4 Tabella dei contenuti
Astro genera automaticamente gli id degli heading (via github-slugger), quindi
gli slug in headings puntano già agli ancoraggi giusti. ✅ verificato: da
## Primo capitolo esce id="primo-capitolo" e il link #primo-capitolo funziona
senza installare alcun plugin.
---
// src/components/Toc.astro ✅ verificato
import type { MarkdownHeading } from 'astro';
interface Props { headings: MarkdownHeading[] }
const { headings } = Astro.props;
type Nodo = MarkdownHeading & { figli: MarkdownHeading[] };
const albero: Nodo[] = [];
for (const h of headings) {
if (h.depth === 2) albero.push({ ...h, figli: [] });
else if (h.depth === 3 && albero.length) albero.at(-1)!.figli.push(h);
}
---
<nav aria-labelledby="toc-titolo">
<h2 id="toc-titolo">In questa pagina</h2>
<ul>
{albero.map((h2) => (
<li>
<a href={`#${h2.slug}`}>{h2.text}</a>
{h2.figli.length > 0 && (
<ul>{h2.figli.map((h3) => <li><a href={`#${h3.slug}`}>{h3.text}</a></li>)}</ul>
)}
</li>
))}
</ul>
</nav>L’evidenziazione della sezione attiva durante lo scroll è l’unico pezzo che
richiede JavaScript: IntersectionObserver sugli heading. È l’esercizio finale
del modulo 5.
4.5 MDX: componenti dentro il contenuto
npx astro add mdxIn un .mdx puoi importare componenti e usare espressioni:
---
title: Configurare il server
---
import Callout from '../../components/Callout.astro';
import Demo from '../../components/Demo.astro';
export const versione = '2.4';
## Prerequisiti (v{versione})
<Callout tipo="attenzione">
Serve Node 22. Con Node 20 il build fallisce senza messaggio chiaro.
</Callout>
<Demo />✅ verificato: un componente .astro importato dentro un .mdx dentro una content
collection viene renderizzato correttamente nella build statica.
Sostituire gli elementi HTML generati dal Markdown
Questa è la funzione che rende MDX interessante per una guida: puoi dire “ogni
<a> del contenuto diventa questo mio componente”.
---
import { getEntry, render } from 'astro:content';
import Link from '../components/Link.astro';
import Titolo from '../components/Titolo.astro';
const pagina = await getEntry('guide', 'setup');
const { Content } = await render(pagina!);
---
<Content components={{ a: Link, h2: Titolo }} />---
// src/components/Link.astro - aggiunge l'icona ai link esterni
const { href, ...resto } = Astro.props;
const esterno = typeof href === 'string' && href.startsWith('http');
---
<a href={href} {...resto}
target={esterno ? '_blank' : undefined}
rel={esterno ? 'noopener noreferrer' : undefined}>
<slot />{esterno && ' ↗'}
</a>Il mapping
componentsfunziona solo sui file.mdx, non sui.md.Per trasformare elementi nei
.mdserve un plugin (vedi 4.7).
.md, .mdx, .mdoc: quale scegliere
.md | .mdx | .mdoc (Markdoc) | |
|---|---|---|---|
| Componenti | no | import ES + JSX | solo tag dichiarati in config |
Espressioni {...} | no | sì | limitate |
| Sicurezza del contenuto | totale | nessuna (esegue JS) | forte (sandbox) |
| Velocità di build | migliore | peggiore | buona |
Regola: .md finché basta, .mdx quando serve interattività. Markdoc ha senso
se il contenuto lo scrive qualcun altro o arriva da un CMS e non vuoi che possa
importare ed eseguire JavaScript arbitrario. Una collection può contenere entrambi
i formati: pattern: '**/*.{md,mdx}'.
4.6 Blocchi di codice
Il syntax highlighting di default è Shiki, a build time, zero JavaScript nel browser.
// astro.config.mjs
export default defineConfig({
markdown: {
shikiConfig: {
themes: { light: 'github-light', dark: 'github-dark' },
},
},
});Con themes (plurale) Shiki emette variabili CSS per entrambi i temi; il CSS che
sceglie quale usare lo scrivi tu:
@media (prefers-color-scheme: dark) {
.astro-code, .astro-code span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
}
}Componente <Code /> per evidenziare codice che arriva da una variabile:
combinalo con ?raw (modulo 2) e mostri file sorgente veri:
---
import { Code } from 'astro:components';
import sorgente from '../snippets/setup.ts?raw';
---
<Code code={sorgente} lang="ts" />Expressive Code: la cosa da installare se scrivi guide
Shiki colora e basta. Per una guida servono titolo del file, righe evidenziate, diff, frame terminale. Tutto questo è Expressive Code:
npx astro add astro-expressive-codePoi, nel Markdown, scrivi direttamente nella riga del fence:
```js title="src/utils.ts" {2-3} ins={5} del={6} "parolaChiave"
```title="..."→ frame con nome file{2-3}→ righe evidenziateins=/del=→ verde/rosso in stile diff"testo"→ evidenzia le occorrenze di quella stringaframe="terminal"→ forza l’aspetto terminale
(Non verificato personalmente su Astro 7: la pagina di installazione di Expressive Code non dichiara esplicitamente la compatibilità con la 7. Starlight, che lo include, supporta Astro 7: quindi in pratica funziona, ma controlla la versione.)
4.7 Plugin: attenzione, qui Astro 7 ha cambiato tutto
Il cambiamento più profondo della versione 7: il motore Markdown non è più unified/remark/rehype, ma Sätteri, scritto in Rust. Sono già inclusi di serie GFM, punteggiatura tipografica, ID degli heading, math e wikilink.
Se scrivi solo Markdown, non cambia niente per te. Il CommonMark standard viene reso identico. Cambia per chi usava i plugin.
Due strade:
A, restare su Sätteri (default, veloce), con la nuova API a visitor:
// plugin-link-esterni.mjs ✅ verificato su Astro 7.2.3
import { defineHastPlugin } from 'satteri';
export const linkEsterni = defineHastPlugin({
name: 'link-esterni',
element: {
filter: ['a'],
visit(node, ctx) {
const href = node.properties.href;
if (typeof href === 'string' && href.startsWith('http')) {
ctx.setProperty(node, 'target', '_blank');
ctx.setProperty(node, 'rel', 'noopener noreferrer');
}
},
},
});// astro.config.mjs ✅ verificato
import { satteri } from '@astrojs/markdown-satteri';
import { linkEsterni } from './plugin-link-esterni.mjs';
export default defineConfig({
markdown: {
processor: satteri({ hastPlugins: [linkEsterni] }),
},
});Dettaglio verificato di persona
La documentazione ufficiale, nell’esempio, registra il plugin chiamandolo (
plugins: [mioPlugin()]). È un refuso:defineHastPlugin/defineMdastPluginrestituiscono un oggetto, non una factory. Va registrato senza parentesi, come sopra. Verificato eseguendo la build: con le parentesi ottieni un errore.
Esiste defineMdastPlugin per intervenire prima (sull’albero Markdown) e
defineHastPlugin per intervenire dopo (sull’albero HTML). Metodi di contesto
utili: ctx.setProperty(), ctx.parent(), ctx.indexOf(), ctx.textContent().
B, tornare a unified, se hai bisogno di plugin remark/rehype esistenti:
npm install @astrojs/markdown-remarkimport { unified } from '@astrojs/markdown-remark';
export default defineConfig({
markdown: {
processor: unified({
remarkPlugins: [remarkReadingTime],
rehypePlugins: [rehypeGithubAlerts],
}),
},
});La trappola dell'aggiornamento
markdown.remarkPluginsemarkdown.rehypePluginscome opzioni top-level continuano a funzionare, ma ora richiedono@astrojs/markdown-remarkinstallato. Aggiorni ad Astro 7, non tocchi la config, e la build si rompe. Inoltre i plugin recma non sono supportati da Sätteri: se ti servono, sei obbligato alla strada B.
Non installare più rehype-slug e rehype-autolink-headings: gli ID degli
heading sono nativi. Ancora oggi tutti i tutorial te li fanno installare.
Disattivare la punteggiatura tipografica (spesso indesiderata su documentazione
tecnica, perché ti trasforma --flag e le virgolette dritte):
processor: satteri({
features: { smartPunctuation: false },
})4.8 Frontmatter calcolato
I plugin possono scrivere dati che poi leggi nel template: è così che si fa il tempo di lettura o il conteggio parole:
// scrive su ctx.data.astro.frontmatter / file.data.astro.frontmatter---
const { Content, remarkPluginFrontmatter } = await render(pagina);
---
<p>{remarkPluginFrontmatter.tempoLettura}</p>
remarkPluginFrontmatternon passa dallo schema zod e ha tipoany.È un canale laterale: comodo, ma non protetto. Non metterci dati critici.
4.9 Starlight: quando non costruirti tutto da solo
Starlight è il tema ufficiale per documentazione, costruito su Astro.
npm create astro@latest -- --template starlightTi dà già fatti: sidebar, ricerca full-text con Pagefind (zero configurazione),
dark mode, i18n, SEO, tipografia, componenti (card, tab, aside) ed Expressive Code
preinstallato. Il contenuto sta in src/content/docs/, in .md, .mdx o .mdoc.
Usa Starlight se vuoi documentazione e basta, e l’aspetto “docs standard” ti va bene. Per il caso “voglio scrivere guide”, è il default sensato.
Parti da Astro puro se il design è un requisito, oppure il sito è misto (vetrina + blog + guide) e la documentazione è una sezione, oppure il modello di contenuto non è una gerarchia di pagine.
Nota: il versionamento della documentazione non è nel core, esiste come plugin
della community (starlight-versions). L’unico plugin ufficiale è Algolia DocSearch.
Strategia che consiglio: fai prima a mano quello che c’è nei paragrafi 4.2-4.6
(collection, TOC, sidebar da getCollection). Poi guarda Starlight. Chi impara
solo Starlight si blocca al primo requisito fuori standard, perché non sa cosa c’è sotto.
Esercizi
- Converti tre note Markdown in una collection: schema con
title,order,draft; rotta[...id]; indice ordinato perorderin home. - Rompi il frontmatter di proposito (
title: 42, o campo mancante) e leggi il messaggio di errore della build. È la funzione per cui stai usando le collections. - Aggiungi il componente
Toc.astroe verifica che i link#slugfunzionino. - Aggiungi MDX e usa dentro una pagina il
Callout.astroche hai fatto nel modulo 2. - Scrivi il plugin
linkEsternidi 4.7 e verifica nell’HTML generato che i link esterni abbianotarget="_blank"e quelli interni no. - Aggiungi Expressive Code e riscrivi un blocco di codice con
title=e righe evidenziate.