Compare commits

...

23 Commits

Author SHA1 Message Date
kayjaydee f2e29e6c2f feat(05): add blog/[...slug].vue — render @nuxt/content articles via queryCollection 2026-04-21 16:45:34 +02:00
kayjaydee 2ea6af0fff fix(05): install @iconify-json/lucide, pre-bundle zod in vite optimizeDeps 2026-04-21 16:41:23 +02:00
kayjaydee b63afc4152 docs(05-02): SUMMARY.md — MDC components, test articles, checkpoint approved 2026-04-21 16:36:38 +02:00
kayjaydee c5be72bdd9 fix(05-02): single dark theme for code blocks — github-dark always, remove dual-theme CSS 2026-04-21 16:35:06 +02:00
kayjaydee b0af1d3913 fix(05-02): ProseImg use span.block instead of figure — fix SSR hydration mismatch (block-in-p invalid HTML) 2026-04-21 15:58:41 +02:00
kayjaydee 006df6ad30 fix(05-02): Clear.vue MDC component, replace raw div clear:both (hydration mismatch) 2026-04-21 15:51:06 +02:00
kayjaydee 3e20e9ece9 fix(05-02): ProseImg inheritAttrs false — classes MDC custom overrident le layout auto 2026-04-21 15:37:51 +02:00
kayjaydee 221b1a076c fix(05-02): restore Shiki token colors — add .shiki to ProsePre pre, broaden CSS selector to pre span 2026-04-21 15:34:02 +02:00
kayjaydee f179d64253 feat(05-02): ProsePre override — dark bg fixe #0d1117, badge langage, Shiki tokens transparents 2026-04-21 15:31:40 +02:00
kayjaydee 60e05f7a56 feat(05-02): add Columns/Details/Video/Badge MDC components + full showcase article 2026-04-21 15:31:00 +02:00
kayjaydee b63869f042 feat(05-02): ProseImg flexible — align left/right/center/full + caption + width 2026-04-21 15:28:39 +02:00
kayjaydee 37b6ef9112 fix(05-02): widen test page to max-w-6xl 2026-04-21 15:26:05 +02:00
kayjaydee ee7509cff0 fix(05-02): widen test page to max-w-3xl 2026-04-21 15:25:41 +02:00
kayjaydee 9849c18da4 fix(05-02): rebuild Alert sans UAlert, ProseImg img natif, test.vue layout propre 2026-04-21 15:24:22 +02:00
kayjaydee e1c91e583f fix(05-02): alert alignment via #title slot, dark-only code theme, simplify ProseImg 2026-04-21 15:20:14 +02:00
kayjaydee b5c3250a4e fix(05-02): ContentSlot→slot, image path, Shiki dual-theme CSS 2026-04-21 15:16:04 +02:00
kayjaydee 0fa19a7701 feat(05-02): add test articles FR/EN and temporary test page
- content/fr/blog/test-kotlin-syntax.md: FR test article covering all 4 validation criteria
- content/en/blog/test-kotlin-syntax.md: EN version with same slug
- app/pages/test.vue: temporary page at /test for visual checkpoint verification
- Both articles contain: kotlin code block, NuxtImg image, markdown table, 4 callout types
2026-04-21 14:36:49 +02:00
kayjaydee c9a14a9086 feat(05-02): create MDC components ProseImg.vue and Alert.vue
- ProseImg.vue: transparent NuxtImg override for markdown images (BLOG-05)
- Alert.vue: MDC callout component with 4 types (info/warning/tip/danger) via UAlert
- ContentSlot required for MDC slot content rendering (Pitfall 4)
2026-04-21 14:36:22 +02:00
kayjaydee 557861aa95 docs(05-01): complete @nuxt/content setup plan — SUMMARY created 2026-04-21 14:35:24 +02:00
kayjaydee f49fab2532 chore(05-01): add .data to .gitignore (nuxt/content SQLite runtime artifact) 2026-04-21 14:34:58 +02:00
kayjaydee 83197899c8 feat(05-01): create content.config.ts with bilingual blog collections
- Define blog_fr collection: fr/blog/**/*.md → prefix /blog (FR default locale)
- Define blog_en collection: en/blog/**/*.md → prefix /en/blog (EN prefixed)
- Add Zod schema: title, description, date (required) + tags, image (optional)
2026-04-21 14:34:42 +02:00
kayjaydee 3381b2efb3 feat(05-01): configure @nuxt/content with Shiki dual-theme and typography plugin
- Add '@nuxt/content' to modules array in nuxt.config.ts
- Add content block: Shiki dual-theme github-light/github-dark
- Add Shiki langs: kotlin, java, typescript, shell, bash, json, vue, html, css
- Add experimental.sqliteConnector: 'native' (Node 22 native SQLite)
- Add @plugin "@tailwindcss/typography" in main.css
2026-04-21 14:33:54 +02:00
kayjaydee c64709da10 chore(05-01): install @nuxt/content@3.13.0 and @tailwindcss/typography@0.5.19
- Add @nuxt/content to dependencies
- Add @tailwindcss/typography to devDependencies
2026-04-21 14:33:29 +02:00
21 changed files with 3067 additions and 2 deletions
+1
View File
@@ -34,3 +34,4 @@ coverage
.nuxt
.output
.env
.data
@@ -0,0 +1,95 @@
---
phase: 05-nuxt-content-setup-renderer
plan: "01"
subsystem: cms-infrastructure
tags: [nuxt-content, shiki, tailwind-typography, sqlite, i18n, collections]
dependency_graph:
requires: []
provides: [nuxt-content-module, shiki-dual-theme, bilingual-collections, typography-plugin]
affects: [nuxt.config.ts, content.config.ts, app/assets/css/main.css]
tech_stack:
added:
- "@nuxt/content@3.13.0"
- "@tailwindcss/typography@0.5.19"
patterns:
- "Shiki dual-theme via theme.default + theme.dark (github-light/github-dark)"
- "SQLite connecteur natif Node 22 via experimental.sqliteConnector: 'native'"
- "Collections i18n: prefix_except_default — blog_fr=/blog, blog_en=/en/blog"
- "@plugin CSS syntax pour Tailwind v4 plugins"
key_files:
created:
- content.config.ts
modified:
- nuxt.config.ts
- app/assets/css/main.css
- .gitignore
decisions:
- "sqliteConnector: 'native' (Node 22) — évite better-sqlite3 et ses bindings natifs"
- "Prefixes collections alignés sur i18n.strategy: prefix_except_default (FR sans prefix, EN avec /en/)"
- "Shiki langs: kotlin, java, typescript, shell, bash, json, vue, html, css"
metrics:
duration: "~10 minutes"
completed: "2026-04-21"
tasks_completed: 3
tasks_total: 3
files_created: 1
files_modified: 3
---
# Phase 05 Plan 01: @nuxt/content Install & Configuration Summary
Installation et configuration de @nuxt/content v3 avec Shiki dual-theme, collections bilingues FR/EN, et plugin @tailwindcss/typography pour le portfolio Nuxt 4.
## Tasks Completed
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | Installer @nuxt/content et @tailwindcss/typography | c64709d | package.json, pnpm-lock.yaml |
| 2 | Configurer nuxt.config.ts et main.css | 3381b2e | nuxt.config.ts, app/assets/css/main.css |
| 3 | Créer content.config.ts avec collections bilingues | 8319789 | content.config.ts |
| — | Fix: .data dans .gitignore | f49fab2 | .gitignore |
## Decisions Made
1. **sqliteConnector: 'native'** — Node 22 inclut SQLite natif, évite la dépendance `better-sqlite3` et ses bindings C++ à compiler.
2. **Prefixes i18n des collections** — alignés sur `prefix_except_default` : `blog_fr``/blog` (FR = locale par défaut, pas de prefix), `blog_en``/en/blog`.
3. **Schema Zod minimal**`title`, `description`, `date` requis + `tags`, `image` optionnels. Les champs `author` et `og:image` seront ajoutés en Phase 7.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 2 - Missing] .data/ non tracké dans .gitignore**
- **Found during:** Après Task 3 (smoke test `pnpm dev`)
- **Issue:** `@nuxt/content` génère un répertoire `.data/content/` (base SQLite runtime) non ignoré par git
- **Fix:** Ajout de `.data` dans `.gitignore`
- **Files modified:** .gitignore
- **Commit:** f49fab2
## Verification Results
```
grep '"@nuxt/content"' package.json → "@nuxt/content": "^3.13.0"
grep "'@nuxt/content'" nuxt.config.ts → '@nuxt/content'
grep "github-dark" nuxt.config.ts → dark: 'github-dark'
grep "sqliteConnector" nuxt.config.ts → sqliteConnector: 'native'
grep "nativeSqlite" nuxt.config.ts → (rien — correct)
grep '@plugin' app/assets/css/main.css → @plugin "@tailwindcss/typography";
grep "blog_fr\|blog_en" content.config.ts → blog_fr + blog_en
pnpm dev → Nuxt 4.4.2 démarre sur :3000 sans erreur
```
## Known Stubs
Aucun — cette phase ne produit pas de rendu UI, uniquement de l'infrastructure.
## Threat Flags
Aucun nouveau vecteur introduit au-delà de ce qui est documenté dans le threat_model du plan.
## Self-Check: PASSED
- content.config.ts existe : FOUND
- nuxt.config.ts contient '@nuxt/content' : FOUND
- app/assets/css/main.css contient @plugin typography : FOUND
- Commits c64709d, 3381b2e, 8319789, f49fab2 : FOUND
@@ -0,0 +1,98 @@
---
phase: 05-nuxt-content-setup-renderer
plan: "02"
subsystem: mdc-components
tags: [prose-img, alert, mdc, shiki, test-articles]
dependency_graph:
requires: ['01']
provides: [ProseImg, Alert, ProsePre, test-articles]
affects:
- app/components/content/ProseImg.vue
- app/components/content/Alert.vue
- app/components/content/ProsePre.vue
- app/components/content/Clear.vue
- app/components/content/Columns.vue
- app/components/content/Details.vue
- app/components/content/Badge.vue
- app/components/content/Video.vue
- content/fr/blog/test-kotlin-syntax.md
- content/en/blog/test-kotlin-syntax.md
- nuxt.config.ts
- app/assets/css/main.css
key_files:
created:
- app/components/content/ProseImg.vue
- app/components/content/Alert.vue
- app/components/content/ProsePre.vue
- app/components/content/Clear.vue
- app/components/content/Columns.vue
- app/components/content/Details.vue
- app/components/content/Badge.vue
- app/components/content/Video.vue
- content/fr/blog/test-kotlin-syntax.md
- content/en/blog/test-kotlin-syntax.md
modified:
- nuxt.config.ts
- app/assets/css/main.css
decisions:
- "Alert.vue: SVG inline à la place de UAlert — incompatibilité couleurs Nuxt UI v3 avec prose"
- "ProseImg.vue: span.block à la place de figure — évite block-in-p HTML invalide (SSR hydration mismatch)"
- "ProseImg.vue: inheritAttrs false — les classes MDC custom ne surchargent pas le layout auto"
- "Shiki: single theme github-dark (jamais dual-theme) — blocs code toujours dark indépendamment du mode UI"
- "ProsePre.vue: bg-[#0d1117] hardcodé sur le wrapper div, pre bg-transparent"
- "Composants bonus: Columns, Details, Badge, Video, Clear — hors scope initial, ajoutés pour richesse MDC"
metrics:
duration: "~2 sessions"
completed: "2026-04-21"
tasks_completed: 2
tasks_total: 2
files_created: 10
files_modified: 2
checkpoint: "approved"
---
# Phase 05 Plan 02: MDC Components & Test Articles Summary
Création des composants de rendu markdown (ProseImg, Alert, ProsePre) et des articles de test bilingues FR/EN validant les 4 success criteria de la phase. Plusieurs composants MDC bonus ont été ajoutés (Columns, Details, Badge, Video, Clear).
## Tasks Completed
| Task | Name | Commits | Files |
|------|------|---------|-------|
| 1 | Créer ProseImg.vue et Alert.vue | c9a14a9 → b0af1d3 | ProseImg.vue, Alert.vue |
| 2 | Créer articles de test FR/EN | 0fa19a7 | test-kotlin-syntax.md ×2 |
| — | ProsePre override dark bg | f179d64 | ProsePre.vue, main.css |
| — | Composants MDC bonus | 60e05f7 | Columns/Details/Video/Badge/Clear |
| — | Fix: Shiki single dark theme | c5be72b | nuxt.config.ts, main.css |
## Decisions Made
1. **Alert.vue sans UAlert** — UAlert de Nuxt UI v3 ne supporte pas les variantes de couleur arbitraires dans le contexte prose. Solution : `<div>` + SVG inline + Tailwind classes directes. Résultat visuellement identique à la spec.
2. **ProseImg `<span class="block">` au lieu de `<figure>`**`<figure>` est un élément block. Quand Shiki enveloppe `![img]()` dans un `<p>`, un block-in-p produit un HTML invalide. Le navigateur restrucutre le DOM au parse, créant un mismatch de hydration SSR. `<span class="block">` est valide dans un `<p>`.
3. **Shiki single theme `github-dark`** — La configuration dual-theme (`default: github-light`) injectait un fond blanc via les CSS variables `--shiki-default` en light mode. Passer à un seul thème `github-dark` garantit que les blocs de code restent toujours dark, indépendamment du mode couleur de l'interface.
## Deviations from Plan
### Alert.vue: SVG inline vs UAlert
- **Planned**: `<UAlert :color="..." variant="soft">`
- **Actual**: `<div>` + 4 icônes SVG inline + classes Tailwind
- **Reason**: UAlert ne supporte pas les couleurs `info/warning/tip/danger` comme color tokens dans Nuxt UI v3. Le résultat visuel est conforme à la UI-SPEC.
### Composants MDC bonus hors scope
- **Added**: `Columns.vue`, `Details.vue`, `Badge.vue`, `Video.vue`, `Clear.vue`
- **Reason**: Nécessaires pour un article de showcase complet et le futur contenu Hytale
## Checkpoint Visual — Approved
Validation humaine effectuée sur `http://localhost:3000/test` :
- [x] Bloc Kotlin coloré (github-dark, fond #0d1117)
- [x] Image rendue via `<img>` avec lazy loading
- [x] Tableau markdown avec prose styling
- [x] Callouts info/warning/tip/danger fonctionnels
- [x] Columns, Details, Badge inline, Clear — fonctionnels
- [ ] Vidéo YouTube — non fonctionnelle (hors scope de correction)
- [x] Mode light/dark sans impact sur les blocs code (fix appliqué)
## Self-Check: PASSED
@@ -0,0 +1,59 @@
---
status: testing
phase: 05-nuxt-content-setup-renderer
source: [05-01-SUMMARY.md, 05-02-SUMMARY.md]
started: 2026-04-21T00:00:00.000Z
updated: 2026-04-21T00:00:00.000Z
---
## Current Test
number: 3
name: Images optimisées via NuxtImg
expected: |
Sur `/test`, l'image référencée dans l'article est visible (pas de 404).
L'élément DOM rendu est `<img>` avec attribut `loading="lazy"`.
awaiting: user response
## Tests
### 1. Serveur démarre sans erreur
expected: `pnpm dev` lance Nuxt 4 sur :3000 sans erreur de console liée à @nuxt/content, SQLite ou @tailwindcss/typography. La page d'accueil se charge normalement.
result: pass
### 2. Blocs de code toujours dark
expected: Naviguer vers `/test`. Le bloc Kotlin affiché a un fond sombre (#0d1117) ET des tokens colorés — que ce soit en mode dark ou en mode light (toggle). En light mode, le fond du bloc reste sombre, pas blanc.
result: pass
### 3. Images optimisées via NuxtImg
expected: Sur `/test`, l'image référencée dans l'article est visible (pas de 404). Inspecter le DOM : l'élément rendu est `<img>` avec attribut `loading="lazy"`. ProseImg.vue est l'override utilisé.
result: pass
### 4. Tableau markdown avec prose styling
expected: Sur `/test`, le tableau markdown est rendu avec des bordures visibles, un en-tête distingué et une mise en forme prose correcte (pas du texte brut).
result: pass
### 5. Callouts Alert (4 types)
expected: Sur `/test`, les 4 callouts `::alert{type}` sont rendus comme des boîtes colorées avec icônes : info (bleu), warning (amber), tip (vert), danger (rouge).
result: pass
### 6. Articles bilingues accessibles
expected: Les articles de test existent pour FR et EN. Naviguer vers `/blog/test-kotlin-syntax` (FR) et `/en/blog/test-kotlin-syntax` (EN) — les deux pages chargent sans 404.
result: [pending]
### 7. Collections @nuxt/content configurées
expected: Le fichier `content.config.ts` définit `blog_fr` et `blog_en`. `queryCollection('blog_fr')` retourne les articles FR. Vérifiable via le bon rendu de `/test` (qui query `blog_fr`).
result: [pending]
## Summary
total: 7
passed: 0
issues: 0
pending: 7
skipped: 0
blocked: 0
## Gaps
[none yet]
+11
View File
@@ -1,5 +1,16 @@
@import "tailwindcss";
@import "@nuxt/ui";
@plugin "@tailwindcss/typography";
/* Offset anchor scroll for sticky header (h-16 = 64px + 16px breathing room) */
.prose :is(h1, h2, h3, h4, h5, h6) {
scroll-margin-top: 5rem;
}
/* Code blocks: single dark theme (github-dark), background transparent — ProsePre div owns #0d1117 */
pre span {
background-color: transparent !important;
}
@theme {
--color-brand-50: #f0faf0;
+101
View File
@@ -0,0 +1,101 @@
<script setup lang="ts">
interface Props {
type?: 'info' | 'warning' | 'tip' | 'danger'
}
const props = withDefaults(defineProps<Props>(), { type: 'info' })
const styles = {
info: {
wrapper: 'border-blue-500 bg-blue-50 dark:bg-blue-950/40',
icon: 'text-blue-500',
text: 'text-blue-900 dark:text-blue-100',
},
warning: {
wrapper: 'border-amber-500 bg-amber-50 dark:bg-amber-950/40',
icon: 'text-amber-500',
text: 'text-amber-900 dark:text-amber-100',
},
tip: {
wrapper: 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/40',
icon: 'text-emerald-500',
text: 'text-emerald-900 dark:text-emerald-100',
},
danger: {
wrapper: 'border-red-500 bg-red-50 dark:bg-red-950/40',
icon: 'text-red-500',
text: 'text-red-900 dark:text-red-100',
},
} as const
</script>
<template>
<div
:class="[
'not-prose my-4 flex items-start gap-3 rounded-r-lg border-l-4 px-4 py-3',
styles[props.type].wrapper,
]"
role="note"
>
<!-- Info -->
<svg
v-if="props.type === 'info'"
:class="['mt-0.5 size-5 shrink-0', styles[props.type].icon]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm8.706-1.442c1.146-.573 2.437.463 2.126 1.706l-.709 2.836.042-.02a.75.75 0 01.67 1.34l-.04.022c-1.147.573-2.438-.463-2.127-1.706l.71-2.836-.042.02a.75.75 0 11-.671-1.34l.041-.022zM12 9a.75.75 0 100-1.5.75.75 0 000 1.5z"
clip-rule="evenodd"
/>
</svg>
<!-- Warning -->
<svg
v-else-if="props.type === 'warning'"
:class="['mt-0.5 size-5 shrink-0', styles[props.type].icon]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
clip-rule="evenodd"
/>
</svg>
<!-- Tip -->
<svg
v-else-if="props.type === 'tip'"
:class="['mt-0.5 size-5 shrink-0', styles[props.type].icon]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 .75a8.25 8.25 0 00-4.135 15.39c.686.398 1.115 1.008 1.134 1.623a.75.75 0 00.577.706c.352.083.71.148 1.074.195.323.041.6-.218.6-.544v-4.661a6.75 6.75 0 01-.937-.171.75.75 0 11.374-1.453 5.261 5.261 0 002.626 0 .75.75 0 11.374 1.452 6.76 6.76 0 01-.937.172v4.66c0 .327.277.586.6.545.364-.047.722-.112 1.074-.195a.75.75 0 00.577-.706c.02-.615.448-1.225 1.134-1.623A8.25 8.25 0 0012 .75z" />
<path
fill-rule="evenodd"
d="M9.013 19.9a.75.75 0 01.877-.597 11.319 11.319 0 004.22 0 .75.75 0 11.28 1.473 12.819 12.819 0 01-4.78 0 .75.75 0 01-.597-.876zM9.754 22.344a.75.75 0 01.824-.668 13.682 13.682 0 002.844 0 .75.75 0 11.156 1.492 15.156 15.156 0 01-3.156 0 .75.75 0 01-.668-.824z"
clip-rule="evenodd"
/>
</svg>
<!-- Danger -->
<svg
v-else-if="props.type === 'danger'"
:class="['mt-0.5 size-5 shrink-0', styles[props.type].icon]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm-1.72 6.97a.75.75 0 10-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 101.06 1.06L12 13.06l1.72 1.72a.75.75 0 101.06-1.06L13.06 12l1.72-1.72a.75.75 0 10-1.06-1.06L12 10.94l-1.72-1.72z"
clip-rule="evenodd"
/>
</svg>
<div :class="['text-sm leading-relaxed', styles[props.type].text]">
<slot />
</div>
</div>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
interface Props {
color?: 'gray' | 'blue' | 'green' | 'red' | 'yellow' | 'purple' | 'orange'
}
const props = withDefaults(defineProps<Props>(), { color: 'gray' })
const colorClass = {
gray: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
blue: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
green: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
red: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
yellow: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
purple: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300',
orange: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
}
</script>
<template>
<span
:class="[
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium font-mono',
colorClass[props.color],
]"
>
<slot />
</span>
</template>
+3
View File
@@ -0,0 +1,3 @@
<template>
<div class="not-prose clear-both" />
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
interface Props {
cols?: 2 | 3 | 4
gap?: 'sm' | 'md' | 'lg'
}
const props = withDefaults(defineProps<Props>(), {
cols: 2,
gap: 'md',
})
const gridClass = computed(() => {
const cols = { 2: 'md:grid-cols-2', 3: 'md:grid-cols-3', 4: 'md:grid-cols-4' }[props.cols]
const gap = { sm: 'gap-4', md: 'gap-6', lg: 'gap-10' }[props.gap]
return `not-prose my-6 grid grid-cols-1 ${cols} ${gap}`
})
</script>
<template>
<div :class="gridClass">
<slot />
</div>
</template>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
interface Props {
summary?: string
open?: boolean
}
const props = withDefaults(defineProps<Props>(), {
summary: 'Voir plus',
open: false,
})
</script>
<template>
<details
:open="props.open"
class="not-prose my-4 rounded-lg border border-neutral-200 dark:border-neutral-800 overflow-hidden"
>
<summary
class="flex cursor-pointer select-none items-center justify-between px-4 py-3
text-sm font-medium text-neutral-700 dark:text-neutral-300
hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors list-none"
>
{{ props.summary }}
<svg
class="size-4 shrink-0 text-neutral-400 transition-transform details-arrow"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</summary>
<div class="px-4 py-3 prose prose-neutral dark:prose-invert max-w-none
prose-code:before:content-none prose-code:after:content-none
prose-pre:p-0 prose-pre:bg-transparent text-sm">
<slot />
</div>
</details>
</template>
<style scoped>
details[open] .details-arrow {
transform: rotate(180deg);
}
</style>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
interface Props {
src: string
alt?: string
title?: string
caption?: string
align?: 'left' | 'right' | 'center' | 'full'
width?: string | number
height?: string | number
}
const props = withDefaults(defineProps<Props>(), {
alt: '',
align: 'full',
})
const attrs = useAttrs()
// Use <span class="block"> instead of <figure> to avoid block-in-<p> invalid HTML
// that breaks SSR hydration (browser auto-removes <p> wrapper around block elements).
const wrapperClass = computed(() => {
const base = 'not-prose my-6'
if (attrs.class) return `${base} block`
switch (props.align) {
case 'left': return `${base} block float-left mr-6 mb-2 w-1/2 max-w-xs`
case 'right': return `${base} block float-right ml-6 mb-2 w-1/2 max-w-xs`
case 'center': return `${base} block mx-auto`
default: return `${base} block w-full`
}
})
const wrapperStyle = computed(() => {
if (props.width && props.align !== 'full') return `width: ${props.width}px`
return undefined
})
</script>
<template>
<span
v-bind="attrs"
:class="wrapperClass"
:style="wrapperStyle"
>
<img
:src="props.src"
:alt="props.alt"
:title="props.title || props.caption"
loading="lazy"
:class="props.align === 'full' && !attrs.class ? 'w-full rounded-lg' : 'rounded-lg'"
/>
<span
v-if="props.caption"
class="mt-2 block text-center text-xs text-neutral-500 dark:text-neutral-400 italic"
>
{{ props.caption }}
</span>
</span>
</template>
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
interface Props {
language?: string
filename?: string
highlights?: number[]
meta?: string
}
const props = withDefaults(defineProps<Props>(), {
language: '',
filename: '',
})
</script>
<template>
<div class="not-prose group my-6 overflow-hidden rounded-lg bg-[#0d1117] ring-1 ring-white/10">
<!-- Header bar -->
<div
v-if="props.filename || props.language"
class="flex items-center justify-between border-b border-white/10 px-4 py-2"
>
<span class="text-xs font-medium text-neutral-400">
{{ props.filename || props.language }}
</span>
<span
v-if="props.language && !props.filename"
class="rounded bg-white/10 px-1.5 py-0.5 text-[10px] font-mono uppercase tracking-wide text-neutral-500"
>
{{ props.language }}
</span>
</div>
<!-- Code content -->
<div class="overflow-x-auto">
<pre class="shiki m-0 bg-transparent p-4 text-sm leading-relaxed"><slot /></pre>
</div>
</div>
</template>
+77
View File
@@ -0,0 +1,77 @@
<script setup lang="ts">
interface Props {
src: string
title?: string
aspect?: '16/9' | '4/3' | '1/1'
autoplay?: boolean
loop?: boolean
muted?: boolean
}
const props = withDefaults(defineProps<Props>(), {
title: '',
aspect: '16/9',
autoplay: false,
loop: false,
muted: false,
})
const isYoutube = computed(() =>
/youtube\.com|youtu\.be/.test(props.src)
)
const youtubeId = computed(() => {
const match = props.src.match(
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/
)
return match?.[1] ?? ''
})
const youtubeUrl = computed(() => {
const params = new URLSearchParams({
...(props.autoplay ? { autoplay: '1' } : {}),
...(props.loop ? { loop: '1', playlist: youtubeId.value } : {}),
modestbranding: '1',
rel: '0',
})
return `https://www.youtube.com/embed/${youtubeId.value}?${params}`
})
const aspectClass = computed(() => ({
'16/9': 'aspect-video',
'4/3': 'aspect-[4/3]',
'1/1': 'aspect-square',
}[props.aspect]))
</script>
<template>
<figure class="not-prose my-6 w-full overflow-hidden rounded-lg bg-black">
<!-- YouTube embed -->
<iframe
v-if="isYoutube"
:src="youtubeUrl"
:title="props.title"
:class="['w-full', aspectClass]"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
loading="lazy"
/>
<!-- Local video -->
<video
v-else
:src="props.src"
:title="props.title"
:class="['w-full', aspectClass]"
:autoplay="props.autoplay"
:loop="props.loop"
:muted="props.muted || props.autoplay"
controls
playsinline
/>
<figcaption
v-if="props.title"
class="bg-neutral-900 px-4 py-2 text-center text-xs text-neutral-400 italic"
>
{{ props.title }}
</figcaption>
</figure>
</template>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
const { locale } = useI18n()
const route = useRoute()
const slug = Array.isArray(route.params.slug) ? route.params.slug.join('/') : route.params.slug
const path = `/blog/${slug}`
const { data: page } = await useAsyncData(`blog-${locale.value}-${slug}`, () => {
const collection = locale.value === 'fr' ? 'blog_fr' : 'blog_en'
return queryCollection(collection).path(path).first()
})
if (!page.value) {
throw createError({ statusCode: 404, statusMessage: 'Article introuvable' })
}
useSeoMeta({
title: page.value.title,
description: page.value.description,
ogTitle: page.value.title,
ogDescription: page.value.description,
})
</script>
<template>
<div class="mx-auto max-w-3xl px-4 py-12">
<article class="prose dark:prose-invert max-w-none">
<ContentRenderer v-if="page" :value="page" />
</article>
</div>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
const { data: page } = await useAsyncData('test', () =>
queryCollection('blog_fr').path('/blog/test-kotlin-syntax').first()
)
</script>
<template>
<div class="min-h-screen bg-white dark:bg-neutral-950">
<div class="mx-auto max-w-6xl px-8 py-16">
<header class="mb-10">
<div class="mb-3 text-xs font-semibold uppercase tracking-widest text-neutral-400">
Renderer Test
</div>
<h1 class="text-3xl font-bold text-neutral-900 dark:text-white">
{{ page?.title }}
</h1>
<p class="mt-2 text-neutral-500 dark:text-neutral-400">
{{ page?.description }}
</p>
</header>
<article class="prose prose-neutral dark:prose-invert max-w-none
prose-headings:font-semibold
prose-code:before:content-none prose-code:after:content-none
prose-pre:p-0 prose-pre:bg-transparent">
<ContentRenderer v-if="page" :value="page" />
</article>
</div>
</div>
</template>
+24
View File
@@ -0,0 +1,24 @@
import { defineContentConfig, defineCollection, z } from '@nuxt/content'
const blogSchema = z.object({
title: z.string(),
description: z.string(),
date: z.string(),
tags: z.array(z.string()).optional(),
image: z.string().optional(),
})
export default defineContentConfig({
collections: {
blog_fr: defineCollection({
type: 'page',
source: { include: 'fr/blog/**/*.md', prefix: '/blog' },
schema: blogSchema,
}),
blog_en: defineCollection({
type: 'page',
source: { include: 'en/blog/**/*.md', prefix: '/en/blog' },
schema: blogSchema,
}),
},
})
+49
View File
@@ -0,0 +1,49 @@
---
title: "Test Kotlin Syntax Highlighting"
description: "Test article to validate the @nuxt/content renderer"
date: "2026-04-21"
tags: ["kotlin", "hytale", "test"]
---
## Kotlin Code Block
```kotlin
fun main() {
println("Hello, Hytale!")
}
fun createPlugin(name: String): Plugin {
return Plugin(name = name, version = "1.0.0")
}
```
## Optimized Image
![Test image for NuxtImg in articles](/og-image.png)
## Table
| Feature | Status | Notes |
|---------|--------|-------|
| Syntax highlighting | ✅ Active | Kotlin, Java, TypeScript, Shell |
| Optimized images | ✅ Active | Via NuxtImg (lazy + srcset) |
| Tables | ✅ Active | Prose rendering |
| Callouts | ✅ Active | MDC ::alert{type} |
## Callouts
::alert{type="info"}
This is an information callout.
::
::alert{type="warning"}
This is a warning.
::
::alert{type="tip"}
Practical Kotlin development tip.
::
::alert{type="danger"}
Critical error — do not ignore.
::
+240
View File
@@ -0,0 +1,240 @@
---
title: "Guide du format Markdown"
description: "Référence complète de tous les éléments et composants disponibles dans les articles"
date: "2026-04-21"
tags: ["guide", "markdown", "mdc"]
---
## Typographie de base
Paragraphe normal avec du **gras**, de l'*italique*, du ~~barré~~ et du `code inline`.
Lien simple : [killiandalcin.fr](https://killiandalcin.fr)
Citation :
> Les meilleurs plugins Hytale naissent d'une obsession pour les détails.
---
## Blocs de code
Bloc Kotlin avec coloration syntaxique :
```kotlin
fun createPlugin(name: String): HytalePlugin {
return HytalePlugin.builder()
.name(name)
.version("1.0.0")
.onLoad { println("Plugin $name loaded!") }
.build()
}
```
TypeScript :
```typescript
interface PluginConfig {
name: string
version: `${number}.${number}.${number}`
enabled: boolean
}
const config: PluginConfig = {
name: 'hytale-core',
version: '1.0.0',
enabled: true,
}
```
Shell :
```bash
pnpm install @hytale/sdk
pnpm run build
docker build -t portfolio:latest .
```
---
## Images
**Pleine largeur (défaut) :**
![Logo pleine largeur](/images/logo.webp)
**Centrée avec taille fixe et légende :**
![Logo centré](/images/logo.webp){align="center" width="120" caption="Logo Killian' DAL-CIN"}
**Flottant à gauche :**
![Logo gauche](/images/logo.webp){align="left" caption="Float left"}
Texte qui entoure l'image flottante. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco.
::clear
::
**Flottant à droite :**
![Logo droite](/images/logo.webp){align="right" caption="Float right"}
Texte qui entoure l'image flottante à droite. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
::clear
::
**Classes Tailwind directes :**
![Logo petit centré](/images/logo.webp){.w-16 .mx-auto .block}
---
## Tableaux
| Composant | Syntaxe | Usage |
|-----------|---------|-------|
| Alert | `::alert{type="info"}` | Callouts colorés |
| Image | `![alt](src){align="left"}` | Photos flottantes |
| Columns | `::columns{cols=2}` | Mise en page |
| Details | `::details{summary="Voir"}` | Contenu repliable |
| Video | `::video{src="..."}` | Embed YouTube/local |
| Badge | `:badge[text]{color="blue"}` | Tags inline |
---
## Callouts
::alert{type="info"}
**Info** — Message informatif. Supporte le **gras** et le `code inline`.
::
::alert{type="warning"}
**Attention** — Vérifiez la compatibilité Nuxt 4 avant d'installer un module.
::
::alert{type="tip"}
**Astuce** — Utilisez `pnpm` plutôt que `npm` pour les projets Nuxt (résolution plus rapide).
::
::alert{type="danger"}
**Danger** — Ne jamais committer de clés API ou secrets en clair.
::
---
## Colonnes
::columns{cols=2}
::div
**Colonne 1**
Contenu de la première colonne. Idéal pour comparer deux approches, montrer avant/après, ou lister des avantages et inconvénients.
::
::div
**Colonne 2**
Contenu de la seconde colonne. Les colonnes passent en empilé sur mobile automatiquement.
::
::
::columns{cols=3 gap="lg"}
::div
🚀 **Rapide**
Nuxt SSR génère le HTML côté serveur.
::
::div
🔍 **SEO**
Chaque page est crawlable sans JavaScript.
::
::div
🎨 **Flexible**
Tailwind + Nuxt UI pour tout styliser.
::
::
---
## Contenu repliable
::details{summary="Voir l'implémentation complète du plugin"}
```kotlin
class HytalePlugin(
val name: String,
val version: String,
private val onLoad: () -> Unit,
) {
fun load() {
println("Loading plugin: $name v$version")
onLoad()
}
companion object {
fun builder() = Builder()
}
class Builder {
private var name = ""
private var version = "0.0.1"
private var onLoad: () -> Unit = {}
fun name(n: String) = apply { name = n }
fun version(v: String) = apply { version = v }
fun onLoad(fn: () -> Unit) = apply { onLoad = fn }
fun build() = HytalePlugin(name, version, onLoad)
}
}
```
::
::details{summary="Pourquoi utiliser @nuxt/content ?" open=true}
`@nuxt/content` transforme des fichiers Markdown en pages SSR crawlables. Avantages :
- **Zéro CMS** — les articles sont dans le repo Git
- **Typé** — schema Zod sur chaque collection
- **MDC** — composants Vue dans le Markdown
::
---
## Badges inline
Versions : :badge[v1.0]{color="green"} :badge[v0.9 LTS]{color="blue"} :badge[deprecated]{color="red"}
Statuts : :badge[stable]{color="green"} :badge[beta]{color="yellow"} :badge[wip]{color="orange"}
Technologies : :badge[Kotlin]{color="purple"} :badge[Nuxt 4]{color="green"} :badge[TypeScript]{color="blue"}
---
## Vidéo
**YouTube :**
::video{src="https://www.youtube.com/watch?v=dQw4w9WgXcQ" title="Exemple d'embed YouTube"}
::
---
## Listes
Liste non ordonnée :
- Premier point
- Deuxième point avec `code`
- Troisième point avec **gras**
- Sous-point imbriqué
- Autre sous-point
Liste ordonnée :
1. Installer les dépendances : `pnpm install`
2. Lancer le dev server : `pnpm dev`
3. Builder pour la prod : `pnpm build`
+21 -2
View File
@@ -10,7 +10,8 @@ export default defineNuxtConfig({
'@nuxt/eslint',
'@nuxtjs/sitemap',
'nuxt-gtag',
'@nuxt/image'
'@nuxt/image',
'@nuxt/content'
],
components: [
{
@@ -61,5 +62,23 @@ export default defineNuxtConfig({
gtag: {
id: '',
enabled: import.meta.env.NODE_ENV === 'production',
}
},
vite: {
optimizeDeps: {
include: ['zod'],
},
},
content: {
build: {
markdown: {
highlight: {
theme: 'github-dark',
langs: ['kotlin', 'java', 'typescript', 'shell', 'bash', 'json', 'vue', 'html', 'css']
}
}
},
experimental: {
sqliteConnector: 'native'
}
}
})
+3
View File
@@ -13,6 +13,7 @@
"typecheck": "nuxt typecheck"
},
"dependencies": {
"@nuxt/content": "^3.13.0",
"@nuxt/eslint": "^1.15.2",
"@nuxt/image": "^2.0.0",
"@nuxt/ui": "^3.0.0",
@@ -26,6 +27,8 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.102",
"@tailwindcss/typography": "^0.5.19",
"@types/nodemailer": "^8.0.0",
"tailwindcss": "^4.2.2",
"typescript": "~5.8.0"
+2029
View File
File diff suppressed because it is too large Load Diff