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
---
 
# Ciao

Il 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/*.mdContent collections
Frontmatterlibero, non validatovalidato con zod: la build fallisce
Tipi TypeScriptnessunoCollectionEntry<'guide'> completo
Ordinamento, filtria mano con import.meta.globgetCollection('guide', filtro)
Relazioni tra contenutinon esistonoreference()
Immagini ottimizzate nel frontmatternosì, con image()
Autocompletamento del frontmatter nell’editornosì
URL slegati dalla struttura cartellenosì

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:

  • z si importa da astro/zod, non dal pacchetto zod installato a mano (eviti mismatch di versione). Astro 6+ usa Zod 4: si scrive z.email(), non z.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 in Date. Con z.date() secco ti arriva un errore di tipo.
  • reference('autori') crea una relazione: nel frontmatter scrivi author: ale e 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.

📖 Content loader reference

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 scrive await render(post), con render importato da astro: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 modo entry.slug non esiste più: si chiama entry.id, e getEntryBySlug() è stato sostituito da getEntry(collection, id).

render(entry) restituisce tre cose:

  • Content: il componente da renderizzare
  • headings: { depth, slug, text }[]
  • remarkPluginFrontmatter: frontmatter arricchito dai plugin (tipo any, 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 mdx

In 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 components funziona solo sui file .mdx, non sui .md.

Per trasformare elementi nei .md serve un plugin (vedi 4.7).

.md, .mdx, .mdoc: quale scegliere

.md.mdx.mdoc (Markdoc)
Componentinoimport ES + JSXsolo tag dichiarati in config
Espressioni {...}nosìlimitate
Sicurezza del contenutototalenessuna (esegue JS)forte (sandbox)
Velocità di buildmigliorepeggiorebuona

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}'.

📖 MDX integration

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-code

Poi, 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 evidenziate
  • ins= / del= → verde/rosso in stile diff
  • "testo" → evidenzia le occorrenze di quella stringa
  • frame="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.)

📖 Syntax highlighting

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 / defineMdastPlugin restituiscono 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-remark
import { unified } from '@astrojs/markdown-remark';
 
export default defineConfig({
  markdown: {
    processor: unified({
      remarkPlugins: [remarkReadingTime],
      rehypePlugins: [rehypeGithubAlerts],
    }),
  },
});

La trappola dell'aggiornamento

markdown.remarkPlugins e markdown.rehypePlugins come opzioni top-level continuano a funzionare, ma ora richiedono @astrojs/markdown-remark installato. 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>

remarkPluginFrontmatter non passa dallo schema zod e ha tipo any.

È 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 starlight

Ti 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

  1. Converti tre note Markdown in una collection: schema con title, order, draft; rotta [...id]; indice ordinato per order in home.
  2. 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.
  3. Aggiungi il componente Toc.astro e verifica che i link #slug funzionino.
  4. Aggiungi MDX e usa dentro una pagina il Callout.astro che hai fatto nel modulo 2.
  5. Scrivi il plugin linkEsterni di 4.7 e verifica nell’HTML generato che i link esterni abbiano target="_blank" e quelli interni no.
  6. Aggiungi Expressive Code e riscrivi un blocco di codice con title= e righe evidenziate.