diff --git a/.env.example b/.env.example index 440e9e3..ef62cc1 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,16 @@ # Admin authentication NUXT_ADMIN_PASSWORD=changeme NUXT_ADMIN_SECRET=change-this-to-a-random-secret-at-least-32-chars + +# Umami analytics — instance Docker (docker-compose.umami.yml) +# UMAMI_DB_PASSWORD et UMAMI_APP_SECRET : générer avec `openssl rand -hex 32` +UMAMI_DB_PASSWORD= +UMAMI_APP_SECRET= +UMAMI_DOMAIN=stats.librodrome.org + +# Variables injectées dans Nuxt (tracking frontend + API stats) +# NUXT_PUBLIC_UMAMI_WEBSITE_ID : Settings → Websites dans l'interface Umami +# NUXT_UMAMI_API_KEY : Settings → API Keys dans l'interface Umami +NUXT_PUBLIC_UMAMI_URL=https://stats.librodrome.org +NUXT_PUBLIC_UMAMI_WEBSITE_ID= +NUXT_UMAMI_API_KEY= diff --git a/CLAUDE.md b/CLAUDE.md index 2f67dfe..ec7367f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,3 +83,49 @@ PORT=3099 node .output/server/index.mjs # Test build prod (toujours avant commi - Shadoks SVG inline thématiques sur chaque page (hidden mobile, opacity 0.18–0.28) - Hexagramme 益 (#42 Yi, Augmentation) dans `layouts/default.vue` - Signature § (logo calligraphique SVG gradient) dans `TheHeader.vue` — ne pas modifier sans demander + +## SEO & Recherche IA — Checklist permanente + +### À chaque nouvelle page Vue + +1. **Appeler `useSeoPage()`** — jamais `useHead({ title })` seul + ```ts + useSeoPage({ + title: 'Titre page — Le Librodrome', // 50–60 chars + description: 'Description...', // 120–155 chars + image: '/images/og-specifique.jpg', // optionnel, sinon og-default.png (logo §) + type: 'website' | 'article' | 'book', // défaut: website + }) + ``` +2. **og:image** : utiliser la couverture `/images/Couv-Economie-du-don.jpg` pour les pages livre/musique ; `og-default.png` (logo §) pour les pages site générales +3. **type: 'article'** pour les chapitres du modele-eco ; **type: 'book'** pour les index livre + +### À chaque nouveau fichier YAML dans `site/pages/` + +Ajouter le bloc `seo:` en tête : +```yaml +seo: + title: "Titre SEO — 50-60 chars max" + description: "Description 120-155 chars, avec mots-clés naturels" + # image: /images/og-specifique.jpg # optionnel + # keywords: [monnaie libre, TRM, économie du don] +``` + +### Règles description SEO + +- Commence par un verbe d'action ou une affirmation forte +- Contient les mots-clés : économie du don, monnaie libre, TRM, June, bassin de vie, autonomie +- 120–155 caractères (ni trop court, ni tronqué) +- Différente du titre et du H1 + +### JSON-LD automatique + +- JSON-LD `Organization` + `Book` : injecté globalement dans `app.vue` — ne pas redupliquer +- Chapitres → og:type `article` via `useSeoPage({ type: 'article' })` +- Sitemap : géré par `@nuxtjs/sitemap` — ajouter manuellement les nouvelles routes statiques dans `nuxt.config.ts > sitemap.urls` + +### Analytics — Nouveaux composants + +- Tout bouton CTA externe : `trackCta(label, url)` depuis `useTracking()` +- Tout `` : capturé automatiquement par `useScrollTracking()` dans `default.vue` +- Tout nouveau lecteur média : appeler `trackAudioPlay` / `trackPdfOpen` depuis `useTracking()` diff --git a/app/app.vue b/app/app.vue index b3eebd6..69bf52f 100644 --- a/app/app.vue +++ b/app/app.vue @@ -17,24 +17,71 @@ const paletteStore = usePaletteStore() onMounted(() => paletteStore.applyToDOM()) +const config = useRuntimeConfig() +const siteUrl = (config.public.siteUrl as string) || 'https://librodrome.org' + // Umami analytics — inject script only when configured -const runtimeConfig = useRuntimeConfig() -if (runtimeConfig.public.umamiWebsiteId && runtimeConfig.public.umamiUrl) { +if (config.public.umamiWebsiteId && config.public.umamiUrl) { useHead({ script: [{ - src: `${runtimeConfig.public.umamiUrl}/script.js`, + src: `${config.public.umamiUrl}/script.js`, defer: true, - 'data-website-id': runtimeConfig.public.umamiWebsiteId, + 'data-website-id': config.public.umamiWebsiteId, }], }) } +// Global SEO defaults — surchargeables page par page via useSeoPage() useHead({ - titleTemplate: (title) => { - return title ? `${title} — Le Librodrome` : 'Le librodrome' - }, - meta: [ - { name: 'description', content: 'Une économie du don — enfin concevable. Un livre et des chansons, lecture guidée et écoute libre.' }, + titleTemplate: (title) => title ? `${title} — Le Librodrome` : 'Le Librodrome', +}) + +useSeoMeta({ + ogSiteName: 'Le Librodrome', + ogType: 'website', + ogLocale: 'fr_FR', + ogImage: `${siteUrl}/og-default.png`, + ogImageWidth: 1200, + ogImageHeight: 630, + twitterCard: 'summary_large_image', + twitterSite: '@librodrome', +}) + +// JSON-LD — Organisation + Livre +useHead({ + script: [ + { + type: 'application/ld+json', + innerHTML: JSON.stringify({ + '@context': 'https://schema.org', + '@graph': [ + { + '@type': 'Organization', + '@id': `${siteUrl}/#organization`, + name: 'Le Librodrome', + url: siteUrl, + logo: { + '@type': 'ImageObject', + url: `${siteUrl}/images/og-default.png`, + }, + description: 'Plateforme d\'autonomie numérique, économique et citoyenne à l\'échelle des bassins de vie.', + }, + { + '@type': 'Book', + '@id': `${siteUrl}/economique/modele-eco#book`, + name: 'Une économie du don — enfin concevable', + author: { '@type': 'Person', name: 'Yvv' }, + publisher: { '@id': `${siteUrl}/#organization` }, + isbn: '979-1-042-45206-3', + inLanguage: 'fr', + image: `${siteUrl}/images/Couv-Economie-du-don.jpg`, + url: `${siteUrl}/economique/modele-eco`, + description: 'Un livre et 9 chansons pour explorer les fondements d\'une économie fondée sur le don.', + license: 'https://creativecommons.org/licenses/by-nc/4.0/', + }, + ], + }), + }, ], }) diff --git a/app/components/book/BookPdfReader.vue b/app/components/book/BookPdfReader.vue index 819023e..bc9537d 100644 --- a/app/components/book/BookPdfReader.vue +++ b/app/components/book/BookPdfReader.vue @@ -40,6 +40,7 @@ const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>() const { data: bpContent } = await usePageContent('book-player') const bookData = useBookData() await bookData.init() +const { trackPdfOpen, trackPdfClose } = useTracking() const overlayRef = ref() const iframeRef = ref() @@ -60,9 +61,18 @@ function close() { isOpen.value = false } +// Tracking state +let pdfOpenedAt = 0 + watch(isOpen, (open) => { if (open) { nextTick(() => overlayRef.value?.focus()) + trackPdfOpen(props.page ? `chapter-p${props.page}` : 'direct') + pdfOpenedAt = Date.now() + } + else if (pdfOpenedAt > 0) { + trackPdfClose(0, Date.now() - pdfOpenedAt) + pdfOpenedAt = 0 } if (import.meta.client) { document.body.style.overflow = open ? 'hidden' : '' diff --git a/app/components/book/BookPlayer.vue b/app/components/book/BookPlayer.vue index 40db53d..6bd34c3 100644 --- a/app/components/book/BookPlayer.vue +++ b/app/components/book/BookPlayer.vue @@ -102,7 +102,7 @@
- +
{{ currentSong.title }} @@ -138,6 +138,7 @@ +