Logo fdylabTitre fdylab

Mon blog Astro : HTML Tailwind + Markdown, le meilleur des deux mondes

17/09/2025 — tech

Contexte

Je voulais un blog simple sur mon site Astro, mais en gardant la liberté de mise en forme que j’ai lorsque j’écris directement en HTML + Tailwind.
En parallèle, je voulais aussi tester l’écriture Markdown (plus rapide, plus légère) sans casser mon flux existant.
Résultat : j’ai monté une solution hybride qui accepte les deux formats.


Pourquoi j’écris (parfois) en HTML Tailwind “brut”

  • Contrôle pixel-perfect : j’applique exactement les classes et les layouts que je veux (encarts, grilles, etc.). Notamment pour le responsive.
  • Pas de friction : pas besoin de chercher la syntaxe Markdown pour des mises en page avancées.
  • Proche d’un workflow WordPress (historique), mais avec la propreté d’Astro + Tailwind.

Les limites :

  • Plus verbeux à écrire.
  • Maintenance plus lourde si je ne factorise pas de composants.
  • Moins portable si je migre ailleurs.

Côté SEO, aucun souci : ce qui compte c’est le HTML sémantique (titres <h1>…<h3>, <p>, <ul>, alt sur les images, meta title/description, perf correcte).
HTML “fait main” ou Markdown → même résultat pour les moteurs, si la structure est propre.


Pourquoi ajouter le Markdown

  • Vitesse d’écriture imbattable.
  • Contenu lisible brut dans le repo.
  • Facilement interopérable (outils, éditeurs, migration).
  • Et avec le plugin Tailwind @tailwindcss/typography (prose), le rendu est joli et responsive sans classes partout.

Architecture du blog (hybride)

src/
├─ content/ # Content Collections (Markdown)
│ ├─ config.ts # schéma du blog
│ └─ blog/
│ └─ mon-article.md
├─ content-html/
│ └─ blog/ # HTML “brut” importé en ?raw
│ └─ astro-build-tips.html
├─ data/
│ └─ posts.ts # catalogue des articles HTML (?raw)
└─ pages/
└─ blog/
├─ [slug].astro # page article → rend HTML ou Markdown
└─ index.astro # liste → fusionne HTML + Markdown

1) Les articles HTML

  • Je stocke mes .html dans src/content-html/blog/.
  • Je les importe en texte brut via Vite ?raw depuis src/data/posts.ts.

src/env.d.ts (pour TypeScript) :

/// <reference types="astro/client" />
declare module '*.html?raw' {
  const content: string;
  export default content;
}

src/data/posts.ts (extrait) :

type Post = {
  slug: string;
  title: string;
  description?: string;
  date: string;
  category: 'tech' | 'voyage' | 'autres' | 'chattovod' | 'easylanguage' | 'mytravelmap';
  cover?: string;
  tags?: string[];
  content: string; // HTML (importé en ?raw)
};

import astroBuildTipsHtml from '../content-html/blog/astro-build-tips.html?raw';

export const posts: Post[] = [
  {
    slug: 'astro-build-tips',
    title: 'Astuces de build Astro',
    description: 'Accélérer et simplifier vos builds Astro.',
    date: '2025-05-01',
    category: 'tech',
    cover: '/images/astro-cover.jpg',
    tags: ['astro', 'performance'],
    content: astroBuildTipsHtml,
  },
];

2) Les articles Markdown (Content Collections)

src/content/config.ts :

import { defineCollection, z } from 'astro:content';

const blog = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string().optional(),
    date: z.coerce.date(),
    category: z.enum(['tech','voyage','autres','chattovod','easylanguage','mytravelmap']).optional(),
    cover: z.string().optional(),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
  }),
});

export const collections = { blog };

Exemple src/content/blog/test.md :

---
title: Mon article test
description: Vérif de la Content Collection
date: 2025-09-17
category: chattovod
tags: [test]
draft: false
---

Ceci est un article **Markdown** de test.

3) Le rendu d’un article ([slug].astro)

  • Cherche d’abord un HTML par slug dans posts.ts.
  • Sinon, charge le Markdown via astro:content.
  • Rend soit set:html={post.content}, soit .
---
import Layout from '../../layouts/Layout.astro';
import { getPostBySlug } from '../../data/posts';

type NormalizedMeta = {
  title: string;
  description?: string;
  date?: Date | string;
  category?: string;
  cover?: string;
  tags?: string[];
};

export async function getStaticPaths() {
  const { posts } = await import('../../data/posts');

  let mdSlugs: string[] = [];
  try {
    const { getCollection } = await import('astro:content');
    const mdPosts = await getCollection('blog', ({ data }) => !data.draft);
    mdSlugs = mdPosts.map((p) => p.slug);
  } catch {}

  const htmlSlugs = posts.map((p) => p.slug);
  const allSlugs = Array.from(new Set([...htmlSlugs, ...mdSlugs]));
  return allSlugs.map((slug) => ({ params: { slug } }));
}

const { slug } = Astro.params;
const htmlPost = getPostBySlug(slug!);

// Markdown fallback
let isMarkdown = false;
let Content: any = null;
let mdEntry: any = null;

if (!htmlPost) {
  try {
    const { getEntry } = await import('astro:content');
    mdEntry = await getEntry('blog', slug!);
    if (mdEntry && !mdEntry.data?.draft) {
      const rendered = await mdEntry.render();
      Content = rendered.Content; // composant (majuscule)
      isMarkdown = true;
    }
  } catch {}
}

const meta: NormalizedMeta = isMarkdown
  ? {
      title: mdEntry?.data?.title ?? slug!,
      description: mdEntry?.data?.description,
      date: mdEntry?.data?.date,
      category: mdEntry?.data?.category,
      cover: mdEntry?.data?.cover,
      tags: mdEntry?.data?.tags ?? [],
    }
  : {
      title: htmlPost!.title,
      description: htmlPost!.description,
      date: htmlPost!.date,
      category: htmlPost!.category,
      cover: htmlPost!.cover,
      tags: htmlPost!.tags ?? [],
    };

const pageTitle = `${meta.title} — Fdylab`;
const pageDesc  = meta.description ?? '';
const pageUrl   = `https://fdylab.com/blog/${slug}`;
---

<Layout title={pageTitle} description={pageDesc} url={pageUrl} image={meta.cover}>
  <article class="max-w-screen-lg mx-auto bg-white rounded-xl shadow px-6 md:px-12 py-10 mt-8">
    <header class="mb-8">
      <h1 class="text-3xl font-bold mb-2">{meta.title}</h1>
      <p class="text-sm text-gray-500">
        {meta.date ? new Date(meta.date as any).toLocaleDateString('fr-FR') : ''} {meta.category ? `— ${meta.category}` : ''}
      </p>
      {meta.cover && <img src={meta.cover} alt={meta.title} class="rounded-xl mt-6" />}
    </header>

    {isMarkdown ? (
      <div class="prose lg:prose-lg max-w-none">
        <Content />
      </div>
    ) : (
      <div class="prose lg:prose-lg max-w-none" set:html={htmlPost!.content} />
    )}

    {meta.tags && meta.tags.length > 0 && (
      <ul class="flex flex-wrap gap-2 mt-8">
        {meta.tags.map((t) => <li class="px-2 py-1 text-xs bg-gray-100 rounded">{t}</li>)}
      </ul>
    )}
  </article>
</Layout>
  1. La liste (index.astro) : fusion HTML + Markdown
---
import Layout from '../../layouts/Layout.astro';
import { sortedPosts } from '../../data/posts';

const PAGE_TITLE = 'Blog — Fdylab';
const PAGE_DESC  = 'Articles par thèmes : tech, voyage, etc.';

// HTML (posts.ts)
const htmlItems = sortedPosts.map((p) => ({
  slug: p.slug,
  title: p.title,
  description: p.description,
  date: new Date(p.date),
  category: p.category,
  cover: p.cover,
}));

// Markdown (content/blog)
let mdItems: any[] = [];
try {
  const { getCollection } = await import('astro:content');
  const mdPosts = await getCollection('blog', ({ data }) => !data.draft);
  mdItems = mdPosts.map((e) => ({
    slug: e.slug,
    title: e.data.title,
    description: e.data.description,
    date: new Date(e.data.date),
    category: e.data.category,
    cover: e.data.cover,
  }));
} catch {}

const all = [...htmlItems, ...mdItems].sort((a, b) => b.date.getTime() - a.date.getTime());
---

<Layout title={PAGE_TITLE} description={PAGE_DESC} url="https://fdylab.com/blog">
  <section class="max-w-screen-lg mx-auto bg-white rounded-xl shadow px-6 md:px-12 py-10 mt-8">
    <h1 class="text-3xl font-bold mb-8">Blog</h1>

    <ul class="space-y-6">
      {all.map((p) => (
        <li class="p-4 bg-white rounded-xl border hover:shadow transition">
          <a href={`/blog/${p.slug}`} class="block">
            <h2 class="text-xl font-semibold">{p.title}</h2>
            {p.description && <p class="text-gray-600 mt-1">{p.description}</p>}
            <p class="text-sm text-gray-500 mt-2">
              {p.date.toLocaleDateString('fr-FR')} {p.category ? `• ${p.category}` : ''}
            </p>
          </a>
        </li>
      ))}
    </ul>
  </section>
</Layout>

Un rendu homogène avec prose

J’utilise le plugin @tailwindcss/typography pour styliser automatiquement tout le contenu riche (titres, listes, code, blockquotes) — HTML et Markdown sont tous deux enveloppés par un conteneur :

<div class="prose lg:prose-lg max-w-none">
  <!-- soit <Content /> (MD), soit set:html (HTML) -->
</div>

Installe/active :

npm i -D @tailwindcss/typography

global.css (extrait) :

@import "tailwindcss";
@plugin "@tailwindcss/typography";

Attention, si vous utilisez une version inférieur à tailwind V4, utilisez tailwind.config.mjs

Astuce : si tes articles HTML embarquent déjà des classes (text-2xl, mt-8, etc.), garde seulement ce qui est spécifique (encarts, grilles…) et laisse prose gérer la typo, pour que MD et HTML aient le même look.

Comparatif Markdown vs HTML “brut”

CritèreHTML + TailwindMarkdown + prose
Vitesse d’écriture❌ Plus verbeux✅ Très rapide
Contrôle visuel✅ Pixel-perfect⚠️ Style global (extensible)
Lisibilité du fichier❌ Peu lisible brut✅ Lisible brut
Maintenance❌ Lourde si beaucoup d’articles✅ Simple
Portabilité / Migration❌ Spécifique✅ Excellente
SEO✅ OK si sémantique✅ OK (génère du HTML propre)
Apprentissage⚠️ Tailwind/HTML✅ Bas seuil d’entrée

Conclusion

J’ai gardé mes anciens articles HTML parce que j’aime le contrôle fin.

J’ai ajouté le Markdown pour écrire plus vite au quotidien et être plus dans les standards sur d’autres projets.

Le routeur et l’index fusionnent les deux sources de contenu, et prose unifie la typo responsive.

En pratique : je peux maintenant choisir au cas par cas le format le plus rapide pour moi, sans contrainte pour le SEO ni pour l’UX.