Files
InsanityLab-web/.superpowers/sdd/review-063972d..29d633c.diff
Adriano b913a2bf90 registri dei piani in git (AI-OS#309)
Refs gitea:Adriano/AI-OS#310

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 10:36:26 +02:00

14288 lines
582 KiB
Diff
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Review package: 063972d..HEAD
## Commits
29d633c chore: robots, favicon e smoke test
f0ad162 feat: area admin con editor tiptap, api crud e upload immagini
f3fb2fe feat: blog pubblico con filtri, paginazione e articolo
e984010 feat: pagina contatti e api email con validazione testata
be4d933 feat: pagine programmi con dettaglio e pricing performance class
ed35491 feat: pagina training
c4cb05e fix: immagine sezione about a risoluzione adeguata
ba89568 feat: pagina about
45e5214 feat: homepage completa da design Figma
2fdd1b9 feat: layout base con header, footer e 404
ac75720 fix: apostrofi tipografici nei dati statici
50ce224 feat: dati statici contenuti da design Figma
6caba6f feat: autenticazione a sessioni, middleware admin e script create-user
d3b5e67 feat: layer sqlite e repository posts con test
42d603b feat: slugify, rate limit ed env helper con test
c9a9ff9 feat: design tokens e css globale
4445c42 fix: loghi insanitylab estratti dal pdf figma al posto dei loghi template
810e5d8 fix: limite altezza in optimize-assets, pesi immagini e report dimensioni
551957e fix: pipeline asset riproducibile con script di ottimizzazione
d8ef59e feat: asset immagini estratti da Figma e mappati
5f7ed1f chore: scaffold progetto Astro 5 con adapter node
9780e51 Aggiunge piano implementazione sito
## Files changed
.env.example | 8 +
.gitignore | 8 +
.superpowers/sdd/task-2-report.md | 247 +
README.md | 137 +
astro.config.mjs | 10 +
data/.gitkeep | 0
.../plans/2026-07-01-insanitylab-website.md | 3513 ++++++++++
package-lock.json | 6875 ++++++++++++++++++++
package.json | 38 +
public/favicon.svg | 4 +
public/img/logo-dark.png | Bin 0 -> 26648 bytes
public/img/logo-light.png | Bin 0 -> 36949 bytes
public/robots.txt | 4 +
scripts/apply-asset-map.mjs | 42 +
scripts/asset-map.json | 37 +
scripts/create-user.mjs | 23 +
scripts/extract-fig-images.sh | 17 +
scripts/extract-logos.sh | 53 +
scripts/optimize-assets.sh | 40 +
scripts/smoke.sh | 17 +
src/assets/img/about-exterior.jpg | Bin 0 -> 190714 bytes
src/assets/img/about-team-group.jpg | Bin 0 -> 25553 bytes
src/assets/img/about-trainer-1.jpg | Bin 0 -> 148343 bytes
src/assets/img/about-trainer-2.jpg | Bin 0 -> 135795 bytes
src/assets/img/about-trainer-3.jpg | Bin 0 -> 118975 bytes
src/assets/img/about-trainer-4.jpg | Bin 0 -> 162617 bytes
src/assets/img/about-training-group.jpg | Bin 0 -> 143401 bytes
src/assets/img/feature-balance.jpg | Bin 0 -> 101290 bytes
src/assets/img/feature-longevity.jpg | Bin 0 -> 25258 bytes
src/assets/img/feature-performance.jpg | Bin 0 -> 79834 bytes
src/assets/img/hero-1.jpg | Bin 0 -> 97196 bytes
src/assets/img/info-balance.jpg | Bin 0 -> 136394 bytes
src/assets/img/info-longevity.jpg | Bin 0 -> 252166 bytes
src/assets/img/info-performance.jpg | Bin 0 -> 260295 bytes
src/assets/img/method-step-1.jpg | Bin 0 -> 105410 bytes
src/assets/img/method-step-2.jpg | Bin 0 -> 175977 bytes
src/assets/img/method-step-3.jpg | Bin 0 -> 11290 bytes
src/assets/img/method-step-4.jpg | Bin 0 -> 30346 bytes
src/assets/img/partner-aon.png | Bin 0 -> 8049 bytes
src/assets/img/partner-edenred.png | Bin 0 -> 6875 bytes
src/assets/img/partner-howden.png | Bin 0 -> 3756 bytes
src/assets/img/partner-welbee.png | Bin 0 -> 12423 bytes
src/assets/img/quote-bg.jpg | Bin 0 -> 469103 bytes
src/assets/img/team-antonello.jpg | Bin 0 -> 149301 bytes
src/assets/img/team-donata.jpg | Bin 0 -> 138907 bytes
src/assets/img/team-eleonora.jpg | Bin 0 -> 145996 bytes
src/assets/img/team-nicola.jpg | Bin 0 -> 171336 bytes
src/assets/img/training-fit-remote.jpg | Bin 0 -> 38375 bytes
src/assets/img/training-one-to-one.jpg | Bin 0 -> 38837 bytes
src/assets/img/training-performance-class.jpg | Bin 0 -> 242256 bytes
src/assets/img/training-small-groups.jpg | Bin 0 -> 142807 bytes
src/components/Agenda.astro | 54 +
src/components/ContactForm.astro | 42 +
src/components/Footer.astro | 49 +
src/components/Header.astro | 45 +
src/components/PageHero.astro | 15 +
src/components/about/Timeline.astro | 34 +
src/components/admin/PostForm.astro | 136 +
src/components/blog/Sidebar.astro | 26 +
src/components/home/FeatureBlocks.astro | 35 +
src/components/home/HeroSlider.astro | 54 +
src/components/home/InfoColumns.astro | 26 +
src/components/home/MethodSteps.astro | 39 +
src/components/home/PartnersStrip.astro | 18 +
src/components/home/ProgramsGrid.astro | 38 +
src/components/home/QuoteBanner.astro | 22 +
src/components/home/TeamSection.astro | 34 +
src/components/home/TrainingCards.astro | 42 +
src/data/agenda.ts | 36 +
src/data/method.ts | 9 +
src/data/partners.ts | 6 +
src/data/pricing.ts | 17 +
src/data/programs.ts | 104 +
src/data/site.ts | 34 +
src/data/team.ts | 24 +
src/data/trainings.ts | 6 +
src/env.d.ts | 7 +
src/layouts/Admin.astro | 36 +
src/layouts/Base.astro | 33 +
src/lib/auth.ts | 48 +
src/lib/blog-const.ts | 6 +
src/lib/contact.ts | 31 +
src/lib/db.ts | 47 +
src/lib/env.ts | 5 +
src/lib/mailer.ts | 25 +
src/lib/post-body.ts | 21 +
src/lib/posts.ts | 74 +
src/lib/rate-limit.ts | 17 +
src/lib/sanitize.ts | 11 +
src/lib/slug.ts | 8 +
src/middleware.ts | 24 +
src/pages/404.astro | 14 +
src/pages/about.astro | 166 +
src/pages/admin/edit/[id].astro | 14 +
src/pages/admin/index.astro | 37 +
src/pages/admin/login.astro | 34 +
src/pages/admin/logout.ts | 11 +
src/pages/admin/new.astro | 10 +
src/pages/api/admin/posts/[id].ts | 29 +
src/pages/api/admin/posts/index.ts | 22 +
src/pages/api/admin/upload.ts | 26 +
src/pages/api/contact.ts | 26 +
src/pages/blog/[slug].astro | 33 +
src/pages/blog/index.astro | 81 +
src/pages/contact.astro | 55 +
src/pages/index.astro | 32 +
src/pages/programs/[slug].astro | 98 +
src/pages/programs/index.astro | 38 +
src/pages/training.astro | 57 +
src/pages/uploads/[...path].ts | 20 +
src/styles/global.css | 54 +
tests/auth.test.ts | 45 +
tests/contact.test.ts | 33 +
tests/posts.test.ts | 74 +
tests/rate-limit.test.ts | 23 +
tests/sanitize.test.ts | 16 +
tests/slug.test.ts | 20 +
tsconfig.json | 5 +
uploads/.gitkeep | 0
vitest.config.ts | 5 +
120 files changed, 13489 insertions(+)
## Diff
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..7b2033f
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,8 @@
+SMTP_HOST=smtp.example.com
+SMTP_PORT=587
+SMTP_USER=user
+SMTP_PASS=pass
+CONTACT_TO=info@insanitylab.it
+CONTACT_FROM=sito@insanitylab.it
+DB_PATH=data/insanitylab.db
+UPLOADS_DIR=uploads
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..68a080b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+dist/
+.astro/
+.env
+data/*.db*
+uploads/*
+!uploads/.gitkeep
+assets-src/
diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md
new file mode 100644
index 0000000..6318927
--- /dev/null
+++ b/.superpowers/sdd/task-2-report.md
@@ -0,0 +1,247 @@
+# Task 2 Report — Estrazione e mappatura asset immagine da Figma
+
+**Data:** 2026-07-01
+**Fonte:** `../FigmaDoc/Web Insanity.fig` (60 immagini uniche + ~66 duplicati = 126 estratte)
+**Destinazione:** `src/assets/img/` (31 file) + `public/img/` (2 loghi)
+
+---
+
+## Mappature completate
+
+### Hero
+| File destinazione | Hash sorgente | Descrizione |
+|---|---|---|
+| `hero-1.jpg` | `8b71f158...` | Uomo con palla medica (medicine ball) che la tiene verso donna in posizione crunch, palestra scura — "uomo con palla medica + donna" ✓ |
+
+### Info sezioni (PERFORMANCE / BALANCE / LONGEVITY)
+| File destinazione | Hash sorgente | Descrizione |
+|---|---|---|
+| `info-performance.jpg` | `0f803439...` | Uomo muscoloso in planche su sfondo grigio neutro |
+| `info-balance.jpg` | `432d397a...` | Due donne sedute schiena a schiena in abiti sportivi beige/neutri |
+| `info-longevity.jpg` | `d7fcf0aa...` | Donna anziana in posizione push-up — longevità attiva |
+
+### Feature sezioni (immagini più grandi per ciascun pilastro)
+| File destinazione | Hash sorgente | Descrizione |
+|---|---|---|
+| `feature-performance.jpg` | `6d602573...` | Uomo con chalk sulle mani — preparazione sollevamento pesi, performance atletica |
+| `feature-balance.jpg` | `16e611de...` | Gruppo yoga all'aperto al tramonto — equilibrio e benessere |
+| `feature-longevity.jpg` | `98f1f8d2...` | Donna anziana in piedi con bottiglia d'acqua, outfit sportivo beige — longevità sana |
+
+### Quote background
+| File destinazione | Hash sorgente | Descrizione |
+|---|---|---|
+| `quote-bg.jpg` | `28e8157d...` | Scritta "PERFORMANCE. BALANCE. LONGEVITY." su muro di intonaco — elemento visivo identitario |
+
+### Training programs
+| File destinazione | Hash sorgente | Descrizione |
+|---|---|---|
+| `training-one-to-one.jpg` | `85808a0c...` | Trainer e cliente a colloquio in palestra, tablet in mano — consulenza personalizzata |
+| `training-small-groups.jpg` | `bb632b8d...` | Due donne con manubri leggeri in posizione atletica — small group training |
+| `training-performance-class.jpg` | `91aa82b5...` | Grande gruppo in sala fitness/danza con palle fitball — performance class |
+| `training-fit-remote.jpg` | `86c1363b...` | Donna a casa sul tappetino con laptop — allenamento remoto online |
+
+### Team Trainer (homepage — 4 ritratti luce dorata)
+| File destinazione | Hash sorgente | Persona identificata |
+|---|---|---|
+| `team-donata.jpg` | `0b6cf694...` | Donna capelli lunghi scuri dritti, luce dorada laterale — Donata ✓ |
+| `team-nicola.jpg` | `1cc33c41...` | Uomo barba folta, turtleneck scuro, luce dorata — Nicola ✓ |
+| `team-eleonora.jpg` | `a75ca105...` | Donna capelli biondi/chiari, stessa luce dorata — Eleonora ✓ |
+| `team-antonello.jpg` | `e3c84f99...` | Uomo occhiali tondi, capelli rasatissimi, luce dorata — Antonello ✓ |
+
+### Il Metodo (4 passi circolari)
+| File destinazione | Hash sorgente | Step identificato |
+|---|---|---|
+| `method-step-1.jpg` | `88cfcd5f...` | Calibro plicometro su addome — valutazione composizione corporea |
+| `method-step-2.jpg` | `d21671bb...` | Donna in cucina con verdure fresche e metro — pianificazione nutrizionale |
+| `method-step-3.jpg` | `a3e84d7d...` | Silhouette uomo in palestra in controluce — sessione allenamento |
+| `method-step-4.jpg` | `e50b6f78...` | Scarpe su tapis roulant — monitoraggio continuo e progressione |
+
+### Partner loghi
+| File destinazione | Hash sorgente | Logo identificato |
+|---|---|---|
+| `partner-edenred.png` | `bee22f37...` | "Ticket Welfare Edenred" — logo rosso/blu su sfondo trasparente ✓ |
+| `partner-welbee.png` | `cf079250...` | "welbee" — logo verde su sfondo bianco ✓ |
+| `partner-aon.png` | `014a7708...` | "Aon" — logo rosso su sfondo bianco ✓ |
+| `partner-howden.png` | `872c3679...` | "HOWDEN" — logo blu su sfondo bianco ✓ |
+
+### Loghi InsanityLab (public/img/)
+| File destinazione | Sorgente | Note |
+|---|---|---|
+| `logo-dark.png` | `../Dati/logo-dark.png` | **Fallback** — logo non presente nel file Figma |
+| `logo-light.png` | `../Dati/logo-light.png` | **Fallback** — logo non presente nel file Figma |
+
+### About page (bonus — non richiesti dal brief)
+| File destinazione | Hash sorgente | Descrizione |
+|---|---|---|
+| `about-exterior.jpg` | `8ffc5f7a...` | Facciata esterna palestra/studio InsanityLab, sera |
+| `about-team-group.jpg` | `32ecd00a...` | Foto di gruppo team InsanityLab in bianco e nero |
+| `about-training-group.jpg` | `d4608b10...` | Gruppo che salta con kettlebell in sala beige |
+| `about-trainer-1.jpg` | `dafcc193...` | Trainer donna capelli scuri ondulati, luce dorata |
+| `about-trainer-2.jpg` | `9c50f1d3...` | Trainer donna capelli scuri più corti, matura, luce dorata |
+| `about-trainer-3.jpg` | `c51a49b9...` | Trainer donna capelli neri lisci, sorriso, sfondo chiaro |
+| `about-trainer-4.jpg` | `3341a11b...` | Trainer uomo giovane senza barba, turtleneck scuro |
+
+---
+
+## Immagini attese ma non trovate nel Figma
+
+| Nome atteso | Motivo mancanza | Azione intrapresa |
+|---|---|---|
+| `logo-dark.png` | Assente in `Web Insanity.fig` | Fallback da `../Dati/logo-dark.png` ✓ |
+| `logo-light.png` | Assente in `Web Insanity.fig` | Fallback da `../Dati/logo-light.png` ✓ |
+
+Tutti e 4 i loghi partner sono stati trovati nel Figma. Nessuna altra immagine richiesta dal brief risulta mancante.
+
+---
+
+## Dimensioni finali
+
+```
+src/assets/img/ 3.7 MB (31 file JPG + 4 PNG partner loghi)
+public/img/ ~3.4 KB (2 PNG logo: logo-dark.png 1.7 KB, logo-light.png 1.7 KB)
+```
+
+Tutte le immagini sono ≤ 1400 px larghezza e ≤ 1600 px altezza. Il file più pesante è quote-bg.jpg a 460 KB. Nessun file supera 500 KB.
+
+*Aggiornato dopo fix round 2 — i numeri precedenti (3.4 MB / nessun file >400 KB) erano errati: la pipeline originale non vincolava l'altezza, lasciando immagini portrait a 2-3 MB ciascuna.*
+
+---
+
+## Note tecnica — PNG → JPG
+
+Le foto trainer e training erano estratte dal Figma come PNG lossless (6–8 MB ciascuna). Convertite a JPG qualità 85 con resize a 1200 px max → risparmio ~98% per file. I loghi partner restano PNG per preservare sfondo trasparente.
+
+Il file `asset-map.json` referenzia gli hash originali (estensione .png/.jpg come estratti). I file di destinazione finali hanno tutti estensione .jpg (eccetto loghi partner .png), il che diverge dai nomi originali nel map ma i file in `src/assets/img/` riflettono il formato convertito.
+
+---
+
+## Conteggio
+
+- **Mappate e presenti:** 33 (31 in src/assets/img + 2 in public/img)
+- **Mancanti nel Figma:** 2 (logo-dark, logo-light) → risolti con fallback
+- **Immagini estratte totali:** 126 (inclusi ~66 duplicati)
+- **Lasciate in assets-src/ (gitignored):** 126 file sorgente
+
+---
+
+## Fix round 1
+
+**Data:** 2026-07-02
+
+### Cosa cambiato
+
+1. **`scripts/asset-map.json`** — rimossa sezione `"public/img"` (chiavi non-hash, rompeva pipeline dopo `extract-fig-images.sh`). Aggiunta sezione top-level `"fallbacks"` con struttura `{ "public/img": { "../Dati/logo-dark.png": "logo-dark.png", "../Dati/logo-light.png": "logo-light.png" } }`. Le destinazioni in `src/assets/img` mantengono l'estensione REALE del sorgente estratto da Figma (`.png` per PNG Figma, `.jpg` per JPEG già presenti in assets-src).
+
+2. **`scripts/apply-asset-map.mjs`** — aggiunto commento d'intestazione sull'ordine pipeline (extract → apply → optimize). Aggiunto loop `fallbacks`: copia da path relativo alla root del repo invece che da `assets-src/`, senza exit 1 per file assenti nel .fig.
+
+3. **`scripts/optimize-assets.sh`** (nuovo) — bash, `set -euo pipefail`. Converte tutti i PNG non-partner in `src/assets/img/` → JPG qualità 85 con resize `1400x>`, poi ridimensiona JPG già esistenti se larghezza > 1400px. Idempotente (nullglob su `.png`).
+
+### Output verifica riproducibilità
+
+```
+node scripts/apply-asset-map.mjs → "Copiate 33 immagini"
+./scripts/optimize-assets.sh → 16 PNG→JPG, 5 JPG ridimensionati (hero-1, info-balance, info-longevity, method-step-1, method-step-2)
+git status --short → elenco file identico al pre-fix (31 img + 4 PNG partner)
+ 18 file binari rigenerati (ricompressione diversa da conversione manuale originale)
+ → ricommittati: repo = output pipeline ✓
+```
+
+### Esito punto 4 — bassa risoluzione
+
+- **`method-step-3.jpg`** (184×276): sorgente in `assets-src/a3e84d7daf0b25c750ef694f4b9fc10bf0f2f1a8.jpg` è già 184×276 — stessa risoluzione del file committato. Nessuna versione ad alta risoluzione disponibile in `assets-src/`. **Richiede sorgente migliore dal cliente.**
+- **`about-team-group.jpg`** (270×480): sorgente in `assets-src/32ecd00a20321e58c700fbaf739e53ac3bf03876.png` è già 270×480. Nessuna versione ad alta risoluzione disponibile. **Richiede sorgente migliore dal cliente.**
+
+---
+
+## Fix round 2
+
+**Data:** 2026-07-02
+
+### Numeri finali
+
+```
+src/assets/img/ 3.7 MB totale (31 JPG + 4 PNG partner)
+```
+
+Top-5 per peso:
+| File | Dimensioni | Peso |
+|---|---|---|
+| quote-bg.jpg | 1087×1600 | 460 KB |
+| info-performance.jpg | 1200×1500 | 256 KB |
+| info-longevity.jpg | 1095×1600 | 248 KB |
+| training-performance-class.jpg | 1400×1400 | 240 KB |
+| about-exterior.jpg | 1400×781 | 188 KB |
+
+Nessun file supera 500 KB. Precedente info-longevity.jpg era 2.7 MB, info-balance.jpg 2.0 MB.
+
+### Cosa cambiato
+
+1. **`scripts/optimize-assets.sh`** — vincolo resize cambiato da `'1400x>'` (solo larghezza) a `'1400x1600>'` (larghezza E altezza). Qualità JPG da 85 a 83. Tutti i JPG già esistenti vengono ri-codificati a q83 con nuovo vincolo (loop separato, skip file appena convertiti per evitare doppia codifica). Risultato: portrait da 2-3 MB → 248-460 KB.
+
+2. **`scripts/asset-map.json`** — aggiunta chiave `"_nota"` in testa per documentare la conversione PNG→JPG operata da optimize-assets.sh (JSON non supporta commenti, chiave dedicata con prefisso `_`).
+
+3. **`scripts/apply-asset-map.mjs`** — aggiunto `destDir.startsWith('_')` nella condizione di skip, così le chiavi meta `_*` del JSON vengono ignorate nella pipeline.
+
+### Esito idempotenza
+
+Terza esecuzione `apply-asset-map.mjs + optimize-assets.sh`: md5sum su tutti i .jpg identico prima e dopo → **IDEMPOTENTE** ✓
+
+### Esito verifica loghi
+
+`public/img/logo-dark.png` (130×19, 1725 B) e `logo-light.png` (135×19, 1701 B) sono **identici** ai file sorgente in `../Dati/logo-dark.png` e `../Dati/logo-light.png` (stessa dimensione byte-level). Non sono placeholder: sono i loghi reali InsanityLab copiati dalla sezione `fallbacks` di asset-map.json tramite apply-asset-map.mjs. Le dimensioni in pixel ridotte (130×19 px) sono caratteristiche del PNG sorgente in Dati/, non di un errore di pipeline.
+
+---
+
+## Fix round 3
+
+**Data:** 2026-07-02
+
+### Problema chiuso
+
+`public/img/logo-dark.png` e `logo-light.png` contenevano i loghi del template "PROWESS++" copiati da `../Dati/` (sorgente errato). Sostituiti con i loghi reali "insanitylab" estratti vettorialmente dal PDF Figma.
+
+### Metodo
+
+Sorgente: `../FigmaDoc/Homepage.pdf` (1334×10460 pt, 1 pagina).
+
+Render esplorativi a 72–150 dpi per individuare le zone logo:
+- **Logo dark** (header, fondo bianco): riga nav in alto, x≈25pt, y≈10pt
+- **Logo light** (footer, fondo scuro `srgb(58,41,41)`): sezione footer, y≈8490pt
+
+Estrazione finale con `pdftoppm -r 300`, poi ImageMagick:
+- Dark: `-fuzz 8% -transparent white -trim -resize 600x -strip`
+- Light: `-crop 850x660+130+60` (esclude tagline e colonne destra), `-fuzz 12% -transparent 'srgb(58,41,41)' -trim -resize 600x -strip`
+
+`-strip` su entrambi garantisce idempotenza md5sum tra esecuzioni successive.
+
+### Coordinate pdftoppm a 300 dpi (pixel)
+
+| Logo | -x | -y | -W | -H | crop interno |
+|---|---|---|---|---|---|
+| dark | 104 | 41 | 895 | 250 | — |
+| light | 0 | 35375 | 1800 | 833 | `850x660+130+60` |
+
+### Dimensioni finali
+
+| File | Dimensioni | Tipo |
+|---|---|---|
+| `public/img/logo-dark.png` | **600×108 px** | TrueColorAlpha (PNG trasparente) |
+| `public/img/logo-light.png` | **600×342 px** | TrueColorAlpha (PNG trasparente) |
+
+### Aggiornamenti pipeline
+
+- Creato `scripts/extract-logos.sh` (bash, `set -euo pipefail`, coordinate fisse, idempotente)
+- `scripts/asset-map.json`: rimossa sezione `fallbacks` (loghi ora da extract-logos.sh, non da `../Dati`)
+- `scripts/apply-asset-map.mjs`: commento pipeline aggiornato a step 4
+- Ordine pipeline: `extract-fig-images.sh → apply-asset-map.mjs → optimize-assets.sh → extract-logos.sh`
+
+### Verifica visiva
+
+- `logo-dark.png`: icona onda/battito cardiaco beige + scritta "insanitylab" scura, layout orizzontale, sfondo trasparente. **Nessun residuo PROWESS++.**
+- `logo-light.png`: icona onda/battito cardiaco beige sopra, scritta "insanitylab" beige sotto, layout stacked, sfondo trasparente. **Nessun residuo PROWESS++.**
+
+### Idempotenza
+
+```
+md5sum run1 = md5sum run2 → IDEMPOTENTE ✓
+```
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..118dd1b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,137 @@
+# InsanityLab Website
+
+Sito vetrina per InsanityLab con blog integrato e form di contatti.
+
+## Descrizione
+
+Il progetto realizza un sito web per InsanityLab, combinando una vetrina aziendale con un sistema di blog gestito tramite SQLite e un form di contatti funzionante. L'architettura è costruita su **Astro 5** con adapter **Node.js** per deployment su server, garantendo performance ottimali e SEO-friendly pages.
+
+## Stack Tecnologico
+
+- **Framework**: Astro 5
+- **Runtime**: Node.js (adapter standalone)
+- **Database**: SQLite (better-sqlite3)
+- **Autenticazione**: bcryptjs
+- **Email**: Nodemailer
+- **Markup Editor**: TipTap (rich text)
+- **Font System**: @fontsource (Montserrat, Open Sans)
+- **Sitemap**: @astrojs/sitemap
+- **Testing**: Vitest
+- **Linguaggio**: TypeScript
+
+## Installazione
+
+### Prerequisiti
+
+- Node.js 18+ e npm
+
+### Setup
+
+```bash
+# Installa dipendenze
+npm install
+
+# Copia il template di configurazione env
+cp .env.example .env
+
+# Crea il primo utente amministratore (dopo aver configurato il DB)
+npm run create-user -- admin tuapassword
+```
+
+Modifica `.env` con le credenziali SMTP, percorsi database e upload directory secondo il tuo ambiente.
+
+## Comandi Disponibili
+
+| Comando | Descrizione |
+|---------|-------------|
+| `npm run dev` | Avvia il server di sviluppo (localhost:3000) |
+| `npm run build` | Compila il progetto per production |
+| `npm run preview` | Visualizza l'anteprima della build (locale) |
+| `npm run start` | Avvia il server production (richiede `npm run build` preliminare) |
+| `npm run test` | Esegui i test con Vitest |
+| `npm run create-user` | Crea un nuovo utente (admin o redattore) |
+
+## Struttura Progetto
+
+```
+insanitylab-website/
+├── src/
+│ ├── pages/ # Pagine Astro (routing automatico)
+│ ├── components/ # Componenti Astro riutilizzabili
+│ ├── layouts/ # Layout master
+│ ├── utils/ # Funzioni di utilità
+│ ├── env.d.ts # Type definitions globali
+│ └── ...
+├── data/ # Database SQLite e dati persistenti
+│ └── insanitylab.db # (generato al primo avvio)
+├── uploads/ # Directory per upload immagini e file
+├── dist/ # Output build (server)
+├── tests/ # Test suite
+├── astro.config.mjs # Configurazione Astro
+├── tsconfig.json # Configurazione TypeScript
+├── vitest.config.ts # Configurazione test framework
+├── .env.example # Template variabili di ambiente
+├── .env # Variabili di ambiente (non versionato)
+├── package.json # Dipendenze e script npm
+└── README.md # Questo file
+```
+
+## Configurazione Ambiente
+
+Il file `.env` richiede le seguenti variabili:
+
+- `SMTP_HOST`: Host SMTP per invio email
+- `SMTP_PORT`: Porta SMTP (solitamente 587)
+- `SMTP_USER`: Utente SMTP
+- `SMTP_PASS`: Password SMTP
+- `CONTACT_TO`: Email di destinazione contatti
+- `CONTACT_FROM`: Email mittente
+- `DB_PATH`: Percorso database SQLite
+- `UPLOADS_DIR`: Cartella upload file
+
+## Development Workflow
+
+1. **Avvia il server locale**: `npm run dev`
+2. **Modifica file** in `src/` (hot reload automatico)
+3. **Test**: `npm run test`
+4. **Build**: `npm run build` (verifica errori di build)
+5. **Commit**: Con prefisso `feat:` (feature) o `chore:` (manutenzione)
+
+## Deployment
+
+```bash
+# Build per production
+npm run build
+
+# Opzionalmente, preview locale
+npm run preview
+
+# In production, il server parte con:
+npm run start
+
+# Variabili ambiente in production vanno impostate sull'hosting
+# (es. Hostinger VPS, Vercel, etc.)
+```
+
+## Riferimenti Tecnici
+
+Per dettagli su architettura, schema database, componenti e specifiche vedi:
+- `/docs/superpowers/specs/2026-07-01-insanitylab-website-design.md`
+- `/docs/superpowers/plans/2026-07-01-insanitylab-website.md`
+
+## Problemi Comuni
+
+### `better-sqlite3` fallisce su npm install
+
+Se `better-sqlite3` non compila, assicurati di avere build tools installati:
+- **Linux**: `sudo apt-get install build-essential python3`
+- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
+- **Windows**: Visual Studio Build Tools o MinGW
+
+### Database locked
+
+Se il database è locato durante i test, verifica che non ci siano processi in background che accedono a `data/insanitylab.db`.
+
+## Licenza
+
+Proprietario InsanityLab.
diff --git a/astro.config.mjs b/astro.config.mjs
new file mode 100644
index 0000000..3c44492
--- /dev/null
+++ b/astro.config.mjs
@@ -0,0 +1,10 @@
+import { defineConfig } from 'astro/config';
+import node from '@astrojs/node';
+import sitemap from '@astrojs/sitemap';
+
+export default defineConfig({
+ site: 'https://insanitylab.tielogic.xyz',
+ output: 'server',
+ adapter: node({ mode: 'standalone' }),
+ integrations: [sitemap()],
+});
diff --git a/data/.gitkeep b/data/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/docs/superpowers/plans/2026-07-01-insanitylab-website.md b/docs/superpowers/plans/2026-07-01-insanitylab-website.md
new file mode 100644
index 0000000..808bfa1
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-01-insanitylab-website.md
@@ -0,0 +1,3513 @@
+# Sito InsanityLab — Piano di implementazione
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Sito vetrina InsanityLab con blog gestibile dal cliente (login + editor TipTap) e form contatti via email, pronto per porting Docker su VPS in fase 2.
+
+**Architecture:** Astro 5 con output `server` (adapter Node standalone): pagine vetrina prerenderizzate, blog/admin server-rendered da SQLite. Un'unica app Node, JS client vanilla minimo.
+
+**Tech Stack:** Astro 5, @astrojs/node, better-sqlite3, bcryptjs, nodemailer, sanitize-html, TipTap, @fontsource (Montserrat, Open Sans), vitest.
+
+**Spec:** `docs/superpowers/specs/2026-07-01-insanitylab-website-design.md`
+
+## Global Constraints
+
+- Directory progetto: `/home/adriano/Documenti/Clienti/InsanityLab/insanitylab-website` (repo git già inizializzato, branch `main`).
+- Route pubbliche in inglese: `/`, `/about`, `/training`, `/programs`, `/programs/[slug]`, `/blog`, `/blog/[slug]`, `/contact`. Admin: `/admin`, `/admin/login`, `/admin/new`, `/admin/edit/[id]`. API: `POST /api/contact`, `/api/admin/posts*`, `POST /api/admin/upload`.
+- Slug programmi: `personal-training`, `performance-class`, `coaching`, `pilates-flow`, `rehab`, `nutrition`.
+- Categorie blog fisse: `Articoli tecnici & scientifici`, `Contenuti educativi`, `LinkedIn / Insight`, `Eventi & Presidi`.
+- Palette: accento tan `#b5a48b`, marrone scuro `#3a2d26`, fondo alternativo `#f5f3f0` (verificare campionando i PDF, Task 3).
+- Testi UI in italiano. Niente Google Fonts CDN (font self-hosted). Niente framework CSS o JS client.
+- DB: `data/insanitylab.db` (gitignored). Upload: `uploads/` a root progetto (gitignored), serviti da route dinamica `/uploads/[...path]`.
+- Variabili d'ambiente lette con fallback `process.env.X ?? import.meta.env.X` (dev: Vite carica `.env`; prod: `node --env-file=.env`).
+- Sito di riferimento design: PDF in `../FigmaDoc/`. Template analizzato: `../Dati/cassetta-me-analisi.md`.
+- Dati di contatto reali: tel `351 7535977`, `info@insanitylab.it`, Via Leandro Alberti 76, 40139 Bologna, Lun–Ven 6.00–21.00, Sab 8.00–19.00, Dom chiuso.
+- Commit frequenti, messaggi brevi in italiano, prefissi `feat:`/`fix:`/`chore:`/`test:`.
+
+---
+
+### Task 1: Scaffold progetto Astro
+
+**Files:**
+- Create: `package.json`, `astro.config.mjs`, `tsconfig.json`, `vitest.config.ts`, `.gitignore`, `.env.example`, `README.md`, `src/pages/index.astro` (placeholder), `src/env.d.ts`, `data/.gitkeep`, `uploads/.gitkeep`
+
+**Interfaces:**
+- Produces: progetto buildabile; script npm `dev`, `build`, `preview`, `start`, `test`, `create-user`.
+
+- [ ] **Step 1: package.json e installazione dipendenze**
+
+Crea `package.json`:
+
+```json
+{
+ "name": "insanitylab-website",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "astro dev",
+ "build": "astro build",
+ "preview": "astro preview",
+ "start": "node --env-file=.env ./dist/server/entry.mjs",
+ "test": "vitest run",
+ "create-user": "node scripts/create-user.mjs"
+ }
+}
+```
+
+Run:
+```bash
+cd /home/adriano/Documenti/Clienti/InsanityLab/insanitylab-website
+npm install astro @astrojs/node @astrojs/sitemap better-sqlite3 bcryptjs nodemailer sanitize-html @fontsource/montserrat @fontsource/open-sans @tiptap/core @tiptap/starter-kit @tiptap/extension-image @tiptap/extension-link
+npm install -D vitest typescript @types/better-sqlite3 @types/bcryptjs @types/nodemailer @types/sanitize-html
+```
+Expected: install senza errori (better-sqlite3 compila nativo).
+
+- [ ] **Step 2: file di configurazione**
+
+`astro.config.mjs`:
+```js
+import { defineConfig } from 'astro/config';
+import node from '@astrojs/node';
+import sitemap from '@astrojs/sitemap';
+
+export default defineConfig({
+ site: 'https://insanitylab.tielogic.xyz',
+ output: 'server',
+ adapter: node({ mode: 'standalone' }),
+ integrations: [sitemap()],
+});
+```
+
+`tsconfig.json`:
+```json
+{
+ "extends": "astro/tsconfigs/strict",
+ "include": [".astro/types.d.ts", "src/**/*", "tests/**/*"],
+ "exclude": ["dist"]
+}
+```
+
+`vitest.config.ts`:
+```ts
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: { include: ['tests/**/*.test.ts'] },
+});
+```
+
+`.gitignore`:
+```
+node_modules/
+dist/
+.astro/
+.env
+data/*.db*
+uploads/*
+!uploads/.gitkeep
+assets-src/
+```
+
+`.env.example`:
+```
+SMTP_HOST=smtp.example.com
+SMTP_PORT=587
+SMTP_USER=user
+SMTP_PASS=pass
+CONTACT_TO=info@insanitylab.it
+CONTACT_FROM=sito@insanitylab.it
+DB_PATH=data/insanitylab.db
+UPLOADS_DIR=uploads
+```
+
+`src/env.d.ts`:
+```ts
+/// <reference types="astro/client" />
+
+declare namespace App {
+ interface Locals {
+ user?: { id: number; username: string };
+ }
+}
+```
+
+`src/pages/index.astro` (placeholder, sostituito nel Task 9):
+```astro
+---
+export const prerender = true;
+---
+<html lang="it"><head><title>InsanityLab</title></head>
+<body><h1>InsanityLab — in costruzione</h1></body></html>
+```
+
+Crea `data/.gitkeep` e `uploads/.gitkeep` vuoti. Copia `.env.example` in `.env`.
+
+- [ ] **Step 3: README.md**
+
+Prosa italiana completa: scopo (sito vetrina InsanityLab con blog e form contatti), setup (`npm install`, `cp .env.example .env`, `npm run create-user -- <user> <pass>`), comandi (`dev`, `build`, `start`, `test`), stack, struttura cartelle, riferimento alla spec in `docs/superpowers/specs/`.
+
+- [ ] **Step 4: verifica build**
+
+Run: `npm run build`
+Expected: build completata, cartella `dist/` creata.
+
+- [ ] **Step 5: commit**
+
+```bash
+git add -A && git commit -m "chore: scaffold progetto Astro 5 con adapter node"
+```
+
+---
+
+### Task 2: Estrazione asset da Figma
+
+**Files:**
+- Create: `scripts/extract-fig-images.sh`, `scripts/asset-map.json`, `scripts/apply-asset-map.mjs`, `src/assets/img/*` (immagini rinominate), `public/img/logo-*.png`
+
+**Interfaces:**
+- Produces: immagini in `src/assets/img/` con nomi semantici (`hero-1.png`, `info-performance.jpg`, `team-donata.jpg`, `program-one-to-one.jpg`, `partner-edenred.png`, ecc.); loghi in `public/img/`.
+
+- [ ] **Step 1: script estrazione**
+
+`scripts/extract-fig-images.sh`:
+```bash
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")/.."
+FIG="../FigmaDoc/Web Insanity.fig"
+OUT="assets-src"
+rm -rf "$OUT" && mkdir -p "$OUT"
+unzip -o -j -q "$FIG" 'images/*' -d "$OUT"
+cd "$OUT"
+for f in *; do
+ case "$(file --brief --mime-type "$f")" in
+ image/png) mv "$f" "$f.png" ;;
+ image/jpeg) mv "$f" "$f.jpg" ;;
+ image/webp) mv "$f" "$f.webp" ;;
+ *) rm -f "$f" ;;
+ esac
+done
+echo "Estratte: $(ls | wc -l) immagini in $OUT/"
+```
+
+Run: `chmod +x scripts/extract-fig-images.sh && ./scripts/extract-fig-images.sh`
+Expected: decine di immagini con estensione in `assets-src/`.
+
+- [ ] **Step 2: identificazione visiva e mappatura**
+
+Visualizza le immagini estratte (tool Read su ogni file, oppure contact sheet con `montage assets-src/*.{png,jpg} -tile 6x -geometry 200x200+4+4 -label '%f' assets-src/sheet_%d.png`). Compila `scripts/asset-map.json` associando ogni hash al nome di destinazione. Formato:
+
+```json
+{
+ "src/assets/img": {
+ "<hash>.jpg": "hero-1.jpg",
+ "<hash>.jpg": "info-performance.jpg",
+ "<hash>.jpg": "info-balance.jpg",
+ "<hash>.jpg": "info-longevity.jpg",
+ "<hash>.jpg": "feature-performance.jpg",
+ "<hash>.jpg": "feature-balance.jpg",
+ "<hash>.jpg": "feature-longevity.jpg",
+ "<hash>.jpg": "quote-bg.jpg",
+ "<hash>.jpg": "training-one-to-one.jpg",
+ "<hash>.jpg": "training-small-groups.jpg",
+ "<hash>.jpg": "training-performance-class.jpg",
+ "<hash>.jpg": "training-fit-remote.jpg",
+ "<hash>.jpg": "team-donata.jpg",
+ "<hash>.jpg": "team-nicola.jpg",
+ "<hash>.jpg": "team-eleonora.jpg",
+ "<hash>.jpg": "team-antonello.jpg",
+ "<hash>.jpg": "method-step-1.jpg",
+ "<hash>.jpg": "method-step-2.jpg",
+ "<hash>.jpg": "method-step-3.jpg",
+ "<hash>.jpg": "method-step-4.jpg",
+ "<hash>.png": "partner-edenred.png",
+ "<hash>.png": "partner-welbee.png",
+ "<hash>.png": "partner-aon.png",
+ "<hash>.png": "partner-howden.png"
+ },
+ "public/img": {
+ "<hash>.png": "logo-dark.png",
+ "<hash>.png": "logo-light.png"
+ }
+}
+```
+
+Nomi aggiuntivi liberi per immagini di about/training trovate nell'archivio (`about-*.jpg`, ecc.). Le immagini non usate restano in `assets-src/` (gitignored). Se un'immagine attesa manca (es. loghi), fallback: `../Dati/logo-dark.png`, `../Dati/logo-light.png` e altri file in `../Dati/`.
+
+- [ ] **Step 3: script applicazione mappa**
+
+`scripts/apply-asset-map.mjs`:
+```js
+import { copyFileSync, mkdirSync, existsSync } from 'node:fs';
+import { readFileSync } from 'node:fs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '..');
+const map = JSON.parse(readFileSync(join(root, 'scripts/asset-map.json'), 'utf8'));
+let n = 0, missing = 0;
+for (const [destDir, entries] of Object.entries(map)) {
+ mkdirSync(join(root, destDir), { recursive: true });
+ for (const [src, dest] of Object.entries(entries)) {
+ const from = join(root, 'assets-src', src);
+ if (!existsSync(from)) { console.error(`MANCANTE: ${src}`); missing++; continue; }
+ copyFileSync(from, join(root, destDir, dest));
+ n++;
+ }
+}
+console.log(`Copiate ${n} immagini${missing ? `, ${missing} mancanti` : ''}`);
+process.exit(missing ? 1 : 0);
+```
+
+Run: `node scripts/apply-asset-map.mjs`
+Expected: `Copiate N immagini`, exit 0.
+
+- [ ] **Step 4: ottimizza pesi**
+
+Le immagini Figma possono superare 5 MB. Ridimensiona quelle sopra 2000 px di larghezza:
+```bash
+cd src/assets/img
+for f in *.jpg *.png; do
+ w=$(identify -format '%w' "$f")
+ [ "$w" -gt 2000 ] && mogrify -resize 2000x "$f" && echo "ridotta: $f"
+done
+```
+Expected: nessuna immagine > 2000 px; `du -sh src/assets/img` sotto ~15 MB.
+
+- [ ] **Step 5: commit**
+
+```bash
+git add -A && git commit -m "feat: asset immagini estratti da Figma e mappati"
+```
+
+---
+
+### Task 3: Design tokens e CSS globale
+
+**Files:**
+- Create: `src/styles/global.css`
+
+**Interfaces:**
+- Produces: variabili CSS `--c-accent`, `--c-accent-dark`, `--c-dark`, `--c-text`, `--c-heading`, `--c-bg-alt`; classi utility `.container`, `.btn`, `.btn--dark`, `.btn--light`, `.section`, `.section--dark`, `.section-heading`, `.eyebrow`; font family var `--font-heading`, `--font-body`.
+
+- [ ] **Step 1: campiona colori esatti dai PDF**
+
+```bash
+S=/tmp/ilab-sample && mkdir -p $S
+pdftoppm -png -r 30 "../FigmaDoc/Homepage.pdf" $S/h
+# bottone SCOPRI hero (~x55,y200 alla risoluzione 30dpi), sezione agenda scura, sfondo chiaro
+convert $S/h-1.png -format "%[pixel:p{60,205}] %[pixel:p{50,1500}] %[pixel:p{300,1360}]" info:
+```
+Annota i valori RGB reali e usali negli step successivi al posto dei default indicati (attesi: tan ~`#b5a48b`, marrone ~`#3a2d26`, chiaro ~`#f5f3f0`).
+
+- [ ] **Step 2: global.css**
+
+```css
+/* === Design tokens (valori campionati da Figma) === */
+:root {
+ --c-accent: #b5a48b;
+ --c-accent-dark: #9c8b70;
+ --c-dark: #3a2d26;
+ --c-dark-2: #4a3a30;
+ --c-heading: #2b2b2b;
+ --c-text: #4b4b4b;
+ --c-text-light: #8a8a8a;
+ --c-bg: #ffffff;
+ --c-bg-alt: #f5f3f0;
+ --font-heading: 'Montserrat', sans-serif;
+ --font-body: 'Open Sans', sans-serif;
+ --header-h: 70px;
+}
+
+/* === Reset === */
+*, *::before, *::after { box-sizing: border-box; }
+body { margin: 0; font-family: var(--font-body); color: var(--c-text); font-size: 16px; line-height: 1.7; background: var(--c-bg); }
+img { max-width: 100%; height: auto; display: block; }
+h1, h2, h3, h4 { font-family: var(--font-heading); color: var(--c-heading); font-weight: 500; letter-spacing: .04em; line-height: 1.2; margin: 0 0 .5em; }
+h1 { font-size: clamp(2.2rem, 5vw, 3.8rem); }
+h2 { font-size: clamp(1.8rem, 4vw, 2.6rem); }
+h3 { font-size: 1.3rem; }
+a { color: inherit; }
+p { margin: 0 0 1em; }
+
+/* === Utility === */
+.container { max-width: 1200px; margin-inline: auto; padding-inline: 20px; }
+.section { padding-block: 90px; }
+.section--alt { background: var(--c-bg-alt); }
+.section--dark { background: var(--c-dark); color: #cfc6bd; }
+.section--dark h2, .section--dark h3 { color: #fff; }
+.eyebrow { font-family: var(--font-heading); text-transform: uppercase; letter-spacing: .25em; font-size: .75rem; color: var(--c-accent-dark); }
+.section-heading { text-align: center; max-width: 640px; margin: 0 auto 56px; }
+.section-heading h2 { text-transform: uppercase; letter-spacing: .12em; }
+
+.btn { display: inline-flex; align-items: center; gap: 14px; font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; text-decoration: none; border: 0; cursor: pointer; padding: 16px 26px; background: var(--c-accent); color: #fff; transition: background .2s; }
+.btn:hover { background: var(--c-accent-dark); }
+.btn::after { content: '→'; font-size: .9rem; }
+.btn--dark { background: var(--c-dark); }
+.btn--dark:hover { background: var(--c-dark-2); }
+.btn--light { background: #fff; color: var(--c-heading); }
+
+/* === Form === */
+.field { width: 100%; border: 1px solid #ddd; background: #fff; padding: 14px 16px; font-family: var(--font-body); font-size: .95rem; color: var(--c-text); margin-bottom: 14px; }
+.field:focus { outline: 2px solid var(--c-accent); outline-offset: -1px; }
+textarea.field { min-height: 120px; resize: vertical; }
+.form-msg { font-size: .9rem; margin-top: 8px; }
+.form-msg--ok { color: #3c7a3c; }
+.form-msg--err { color: #b03030; }
+
+/* honeypot */
+.hp { position: absolute; left: -9999px; opacity: 0; }
+```
+
+- [ ] **Step 2b: aggiorna i valori con quelli campionati allo Step 1.**
+
+- [ ] **Step 3: commit**
+
+```bash
+git add src/styles/global.css && git commit -m "feat: design tokens e css globale"
+```
+
+---
+
+### Task 4: Lib di base — slugify e rate limit (TDD)
+
+**Files:**
+- Create: `src/lib/slug.ts`, `src/lib/rate-limit.ts`, `src/lib/env.ts`
+- Test: `tests/slug.test.ts`, `tests/rate-limit.test.ts`
+
+**Interfaces:**
+- Produces: `slugify(input: string): string`; `rateLimit(key: string, max: number, windowMs: number): boolean` (true = consentito); `getEnv(name: string, fallback?: string): string`.
+
+- [ ] **Step 1: test slugify (falliranno)**
+
+`tests/slug.test.ts`:
+```ts
+import { describe, it, expect } from 'vitest';
+import { slugify } from '../src/lib/slug';
+
+describe('slugify', () => {
+ it('minuscole e trattini', () => {
+ expect(slugify('Il Metodo InsanityLab')).toBe('il-metodo-insanitylab');
+ });
+ it('rimuove accenti', () => {
+ expect(slugify('Attività fisica è già qui')).toBe('attivita-fisica-e-gia-qui');
+ });
+ it('comprime simboli e spazi multipli', () => {
+ expect(slugify('Blog & Edugo -- news!!')).toBe('blog-edugo-news');
+ });
+ it('niente trattini ai bordi', () => {
+ expect(slugify(' ciao ')).toBe('ciao');
+ });
+ it('stringa vuota resta vuota', () => {
+ expect(slugify('')).toBe('');
+ });
+});
+```
+
+- [ ] **Step 2: verifica che falliscano**
+
+Run: `npx vitest run tests/slug.test.ts`
+Expected: FAIL (`slugify` non esiste).
+
+- [ ] **Step 3: implementa slugify**
+
+`src/lib/slug.ts`:
+```ts
+export function slugify(input: string): string {
+ return input
+ .toLowerCase()
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+}
+```
+
+- [ ] **Step 4: test rate limit (falliranno)**
+
+`tests/rate-limit.test.ts`:
+```ts
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { rateLimit, _resetBuckets } from '../src/lib/rate-limit';
+
+afterEach(() => { _resetBuckets(); vi.useRealTimers(); });
+
+describe('rateLimit', () => {
+ it('consente fino a max richieste', () => {
+ expect(rateLimit('a', 2, 1000)).toBe(true);
+ expect(rateLimit('a', 2, 1000)).toBe(true);
+ expect(rateLimit('a', 2, 1000)).toBe(false);
+ });
+ it('chiavi indipendenti', () => {
+ expect(rateLimit('a', 1, 1000)).toBe(true);
+ expect(rateLimit('b', 1, 1000)).toBe(true);
+ });
+ it('la finestra scade', () => {
+ vi.useFakeTimers();
+ expect(rateLimit('a', 1, 1000)).toBe(true);
+ expect(rateLimit('a', 1, 1000)).toBe(false);
+ vi.advanceTimersByTime(1100);
+ expect(rateLimit('a', 1, 1000)).toBe(true);
+ });
+});
+```
+
+- [ ] **Step 5: implementa rate-limit ed env**
+
+`src/lib/rate-limit.ts`:
+```ts
+const buckets = new Map<string, number[]>();
+
+export function rateLimit(key: string, max: number, windowMs: number): boolean {
+ const now = Date.now();
+ const recent = (buckets.get(key) ?? []).filter((t) => now - t < windowMs);
+ if (recent.length >= max) {
+ buckets.set(key, recent);
+ return false;
+ }
+ recent.push(now);
+ buckets.set(key, recent);
+ return true;
+}
+
+export function _resetBuckets(): void {
+ buckets.clear();
+}
+```
+
+`src/lib/env.ts`:
+```ts
+export function getEnv(name: string, fallback?: string): string {
+ const v = process.env[name] ?? (import.meta.env ? import.meta.env[name] : undefined) ?? fallback;
+ if (v === undefined) throw new Error(`Variabile d'ambiente mancante: ${name}`);
+ return v;
+}
+```
+
+- [ ] **Step 6: verifica test verdi**
+
+Run: `npm test`
+Expected: tutti PASS.
+
+- [ ] **Step 7: commit**
+
+```bash
+git add src/lib tests && git commit -m "feat: slugify, rate limit ed env helper con test"
+```
+
+---
+
+### Task 5: Layer database — schema e repository posts (TDD)
+
+**Files:**
+- Create: `src/lib/db.ts`, `src/lib/posts.ts`
+- Test: `tests/posts.test.ts`
+
+**Interfaces:**
+- Consumes: `slugify` da Task 4 (nei consumer, non qui).
+- Produces:
+ - `createDb(path?: string): Database.Database` — apre/crea DB, applica schema idempotente.
+ - `getDb(): Database.Database` — singleton su `DB_PATH`.
+ - Tipo `Post { id: number; title: string; slug: string; category: string; excerpt: string; body_html: string; cover: string | null; draft: number; published_at: string | null; created_at: string; updated_at: string }`.
+ - `PostInput { title: string; slug: string; category: string; excerpt: string; body_html: string; cover?: string | null; draft: boolean }`.
+ - `createPost(db, input: PostInput): number` (ritorna id; se `draft=false` imposta `published_at`).
+ - `updatePost(db, id: number, input: PostInput): void` (imposta `published_at` alla prima pubblicazione, aggiorna `updated_at`).
+ - `deletePost(db, id: number): void`
+ - `getPostById(db, id: number): Post | undefined`
+ - `getPublishedBySlug(db, slug: string): Post | undefined`
+ - `listPublished(db, opts?: { category?: string; page?: number; perPage?: number }): { items: Post[]; total: number }` (ordinati per `published_at` desc)
+ - `listAllPosts(db): Post[]` (anche bozze, per admin)
+ - `listRecent(db, limit?: number): Post[]`
+ - `listArchiveMonths(db): { month: string; count: number }[]` (formato `YYYY-MM`)
+
+- [ ] **Step 1: implementa db.ts**
+
+`src/lib/db.ts`:
+```ts
+import Database from 'better-sqlite3';
+import { mkdirSync } from 'node:fs';
+import { dirname } from 'node:path';
+
+const SCHEMA = `
+CREATE TABLE IF NOT EXISTS posts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL,
+ slug TEXT NOT NULL UNIQUE,
+ category TEXT NOT NULL,
+ excerpt TEXT NOT NULL DEFAULT '',
+ body_html TEXT NOT NULL DEFAULT '',
+ cover TEXT,
+ draft INTEGER NOT NULL DEFAULT 1,
+ published_at TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS sessions (
+ token TEXT PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ expires_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_posts_pub ON posts (draft, published_at DESC);
+`;
+
+export function createDb(path?: string): Database.Database {
+ const p = path ?? process.env.DB_PATH ?? 'data/insanitylab.db';
+ if (p !== ':memory:') mkdirSync(dirname(p), { recursive: true });
+ const db = new Database(p);
+ db.pragma('journal_mode = WAL');
+ db.pragma('foreign_keys = ON');
+ db.exec(SCHEMA);
+ return db;
+}
+
+let singleton: Database.Database | null = null;
+
+export function getDb(): Database.Database {
+ if (!singleton) singleton = createDb();
+ return singleton;
+}
+```
+
+- [ ] **Step 2: test posts (falliranno)**
+
+`tests/posts.test.ts`:
+```ts
+import { describe, it, expect, beforeEach } from 'vitest';
+import type Database from 'better-sqlite3';
+import { createDb } from '../src/lib/db';
+import {
+ createPost, updatePost, deletePost, getPostById, getPublishedBySlug,
+ listPublished, listAllPosts, listRecent, listArchiveMonths, type PostInput,
+} from '../src/lib/posts';
+
+let db: Database.Database;
+beforeEach(() => { db = createDb(':memory:'); });
+
+const base: PostInput = {
+ title: 'Titolo', slug: 'titolo', category: 'Contenuti educativi',
+ excerpt: 'estratto', body_html: '<p>corpo</p>', cover: null, draft: false,
+};
+
+describe('posts repository', () => {
+ it('crea e rilegge un post pubblicato', () => {
+ const id = createPost(db, base);
+ const post = getPostById(db, id)!;
+ expect(post.title).toBe('Titolo');
+ expect(post.draft).toBe(0);
+ expect(post.published_at).not.toBeNull();
+ });
+
+ it('bozza non ha published_at e non appare nelle liste pubbliche', () => {
+ const id = createPost(db, { ...base, slug: 'bozza', draft: true });
+ expect(getPostById(db, id)!.published_at).toBeNull();
+ expect(getPublishedBySlug(db, 'bozza')).toBeUndefined();
+ expect(listPublished(db).total).toBe(0);
+ expect(listAllPosts(db)).toHaveLength(1);
+ });
+
+ it('slug duplicato lancia', () => {
+ createPost(db, base);
+ expect(() => createPost(db, base)).toThrow();
+ });
+
+ it('update imposta published_at alla prima pubblicazione e lo conserva', () => {
+ const id = createPost(db, { ...base, draft: true });
+ updatePost(db, id, { ...base, draft: false });
+ const first = getPostById(db, id)!.published_at;
+ expect(first).not.toBeNull();
+ updatePost(db, id, { ...base, title: 'Nuovo', draft: false });
+ expect(getPostById(db, id)!.published_at).toBe(first);
+ expect(getPostById(db, id)!.title).toBe('Nuovo');
+ });
+
+ it('listPublished filtra per categoria e pagina', () => {
+ for (let i = 0; i < 12; i++) {
+ createPost(db, { ...base, slug: `post-${i}`, category: i % 2 ? 'Contenuti educativi' : 'Eventi & Presidi' });
+ }
+ const all = listPublished(db, { page: 1, perPage: 9 });
+ expect(all.total).toBe(12);
+ expect(all.items).toHaveLength(9);
+ const edu = listPublished(db, { category: 'Contenuti educativi' });
+ expect(edu.total).toBe(6);
+ });
+
+ it('delete rimuove', () => {
+ const id = createPost(db, base);
+ deletePost(db, id);
+ expect(getPostById(db, id)).toBeUndefined();
+ });
+
+ it('listRecent e archivio mesi', () => {
+ createPost(db, base);
+ expect(listRecent(db, 4)).toHaveLength(1);
+ const months = listArchiveMonths(db);
+ expect(months).toHaveLength(1);
+ expect(months[0].count).toBe(1);
+ expect(months[0].month).toMatch(/^\d{4}-\d{2}$/);
+ });
+});
+```
+
+- [ ] **Step 3: verifica che falliscano**
+
+Run: `npx vitest run tests/posts.test.ts`
+Expected: FAIL (modulo `posts` mancante).
+
+- [ ] **Step 4: implementa posts.ts**
+
+`src/lib/posts.ts`:
+```ts
+import type Database from 'better-sqlite3';
+
+export interface Post {
+ id: number; title: string; slug: string; category: string; excerpt: string;
+ body_html: string; cover: string | null; draft: number;
+ published_at: string | null; created_at: string; updated_at: string;
+}
+
+export interface PostInput {
+ title: string; slug: string; category: string; excerpt: string;
+ body_html: string; cover?: string | null; draft: boolean;
+}
+
+export function createPost(db: Database.Database, input: PostInput): number {
+ const res = db.prepare(
+ `INSERT INTO posts (title, slug, category, excerpt, body_html, cover, draft, published_at)
+ VALUES (@title, @slug, @category, @excerpt, @body_html, @cover, @draft,
+ CASE WHEN @draft = 0 THEN datetime('now') ELSE NULL END)`
+ ).run({ ...input, cover: input.cover ?? null, draft: input.draft ? 1 : 0 });
+ return Number(res.lastInsertRowid);
+}
+
+export function updatePost(db: Database.Database, id: number, input: PostInput): void {
+ db.prepare(
+ `UPDATE posts SET title = @title, slug = @slug, category = @category,
+ excerpt = @excerpt, body_html = @body_html, cover = @cover, draft = @draft,
+ published_at = CASE WHEN @draft = 0 AND published_at IS NULL THEN datetime('now') ELSE published_at END,
+ updated_at = datetime('now')
+ WHERE id = @id`
+ ).run({ ...input, id, cover: input.cover ?? null, draft: input.draft ? 1 : 0 });
+}
+
+export function deletePost(db: Database.Database, id: number): void {
+ db.prepare('DELETE FROM posts WHERE id = ?').run(id);
+}
+
+export function getPostById(db: Database.Database, id: number): Post | undefined {
+ return db.prepare('SELECT * FROM posts WHERE id = ?').get(id) as Post | undefined;
+}
+
+export function getPublishedBySlug(db: Database.Database, slug: string): Post | undefined {
+ return db.prepare('SELECT * FROM posts WHERE slug = ? AND draft = 0').get(slug) as Post | undefined;
+}
+
+export function listPublished(
+ db: Database.Database,
+ opts: { category?: string; page?: number; perPage?: number } = {},
+): { items: Post[]; total: number } {
+ const { category, page = 1, perPage = 9 } = opts;
+ const where = category ? 'WHERE draft = 0 AND category = @category' : 'WHERE draft = 0';
+ const total = (db.prepare(`SELECT COUNT(*) AS n FROM posts ${where}`)
+ .get({ category }) as { n: number }).n;
+ const items = db.prepare(
+ `SELECT * FROM posts ${where} ORDER BY published_at DESC LIMIT @limit OFFSET @offset`
+ ).all({ category, limit: perPage, offset: (page - 1) * perPage }) as Post[];
+ return { items, total };
+}
+
+export function listAllPosts(db: Database.Database): Post[] {
+ return db.prepare('SELECT * FROM posts ORDER BY updated_at DESC').all() as Post[];
+}
+
+export function listRecent(db: Database.Database, limit = 4): Post[] {
+ return db.prepare(
+ 'SELECT * FROM posts WHERE draft = 0 ORDER BY published_at DESC LIMIT ?'
+ ).all(limit) as Post[];
+}
+
+export function listArchiveMonths(db: Database.Database): { month: string; count: number }[] {
+ return db.prepare(
+ `SELECT strftime('%Y-%m', published_at) AS month, COUNT(*) AS count
+ FROM posts WHERE draft = 0 GROUP BY month ORDER BY month DESC`
+ ).all() as { month: string; count: number }[];
+}
+```
+
+- [ ] **Step 5: verifica test verdi**
+
+Run: `npm test`
+Expected: tutti PASS.
+
+- [ ] **Step 6: commit**
+
+```bash
+git add src/lib/db.ts src/lib/posts.ts tests/posts.test.ts && git commit -m "feat: layer sqlite e repository posts con test"
+```
+
+---
+
+### Task 6: Autenticazione, middleware e script create-user (TDD)
+
+**Files:**
+- Create: `src/lib/auth.ts`, `src/middleware.ts`, `scripts/create-user.mjs`
+- Test: `tests/auth.test.ts`
+
+**Interfaces:**
+- Consumes: `createDb`/`getDb` (Task 5), `rateLimit` (Task 4).
+- Produces:
+ - `hashPassword(plain: string): string`, `verifyPassword(plain: string, hash: string): boolean`
+ - `createUser(db, username: string, password: string): number`
+ - `login(db, username: string, password: string): string | null` — token sessione o null.
+ - `getSessionUser(db, token: string): { id: number; username: string } | null` — null se scaduta (e la elimina).
+ - `logout(db, token: string): void`
+ - Costante `SESSION_COOKIE = 'session'`, durata 7 giorni.
+ - Middleware: protegge `/admin*` (tranne `/admin/login`) e `/api/admin*`; popola `Astro.locals.user`.
+
+- [ ] **Step 1: test auth (falliranno)**
+
+`tests/auth.test.ts`:
+```ts
+import { describe, it, expect, beforeEach } from 'vitest';
+import type Database from 'better-sqlite3';
+import { createDb } from '../src/lib/db';
+import { hashPassword, verifyPassword, createUser, login, getSessionUser, logout } from '../src/lib/auth';
+
+let db: Database.Database;
+beforeEach(() => { db = createDb(':memory:'); });
+
+describe('auth', () => {
+ it('hash e verifica password', () => {
+ const h = hashPassword('segreta123');
+ expect(h).not.toBe('segreta123');
+ expect(verifyPassword('segreta123', h)).toBe(true);
+ expect(verifyPassword('sbagliata', h)).toBe(false);
+ });
+
+ it('login corretto crea sessione recuperabile', () => {
+ createUser(db, 'adriano', 'segreta123');
+ const token = login(db, 'adriano', 'segreta123');
+ expect(token).toBeTruthy();
+ const user = getSessionUser(db, token!);
+ expect(user).toMatchObject({ username: 'adriano' });
+ });
+
+ it('login errato ritorna null', () => {
+ createUser(db, 'adriano', 'segreta123');
+ expect(login(db, 'adriano', 'sbagliata')).toBeNull();
+ expect(login(db, 'inesistente', 'x')).toBeNull();
+ });
+
+ it('sessione scaduta viene rifiutata ed eliminata', () => {
+ createUser(db, 'adriano', 'segreta123');
+ const token = login(db, 'adriano', 'segreta123')!;
+ db.prepare("UPDATE sessions SET expires_at = datetime('now', '-1 day')").run();
+ expect(getSessionUser(db, token)).toBeNull();
+ expect(db.prepare('SELECT COUNT(*) AS n FROM sessions').get()).toMatchObject({ n: 0 });
+ });
+
+ it('logout elimina la sessione', () => {
+ createUser(db, 'adriano', 'segreta123');
+ const token = login(db, 'adriano', 'segreta123')!;
+ logout(db, token);
+ expect(getSessionUser(db, token)).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 2: verifica che falliscano**
+
+Run: `npx vitest run tests/auth.test.ts`
+Expected: FAIL (modulo `auth` mancante).
+
+- [ ] **Step 3: implementa auth.ts**
+
+`src/lib/auth.ts`:
+```ts
+import type Database from 'better-sqlite3';
+import bcrypt from 'bcryptjs';
+import { randomBytes } from 'node:crypto';
+
+export const SESSION_COOKIE = 'session';
+const SESSION_DAYS = 7;
+
+export function hashPassword(plain: string): string {
+ return bcrypt.hashSync(plain, 12);
+}
+
+export function verifyPassword(plain: string, hash: string): boolean {
+ return bcrypt.compareSync(plain, hash);
+}
+
+export function createUser(db: Database.Database, username: string, password: string): number {
+ const res = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)')
+ .run(username, hashPassword(password));
+ return Number(res.lastInsertRowid);
+}
+
+export function login(db: Database.Database, username: string, password: string): string | null {
+ const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as
+ | { id: number; password_hash: string } | undefined;
+ if (!user || !verifyPassword(password, user.password_hash)) return null;
+ const token = randomBytes(32).toString('hex');
+ db.prepare(
+ `INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, datetime('now', '+${SESSION_DAYS} days'))`
+ ).run(token, user.id);
+ return token;
+}
+
+export function getSessionUser(db: Database.Database, token: string): { id: number; username: string } | null {
+ const row = db.prepare(
+ `SELECT s.token, s.expires_at, u.id, u.username
+ FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ?`
+ ).get(token) as { token: string; expires_at: string; id: number; username: string } | undefined;
+ if (!row) return null;
+ if (row.expires_at <= new Date().toISOString().slice(0, 19).replace('T', ' ')) {
+ db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
+ return null;
+ }
+ return { id: row.id, username: row.username };
+}
+
+export function logout(db: Database.Database, token: string): void {
+ db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
+}
+```
+
+- [ ] **Step 4: verifica test verdi**
+
+Run: `npm test`
+Expected: tutti PASS.
+
+- [ ] **Step 5: middleware**
+
+`src/middleware.ts`:
+```ts
+import { defineMiddleware } from 'astro:middleware';
+import { getDb } from './lib/db';
+import { getSessionUser, SESSION_COOKIE } from './lib/auth';
+
+export const onRequest = defineMiddleware((context, next) => {
+ const { pathname } = context.url;
+ const isProtected =
+ (pathname.startsWith('/admin') && pathname !== '/admin/login') ||
+ pathname.startsWith('/api/admin');
+ if (!isProtected) return next();
+
+ const token = context.cookies.get(SESSION_COOKIE)?.value;
+ const user = token ? getSessionUser(getDb(), token) : null;
+ if (!user) {
+ if (pathname.startsWith('/api/')) {
+ return new Response(JSON.stringify({ error: 'Non autorizzato' }), {
+ status: 401, headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ return context.redirect('/admin/login');
+ }
+ context.locals.user = user;
+ return next();
+});
+```
+
+- [ ] **Step 6: script create-user**
+
+`scripts/create-user.mjs`:
+```js
+import Database from 'better-sqlite3';
+import bcrypt from 'bcryptjs';
+import { mkdirSync } from 'node:fs';
+import { dirname } from 'node:path';
+
+const [, , username, password] = process.argv;
+if (!username || !password) {
+ console.error('Uso: npm run create-user -- <username> <password>');
+ process.exit(1);
+}
+const path = process.env.DB_PATH ?? 'data/insanitylab.db';
+mkdirSync(dirname(path), { recursive: true });
+const db = new Database(path);
+db.exec(`CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL
+);`);
+db.prepare(
+ `INSERT INTO users (username, password_hash) VALUES (?, ?)
+ ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash`
+).run(username, bcrypt.hashSync(password, 12));
+console.log(`Utente "${username}" creato/aggiornato in ${path}`);
+```
+
+Run: `npm run create-user -- admin prova1234 && sqlite3 data/insanitylab.db 'SELECT username FROM users;'`
+Expected: `admin`.
+
+- [ ] **Step 7: commit**
+
+```bash
+git add src/lib/auth.ts src/middleware.ts scripts/create-user.mjs tests/auth.test.ts
+git commit -m "feat: autenticazione a sessioni, middleware admin e script create-user"
+```
+
+---
+
+### Task 7: Layout base, Header, Footer, 404
+
+**Files:**
+- Create: `src/layouts/Base.astro`, `src/components/Header.astro`, `src/components/Footer.astro`, `src/components/ContactForm.astro`, `src/pages/404.astro`
+- Modify: `src/pages/index.astro` (usa Base)
+- Depends: `src/data/site.ts` (Task 8 — eseguire Task 8 prima o creare qui il file e ampliarlo lì; ordine consigliato: Task 8 poi Task 7 se si preferisce, sono indipendenti dagli altri)
+
+**Interfaces:**
+- Consumes: `site` da `src/data/site.ts` (Task 8): `{ name, phone, email, address, cityLine, hours: string[], socials: { label, url }[], navigation: { label, href }[] }`.
+- Produces: `<Base title description>` layout con slot; `<ContactForm variant="full" | "footer" />` che POSTa a `/api/contact` via fetch.
+
+- [ ] **Step 1: Base.astro**
+
+```astro
+---
+import '@fontsource/montserrat/400.css';
+import '@fontsource/montserrat/500.css';
+import '@fontsource/montserrat/600.css';
+import '@fontsource/montserrat/700.css';
+import '@fontsource/open-sans/400.css';
+import '@fontsource/open-sans/600.css';
+import '../styles/global.css';
+import Header from '../components/Header.astro';
+import Footer from '../components/Footer.astro';
+
+interface Props { title: string; description?: string }
+const { title, description = 'InsanityLab — Performance. Balance. Longevity. Allenamento personalizzato, piccoli gruppi e benessere a Bologna.' } = Astro.props;
+---
+<!doctype html>
+<html lang="it">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>{title} — InsanityLab</title>
+ <meta name="description" content={description} />
+ <meta property="og:title" content={`${title} — InsanityLab`} />
+ <meta property="og:description" content={description} />
+ <meta property="og:type" content="website" />
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
+ <link rel="sitemap" href="/sitemap-index.xml" />
+ </head>
+ <body>
+ <Header />
+ <main><slot /></main>
+ <Footer />
+ </body>
+</html>
+```
+
+- [ ] **Step 2: Header.astro**
+
+Nav da `site.navigation`: Insanitylab→`/about`, Training→`/training`, Programmi→`/programs`, Blog & Edugo→`/blog`, Contatti→`/contact`. Logo `/img/logo-dark.png` a sinistra, link `/`.
+
+```astro
+---
+import { site } from '../data/site';
+const path = Astro.url.pathname;
+---
+<header class="hdr" id="site-header">
+ <div class="container hdr__in">
+ <a href="/" class="hdr__logo"><img src="/img/logo-dark.png" alt="InsanityLab" width="150" /></a>
+ <nav class="hdr__nav" id="site-nav" aria-label="principale">
+ {site.navigation.map((item) => (
+ <a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{item.label}</a>
+ ))}
+ </nav>
+ <button class="hdr__burger" id="nav-toggle" aria-label="Apri menu" aria-expanded="false">
+ <span></span><span></span><span></span>
+ </button>
+ </div>
+</header>
+
+<style>
+ .hdr { position: sticky; top: 0; z-index: 100; background: #fff; transition: box-shadow .2s; }
+ .hdr.is-scrolled { box-shadow: 0 2px 14px rgba(0,0,0,.08); }
+ .hdr__in { display: flex; align-items: center; justify-content: space-between; height: var(--header-h); }
+ .hdr__nav { display: flex; gap: 34px; }
+ .hdr__link { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
+ .hdr__link:hover, .hdr__link.is-active { color: var(--c-accent-dark); }
+ .hdr__burger { display: none; background: none; border: 0; cursor: pointer; padding: 8px; }
+ .hdr__burger span { display: block; width: 22px; height: 2px; background: var(--c-heading); margin: 5px 0; }
+ @media (max-width: 991px) {
+ .hdr__nav { display: none; position: absolute; top: var(--header-h); left: 0; right: 0; background: #fff; flex-direction: column; gap: 0; padding: 10px 20px 20px; box-shadow: 0 10px 20px rgba(0,0,0,.08); }
+ .hdr__nav.is-open { display: flex; }
+ .hdr__link { padding: 12px 0; }
+ .hdr__burger { display: block; }
+ }
+</style>
+
+<script>
+ const header = document.getElementById('site-header')!;
+ const toggle = document.getElementById('nav-toggle')!;
+ const nav = document.getElementById('site-nav')!;
+ addEventListener('scroll', () => header.classList.toggle('is-scrolled', scrollY > 10), { passive: true });
+ toggle.addEventListener('click', () => {
+ const open = nav.classList.toggle('is-open');
+ toggle.setAttribute('aria-expanded', String(open));
+ });
+</script>
+```
+
+- [ ] **Step 3: ContactForm.astro**
+
+```astro
+---
+interface Props { variant?: 'full' | 'footer' }
+const { variant = 'full' } = Astro.props;
+---
+<form class:list={['cform', `cform--${variant}`]} method="post" action="/api/contact" novalidate>
+ <input class="field" name="firstName" placeholder="Nome" required maxlength="100" />
+ {variant === 'full' && <input class="field" name="lastName" placeholder="Cognome" maxlength="100" />}
+ {variant === 'full' && <input class="field" name="phone" placeholder="Telefono" maxlength="40" />}
+ <input class="field" type="email" name="email" placeholder="E-mail" required maxlength="200" />
+ <textarea class="field" name="message" placeholder="Messaggio" required maxlength="5000"></textarea>
+ <input class="hp" type="text" name="website" tabindex="-1" autocomplete="off" />
+ <button class="btn" type="submit">Invia messaggio</button>
+ <p class="form-msg" hidden></p>
+</form>
+
+<script>
+ document.querySelectorAll<HTMLFormElement>('.cform').forEach((form) => {
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const msg = form.querySelector<HTMLParagraphElement>('.form-msg')!;
+ const btn = form.querySelector<HTMLButtonElement>('button')!;
+ btn.disabled = true;
+ msg.hidden = true;
+ try {
+ const res = await fetch('/api/contact', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(Object.fromEntries(new FormData(form))),
+ });
+ const data = await res.json();
+ msg.textContent = res.ok ? 'Messaggio inviato, ti ricontatteremo al più presto.' : (data.error ?? 'Errore di invio, riprova più tardi.');
+ msg.className = `form-msg ${res.ok ? 'form-msg--ok' : 'form-msg--err'}`;
+ if (res.ok) form.reset();
+ } catch {
+ msg.textContent = 'Errore di rete, riprova più tardi.';
+ msg.className = 'form-msg form-msg--err';
+ }
+ msg.hidden = false;
+ btn.disabled = false;
+ });
+ });
+</script>
+```
+
+- [ ] **Step 4: Footer.astro**
+
+Quattro colonne come da Figma (Homepage.pdf, fondo `--c-dark`): brand (logo light, tagline "PERFORMANCE. BALANCE. LONGEVITY.", testo breve, telefono, indirizzo, orari), colonna TRAINING (One to one, Small groups, Performance class, Fit remote → `/training`), colonna PROGRAMMI (le 6 voci → `/programs/<slug>`), colonna CONTACT con `<ContactForm variant="footer" />`. Barra finale: `© {new Date().getFullYear()} Insanitylab, All Rights Reserved` + "Seguici" con link social da `site.socials`.
+
+```astro
+---
+import { site } from '../data/site';
+import ContactForm from './ContactForm.astro';
+const year = new Date().getFullYear();
+---
+<footer class="ftr">
+ <div class="container ftr__grid">
+ <div>
+ <img src="/img/logo-light.png" alt="InsanityLab" width="140" loading="lazy" />
+ <p class="ftr__tag">PERFORMANCE.&nbsp; BALANCE.&nbsp; LONGEVITY.</p>
+ <p>Promuoviamo il movimento come cura di sé. Abitudini sostenibili per benessere fisico e mentale.</p>
+ <p>{site.phone}<br />{site.address}, {site.cityLine}</p>
+ {site.hours.map((h) => <p class="ftr__hours">{h}</p>)}
+ </div>
+ <nav aria-label="training">
+ <h4>Training</h4>
+ {site.footerTraining.map((l) => <a href={l.href}>{l.label}</a>)}
+ </nav>
+ <nav aria-label="programmi">
+ <h4>Programmi</h4>
+ {site.footerPrograms.map((l) => <a href={l.href}>{l.label}</a>)}
+ </nav>
+ <div>
+ <h4>Contact</h4>
+ <ContactForm variant="footer" />
+ </div>
+ </div>
+ <div class="ftr__bar">
+ <div class="container ftr__bar-in">
+ <p>© {year} <strong>Insanitylab</strong>, All Rights Reserved</p>
+ <p>Seguici {site.socials.map((s) => <a href={s.url} rel="noopener" target="_blank">{s.label}</a>)}</p>
+ </div>
+ </div>
+</footer>
+
+<style>
+ .ftr { background: var(--c-dark); color: #b9aea4; font-size: .88rem; }
+ .ftr a { color: #b9aea4; text-decoration: none; display: block; padding: 4px 0; }
+ .ftr a:hover { color: #fff; }
+ .ftr h4 { color: #fff; text-transform: uppercase; letter-spacing: .18em; font-size: .78rem; margin-bottom: 18px; }
+ .ftr__grid { display: grid; grid-template-columns: 1.3fr 1fr 1fr 1.3fr; gap: 40px; padding-block: 70px; }
+ .ftr__tag { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .2em; color: #fff; }
+ .ftr__hours { margin: 0; }
+ .ftr__bar { border-top: 1px solid rgba(255,255,255,.08); padding-block: 18px; }
+ .ftr__bar-in { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
+ .ftr__bar-in a { display: inline; margin-left: 12px; }
+ @media (max-width: 991px) { .ftr__grid { grid-template-columns: 1fr 1fr; } }
+ @media (max-width: 600px) { .ftr__grid { grid-template-columns: 1fr; } }
+</style>
+```
+
+- [ ] **Step 5: 404.astro**
+
+```astro
+---
+import Base from '../layouts/Base.astro';
+export const prerender = true;
+---
+<Base title="Pagina non trovata">
+ <section class="section" style="text-align:center; padding-block:140px;">
+ <div class="container">
+ <p class="eyebrow">Errore 404</p>
+ <h1>Pagina non trovata</h1>
+ <p>La pagina che cerchi non esiste o è stata spostata.</p>
+ <a class="btn" href="/">Torna alla home</a>
+ </div>
+ </section>
+</Base>
+```
+
+- [ ] **Step 6: aggiorna index placeholder per usare Base**
+
+```astro
+---
+import Base from '../layouts/Base.astro';
+export const prerender = true;
+---
+<Base title="Performance. Balance. Longevity.">
+ <section class="section"><div class="container"><h1>Homepage in costruzione</h1></div></section>
+</Base>
+```
+
+- [ ] **Step 7: verifica visiva e build**
+
+Run: `npm run dev` e apri `http://localhost:4321/` — header sticky con nav, footer scuro 4 colonne, pagina 404 su URL inesistente. Poi `npm run build`.
+Expected: nessun errore; nav mobile funzionante sotto 992px.
+
+- [ ] **Step 8: commit**
+
+```bash
+git add -A && git commit -m "feat: layout base con header, footer e 404"
+```
+
+---
+
+### Task 8: Dati statici dei contenuti
+
+**Files:**
+- Create: `src/data/site.ts`, `src/data/programs.ts`, `src/data/trainings.ts`, `src/data/agenda.ts`, `src/data/team.ts`, `src/data/method.ts`, `src/data/pricing.ts`, `src/data/partners.ts`
+
+**Interfaces:**
+- Produces: export nominati usati dalle pagine. Testi reali dai PDF Figma (riportati sotto). Nessun lorem ipsum salvo dove il Figma stesso è placeholder (agenda, che riusa i nomi del template).
+
+- [ ] **Step 1: site.ts**
+
+```ts
+export const site = {
+ name: 'InsanityLab',
+ phone: '351 7535977',
+ email: 'info@insanitylab.it',
+ address: 'Via Leandro Alberti, 76',
+ cityLine: '40139 Bologna',
+ hours: ['Lun – Ven 6.00 – 21.00', 'Sab 8.00 – 19.00 · Dom chiuso'],
+ socials: [
+ { label: 'Instagram', url: 'https://www.instagram.com/insanitylab' },
+ { label: 'Facebook', url: 'https://www.facebook.com/insanitylab' },
+ { label: 'LinkedIn', url: 'https://www.linkedin.com/company/insanitylab' },
+ ],
+ navigation: [
+ { label: 'Insanitylab', href: '/about' },
+ { label: 'Training', href: '/training' },
+ { label: 'Programmi', href: '/programs' },
+ { label: 'Blog & Edugo', href: '/blog' },
+ { label: 'Contatti', href: '/contact' },
+ ],
+ footerTraining: [
+ { label: 'One to one', href: '/training#one-to-one' },
+ { label: 'Small groups', href: '/training#small-groups' },
+ { label: 'Performance class', href: '/training#performance-class' },
+ { label: 'Fit remote', href: '/training#fit-remote' },
+ ],
+ footerPrograms: [
+ { label: 'Personal Training', href: '/programs/personal-training' },
+ { label: 'Performance Class', href: '/programs/performance-class' },
+ { label: 'Coaching', href: '/programs/coaching' },
+ { label: 'Rehab', href: '/programs/rehab' },
+ { label: 'Pilates Flow', href: '/programs/pilates-flow' },
+ { label: 'Nutrizione', href: '/programs/nutrition' },
+ ],
+};
+```
+
+- [ ] **Step 2: programs.ts**
+
+Excerpt = testi reali di Programmi.pdf. `longDescription` e `faq`: per `performance-class` dai testi di Performance class.pdf; per gli altri derivati dall'excerpt e da Training.pdf (il cliente li integrerà — testo comunque sensato, niente lorem).
+
+```ts
+export interface Program {
+ slug: string;
+ title: string;
+ subtitle: string;
+ excerpt: string;
+ longDescription: string;
+ features: string[];
+ faq: { q: string; a: string }[];
+ hasPricing: boolean;
+}
+
+export const programs: Program[] = [
+ {
+ slug: 'personal-training',
+ title: 'Personal Training',
+ subtitle: 'One to One / Small Group',
+ excerpt: 'Il personal training InsanityLab è un percorso one-to-one su misura, seguito da un professionista dedicato, per raggiungere in modo sicuro ed efficace obiettivi di forma fisica, performance e benessere.',
+ longDescription: 'Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati, con supporto costante, attenzione totale e allenamenti personalizzati. Disponibile anche in formula small group (massimo 3 persone) per unire attenzione personalizzata, motivazione e condivisione.',
+ features: ['Trainer dedicato', 'Percorso su misura', 'Valutazione iniziale e monitoraggio', 'Formula one to one o small group (max 3)'],
+ faq: [
+ { q: 'Come si svolge la prima seduta?', a: 'Con una consulenza conoscitiva iniziale: raccogliamo l’anamnesi, definiamo gli obiettivi e impostiamo il percorso più adatto.' },
+ { q: 'Con quale frequenza ci si allena?', a: 'La frequenza viene definita insieme al trainer in base a obiettivi, disponibilità e livello di partenza.' },
+ { q: 'È adatto a chi parte da zero?', a: 'Sì: il percorso è costruito sul tuo livello, con progressioni graduali e supervisione costante.' },
+ ],
+ hasPricing: false,
+ },
+ {
+ slug: 'performance-class',
+ title: 'Performance Class',
+ subtitle: 'Gruppo max 8 persone',
+ excerpt: 'La Performance Class è un allenamento in piccoli gruppi, basato su una programmazione progressiva che combina forza e condizionamento per ottenere risultati concreti e costanti nel tempo.',
+ longDescription: 'La Performance Class è un allenamento in piccoli gruppi con programmazione progressiva e scientifica, pensato per migliorare forza, composizione corporea e performance attraverso sessioni strutturate che combinano forza, ipertrofia e condizionamento metabolico. Non si improvvisa: ogni sessione ha un posto preciso all’interno di un piano più ampio.',
+ features: ['Allenamento ibrido', 'Forza, ipertrofia, condizionamento metabolico e lavoro funzionale', 'Sessioni da 50 minuti', 'Fasi di bassa, media e alta intensità', 'Programmazione mensile'],
+ faq: [
+ { q: 'Che cosa si intende per Performance Class?', a: 'Un allenamento in piccoli gruppi (massimo 8 persone) guidato da trainer certificati, con programmazione progressiva mensile: ogni sessione ha un posto preciso all’interno di un piano più ampio.' },
+ { q: 'Che tipo di allenamento viene svolto?', a: 'Un allenamento ibrido che alterna forza, ipertrofia, condizionamento metabolico e lavoro funzionale. Le sessioni durano 50 minuti e prevedono fasi di bassa, media e alta intensità, calibrate sulla programmazione mensile.' },
+ { q: 'A chi è consigliato questo servizio?', a: 'A chi vuole allenarsi con qualità e continuità in un contesto di gruppo motivante, mantenendo una programmazione seria e misurabile.' },
+ { q: 'Qual è la differenza rispetto a una class tradizionale?', a: 'La programmazione: non è una lezione isolata ma parte di un piano mensile progressivo, con carichi e intensità calibrati.' },
+ { q: 'Che tipo di risultati o miglioramenti si ottengono?', a: 'Miglioramento di forza, composizione corporea e performance, con risultati concreti e costanti nel tempo.' },
+ { q: 'Qual è l’intensità del lavoro?', a: 'Alternata: fasi di bassa, media e alta intensità all’interno della stessa sessione, adattate al livello dei partecipanti.' },
+ ],
+ hasPricing: true,
+ },
+ {
+ slug: 'coaching',
+ title: 'Coaching',
+ subtitle: 'Programma di allenamento e monitoraggio',
+ excerpt: 'Il coaching InsanityLab è un percorso personalizzato che, con programma su misura e supporto costante, guida la persona verso i propri obiettivi sviluppando autonomia e continuità nell’allenamento.',
+ longDescription: 'Con il coaching hai un programma di allenamento su misura e un monitoraggio costante, ovunque ti alleni: in palestra, a casa o in viaggio. Il percorso sviluppa autonomia e continuità, adattandosi al tuo stile di vita (formula Fit Remote).',
+ features: ['Programma su misura', 'Supporto e monitoraggio costanti', 'Ovunque ti alleni (Fit Remote)', 'Sviluppo di autonomia'],
+ faq: [
+ { q: 'Come funziona il monitoraggio?', a: 'Il trainer segue i tuoi progressi con check periodici e adatta il programma in base ai risultati.' },
+ { q: 'Serve attrezzatura?', a: 'Il programma viene costruito sull’attrezzatura che hai a disposizione, in palestra o a casa.' },
+ { q: 'È adatto se viaggio spesso?', a: 'Sì: la formula Fit Remote è pensata per chi si allena in luoghi diversi.' },
+ ],
+ hasPricing: false,
+ },
+ {
+ slug: 'pilates-flow',
+ title: 'Pilates Flow',
+ subtitle: 'Movimento e controllo',
+ excerpt: 'Il Pilates Flow migliora postura, core e consapevolezza corporea con un movimento fluido, mentre l’osteopatia supporta recupero, prevenzione e benessere globale integrandosi con l’allenamento.',
+ longDescription: 'Il Pilates Flow unisce movimento fluido e controllo: migliora postura, core e consapevolezza corporea. Integrato con l’osteopatia, supporta recupero, prevenzione e benessere globale all’interno del percorso di allenamento.',
+ features: ['Postura e core', 'Movimento fluido e controllo', 'Integrazione con osteopatia', 'Benessere globale'],
+ faq: [
+ { q: 'A chi è adatto il Pilates Flow?', a: 'A chiunque voglia migliorare postura, mobilità e controllo del movimento, a ogni livello.' },
+ { q: 'Serve esperienza precedente?', a: 'No: le progressioni sono graduali e seguite da professionisti qualificati.' },
+ { q: 'Come si integra con l’osteopatia?', a: 'Il lavoro osteopatico supporta recupero e prevenzione, in sinergia con le sessioni di movimento.' },
+ ],
+ hasPricing: false,
+ },
+ {
+ slug: 'rehab',
+ title: 'Rehab',
+ subtitle: 'Recupero post-infortunio, patologie muscolo-scheletriche',
+ excerpt: 'Il Rehab InsanityLab è un percorso supervisionato che accompagna dal recupero riabilitativo al ritorno all’attività fisica in modo sicuro, progressivo e personalizzato.',
+ longDescription: 'Il Rehab accompagna dal recupero post-infortunio o da patologie muscolo-scheletriche fino al ritorno all’attività fisica. Percorso supervisionato da chinesiologi e osteopati, sicuro, progressivo e personalizzato.',
+ features: ['Recupero post-infortunio', 'Patologie muscolo-scheletriche', 'Supervisione di chinesiologi e osteopati', 'Progressione sicura e personalizzata'],
+ faq: [
+ { q: 'Chi segue il percorso Rehab?', a: 'Professionisti specializzati in rieducazione funzionale ed esercizio adattato, in sinergia con l’area osteopatica.' },
+ { q: 'Serve una diagnosi medica?', a: 'Se disponibile è utile portarla alla prima consulenza; il percorso si integra con le indicazioni dei sanitari.' },
+ { q: 'Quanto dura il percorso?', a: 'Dipende dalla condizione di partenza: la progressione viene definita e aggiornata durante il percorso.' },
+ ],
+ hasPricing: false,
+ },
+ {
+ slug: 'nutrition',
+ title: 'Nutrizione',
+ subtitle: 'Alimentazione e stile di vita',
+ excerpt: 'La nutrizione in InsanityLab è un servizio personalizzato che integra alimentazione e allenamento per migliorare energia, performance e benessere con piani su misura e sostenibili.',
+ longDescription: 'Il servizio di nutrizione integra alimentazione e allenamento: piani su misura e sostenibili per migliorare energia, performance e benessere, costruiti sul tuo stile di vita.',
+ features: ['Piani alimentari su misura', 'Integrazione con l’allenamento', 'Sostenibilità nel tempo', 'Focus su energia e performance'],
+ faq: [
+ { q: 'Come inizia il percorso di nutrizione?', a: 'Con una valutazione iniziale di abitudini, obiettivi e allenamento, da cui nasce il piano personalizzato.' },
+ { q: 'È integrato con il training?', a: 'Sì: alimentazione e allenamento vengono programmati in modo coerente.' },
+ { q: 'Sono previsti controlli periodici?', a: 'Sì, con aggiornamento del piano in base ai risultati.' },
+ ],
+ hasPricing: false,
+ },
+];
+
+export function getProgram(slug: string): Program | undefined {
+ return programs.find((p) => p.slug === slug);
+}
+```
+
+- [ ] **Step 3: trainings.ts (4 card Training)**
+
+```ts
+export const trainings = [
+ { id: 'one-to-one', title: 'One to One', caption: 'Allenamento con un trainer dedicato', image: 'training-one-to-one', description: 'Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati. Supporto costante, attenzione totale e allenamenti personalizzati per dare il massimo.', cta: { label: 'Contattaci', href: '/contact' } },
+ { id: 'small-groups', title: 'Small Groups', caption: 'Mini gruppi (max 3 persone) con trainer dedicato', image: 'training-small-groups', description: 'Allenati in mini gruppi da massimo 3 persone con un trainer dedicato. Un’esperienza small group che unisce attenzione personalizzata, motivazione e condivisione. Programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme.', cta: { label: 'Contattaci', href: '/contact' } },
+ { id: 'performance-class', title: 'Performance Class', caption: 'Classi fino a 8 persone con trainer certificato', image: 'training-performance-class', description: 'Allenati in classi fino a 8 persone guidate da trainer certificati. Sessioni dinamiche ad alta intensità pensate per migliorare forza, resistenza e performance. Energia, motivazione e allenamenti strutturati per dare il massimo in ogni workout.', cta: { label: 'Scopri', href: '/programs/performance-class' } },
+ { id: 'fit-remote', title: 'Fit Remote', caption: 'Piano di allenamento su misura, ovunque ti alleni.', image: 'training-fit-remote', description: 'Con Fit Remote hai un piano di allenamento su misura, ovunque ti alleni. Programmi personalizzati creati sui tuoi obiettivi, da seguire in palestra, a casa o in viaggio. Supporto costante e allenamenti pensati per adattarsi al tuo stile di vita.', cta: { label: 'Contattaci', href: '/contact' } },
+] as const;
+```
+
+- [ ] **Step 4: agenda.ts (orario placeholder dal template, come nel Figma)**
+
+```ts
+export const disciplines = ['Events', 'Crossfit', 'Cardio', 'Box', 'Meditation', 'Yoga Classes', 'Body Balance'] as const;
+export const days = ['Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato', 'Domenica'] as const;
+export const slots = ['10.00', '12.00', '16.00', '18.00', '20.00'] as const;
+
+export interface AgendaEntry { day: number; slot: string; discipline: string; time: string; trainer: string }
+
+// Placeholder dal template Prowess (come nel Figma); il cliente fornirà l'orario reale.
+export const agenda: AgendaEntry[] = [
+ { day: 0, slot: '10.00', discipline: 'Crossfit', time: '10.00 - 11.00', trainer: 'Amanda Snow' },
+ { day: 0, slot: '12.00', discipline: 'Box', time: '12.00 - 13.00', trainer: 'Matthew Dunn' },
+ { day: 0, slot: '18.00', discipline: 'Cardio', time: '18.00 - 19.00', trainer: 'Randy Nelson' },
+ { day: 0, slot: '20.00', discipline: 'Crossfit', time: '20.00 - 21.00', trainer: 'Amanda Snow' },
+ { day: 1, slot: '10.00', discipline: 'Cardio', time: '10.00 - 11.00', trainer: 'Randy Nelson' },
+ { day: 1, slot: '12.00', discipline: 'Crossfit', time: '12.00 - 13.00', trainer: 'Amanda Snow' },
+ { day: 1, slot: '16.00', discipline: 'Body Balance', time: '16.00 - 17.00', trainer: 'Barbara Holland' },
+ { day: 1, slot: '20.00', discipline: 'Box', time: '20.00 - 21.00', trainer: 'Matthew Dunn' },
+ { day: 2, slot: '10.00', discipline: 'Crossfit', time: '10.00 - 11.00', trainer: 'Amanda Snow' },
+ { day: 2, slot: '12.00', discipline: 'Meditation', time: '12.00 - 13.00', trainer: 'Nicole Fuller' },
+ { day: 2, slot: '16.00', discipline: 'Box', time: '16.00 - 17.00', trainer: 'Matthew Dunn' },
+ { day: 2, slot: '20.00', discipline: 'Body Balance', time: '20.00 - 21.00', trainer: 'Barbara Holland' },
+ { day: 3, slot: '10.00', discipline: 'Body Balance', time: '10.00 - 11.00', trainer: 'Barbara Holland' },
+ { day: 3, slot: '12.00', discipline: 'Box', time: '12.00 - 13.00', trainer: 'Matthew Dunn' },
+ { day: 3, slot: '16.00', discipline: 'Crossfit', time: '16.00 - 17.00', trainer: 'Amanda Snow' },
+ { day: 3, slot: '20.00', discipline: 'Body Balance', time: '20.00 - 21.00', trainer: 'Randy Nelson' },
+ { day: 4, slot: '12.00', discipline: 'Crossfit', time: '12.00 - 13.00', trainer: 'Amanda Snow' },
+ { day: 4, slot: '16.00', discipline: 'Body Balance', time: '16.00 - 17.00', trainer: 'Barbara Holland' },
+ { day: 4, slot: '18.00', discipline: 'Cardio', time: '18.00 - 19.00', trainer: 'Randy Nelson' },
+ { day: 4, slot: '20.00', discipline: 'Yoga Classes', time: '20.00 - 21.00', trainer: 'Amanda Snow' },
+ { day: 5, slot: '10.00', discipline: 'Cardio', time: '10.00 - 11.00', trainer: 'Randy Nelson' },
+ { day: 5, slot: '12.00', discipline: 'Body Balance', time: '12.00 - 13.00', trainer: 'Barbara Holland' },
+ { day: 5, slot: '18.00', discipline: 'Box', time: '18.00 - 19.00', trainer: 'Matthew Dunn' },
+ { day: 6, slot: '12.00', discipline: 'Crossfit', time: '12.00 - 13.00', trainer: 'Amanda Snow' },
+ { day: 6, slot: '16.00', discipline: 'Cardio', time: '16.00 - 17.00', trainer: 'Randy Nelson' },
+ { day: 6, slot: '18.00', discipline: 'Box', time: '18.00 - 19.00', trainer: 'Matthew Dunn' },
+ { day: 6, slot: '20.00', discipline: 'Body Balance', time: '20.00 - 21.00', trainer: 'Barbara Holland' },
+];
+```
+
+- [ ] **Step 5: team.ts**
+
+```ts
+export interface Member { name: string; shortName: string; role: string; bio: string; image?: string; founder: boolean }
+
+export const team: Member[] = [
+ { name: 'Donata Dal Pastro', shortName: 'Donata', role: 'Chinesiologa e Osteopata', founder: true, image: 'team-donata',
+ bio: 'Donata Dal Pastro è chinesiologa e osteopata specializzata in rieducazione funzionale, riatletizzazione sportiva ed esercizio adattato. Supervisiona l’area dedicata a prevenzione, recupero e benessere, con competenze specifiche anche in pediatria e gravidanza.' },
+ { name: 'Nicola Antonelli', shortName: 'Nicola', role: 'Chinesiologo e Osteopata', founder: true, image: 'team-nicola',
+ bio: 'Nicola Antonelli è chinesiologo e osteopata specializzato in performance sportiva, functional training e bodybuilding. Coordina lo sviluppo tecnico dei percorsi dedicati alla performance e all’allenamento avanzato.' },
+ { name: 'Eva Cassetta', shortName: 'Eva', role: 'Business Manager – Balance & Strategy', founder: true,
+ bio: 'Eva Cassetta ricopre il ruolo di Business Manager – Balance & Strategy e si occupa della gestione amministrativa, economica e finanziaria del progetto, garantendone solidità, organizzazione e sostenibilità nel tempo.' },
+ { name: 'Alessandro Lazzari', shortName: 'Alessandro', role: 'Trainer – Functional & Movement', founder: false,
+ bio: 'Trainer – Functional & Movement. Laureando in Scienze Motorie, specializzato in running e atletica leggera.' },
+ { name: 'Eleonora Cassani', shortName: 'Eleonora', role: 'Trainer – Planning Manager', founder: false, image: 'team-eleonora',
+ bio: 'Trainer – Planning Manager. Chinesiologa specializzata in attività preventiva e adattata, con focus su allenamento in gravidanza e post-gravidanza.' },
+ { name: "Antonello D'Alessandro", shortName: 'Antonello', role: 'Trainer – Exercise & Health', founder: false, image: 'team-antonello',
+ bio: 'Trainer – Exercise & Health. Chinesiologo specializzato in attività preventiva e adattata, Dottore in Osteopatia.' },
+ { name: 'Alice Roda', shortName: 'Alice', role: 'Trainer – Functional & Movement', founder: false,
+ bio: 'Trainer – Functional & Movement. Laureanda in Scienze Motorie e Preparatore Atletico. Scienza del movimento e massima cura della persona: un approccio al functional training empatico e accogliente, dove l’ascolto umano guida e supporta ogni tuo progresso.' },
+ { name: 'Davide Bisagni', shortName: 'Davide', role: 'Trainer – Functional & Movement', founder: false,
+ bio: 'Trainer – Functional & Movement. Chinesiologo e allenamento funzionale al servizio del corpo: massima cura del gesto atletico e attenzione mirata al recupero per prestazioni sostenibili e durature.' },
+ { name: 'Giulia Barbàra', shortName: 'Giulia', role: 'Trainer – Pilates, Yoga & Mindfulness', founder: false,
+ bio: 'Trainer – Pilates, Yoga & Mindfulness. Laureata in Psicologia Clinica e Riabilitazione, insegnante di Pilates, Yoga e Mindfulness, con approccio orientato al benessere e all’equilibrio mente-corpo.' },
+];
+
+export const homeTeam = ['Donata', 'Nicola', 'Eleonora', 'Antonello'];
+```
+
+- [ ] **Step 6: method.ts, pricing.ts, partners.ts**
+
+`src/data/method.ts`:
+```ts
+export const methodIntro = 'Il Metodo Insanitylab si basa su un percorso strutturato in fasi, dove le prime due rappresentano il focus standard del lavoro: valutazione iniziale e avvio dell’allenamento personalizzato. A queste possono essere aggiunti servizi e approfondimenti extra, in base alle esigenze e agli obiettivi della persona.';
+export const methodExtraNote = 'Disponibili su richiesta, non incluse nel percorso standard.';
+
+export const methodSteps = [
+ { n: 1, title: 'Prima consulenza conoscitiva iniziale', text: 'Dove raccogliamo un’anamnesi iniziale per comprendere il cliente, i suoi obiettivi e guidarlo verso il percorso più adatto.', extra: false, image: 'method-step-1' },
+ { n: 2, title: 'Prima seduta operativa', text: 'In cui si effettua la profilazione iniziale e la valutazione sul campo per definire il punto di partenza e impostare un percorso personalizzato.', extra: false, image: 'method-step-2' },
+ { n: 3, title: 'Plicometria', text: 'Test che misura il grasso sottocutaneo per stimare la massa grassa, disponibile su richiesta o come servizio aggiuntivo.', extra: true, image: 'method-step-3' },
+ { n: 4, title: 'Analisi antropometriche', text: 'Le analisi antropometriche sono misurazioni del corpo utili a valutare la composizione fisica e lo stato di forma.', extra: true, image: 'method-step-4' },
+];
+```
+
+`src/data/pricing.ts` (prezzi da Performance class.pdf):
+```ts
+export const performanceClassPricing = {
+ heading: 'Acquista la tua performance class',
+ intro: 'Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un’esperienza di allenamento completa e mirata.',
+ groups: [
+ {
+ label: 'Programmi performance class semestrale',
+ open: true,
+ plans: [
+ { monthly: 130, entries: 1, note: 'tot: 780€ · da 23€ lezione singola', installments: '2 rate da 390€ – inizio 60', best: 'best price 720€' },
+ { monthly: 234, entries: 2, note: 'tot: 1.400€ · da 23€ lezione singola', installments: '2 rate da 700€ – inizio 60', best: 'best price 1.250€' },
+ { monthly: 300, entries: 3, note: 'tot: 1.800€ · da 23€ lezione singola', installments: '2 rate da 900€ – inizio 60', best: 'best price 1.650€' },
+ ],
+ },
+ { label: 'Programmi performance class trimestrale', open: false, plans: [] },
+ { label: 'Programmi performance class mensile', open: false, plans: [] },
+ ],
+};
+```
+(I gruppi senza piani mostrano "Contattaci per i dettagli" — nel Figma sono accordion chiusi senza contenuto visibile.)
+
+`src/data/partners.ts`:
+```ts
+export const partners = [
+ { name: 'Ticket Welfare Edenred', image: 'partner-edenred' },
+ { name: 'Welbee', image: 'partner-welbee' },
+ { name: 'AON', image: 'partner-aon' },
+ { name: 'Howden', image: 'partner-howden' },
+];
+```
+
+- [ ] **Step 7: verifica typecheck e commit**
+
+Run: `npx astro check 2>&1 | tail -5` (o `npx tsc --noEmit`)
+Expected: nessun errore sui file dati.
+
+```bash
+git add src/data && git commit -m "feat: dati statici contenuti da design Figma"
+```
+
+---
+
+### Task 9: Homepage
+
+**Files:**
+- Create: `src/components/home/HeroSlider.astro`, `src/components/home/InfoColumns.astro`, `src/components/home/FeatureBlocks.astro`, `src/components/home/QuoteBanner.astro`, `src/components/home/TrainingCards.astro`, `src/components/home/ProgramsGrid.astro`, `src/components/Agenda.astro`, `src/components/home/TeamSection.astro`, `src/components/home/MethodSteps.astro`, `src/components/home/PartnersStrip.astro`
+- Modify: `src/pages/index.astro`
+
+**Interfaces:**
+- Consumes: dati Task 8, immagini Task 2 (`src/assets/img/*` via `astro:assets`), utility CSS Task 3.
+- Produces: `<Agenda />` riusato dal Task 11; `<TrainingCards />` riusato dal Task 11.
+
+Riferimento visivo: `../FigmaDoc/Homepage.pdf`. Per confronto genera le fette: `pdftoppm -png -r 50 ../FigmaDoc/Homepage.pdf /tmp/ilab-home && convert /tmp/ilab-home-1.png -crop x1800 +repage /tmp/ilab-home-slice_%d.png`.
+
+- [ ] **Step 1: HeroSlider.astro**
+
+Hero fullscreen: titolo `PERFORMANCE.` / `BALANCE.` / `LONGEVITY.` su righe, bottone SCOPRI → `/programs`, immagine hero a destra, pattern di linee orizzontali a sinistra, frecce navigazione. Il Figma mostra una sola slide compiuta: implementare struttura a slide con array (1 slide ora, estendibile).
+
+```astro
+---
+import { Image } from 'astro:assets';
+import hero1 from '../../assets/img/hero-1.jpg';
+const slides = [
+ { title: ['PERFORMANCE.', 'BALANCE.', 'LONGEVITY.'], image: hero1, alt: 'Atleti InsanityLab' },
+];
+---
+<section class="hero" aria-label="presentazione">
+ {slides.map((s, i) => (
+ <div class:list={['hero__slide', { 'is-active': i === 0 }]}>
+ <div class="container hero__in">
+ <div class="hero__text">
+ <h1>{s.title.map((line) => <span class="hero__line">{line}</span>)}</h1>
+ <a class="btn" href="/programs">Scopri</a>
+ </div>
+ <Image src={s.image} alt={s.alt} class="hero__img" widths={[600, 1000, 1400]} sizes="(max-width: 991px) 100vw, 60vw" loading="eager" />
+ </div>
+ </div>
+ ))}
+ {slides.length > 1 && (
+ <>
+ <button class="hero__arrow hero__arrow--prev" aria-label="precedente">‹</button>
+ <button class="hero__arrow hero__arrow--next" aria-label="successiva">›</button>
+ </>
+ )}
+</section>
+
+<style>
+ .hero { position: relative; background:
+ repeating-linear-gradient(to bottom, transparent 0 10px, rgba(0,0,0,.05) 10px 11px) left top / 40% 60% no-repeat, #fff; }
+ .hero__slide { display: none; }
+ .hero__slide.is-active { display: block; }
+ .hero__in { display: grid; grid-template-columns: 1fr 1.2fr; align-items: center; min-height: calc(100vh - var(--header-h)); gap: 30px; }
+ .hero__line { display: block; }
+ .hero__text h1 { font-weight: 500; }
+ .hero__img { object-fit: cover; height: 100%; max-height: calc(100vh - var(--header-h)); }
+ .hero__arrow { position: absolute; top: 50%; transform: translateY(-50%); background: none; border: 0; font-size: 3rem; color: var(--c-heading); cursor: pointer; }
+ .hero__arrow--prev { left: 12px; } .hero__arrow--next { right: 12px; }
+ @media (max-width: 991px) { .hero__in { grid-template-columns: 1fr; padding-block: 40px; min-height: 0; } }
+</style>
+
+<script>
+ const slides = document.querySelectorAll('.hero__slide');
+ if (slides.length > 1) {
+ let cur = 0;
+ const show = (i: number) => {
+ slides[cur].classList.remove('is-active');
+ cur = (i + slides.length) % slides.length;
+ slides[cur].classList.add('is-active');
+ };
+ document.querySelector('.hero__arrow--prev')?.addEventListener('click', () => show(cur - 1));
+ document.querySelector('.hero__arrow--next')?.addEventListener('click', () => show(cur + 1));
+ }
+</script>
+```
+
+- [ ] **Step 2: InfoColumns.astro**
+
+Tre colonne full-width con foto di sfondo e overlay scuro, titoli `PERFORMANCE.` `BALANCE.` `LONGEVITY.`; ogni colonna linka `/programs`.
+
+```astro
+---
+import infoPerformance from '../../assets/img/info-performance.jpg';
+import infoBalance from '../../assets/img/info-balance.jpg';
+import infoLongevity from '../../assets/img/info-longevity.jpg';
+const cols = [
+ { title: 'PERFORMANCE.', image: infoPerformance },
+ { title: 'BALANCE.', image: infoBalance },
+ { title: 'LONGEVITY.', image: infoLongevity },
+];
+---
+<section class="info" aria-label="pilastri">
+ {cols.map((c) => (
+ <a class="info__col" href="/programs" style={`background-image:url(${c.image.src})`}>
+ <span class="info__title">{c.title}</span>
+ </a>
+ ))}
+</section>
+
+<style>
+ .info { display: grid; grid-template-columns: repeat(3, 1fr); }
+ .info__col { position: relative; min-height: 320px; background-size: cover; background-position: center; display: flex; align-items: center; justify-content: center; text-decoration: none; }
+ .info__col::before { content: ''; position: absolute; inset: 0; background: rgba(30,24,20,.35); transition: background .2s; }
+ .info__col:hover::before { background: rgba(30,24,20,.15); }
+ .info__title { position: relative; color: #fff; font-family: var(--font-heading); font-weight: 600; letter-spacing: .1em; font-size: 1.1rem; }
+ @media (max-width: 767px) { .info { grid-template-columns: 1fr; } }
+</style>
+```
+
+- [ ] **Step 3: FeatureBlocks.astro**
+
+Tre blocchi alternati testo/immagine (testi reali da Homepage.pdf):
+
+```astro
+---
+import { Image } from 'astro:assets';
+import featPerformance from '../../assets/img/feature-performance.jpg';
+import featBalance from '../../assets/img/feature-balance.jpg';
+import featLongevity from '../../assets/img/feature-longevity.jpg';
+const blocks = [
+ { title: 'PERFORMANCE', image: featPerformance, reverse: false,
+ text: 'Allenamenti progettati su misura per adattarsi ai tuoi obiettivi e al tuo livello. Programmi mirati che sviluppano forza, resistenza e funzionalità in modo equilibrato. Un metodo efficace per migliorare le performance e ottenere risultati concreti e duraturi.' },
+ { title: 'BALANCE', image: featBalance, reverse: true,
+ text: 'L’equilibrio tra corpo e mente è la base di ogni performance duratura. Attraverso movimento, recupero e gestione dello stress, costruiamo stabilità e controllo. Un approccio integrato che sostiene benessere, energia e continuità nel tempo.' },
+ { title: 'LONGEVITY', image: featLongevity, reverse: false,
+ text: 'Benessere completo tra movimento, nutrizione e stile di vita. Un percorso personalizzato che unisce prevenzione, recupero ed equilibrio. Investi nella tua salute e qualità della vita nel tempo.' },
+];
+---
+<section class="section">
+ <div class="container">
+ {blocks.map((b) => (
+ <div class:list={['feat', { 'feat--reverse': b.reverse }]}>
+ <Image src={b.image} alt={b.title} class="feat__img" widths={[500, 900]} sizes="(max-width: 767px) 100vw, 45vw" />
+ <div class="feat__text">
+ <h2>{b.title}</h2>
+ <p>{b.text}</p>
+ <a class="btn" href="/programs">Scopri</a>
+ </div>
+ </div>
+ ))}
+ </div>
+</section>
+
+<style>
+ .feat { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; padding-block: 40px; }
+ .feat--reverse .feat__img { order: 2; }
+ .feat__text h2 { text-transform: uppercase; letter-spacing: .1em; }
+ @media (max-width: 767px) { .feat { grid-template-columns: 1fr; gap: 24px; } .feat--reverse .feat__img { order: 0; } }
+</style>
+```
+
+- [ ] **Step 4: QuoteBanner.astro**
+
+Banner scuro con foto di sfondo e citazione (nel Figma non è marquee ma banner statico con virgolette):
+
+```astro
+---
+import quoteBg from '../../assets/img/quote-bg.jpg';
+---
+<section class="quote" style={`background-image:url(${quoteBg.src})`}>
+ <div class="container quote__in">
+ <p class="quote__mark">“</p>
+ <blockquote>
+ <p><strong>PERFORMANCE</strong> È SPINGERE OLTRE I PROPRI LIMITI, <strong>BALANCE</strong> È SAPERLI ASCOLTARE, <strong>LONGEVITY</strong> È COSTRUIRE NEL TEMPO CIÒ CHE DAVVERO CONTA.</p>
+ <cite>Insanitylab</cite>
+ </blockquote>
+ </div>
+</section>
+
+<style>
+ .quote { position: relative; background-size: cover; background-position: center; text-align: center; }
+ .quote::before { content: ''; position: absolute; inset: 0; background: rgba(46,32,24,.82); }
+ .quote__in { position: relative; padding-block: 110px; max-width: 820px; }
+ .quote__mark { color: #fff; font-size: 4rem; font-family: var(--font-heading); margin: 0; line-height: 1; }
+ .quote blockquote { margin: 0; }
+ .quote blockquote p { color: #fff; font-family: var(--font-heading); font-size: clamp(1.1rem, 2.4vw, 1.6rem); letter-spacing: .06em; line-height: 1.6; }
+ .quote cite { display: block; margin-top: 24px; color: var(--c-accent); font-style: normal; font-family: var(--font-heading); font-size: .75rem; letter-spacing: .3em; text-transform: uppercase; }
+</style>
+```
+
+- [ ] **Step 5: TrainingCards.astro**
+
+Griglia 2×4 in alternanza card testo (scura/tan) e card foto, dati da `trainings.ts` (riusato in `/training`):
+
+```astro
+---
+import { Image } from 'astro:assets';
+import { trainings } from '../../data/trainings';
+const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/training-*.{jpg,png}', { eager: true });
+const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
+const tones = ['dark', 'tan', 'dark', 'tan'];
+---
+<section class="tcards">
+ {trainings.map((t, i) => (
+ <>
+ <div class:list={['tcards__cell', `tcards__cell--${tones[i]}`, { 'tcards__cell--swap': i % 2 === 1 }]}>
+ <h3>{t.title}</h3>
+ <p>{t.caption}</p>
+ <a class="tcards__link" href={t.cta.href}>Scopri →</a>
+ </div>
+ {imgOf(t.image) && <Image src={imgOf(t.image)!} alt={t.title} class="tcards__img" widths={[400, 700]} sizes="(max-width: 767px) 50vw, 25vw" />}
+ </>
+ ))}
+</section>
+
+<style>
+ .tcards { display: grid; grid-template-columns: repeat(4, 1fr); }
+ .tcards__cell { padding: 48px 36px; display: flex; flex-direction: column; justify-content: center; }
+ .tcards__cell--dark { background: var(--c-dark); }
+ .tcards__cell--dark h3, .tcards__cell--dark p { color: #efe9e3; }
+ .tcards__cell--tan { background: var(--c-accent); }
+ .tcards__cell--tan h3, .tcards__cell--tan p { color: #fff; }
+ .tcards__cell h3 { text-transform: uppercase; letter-spacing: .12em; }
+ .tcards__cell p { font-size: .88rem; }
+ .tcards__link { color: #fff; font-family: var(--font-heading); font-size: .7rem; letter-spacing: .2em; text-transform: uppercase; text-decoration: none; margin-top: 18px; }
+ .tcards__img { width: 100%; height: 100%; object-fit: cover; aspect-ratio: 1; }
+ /* alterna ordine nella riga 2: foto prima della card */
+ .tcards__cell--swap { order: 1; }
+ @media (max-width: 767px) { .tcards { grid-template-columns: repeat(2, 1fr); } .tcards__cell--swap { order: 0; } }
+</style>
+```
+(Verifica l'alternanza card/foto contro il PDF: riga 1 = card, foto, card, foto; riga 2 = foto, card, foto, card. Correggi con `order` finché combacia.)
+
+- [ ] **Step 6: ProgramsGrid.astro**
+
+Intestazione "PROGRAMMI" + griglia 3×2 di voci con icona linea, titolo e sottotitolo, link alla pagina dettaglio:
+
+```astro
+---
+import { programs } from '../../data/programs';
+const icons: Record<string, string> = {
+ 'personal-training': 'M4 12h3m10 0h3M9 8v8m6-8v8M7 10v4m10-4v4',
+ 'performance-class': 'M13 3l-2 7h4l-6 11 2-8H7l4-10z',
+ 'coaching': 'M12 3a4 4 0 110 8 4 4 0 010-8zm-7 18a7 7 0 0114 0',
+ 'pilates-flow': 'M12 4a2 2 0 110 4 2 2 0 010-4zM6 20c2-6 10-6 12 0M12 8v6',
+ 'rehab': 'M12 21C7 17 3 13 3 9a5 5 0 019-3 5 5 0 019 3c0 4-4 8-9 12z',
+ 'nutrition': 'M12 3c4 0 7 3 7 8s-3 10-7 10-7-5-7-10 3-8 7-8zm0-1v4',
+};
+---
+<section class="section">
+ <div class="container">
+ <div class="section-heading">
+ <h2>Programmi</h2>
+ <p>Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un’esperienza di allenamento completa e mirata.</p>
+ </div>
+ <div class="pgrid">
+ {programs.map((p) => (
+ <a class="pgrid__item" href={`/programs/${p.slug}`}>
+ <svg viewBox="0 0 24 24" fill="none" stroke="var(--c-accent-dark)" stroke-width="1.4" width="52" height="52" aria-hidden="true"><path d={icons[p.slug]} /></svg>
+ <h3>{p.title}</h3>
+ <p>{p.subtitle}</p>
+ </a>
+ ))}
+ </div>
+ </div>
+</section>
+
+<style>
+ .pgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; text-align: center; }
+ .pgrid__item { text-decoration: none; }
+ .pgrid__item svg { margin-inline: auto; }
+ .pgrid__item h3 { text-transform: uppercase; letter-spacing: .12em; font-size: .95rem; margin: 18px 0 6px; }
+ .pgrid__item p { font-size: .85rem; color: var(--c-text-light); margin: 0; }
+ @media (max-width: 767px) { .pgrid { grid-template-columns: 1fr 1fr; } }
+ @media (max-width: 480px) { .pgrid { grid-template-columns: 1fr; } }
+</style>
+```
+(Se nell'archivio Figma sono presenti le icone originali dei programmi, usale al posto degli SVG inline: mappale nel Task 2 come `icon-<slug>.png`.)
+
+- [ ] **Step 7: Agenda.astro (condiviso)**
+
+Sezione scura con tab discipline e griglia settimanale:
+
+```astro
+---
+import { agenda, disciplines, days, slots } from '../data/agenda';
+---
+<section class="section section--dark agenda">
+ <div class="container">
+ <div class="section-heading"><h2>Agenda</h2></div>
+ <div class="agenda__tabs" role="tablist">
+ {disciplines.map((d, i) => (
+ <button class:list={['agenda__tab', { 'is-active': i === 0 }]} data-filter={d} role="tab" aria-selected={i === 0 ? 'true' : 'false'}>{d}</button>
+ ))}
+ </div>
+ <div class="agenda__grid">
+ <div class="agenda__corner"></div>
+ {days.map((d) => <div class="agenda__day">{d}</div>)}
+ {slots.map((slot) => (
+ <>
+ <div class="agenda__slot">{slot}</div>
+ {days.map((_, dayIdx) => {
+ const entry = agenda.find((e) => e.day === dayIdx && e.slot === slot);
+ return entry
+ ? <div class="agenda__cell" data-discipline={entry.discipline}><strong>{entry.discipline}</strong><span>{entry.time}</span><em>{entry.trainer}</em></div>
+ : <div class="agenda__cell agenda__cell--empty"></div>;
+ })}
+ </>
+ ))}
+ </div>
+ </div>
+</section>
+
+<style>
+ .agenda__tabs { display: flex; flex-wrap: wrap; gap: 4px; justify-content: center; margin-bottom: 40px; }
+ .agenda__tab { background: transparent; color: #b9aea4; border: 0; cursor: pointer; font-family: var(--font-heading); font-size: .68rem; letter-spacing: .18em; text-transform: uppercase; padding: 10px 18px; }
+ .agenda__tab.is-active { background: var(--c-accent); color: #fff; }
+ .agenda__grid { display: grid; grid-template-columns: 70px repeat(7, 1fr); gap: 2px; }
+ .agenda__day, .agenda__slot { font-family: var(--font-heading); font-size: .66rem; letter-spacing: .14em; text-transform: uppercase; color: #b9aea4; display: flex; align-items: center; justify-content: center; padding: 10px 4px; }
+ .agenda__cell { background: var(--c-accent); color: #fff; padding: 14px 8px; text-align: center; font-size: .72rem; display: flex; flex-direction: column; gap: 2px; }
+ .agenda__cell strong { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .1em; text-transform: uppercase; }
+ .agenda__cell em { font-style: normal; opacity: .85; }
+ .agenda__cell--empty { background: transparent; }
+ .agenda__cell.is-dim { opacity: .15; }
+ @media (max-width: 991px) { .agenda__grid { grid-template-columns: 60px repeat(7, minmax(90px, 1fr)); overflow-x: auto; } }
+</style>
+
+<script>
+ document.querySelectorAll<HTMLElement>('.agenda').forEach((root) => {
+ const tabs = root.querySelectorAll<HTMLButtonElement>('.agenda__tab');
+ const cells = root.querySelectorAll<HTMLElement>('.agenda__cell[data-discipline]');
+ tabs.forEach((tab) => tab.addEventListener('click', () => {
+ tabs.forEach((t) => { t.classList.toggle('is-active', t === tab); t.setAttribute('aria-selected', String(t === tab)); });
+ const f = tab.dataset.filter!;
+ cells.forEach((c) => c.classList.toggle('is-dim', f !== 'Events' && c.dataset.discipline !== f));
+ }));
+ });
+</script>
+```
+
+- [ ] **Step 8: TeamSection.astro, MethodSteps.astro, PartnersStrip.astro**
+
+`TeamSection.astro` — titolo "TEAM TRAINER" a sinistra, bottone "Scopri team" → `/about`, 4 ritratti con nome verticale sul bordo:
+```astro
+---
+import { Image } from 'astro:assets';
+import { team, homeTeam } from '../../data/team';
+const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/team-*.{jpg,png}', { eager: true });
+const members = homeTeam.map((short) => team.find((m) => m.shortName === short)!).filter((m) => m.image);
+const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
+---
+<section class="section">
+ <div class="container">
+ <div class="tteam__head">
+ <h2>Team<br />Trainer</h2>
+ <a class="btn" href="/about">Scopri team</a>
+ </div>
+ <div class="tteam__grid">
+ {members.map((m) => (
+ <figure class="tteam__card">
+ <Image src={imgOf(m.image!)!} alt={m.name} widths={[300, 500]} sizes="(max-width: 767px) 50vw, 25vw" />
+ <figcaption>{m.shortName}</figcaption>
+ </figure>
+ ))}
+ </div>
+ </div>
+</section>
+
+<style>
+ .tteam__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 40px; }
+ .tteam__head h2 { text-transform: uppercase; letter-spacing: .1em; margin: 0; }
+ .tteam__grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; }
+ .tteam__card { position: relative; margin: 0; }
+ .tteam__card img { aspect-ratio: 3/4; object-fit: cover; width: 100%; }
+ .tteam__card figcaption { position: absolute; right: 10px; bottom: 14px; background: #fff; padding: 10px 6px; writing-mode: vertical-rl; font-family: var(--font-heading); font-size: .62rem; letter-spacing: .3em; text-transform: uppercase; color: var(--c-accent-dark); }
+ @media (max-width: 767px) { .tteam__grid { grid-template-columns: 1fr 1fr; } }
+</style>
+```
+
+`MethodSteps.astro` — intestazione "IL METODO INSANITYLAB" + 4 cerchi numerati con linea tratteggiata; nota extra sopra gli step 3–4:
+```astro
+---
+import { Image } from 'astro:assets';
+import { methodIntro, methodExtraNote, methodSteps } from '../../data/method';
+const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/method-*.{jpg,png}', { eager: true });
+const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
+---
+<section class="section section--alt">
+ <div class="container">
+ <div class="section-heading">
+ <h2>Il metodo<br />InsanityLab</h2>
+ <p set:html={methodIntro.replace('Metodo Insanitylab', '<strong>Metodo Insanitylab</strong>')} />
+ </div>
+ <p class="method__note">{methodExtraNote}</p>
+ <div class="method__row">
+ {methodSteps.map((s) => (
+ <div class:list={['method__step', { 'method__step--extra': s.extra }]}>
+ <div class="method__circle">
+ {imgOf(s.image) && <Image src={imgOf(s.image)!} alt="" widths={[220]} sizes="220px" />}
+ <span>{s.n}</span>
+ </div>
+ <h3>{s.title}</h3>
+ <p>{s.text}</p>
+ </div>
+ ))}
+ </div>
+ </div>
+</section>
+
+<style>
+ .method__note { text-align: right; font-size: .8rem; color: var(--c-text-light); max-width: 260px; margin-left: auto; }
+ .method__row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 30px; text-align: center; position: relative; }
+ .method__circle { position: relative; width: 150px; height: 150px; margin: 0 auto 20px; border: 1px dashed var(--c-accent-dark); border-radius: 50%; padding: 8px; }
+ .method__circle img { border-radius: 50%; width: 100%; height: 100%; object-fit: cover; }
+ .method__circle span { position: absolute; top: -6px; left: -6px; font-family: var(--font-heading); font-size: 2.4rem; color: var(--c-heading); background: var(--c-bg-alt); padding: 0 8px; }
+ .method__step h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .14em; }
+ .method__step p { font-size: .85rem; color: var(--c-text-light); }
+ @media (max-width: 767px) { .method__row { grid-template-columns: 1fr 1fr; } }
+</style>
+```
+
+`PartnersStrip.astro` — striscia tan con i 4 loghi:
+```astro
+---
+import { Image } from 'astro:assets';
+import { partners } from '../../data/partners';
+const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/partner-*.{jpg,png}', { eager: true });
+const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
+---
+<section class="pstrip" aria-label="partner">
+ <div class="container pstrip__in">
+ {partners.map((p) => imgOf(p.image) && <Image src={imgOf(p.image)!} alt={p.name} height={44} />)}
+ </div>
+</section>
+
+<style>
+ .pstrip { background: var(--c-accent); padding-block: 40px; }
+ .pstrip__in { display: flex; align-items: center; justify-content: space-around; gap: 30px; flex-wrap: wrap; }
+ .pstrip img { height: 44px; width: auto; opacity: .9; }
+</style>
+```
+
+- [ ] **Step 9: componi index.astro**
+
+```astro
+---
+import Base from '../layouts/Base.astro';
+import HeroSlider from '../components/home/HeroSlider.astro';
+import InfoColumns from '../components/home/InfoColumns.astro';
+import FeatureBlocks from '../components/home/FeatureBlocks.astro';
+import QuoteBanner from '../components/home/QuoteBanner.astro';
+import TrainingCards from '../components/home/TrainingCards.astro';
+import ProgramsGrid from '../components/home/ProgramsGrid.astro';
+import Agenda from '../components/Agenda.astro';
+import TeamSection from '../components/home/TeamSection.astro';
+import MethodSteps from '../components/home/MethodSteps.astro';
+import PartnersStrip from '../components/home/PartnersStrip.astro';
+export const prerender = true;
+---
+<Base title="Performance. Balance. Longevity.">
+ <HeroSlider />
+ <InfoColumns />
+ <FeatureBlocks />
+ <QuoteBanner />
+ <section class="section" style="padding-bottom:0">
+ <div class="section-heading">
+ <h2>Training</h2>
+ <p>Porta i tuoi allenamenti al livello successivo: trova il percorso più adatto alle tue esigenze.</p>
+ </div>
+ </section>
+ <TrainingCards />
+ <ProgramsGrid />
+ <Agenda />
+ <TeamSection />
+ <MethodSteps />
+ <PartnersStrip />
+</Base>
+```
+
+- [ ] **Step 10: confronto visivo con Figma**
+
+Run: `npm run dev`, apri `http://localhost:4321/` e confronta sezione per sezione con `/tmp/ilab-home-slice_*.png`. Correggi spaziature, dimensioni titoli, colori finché la corrispondenza è fedele (non pixel-perfect: stessa gerarchia, palette, proporzioni). Verifica mobile a 375px.
+
+- [ ] **Step 11: build e commit**
+
+Run: `npm run build`
+Expected: build ok.
+
+```bash
+git add -A && git commit -m "feat: homepage completa da design Figma"
+```
+
+---
+
+### Task 10: Pagina About (`/about`)
+
+**Files:**
+- Create: `src/pages/about.astro`, `src/components/about/Timeline.astro`, `src/components/PageHero.astro`
+
+**Interfaces:**
+- Consumes: `team` (Task 8), immagini `about-*`/`team-*` (Task 2), `site`.
+- Produces: `<PageHero title />` riusato da training/programs/blog/contact (banner titolo pagina su fondo chiaro con eyebrow).
+
+Riferimento: `../FigmaDoc/Insanitylab.pdf` (generare fette come per la homepage). Sezioni nell'ordine: hero titolo "Insanitylab", Mission, Direzione (+ bottone Scopri), Approccio (+ bottone Contattaci), About, Storylab (timeline), Convenzioni, Team (3 fondatori + 6 trainer), Metodo (testo), In-Sanity Studios, Vision.
+
+- [ ] **Step 1: PageHero.astro**
+
+```astro
+---
+interface Props { title: string; eyebrow?: string }
+const { title, eyebrow } = Astro.props;
+---
+<section class="phero">
+ <div class="container">
+ {eyebrow && <p class="eyebrow">{eyebrow}</p>}
+ <h1>{title}</h1>
+ </div>
+</section>
+
+<style>
+ .phero { background: var(--c-bg-alt); padding-block: 70px; }
+ .phero h1 { text-transform: lowercase; font-weight: 500; margin: 0; }
+</style>
+```
+
+- [ ] **Step 2: Timeline.astro (Storylab)**
+
+Blocco 2021-2024 aperto con testo; 2025, 2026 e OGGI come accordion `<details>`:
+
+```astro
+---
+const openText = `Il percorso di crescita di InsanityLab si è sviluppato attraverso tappe precise e coerenti con la visione iniziale. Nato nel 2021 con un piccolo team e grandi ambizioni, il progetto ha conosciuto una forte evoluzione nel 2024, anno in cui il servizio si è ampliato attraverso nuovi format come gli small group e una sempre maggiore specializzazione nella profilazione fisica del cliente, grazie a sistemi avanzati di valutazione e monitoraggio. Parallelamente, anche l’area dedicata alla riabilitazione e all’esercizio preventivo si è consolidata, ponendo le basi per un importante salto di qualità.`;
+const stages = [
+ { label: 'INSANITYLAB 2025', text: 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.' },
+ { label: 'INSANITYLAB 2026', text: 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.' },
+ { label: 'INSANITYLAB OGGI', text: 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.' },
+];
+---
+<section class="section">
+ <div class="container">
+ <div class="section-heading">
+ <h2>Storylab</h2>
+ <p>Scopri la nostra storia: come nasce InsanityLab, perché abbiamo deciso di creare qualcosa di diverso e chi sono i visionari che hanno trasformato questa idea in un nuovo modo di vivere benessere, movimento e salute.</p>
+ </div>
+ <div class="tl">
+ <h3 class="tl__label">INSANITYLAB 2021-2024</h3>
+ <p>{openText}</p>
+ {stages.map((s) => (
+ <details class="tl__stage">
+ <summary>+ {s.label}</summary>
+ <p>{s.text}</p>
+ </details>
+ ))}
+ </div>
+ </div>
+</section>
+
+<style>
+ .tl { max-width: 720px; margin-inline: auto; }
+ .tl__label { text-transform: uppercase; letter-spacing: .16em; font-size: .9rem; }
+ .tl__stage { border-top: 1px solid #e4ded6; padding-block: 14px; }
+ .tl__stage summary { cursor: pointer; font-family: var(--font-heading); font-size: .82rem; letter-spacing: .16em; text-transform: uppercase; color: var(--c-heading); list-style: none; }
+ .tl__stage summary::-webkit-details-marker { display: none; }
+</style>
+```
+
+- [ ] **Step 3: about.astro**
+
+Testi reali da Insanitylab.pdf (già estratti — riportati integralmente qui sotto). Struttura: alternanza di sezioni testo/immagine come nel PDF.
+
+```astro
+---
+import Base from '../layouts/Base.astro';
+import PageHero from '../components/PageHero.astro';
+import Timeline from '../components/about/Timeline.astro';
+import { Image } from 'astro:assets';
+import { team } from '../data/team';
+import { site } from '../data/site';
+const images = import.meta.glob<{ default: ImageMetadata }>('../assets/img/*.{jpg,png}', { eager: true });
+const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1]?.default;
+const founders = team.filter((m) => m.founder);
+const staff = team.filter((m) => !m.founder);
+export const prerender = true;
+---
+<Base title="Insanitylab" description="Mission, storia, team e metodo di InsanityLab: un laboratorio esclusivo dove movimento, prevenzione e performance convivono in un approccio personalizzato.">
+ <PageHero title="Insanitylab" />
+
+ <section class="section">
+ <div class="container section-heading">
+ <h2>Mission</h2>
+ <p>In InsanityLab crediamo che il movimento non debba essere vissuto come un obbligo o un sacrificio, ma come una forma autentica di cura di sé. La nostra missione è accompagnare ogni persona in un percorso di consapevolezza, aiutandola a riscoprire il benessere fisico e mentale attraverso un approccio sostenibile, concreto e duraturo.</p>
+ </div>
+ </section>
+
+ <section class="section section--alt">
+ <div class="container about-2col">
+ <div>
+ <h2>Direzione</h2>
+ <p>Non ci limitiamo ad allenare il corpo: creiamo percorsi personalizzati che uniscono movimento, equilibrio e benessere, attraverso il supporto di professionisti qualificati, programmi su misura e un approccio centrato sulla persona e su risultati duraturi.</p>
+ <a class="btn" href="/programs">Scopri</a>
+ </div>
+ <div>
+ <h2>Approccio</h2>
+ <p>InsanityLab si distingue per un approccio serio e personalizzato al benessere, basato su competenze specializzate, costanza e cura quotidiana, rivolto a chiunque voglia migliorare il proprio stile di vita in modo consapevole e duraturo.</p>
+ <a class="btn btn--dark" href="/contact">Contattaci</a>
+ </div>
+ </div>
+ </section>
+
+ <section class="section">
+ <div class="container section-heading">
+ <h2>About</h2>
+ <p>InsanityLab nasce con l’obiettivo di unire fitness e salute in un unico percorso dedicato al benessere globale, superando il concetto tradizionale di palestra. Un laboratorio esclusivo, guidato da osteopati e chinesiologi e professionisti specializzati, dove movimento, prevenzione e performance convivono all’interno di un approccio personalizzato e consapevole.</p>
+ </div>
+ </section>
+
+ <Timeline />
+
+ <section class="section section--dark">
+ <div class="container">
+ <div class="section-heading">
+ <h2>Convenzioni</h2>
+ <p>InsanityLab collabora con aziende, enti e organizzazioni che desiderano investire concretamente nel benessere delle proprie persone, attraverso programmi dedicati alla salute, al movimento e alla qualità della vita. Le convenzioni nascono con l’obiettivo di portare il valore del benessere anche all’interno del contesto professionale, creando percorsi che favoriscano equilibrio fisico, prevenzione e performance quotidiana.</p>
+ </div>
+ <ul class="conv">
+ <li>Le realtà convenzionate possono accedere a programmi di welfare aziendale personalizzati, con percorsi individuali, small group training e classi di gruppo seguiti da professionisti qualificati.</li>
+ <li>Ogni collaborazione parte da una consulenza gratuita preliminare per analizzare esigenze e obiettivi, così da sviluppare soluzioni efficaci e sostenibili.</li>
+ <li>Le convenzioni vengono definite su misura insieme a InsanityLab, in base alle caratteristiche e alle necessità dell’azienda partner.</li>
+ </ul>
+ <p class="conv__cta">Per ricevere maggiori informazioni o attivare una convenzione: {site.email} · {site.phone}</p>
+ </div>
+ </section>
+
+ <section class="section">
+ <div class="container">
+ <div class="section-heading">
+ <h2>Team</h2>
+ <p>Il team di InsanityLab riunisce professionisti specializzati in allenamento, salute e benessere, con un approccio multidisciplinare basato su competenza, personalizzazione e attenzione alla persona.</p>
+ </div>
+ <div class="tgrid tgrid--founders">
+ {founders.map((m) => (
+ <article class="tcard">
+ {m.image && imgOf(m.image) && <Image src={imgOf(m.image)!} alt={m.name} widths={[300, 500]} sizes="(max-width:767px) 100vw, 33vw" />}
+ <h3>{m.shortName}</h3>
+ <p class="tcard__role">{m.role}</p>
+ <p>{m.bio}</p>
+ </article>
+ ))}
+ </div>
+ <p class="section-heading" style="margin-top:60px">Accanto ai fondatori opera un team in continua crescita composto da trainer e professionisti con specializzazioni complementari:</p>
+ <div class="tgrid">
+ {staff.map((m) => (
+ <article class="tcard">
+ {m.image && imgOf(m.image) && <Image src={imgOf(m.image)!} alt={m.name} widths={[300, 500]} sizes="(max-width:767px) 100vw, 33vw" />}
+ <h3>{m.shortName}</h3>
+ <p class="tcard__role">{m.role}</p>
+ <p>{m.bio}</p>
+ </article>
+ ))}
+ </div>
+ </div>
+ </section>
+
+ <section class="section section--alt">
+ <div class="container section-heading">
+ <h2>Metodo</h2>
+ <p>Il team InsanityLab lavora con un approccio empatico e personalizzato, seguendo ogni persona in un percorso continuo e su misura. Unisce competenze multidisciplinari in allenamento, prevenzione, riabilitazione e performance, supportate da formazione costante. Il metodo si basa su sinergia, condivisione e qualità, per generare risultati concreti e duraturi.</p>
+ <p>In poche parole, il team di InsanityLab è composto da professionisti che credono profondamente in ciò che fanno: un gruppo unito da valori condivisi, formato per eccellere e guidato dalla convinzione che il movimento sia uno strumento di benessere, equilibrio e qualità della vita.</p>
+ </div>
+ </section>
+
+ <section class="section">
+ <div class="container section-heading">
+ <h2>In-Sanity Studios</h2>
+ <p>IN-SANITY STUDIOS è il nuovo spazio professionale nato dall’evoluzione di InsanityLab, dedicato ai professionisti della salute, del benessere e della performance. Più che un semplice insieme di studi professionali, rappresenta un ecosistema multidisciplinare pensato per favorire collaborazione, qualità del servizio e integrazione tra competenze diverse.</p>
+ <p>Il progetto nasce con l’obiettivo di offrire a liberi professionisti uno spazio elegante, riservato e già attrezzato, in cui poter svolgere la propria attività all’interno di un contesto consolidato e altamente qualificato, senza dover affrontare i costi e le complessità di una struttura indipendente.</p>
+ <p>IN-SANITY STUDIOS si rivolge a figure specializzate nel settore salute e wellness, come fisioterapisti, osteopati, psicologi, nutrizionisti, massaggiatori e altri professionisti dedicati alla cura della persona. Ogni studio è progettato per garantire comfort, professionalità e continuità operativa, all’interno di un ambiente coerente con la filosofia di InsanityLab: mettere il benessere globale della persona al centro del percorso.</p>
+ </div>
+ </section>
+
+ <section class="section section--alt">
+ <div class="container section-heading">
+ <h2>Vision</h2>
+ <p>InsanityLab nasce con l’obiettivo di aiutare le persone a ritrovare energia ed equilibrio attraverso il movimento, promuovendo un approccio autentico, scientifico e umano al benessere. La sua visione va oltre l’estetica e la performance: allenarsi significa prendersi cura di sé, costruendo abitudini sane, sostenibili e orientate alla longevità, alla prevenzione e alla qualità della vita.</p>
+ <p>L’obiettivo è diventare un punto di riferimento capace di lasciare un impatto reale nella comunità, trasformando il movimento in uno strumento di salute, consapevolezza e identità.</p>
+ </div>
+ </section>
+</Base>
+
+<style>
+ .about-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; }
+ .conv { max-width: 760px; margin: 0 auto 30px; display: grid; gap: 18px; padding-left: 20px; }
+ .conv__cta { text-align: center; font-family: var(--font-heading); letter-spacing: .06em; }
+ .tgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 40px; }
+ .tcard img { aspect-ratio: 3/4; object-fit: cover; margin-bottom: 16px; }
+ .tcard h3 { text-transform: lowercase; letter-spacing: .2em; }
+ .tcard__role { font-size: .8rem; text-transform: uppercase; letter-spacing: .12em; color: var(--c-accent-dark); }
+ .tcard p { font-size: .9rem; }
+ @media (max-width: 767px) { .about-2col, .tgrid { grid-template-columns: 1fr; } }
+</style>
+```
+Nota: se nell'archivio Figma trovi le immagini delle sezioni about (mission, direzione, in-sanity studios), aggiungi accanto ai testi con lo stesso pattern `Image` — confronta col PDF.
+
+- [ ] **Step 2b: i tre accordion Storylab (2025, 2026, OGGI) hanno testo placeholder esplicito "Contenuto in arrivo" — segnala all'operatore nel riepilogo finale del task che servono i testi dal cliente.**
+
+- [ ] **Step 4: verifica e commit**
+
+Run: `npm run dev`, confronta con le fette di `Insanitylab.pdf`; poi `npm run build`.
+
+```bash
+git add -A && git commit -m "feat: pagina about"
+```
+
+---
+
+### Task 11: Pagina Training (`/training`)
+
+**Files:**
+- Create: `src/pages/training.astro`
+
+**Interfaces:**
+- Consumes: `trainings` (Task 8), `<TrainingCards />`, `<Agenda />` (Task 9), `<PageHero />` (Task 10).
+
+Riferimento: `../FigmaDoc/Training.pdf`. Struttura: hero "training", intestazione Training, griglia 4 card (riuso `TrainingCards`), 4 blocchi alternati testo/immagine (one to one, small groups, performance class, fit remote) con ancore `id`, Agenda, CTA "Prenota la lezione di prova".
+
+- [ ] **Step 1: training.astro**
+
+```astro
+---
+import Base from '../layouts/Base.astro';
+import PageHero from '../components/PageHero.astro';
+import TrainingCards from '../components/home/TrainingCards.astro';
+import Agenda from '../components/Agenda.astro';
+import { Image } from 'astro:assets';
+import { trainings } from '../data/trainings';
+const images = import.meta.glob<{ default: ImageMetadata }>('../assets/img/training-*.{jpg,png}', { eager: true });
+const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1]?.default;
+export const prerender = true;
+---
+<Base title="Training" description="One to One, Small Groups, Performance Class e Fit Remote: trova il training InsanityLab più adatto a te.">
+ <PageHero title="training" />
+
+ <section class="section" style="padding-bottom:0">
+ <div class="section-heading">
+ <h2>Training</h2>
+ <p>Porta i tuoi allenamenti al livello successivo: programmi completi e percorsi su misura. Trova il training più adatto alle tue esigenze.</p>
+ </div>
+ </section>
+ <TrainingCards />
+
+ <section class="section">
+ <div class="container">
+ {trainings.map((t, i) => (
+ <div class:list={['tblock', { 'tblock--reverse': i % 2 === 1 }]} id={t.id}>
+ {imgOf(t.image) && <Image src={imgOf(t.image)!} alt={t.title} widths={[500, 900]} sizes="(max-width:767px) 100vw, 45vw" />}
+ <div>
+ <h2>{t.title.toLowerCase()}</h2>
+ <p>{t.description}</p>
+ <a class="btn" href={t.cta.href}>{t.cta.label}</a>
+ </div>
+ </div>
+ ))}
+ </div>
+ </section>
+
+ <Agenda />
+
+ <section class="section section--alt">
+ <div class="container trial">
+ <div>
+ <h2>Prenota la lezione di prova</h2>
+ <p>La lezione di prova è l’occasione perfetta per conoscere il metodo, vivere l’esperienza in prima persona e capire il percorso più adatto a te. Inizia oggi il tuo percorso: prenota la tua lezione di prova.</p>
+ <a class="btn btn--dark" href="/contact">Prenota</a>
+ </div>
+ </div>
+ </section>
+</Base>
+
+<style>
+ .tblock { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; padding-block: 40px; scroll-margin-top: calc(var(--header-h) + 20px); }
+ .tblock--reverse img { order: 2; }
+ .tblock h2 { letter-spacing: .1em; }
+ .trial { max-width: 720px; text-align: center; }
+ @media (max-width: 767px) { .tblock { grid-template-columns: 1fr; gap: 24px; } .tblock--reverse img { order: 0; } }
+</style>
+```
+
+- [ ] **Step 2: verifica e commit**
+
+Run: `npm run dev`, confronta con `Training.pdf`; ancore `#one-to-one` ecc. funzionanti dal footer; `npm run build`.
+
+```bash
+git add -A && git commit -m "feat: pagina training"
+```
+
+---
+
+### Task 12: Pagine Programmi (`/programs`, `/programs/[slug]`)
+
+**Files:**
+- Create: `src/pages/programs/index.astro`, `src/pages/programs/[slug].astro`
+
+**Interfaces:**
+- Consumes: `programs`, `getProgram` (Task 8), `performanceClassPricing` (Task 8), `<PageHero />`.
+- Produces: pagine prerenderizzate via `getStaticPaths`.
+
+Riferimento: `../FigmaDoc/Programmi.pdf` e `../FigmaDoc/Performance class.pdf`.
+
+- [ ] **Step 1: programs/index.astro**
+
+```astro
+---
+import Base from '../../layouts/Base.astro';
+import PageHero from '../../components/PageHero.astro';
+import { programs } from '../../data/programs';
+export const prerender = true;
+---
+<Base title="Programmi" description="I programmi InsanityLab: Personal Training, Performance Class, Coaching, Pilates Flow, Rehab e Nutrizione.">
+ <PageHero title="programmi" />
+ <section class="section">
+ <div class="container">
+ <div class="section-heading">
+ <h2>Programmi</h2>
+ <p>Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un’esperienza di allenamento completa e mirata.</p>
+ </div>
+ <div class="plist">
+ {programs.map((p) => (
+ <article class="plist__card">
+ <h3>{p.title}</h3>
+ <p class="plist__sub">{p.subtitle}</p>
+ <p>{p.excerpt}</p>
+ <a class="plist__more" href={`/programs/${p.slug}`}>Per saperne di più →</a>
+ </article>
+ ))}
+ </div>
+ </div>
+ </section>
+</Base>
+
+<style>
+ .plist { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; }
+ .plist__card h3 { letter-spacing: .08em; }
+ .plist__sub { font-size: .8rem; text-transform: uppercase; letter-spacing: .12em; color: var(--c-accent-dark); }
+ .plist__card p { font-size: .92rem; }
+ .plist__more { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
+ .plist__more:hover { color: var(--c-accent-dark); }
+ @media (max-width: 991px) { .plist { grid-template-columns: 1fr 1fr; } }
+ @media (max-width: 600px) { .plist { grid-template-columns: 1fr; } }
+</style>
+```
+
+- [ ] **Step 2: programs/[slug].astro**
+
+Layout da Performance class.pdf: FAQ accordion in alto, blocco descrizione con feature list, sidebar (categorie blog + altri programmi), pricing solo se `hasPricing`.
+
+```astro
+---
+import Base from '../../layouts/Base.astro';
+import PageHero from '../../components/PageHero.astro';
+import { programs } from '../../data/programs';
+import { performanceClassPricing } from '../../data/pricing';
+export const prerender = true;
+
+export function getStaticPaths() {
+ return programs.map((p) => ({ params: { slug: p.slug }, props: { program: p } }));
+}
+const { program } = Astro.props;
+const others = programs.filter((p) => p.slug !== program.slug);
+const blogCategories = ['Fitness', 'Salute', 'Stile di vita', 'Nutrizione'];
+---
+<Base title={program.title} description={program.excerpt}>
+ <PageHero title={program.title.toUpperCase()} />
+
+ <section class="section">
+ <div class="container prog">
+ <div>
+ <h2>{program.title.toUpperCase()}</h2>
+ <p>{program.longDescription}</p>
+ <ul class="prog__features">
+ {program.features.map((f) => <li>{f}</li>)}
+ </ul>
+ <div class="prog__faq">
+ {program.faq.map((item, i) => (
+ <details open={i === 0}>
+ <summary>{item.q}</summary>
+ <p>{item.a}</p>
+ </details>
+ ))}
+ </div>
+ </div>
+ <aside class="prog__side">
+ <h3>Categorie</h3>
+ {blogCategories.map((c) => <p><a href="/blog">{c}</a></p>)}
+ <h3>Altri programmi</h3>
+ {others.map((o) => <p><a href={`/programs/${o.slug}`}>{o.title}</a></p>)}
+ </aside>
+ </div>
+ </section>
+
+ {program.hasPricing && (
+ <section class="section section--alt">
+ <div class="container">
+ <div class="section-heading">
+ <h2>{performanceClassPricing.heading}</h2>
+ <p>{performanceClassPricing.intro}</p>
+ </div>
+ {performanceClassPricing.groups.map((g) => (
+ <details class="price__group" open={g.open}>
+ <summary>{g.open ? '+' : '–'} {g.label}</summary>
+ {g.plans.length === 0 ? (
+ <p class="price__empty">Contattaci per i dettagli di questo programma. <a href="/contact">Scrivici →</a></p>
+ ) : (
+ <div class="price__row">
+ {g.plans.map((p) => (
+ <div class="price__card">
+ <p class="price__amount">{p.monthly}€</p>
+ <p class="price__per">al mese, semestrale</p>
+ <p class="price__entries">{p.entries} {p.entries === 1 ? 'ingresso settimanale' : 'ingressi settimanali'}</p>
+ <p>{p.note}</p>
+ <p>{p.installments}</p>
+ <p class="price__best">{p.best}</p>
+ <a class="btn" href="/contact">Acquista</a>
+ </div>
+ ))}
+ </div>
+ )}
+ </details>
+ ))}
+ </div>
+ </section>
+ )}
+</Base>
+
+<style>
+ .prog { display: grid; grid-template-columns: 2.2fr 1fr; gap: 60px; }
+ .prog__features { padding-left: 20px; display: grid; gap: 8px; margin-bottom: 30px; }
+ .prog__features li::marker { color: var(--c-accent); }
+ .prog__faq details { border-top: 1px solid #e4ded6; padding-block: 14px; }
+ .prog__faq summary { cursor: pointer; font-family: var(--font-heading); font-size: .85rem; letter-spacing: .08em; color: var(--c-heading); }
+ .prog__side h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; margin-top: 30px; }
+ .prog__side a { text-decoration: none; color: var(--c-text); }
+ .prog__side a:hover { color: var(--c-accent-dark); }
+ .price__group { border-top: 1px solid #ddd5ca; padding-block: 18px; }
+ .price__group summary { cursor: pointer; font-family: var(--font-heading); letter-spacing: .12em; text-transform: uppercase; font-size: .85rem; color: var(--c-heading); }
+ .price__row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 30px; margin-top: 30px; }
+ .price__card { background: #fff; padding: 36px 28px; text-align: center; }
+ .price__amount { font-family: var(--font-heading); font-size: 2.6rem; color: var(--c-heading); margin: 0; }
+ .price__per { color: var(--c-text-light); font-size: .8rem; }
+ .price__entries { font-family: var(--font-heading); text-transform: uppercase; letter-spacing: .1em; font-size: .82rem; color: var(--c-heading); }
+ .price__card p { margin-bottom: 6px; font-size: .85rem; }
+ .price__best { font-weight: 600; color: var(--c-accent-dark); }
+ .price__card .btn { margin-top: 16px; }
+ @media (max-width: 991px) { .prog { grid-template-columns: 1fr; } .price__row { grid-template-columns: 1fr; } }
+</style>
+```
+
+- [ ] **Step 3: verifica e commit**
+
+Run: `npm run dev`; controlla `/programs`, `/programs/performance-class` (pricing visibile, 3 card, accordion trimestrale/mensile chiusi), `/programs/rehab` (senza pricing). Confronta con i PDF. `npm run build` — devono comparire 6 pagine `programs/*` prerenderizzate.
+
+```bash
+git add -A && git commit -m "feat: pagine programmi con dettaglio e pricing performance class"
+```
+
+---
+
+### Task 13: Pagina Contatti e API email (TDD sulla validazione)
+
+**Files:**
+- Create: `src/lib/contact.ts`, `src/lib/mailer.ts`, `src/pages/api/contact.ts`, `src/pages/contact.astro`
+- Test: `tests/contact.test.ts`
+
+**Interfaces:**
+- Consumes: `rateLimit` (Task 4), `getEnv` (Task 4), `<ContactForm variant="full" />` (Task 7), `<PageHero />` (Task 10), `site` (Task 8).
+- Produces:
+ - `validateContact(data: unknown): { ok: true; value: ContactData } | { ok: false; error: string }` con `ContactData { firstName: string; lastName: string; phone: string; email: string; message: string; website: string }` (campi assenti → stringa vuota).
+ - `sendContactEmail(data: ContactData): Promise<void>` (nodemailer).
+ - `POST /api/contact` → 200 `{ ok: true }` | 400 `{ error }` | 429 `{ error }` | 502 `{ error }`.
+
+- [ ] **Step 1: test validazione (falliranno)**
+
+`tests/contact.test.ts`:
+```ts
+import { describe, it, expect } from 'vitest';
+import { validateContact } from '../src/lib/contact';
+
+describe('validateContact', () => {
+ const good = { firstName: 'Mario', email: 'mario@example.com', message: 'Ciao, vorrei informazioni.' };
+
+ it('accetta payload valido e normalizza i campi opzionali', () => {
+ const r = validateContact(good);
+ expect(r.ok).toBe(true);
+ if (r.ok) {
+ expect(r.value.lastName).toBe('');
+ expect(r.value.email).toBe('mario@example.com');
+ }
+ });
+
+ it('rifiuta email non valida', () => {
+ expect(validateContact({ ...good, email: 'non-email' }).ok).toBe(false);
+ });
+
+ it('rifiuta campi obbligatori mancanti o vuoti', () => {
+ expect(validateContact({ ...good, firstName: ' ' }).ok).toBe(false);
+ expect(validateContact({ ...good, message: '' }).ok).toBe(false);
+ expect(validateContact(null).ok).toBe(false);
+ });
+
+ it('rifiuta honeypot compilato', () => {
+ expect(validateContact({ ...good, website: 'spam.com' }).ok).toBe(false);
+ });
+
+ it('rifiuta messaggi oltre 5000 caratteri', () => {
+ expect(validateContact({ ...good, message: 'x'.repeat(5001) }).ok).toBe(false);
+ });
+});
+```
+
+- [ ] **Step 2: verifica che falliscano**
+
+Run: `npx vitest run tests/contact.test.ts` — Expected: FAIL.
+
+- [ ] **Step 3: implementa contact.ts**
+
+`src/lib/contact.ts`:
+```ts
+export interface ContactData {
+ firstName: string; lastName: string; phone: string;
+ email: string; message: string; website: string;
+}
+
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+function str(v: unknown, max: number): string {
+ return typeof v === 'string' ? v.trim().slice(0, max + 1) : '';
+}
+
+export function validateContact(data: unknown):
+ | { ok: true; value: ContactData }
+ | { ok: false; error: string } {
+ if (typeof data !== 'object' || data === null) return { ok: false, error: 'Dati non validi.' };
+ const d = data as Record<string, unknown>;
+ const value: ContactData = {
+ firstName: str(d.firstName, 100),
+ lastName: str(d.lastName, 100),
+ phone: str(d.phone, 40),
+ email: str(d.email, 200),
+ message: str(d.message, 5000),
+ website: str(d.website, 200),
+ };
+ if (value.website) return { ok: false, error: 'Richiesta non valida.' };
+ if (!value.firstName) return { ok: false, error: 'Il nome è obbligatorio.' };
+ if (!EMAIL_RE.test(value.email)) return { ok: false, error: 'Inserisci una e-mail valida.' };
+ if (!value.message) return { ok: false, error: 'Il messaggio è obbligatorio.' };
+ if (value.message.length > 5000) return { ok: false, error: 'Messaggio troppo lungo.' };
+ return { ok: true, value };
+}
+```
+Nota: `str()` taglia a `max + 1` così un messaggio oltre il limite non viene accettato silenziosamente ma rifiutato dal check di lunghezza.
+
+- [ ] **Step 4: verifica test verdi**
+
+Run: `npm test` — Expected: PASS.
+
+- [ ] **Step 5: mailer.ts e api/contact.ts**
+
+`src/lib/mailer.ts`:
+```ts
+import nodemailer from 'nodemailer';
+import { getEnv } from './env';
+import type { ContactData } from './contact';
+
+export async function sendContactEmail(data: ContactData): Promise<void> {
+ const transporter = nodemailer.createTransport({
+ host: getEnv('SMTP_HOST'),
+ port: Number(getEnv('SMTP_PORT', '587')),
+ secure: Number(getEnv('SMTP_PORT', '587')) === 465,
+ auth: { user: getEnv('SMTP_USER'), pass: getEnv('SMTP_PASS') },
+ });
+ await transporter.sendMail({
+ from: getEnv('CONTACT_FROM'),
+ to: getEnv('CONTACT_TO', 'info@insanitylab.it'),
+ replyTo: data.email,
+ subject: `Nuovo contatto dal sito: ${data.firstName} ${data.lastName}`.trim(),
+ text: [
+ `Nome: ${data.firstName} ${data.lastName}`.trim(),
+ data.phone && `Telefono: ${data.phone}`,
+ `E-mail: ${data.email}`,
+ '',
+ data.message,
+ ].filter(Boolean).join('\n'),
+ });
+}
+```
+
+`src/pages/api/contact.ts`:
+```ts
+import type { APIRoute } from 'astro';
+import { validateContact } from '../../lib/contact';
+import { sendContactEmail } from '../../lib/mailer';
+import { rateLimit } from '../../lib/rate-limit';
+
+export const prerender = false;
+
+const json = (status: number, body: object) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+export const POST: APIRoute = async ({ request, clientAddress }) => {
+ if (!rateLimit(`contact:${clientAddress}`, 5, 60 * 60 * 1000)) {
+ return json(429, { error: 'Troppe richieste, riprova più tardi.' });
+ }
+ let data: unknown;
+ try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
+ const result = validateContact(data);
+ if (!result.ok) return json(400, { error: result.error });
+ try {
+ await sendContactEmail(result.value);
+ } catch (err) {
+ console.error('Invio email fallito:', err);
+ return json(502, { error: 'Invio non riuscito, riprova più tardi o scrivici direttamente.' });
+ }
+ return json(200, { ok: true });
+};
+```
+
+- [ ] **Step 6: contact.astro**
+
+Layout da Contatti.pdf: mappa a sinistra (click-to-load), form a destra, sotto indirizzo/telefono/email + invito.
+
+```astro
+---
+import Base from '../layouts/Base.astro';
+import PageHero from '../components/PageHero.astro';
+import ContactForm from '../components/ContactForm.astro';
+import { site } from '../data/site';
+export const prerender = true;
+const mapsQuery = encodeURIComponent(`${site.address}, ${site.cityLine}`);
+---
+<Base title="Contatti" description="Contatta InsanityLab: Via Leandro Alberti 76, Bologna. Compila il form o chiamaci per una consulenza iniziale.">
+ <PageHero title="Contattaci" />
+ <section class="section">
+ <div class="container cgrid">
+ <div class="cmap" id="map-box">
+ <button class="btn btn--dark" id="map-load">Carica la mappa (Google Maps)</button>
+ <p class="cmap__note">La mappa viene caricata da Google solo dopo il tuo consenso.</p>
+ </div>
+ <ContactForm variant="full" />
+ </div>
+ <div class="container cinfo">
+ <div>
+ <h3>Indirizzo</h3>
+ <p>{site.address} {site.cityLine}</p>
+ </div>
+ <div>
+ <h3>Telefono</h3>
+ <p>{site.phone}</p>
+ </div>
+ <div>
+ <h3>E-mail</h3>
+ <p><a href={`mailto:${site.email}`}>{site.email}</a></p>
+ </div>
+ </div>
+ <div class="container">
+ <p class="cinvite">Per informazioni o per richiedere una consulenza iniziale, compila il form. Un team specializzato ti accoglierà e ti ricontatterà al più presto, oppure lasciaci il tuo contatto.</p>
+ </div>
+ </section>
+</Base>
+
+<style>
+ .cgrid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 50px; margin-bottom: 50px; }
+ .cmap { background: var(--c-bg-alt); min-height: 420px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; }
+ .cmap iframe { width: 100%; height: 100%; border: 0; min-height: 420px; }
+ .cmap__note { font-size: .8rem; color: var(--c-text-light); }
+ .cinfo { display: grid; grid-template-columns: repeat(3, 1fr); gap: 30px; margin-bottom: 30px; }
+ .cinfo h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; }
+ .cinvite { max-width: 640px; }
+ @media (max-width: 991px) { .cgrid, .cinfo { grid-template-columns: 1fr; } }
+</style>
+
+<script define:vars={{ mapsQuery }}>
+ document.getElementById('map-load')?.addEventListener('click', () => {
+ const box = document.getElementById('map-box');
+ box.innerHTML = `<iframe src="https://www.google.com/maps?q=${mapsQuery}&output=embed" loading="lazy" title="Mappa InsanityLab" referrerpolicy="no-referrer-when-downgrade"></iframe>`;
+ });
+</script>
+```
+
+- [ ] **Step 7: verifica manuale API**
+
+Run (con dev server attivo):
+```bash
+curl -s -X POST localhost:4321/api/contact -H 'Content-Type: application/json' -d '{"firstName":"Test","email":"t@t.it","message":"ciao"}'
+```
+Expected: `{"error":"Invio non riuscito..."}` (502, SMTP finto in `.env`) — la catena valida→invia funziona. Con honeypot: `-d '{"firstName":"T","email":"t@t.it","message":"x","website":"spam"}'` → 400.
+
+- [ ] **Step 8: commit**
+
+```bash
+git add -A && git commit -m "feat: pagina contatti e api email con validazione testata"
+```
+
+---
+
+### Task 14: Blog pubblico (`/blog`, `/blog/[slug]`)
+
+**Files:**
+- Create: `src/pages/blog/index.astro`, `src/pages/blog/[slug].astro`, `src/lib/blog-const.ts`, `src/components/blog/Sidebar.astro`
+
+**Interfaces:**
+- Consumes: `getDb`, `listPublished`, `getPublishedBySlug`, `listRecent`, `listArchiveMonths` (Task 5), `<PageHero />`.
+- Produces: `CATEGORIES: string[]` in `blog-const.ts` (usato anche dall'admin, Task 15).
+
+- [ ] **Step 1: blog-const.ts**
+
+```ts
+export const CATEGORIES = [
+ 'Articoli tecnici & scientifici',
+ 'Contenuti educativi',
+ 'LinkedIn / Insight',
+ 'Eventi & Presidi',
+] as const;
+```
+
+- [ ] **Step 2: blog/index.astro (SSR)**
+
+```astro
+---
+import Base from '../../layouts/Base.astro';
+import PageHero from '../../components/PageHero.astro';
+import { getDb } from '../../lib/db';
+import { listPublished } from '../../lib/posts';
+import { CATEGORIES } from '../../lib/blog-const';
+export const prerender = false;
+
+const PER_PAGE = 9;
+const url = Astro.url;
+const category = url.searchParams.get('categoria') ?? undefined;
+const page = Math.max(1, Number(url.searchParams.get('pagina') ?? '1') || 1);
+const { items, total } = listPublished(getDb(), {
+ category: category && (CATEGORIES as readonly string[]).includes(category) ? category : undefined,
+ page, perPage: PER_PAGE,
+});
+const pages = Math.max(1, Math.ceil(total / PER_PAGE));
+const fmt = (d: string | null) => d ? new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' }).format(new Date(d)) : '';
+const linkFor = (cat?: string, p = 1) => {
+ const q = new URLSearchParams();
+ if (cat) q.set('categoria', cat);
+ if (p > 1) q.set('pagina', String(p));
+ const s = q.toString();
+ return `/blog${s ? `?${s}` : ''}`;
+};
+---
+<Base title="Blog & Edugo" description="Edugo è lo spazio di InsanityLab dedicato alla conoscenza: articoli, guide e approfondimenti su allenamento, nutrizione e salute.">
+ <PageHero title="Blog & Edugo" />
+ <section class="section">
+ <div class="container">
+ <div class="section-heading">
+ <h2>Blog & Edugo</h2>
+ <p>Edugo è lo spazio di InsanityLab dedicato alla conoscenza: articoli, guide e approfondimenti su allenamento, nutrizione e salute per capire il corpo e migliorare il proprio benessere. Perché la conoscenza è il primo passo del cambiamento.</p>
+ </div>
+ <nav class="bfilters" aria-label="categorie">
+ <a class:list={['bfilters__item', { 'is-active': !category }]} href={linkFor()}>Mostra tutto</a>
+ {CATEGORIES.map((c) => (
+ <a class:list={['bfilters__item', { 'is-active': category === c }]} href={linkFor(c)}>{c}</a>
+ ))}
+ </nav>
+ {items.length === 0 ? (
+ <p style="text-align:center">Nessun articolo pubblicato{category ? ' in questa categoria' : ''}, torna a trovarci presto.</p>
+ ) : (
+ <div class="bgrid">
+ {items.map((post) => (
+ <article class="bcard">
+ {post.cover && <a href={`/blog/${post.slug}`}><img src={post.cover} alt="" loading="lazy" /></a>}
+ <p class="bcard__date">{fmt(post.published_at)}</p>
+ <h3><a href={`/blog/${post.slug}`}>{post.title}</a></h3>
+ <p>{post.excerpt}</p>
+ <a class="bcard__more" href={`/blog/${post.slug}`}>Per saperne di più →</a>
+ </article>
+ ))}
+ </div>
+ )}
+ {pages > 1 && (
+ <nav class="bpages" aria-label="paginazione">
+ {Array.from({ length: pages }, (_, i) => (
+ <a class:list={['bpages__n', { 'is-active': page === i + 1 }]} href={linkFor(category, i + 1)}>{i + 1}</a>
+ ))}
+ </nav>
+ )}
+ </div>
+ </section>
+</Base>
+
+<style>
+ .bfilters { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-bottom: 50px; }
+ .bfilters__item { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .16em; text-transform: uppercase; text-decoration: none; color: var(--c-text); padding: 10px 16px; }
+ .bfilters__item.is-active { background: var(--c-accent); color: #fff; }
+ .bgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; }
+ .bcard img { aspect-ratio: 4/3; object-fit: cover; width: 100%; margin-bottom: 14px; }
+ .bcard__date { font-size: .78rem; color: var(--c-text-light); margin-bottom: 4px; }
+ .bcard h3 a { text-decoration: none; }
+ .bcard__more { font-family: var(--font-heading); font-size: .7rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
+ .bpages { display: flex; gap: 8px; justify-content: center; margin-top: 50px; }
+ .bpages__n { font-family: var(--font-heading); text-decoration: none; padding: 8px 14px; color: var(--c-text); }
+ .bpages__n.is-active { background: var(--c-dark); color: #fff; }
+ @media (max-width: 991px) { .bgrid { grid-template-columns: 1fr 1fr; } }
+ @media (max-width: 600px) { .bgrid { grid-template-columns: 1fr; } }
+</style>
+```
+
+- [ ] **Step 3: Sidebar.astro**
+
+```astro
+---
+import { getDb } from '../../lib/db';
+import { listRecent, listArchiveMonths } from '../../lib/posts';
+import { CATEGORIES } from '../../lib/blog-const';
+const db = getDb();
+const recent = listRecent(db, 4);
+const months = listArchiveMonths(db);
+const monthLabel = (m: string) => new Intl.DateTimeFormat('it-IT', { month: 'long', year: 'numeric' }).format(new Date(`${m}-01`));
+---
+<aside class="bside">
+ <h3>Categorie</h3>
+ {CATEGORIES.map((c) => <p><a href={`/blog?categoria=${encodeURIComponent(c)}`}>{c}</a></p>)}
+ <h3>Ultimi articoli</h3>
+ {recent.map((p) => (
+ <p><a href={`/blog/${p.slug}`}>{p.title}</a></p>
+ ))}
+ <h3>Archivio</h3>
+ {months.map((m) => <p class="bside__month">{monthLabel(m.month)} ({m.count})</p>)}
+</aside>
+
+<style>
+ .bside h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; margin-top: 30px; }
+ .bside a { text-decoration: none; color: var(--c-text); }
+ .bside a:hover { color: var(--c-accent-dark); }
+ .bside__month { text-transform: capitalize; }
+</style>
+```
+
+- [ ] **Step 4: blog/[slug].astro (SSR)**
+
+```astro
+---
+import Base from '../../layouts/Base.astro';
+import Sidebar from '../../components/blog/Sidebar.astro';
+import { getDb } from '../../lib/db';
+import { getPublishedBySlug } from '../../lib/posts';
+export const prerender = false;
+
+const post = getPublishedBySlug(getDb(), Astro.params.slug!);
+if (!post) return Astro.redirect('/404');
+const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' });
+---
+<Base title={post.title} description={post.excerpt}>
+ <section class="section">
+ <div class="container art">
+ <article>
+ {post.cover && <img class="art__cover" src={post.cover} alt="" />}
+ <p class="art__meta">{post.published_at && fmt.format(new Date(post.published_at))} · {post.category}</p>
+ <h1>{post.title}</h1>
+ <div class="art__body" set:html={post.body_html} />
+ </article>
+ <Sidebar />
+ </div>
+ </section>
+</Base>
+
+<style>
+ .art { display: grid; grid-template-columns: 2.4fr 1fr; gap: 60px; }
+ .art__cover { width: 100%; margin-bottom: 24px; }
+ .art__meta { font-size: .8rem; color: var(--c-text-light); }
+ .art__body :global(img) { margin-block: 20px; }
+ .art__body :global(blockquote) { border-left: 3px solid var(--c-accent); margin: 24px 0; padding: 6px 0 6px 20px; font-style: italic; color: var(--c-heading); }
+ @media (max-width: 991px) { .art { grid-template-columns: 1fr; } }
+</style>
+```
+Nota: `body_html` è già sanitizzato al salvataggio (Task 15); `set:html` è accettabile solo per questo campo.
+
+- [ ] **Step 5: verifica con dati di prova**
+
+```bash
+sqlite3 data/insanitylab.db "INSERT INTO posts (title, slug, category, excerpt, body_html, draft, published_at) VALUES ('Articolo di prova', 'articolo-di-prova', 'Contenuti educativi', 'Un estratto di prova.', '<p>Corpo <strong>di prova</strong>.</p>', 0, datetime('now'));"
+curl -s localhost:4321/blog | grep -o 'Articolo di prova' | head -1
+curl -s localhost:4321/blog/articolo-di-prova | grep -o 'Corpo' | head -1
+curl -s -o /dev/null -w '%{http_code}' localhost:4321/blog/inesistente
+```
+Expected: `Articolo di prova`, `Corpo`, redirect/404 per lo slug inesistente. Verifica filtri categoria e paginazione con più insert se serve.
+
+- [ ] **Step 6: commit**
+
+```bash
+git add -A && git commit -m "feat: blog pubblico con filtri, paginazione e articolo"
+```
+
+---
+
+### Task 15: Area admin — login, lista, editor TipTap, API CRUD e upload (TDD su sanitizzazione)
+
+**Files:**
+- Create: `src/lib/sanitize.ts`, `src/lib/post-body.ts`, `src/layouts/Admin.astro`, `src/pages/admin/login.astro`, `src/pages/admin/logout.ts`, `src/pages/admin/index.astro`, `src/pages/admin/new.astro`, `src/pages/admin/edit/[id].astro`, `src/components/admin/PostForm.astro`, `src/pages/api/admin/posts/index.ts`, `src/pages/api/admin/posts/[id].ts`, `src/pages/api/admin/upload.ts`, `src/pages/uploads/[...path].ts`
+- Test: `tests/sanitize.test.ts`
+
+**Interfaces:**
+- Consumes: auth/middleware (Task 6), posts (Task 5), `slugify` (Task 4), `CATEGORIES` (Task 14), `rateLimit` (Task 4), `getEnv` (Task 4).
+- Produces:
+ - `sanitizeHtml(html: string): string` in `src/lib/sanitize.ts`.
+ - `POST /api/admin/posts` body `{ title, slug?, category, excerpt, body_html, cover?, draft }` → 201 `{ id, slug }`.
+ - `PUT /api/admin/posts/[id]` stesso body → 200; `DELETE /api/admin/posts/[id]` → 200.
+ - `POST /api/admin/upload` (multipart, campo `file`) → 200 `{ url: '/uploads/<nome>' }`.
+ - `GET /uploads/<file>` serve da `UPLOADS_DIR`.
+
+- [ ] **Step 1: test sanitizzazione (falliranno)**
+
+`tests/sanitize.test.ts`:
+```ts
+import { describe, it, expect } from 'vitest';
+import { sanitizeHtml } from '../src/lib/sanitize';
+
+describe('sanitizeHtml', () => {
+ it('mantiene la formattazione consentita', () => {
+ const html = '<h2>Titolo</h2><p>Testo <strong>forte</strong> ed <em>enfasi</em></p><ul><li>voce</li></ul><blockquote>citazione</blockquote>';
+ expect(sanitizeHtml(html)).toBe(html);
+ });
+ it('rimuove script e handler', () => {
+ expect(sanitizeHtml('<p onclick="x()">ciao</p><script>alert(1)</script>')).toBe('<p>ciao</p>');
+ });
+ it('consente immagini locali e https, blocca javascript:', () => {
+ expect(sanitizeHtml('<img src="/uploads/a.jpg">')).toContain('/uploads/a.jpg');
+ expect(sanitizeHtml('<a href="javascript:alert(1)">x</a>')).toBe('<a>x</a>');
+ });
+});
+```
+
+- [ ] **Step 2: verifica che falliscano, poi implementa sanitize.ts**
+
+Run: `npx vitest run tests/sanitize.test.ts` — FAIL. Poi:
+
+`src/lib/sanitize.ts`:
+```ts
+import sanitize from 'sanitize-html';
+
+export function sanitizeHtml(html: string): string {
+ return sanitize(html, {
+ allowedTags: ['h2', 'h3', 'p', 'strong', 'em', 'u', 's', 'ul', 'ol', 'li', 'a', 'img', 'blockquote', 'br', 'hr'],
+ allowedAttributes: { a: ['href', 'rel', 'target'], img: ['src', 'alt'] },
+ allowedSchemes: ['https', 'http', 'mailto'],
+ allowedSchemesAppliedToAttributes: ['href', 'src'],
+ allowProtocolRelative: false,
+ });
+}
+```
+
+Run: `npm test` — PASS. (Se `sanitize-html` mantiene `src` relativi di default lo verifichi col test; in caso contrario aggiungi `allowedSchemesByTag` o normalizza i percorsi.)
+
+- [ ] **Step 3: layout Admin e login**
+
+`src/layouts/Admin.astro`:
+```astro
+---
+interface Props { title: string }
+const { title } = Astro.props;
+const user = Astro.locals.user;
+---
+<!doctype html>
+<html lang="it">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>{title} — Admin InsanityLab</title>
+ <meta name="robots" content="noindex" />
+ <style is:global>
+ body { font-family: system-ui, sans-serif; margin: 0; background: #f4f2ef; color: #333; }
+ .abar { background: #3a2d26; color: #fff; padding: 12px 24px; display: flex; justify-content: space-between; align-items: center; }
+ .abar a { color: #d9cfc4; text-decoration: none; margin-left: 16px; }
+ .awrap { max-width: 960px; margin: 30px auto; padding: 0 20px; }
+ .abtn { background: #b5a48b; color: #fff; border: 0; padding: 10px 18px; cursor: pointer; font-size: .9rem; text-decoration: none; display: inline-block; }
+ .abtn--danger { background: #a33; }
+ .afield { width: 100%; padding: 10px; margin-bottom: 14px; border: 1px solid #ccc; box-sizing: border-box; font: inherit; }
+ table { width: 100%; border-collapse: collapse; background: #fff; }
+ th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #eee; font-size: .92rem; }
+ .pill { font-size: .75rem; padding: 2px 10px; border-radius: 10px; background: #e5decf; }
+ .pill--draft { background: #f3d9a4; }
+ </style>
+ </head>
+ <body>
+ <div class="abar">
+ <strong>InsanityLab · Admin</strong>
+ <span>
+ {user && <>Ciao, {user.username} <a href="/admin">Articoli</a> <a href="/admin/new">Nuovo</a> <a href="/admin/logout">Esci</a> <a href="/blog" target="_blank">Vedi sito ↗</a></>}
+ </span>
+ </div>
+ <div class="awrap"><slot /></div>
+ </body>
+</html>
+```
+
+`src/pages/admin/login.astro` (POST gestito nel frontmatter):
+```astro
+---
+import Admin from '../../layouts/Admin.astro';
+import { getDb } from '../../lib/db';
+import { login, SESSION_COOKIE } from '../../lib/auth';
+import { rateLimit } from '../../lib/rate-limit';
+export const prerender = false;
+
+let error = '';
+if (Astro.request.method === 'POST') {
+ if (!rateLimit(`login:${Astro.clientAddress}`, 5, 15 * 60 * 1000)) {
+ error = 'Troppi tentativi: riprova tra 15 minuti.';
+ } else {
+ const form = await Astro.request.formData();
+ const token = login(getDb(), String(form.get('username') ?? ''), String(form.get('password') ?? ''));
+ if (token) {
+ Astro.cookies.set(SESSION_COOKIE, token, {
+ httpOnly: true, sameSite: 'lax', path: '/',
+ secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
+ });
+ return Astro.redirect('/admin');
+ }
+ error = 'Credenziali non valide.';
+ }
+}
+---
+<Admin title="Login">
+ <h1>Accesso</h1>
+ {error && <p style="color:#a33">{error}</p>}
+ <form method="post" style="max-width:360px">
+ <input class="afield" name="username" placeholder="Utente" required autocomplete="username" />
+ <input class="afield" type="password" name="password" placeholder="Password" required autocomplete="current-password" />
+ <button class="abtn" type="submit">Entra</button>
+ </form>
+</Admin>
+```
+
+`src/pages/admin/logout.ts`:
+```ts
+import type { APIRoute } from 'astro';
+import { getDb } from '../../lib/db';
+import { logout, SESSION_COOKIE } from '../../lib/auth';
+export const prerender = false;
+
+export const GET: APIRoute = ({ cookies, redirect }) => {
+ const token = cookies.get(SESSION_COOKIE)?.value;
+ if (token) logout(getDb(), token);
+ cookies.delete(SESSION_COOKIE, { path: '/' });
+ return redirect('/admin/login');
+};
+```
+
+- [ ] **Step 4: lista articoli**
+
+`src/pages/admin/index.astro`:
+```astro
+---
+import Admin from '../../layouts/Admin.astro';
+import { getDb } from '../../lib/db';
+import { listAllPosts } from '../../lib/posts';
+export const prerender = false;
+const posts = listAllPosts(getDb());
+const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' });
+---
+<Admin title="Articoli">
+ <h1>Articoli</h1>
+ <p><a class="abtn" href="/admin/new">+ Nuovo articolo</a></p>
+ <table>
+ <thead><tr><th>Titolo</th><th>Categoria</th><th>Stato</th><th>Aggiornato</th><th></th></tr></thead>
+ <tbody>
+ {posts.map((p) => (
+ <tr>
+ <td><a href={`/admin/edit/${p.id}`}>{p.title}</a></td>
+ <td>{p.category}</td>
+ <td><span class:list={['pill', { 'pill--draft': p.draft }]}>{p.draft ? 'Bozza' : 'Pubblicato'}</span></td>
+ <td>{fmt.format(new Date(p.updated_at.replace(' ', 'T') + 'Z'))}</td>
+ <td><button class="abtn abtn--danger" data-del={p.id}>Elimina</button></td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ {posts.length === 0 && <p>Nessun articolo: creane uno nuovo.</p>}
+</Admin>
+
+<script>
+ document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
+ b.addEventListener('click', async () => {
+ if (!confirm('Eliminare definitivamente questo articolo?')) return;
+ const res = await fetch(`/api/admin/posts/${b.dataset.del}`, { method: 'DELETE' });
+ if (res.ok) location.reload();
+ else alert('Eliminazione non riuscita.');
+ }));
+</script>
+```
+
+- [ ] **Step 5: PostForm.astro con TipTap**
+
+```astro
+---
+import { CATEGORIES } from '../../lib/blog-const';
+import type { Post } from '../../lib/posts';
+interface Props { post?: Post }
+const { post } = Astro.props;
+---
+<form id="post-form" data-id={post?.id ?? ''}>
+ <input class="afield" name="title" placeholder="Titolo" required value={post?.title ?? ''} />
+ <input class="afield" name="slug" placeholder="Slug (vuoto = generato dal titolo)" value={post?.slug ?? ''} />
+ <select class="afield" name="category">
+ {CATEGORIES.map((c) => <option value={c} selected={post?.category === c}>{c}</option>)}
+ </select>
+ <textarea class="afield" name="excerpt" placeholder="Estratto (anteprima in lista blog)" maxlength="300">{post?.excerpt ?? ''}</textarea>
+
+ <label>Copertina: <input type="file" id="cover-file" accept="image/*" /></label>
+ <input type="hidden" name="cover" value={post?.cover ?? ''} />
+ <p id="cover-preview">{post?.cover && <img src={post.cover} style="max-width:200px" />}</p>
+
+ <div class="ed-toolbar" id="ed-toolbar">
+ <button type="button" data-cmd="h2">H2</button>
+ <button type="button" data-cmd="h3">H3</button>
+ <button type="button" data-cmd="bold"><b>B</b></button>
+ <button type="button" data-cmd="italic"><i>I</i></button>
+ <button type="button" data-cmd="bullet">• Elenco</button>
+ <button type="button" data-cmd="ordered">1. Elenco</button>
+ <button type="button" data-cmd="quote">“ Citazione</button>
+ <button type="button" data-cmd="link">Link</button>
+ <button type="button" data-cmd="image">Immagine</button>
+ </div>
+ <div id="editor" class="ed-body"></div>
+
+ <label><input type="checkbox" name="draft" checked={post ? post.draft === 1 : true} /> Bozza (non visibile sul sito)</label>
+ <p><button class="abtn" type="submit">Salva</button> <span id="save-msg"></span></p>
+</form>
+
+<style>
+ .ed-toolbar { display: flex; gap: 6px; flex-wrap: wrap; background: #fff; border: 1px solid #ccc; border-bottom: 0; padding: 8px; }
+ .ed-toolbar button { background: #eee; border: 1px solid #ccc; padding: 4px 10px; cursor: pointer; }
+ .ed-toolbar button.is-active { background: #b5a48b; color: #fff; }
+ .ed-body { background: #fff; border: 1px solid #ccc; min-height: 340px; padding: 12px 16px; margin-bottom: 14px; }
+ .ed-body :global(.ProseMirror) { outline: none; min-height: 320px; }
+</style>
+
+<script>
+ import { Editor } from '@tiptap/core';
+ import StarterKit from '@tiptap/starter-kit';
+ import Image from '@tiptap/extension-image';
+ import Link from '@tiptap/extension-link';
+
+ const form = document.getElementById('post-form') as HTMLFormElement;
+ const initial = (document.getElementById('initial-body') as HTMLScriptElement | null)?.textContent ?? '';
+
+ const editor = new Editor({
+ element: document.getElementById('editor')!,
+ extensions: [
+ StarterKit.configure({ heading: { levels: [2, 3] } }),
+ Image,
+ Link.configure({ openOnClick: false }),
+ ],
+ content: initial,
+ });
+
+ async function uploadFile(file: File): Promise<string | null> {
+ const fd = new FormData();
+ fd.append('file', file);
+ const res = await fetch('/api/admin/upload', { method: 'POST', body: fd });
+ if (!res.ok) { alert('Upload non riuscito.'); return null; }
+ return (await res.json()).url;
+ }
+
+ document.getElementById('ed-toolbar')!.addEventListener('click', async (e) => {
+ const btn = (e.target as HTMLElement).closest('button');
+ if (!btn) return;
+ const c = editor.chain().focus();
+ switch (btn.dataset.cmd) {
+ case 'h2': c.toggleHeading({ level: 2 }).run(); break;
+ case 'h3': c.toggleHeading({ level: 3 }).run(); break;
+ case 'bold': c.toggleBold().run(); break;
+ case 'italic': c.toggleItalic().run(); break;
+ case 'bullet': c.toggleBulletList().run(); break;
+ case 'ordered': c.toggleOrderedList().run(); break;
+ case 'quote': c.toggleBlockquote().run(); break;
+ case 'link': {
+ const url = prompt('URL del link:');
+ if (url) c.setLink({ href: url }).run();
+ break;
+ }
+ case 'image': {
+ const input = document.createElement('input');
+ input.type = 'file'; input.accept = 'image/*';
+ input.onchange = async () => {
+ const url = input.files?.[0] && await uploadFile(input.files[0]);
+ if (url) editor.chain().focus().setImage({ src: url }).run();
+ };
+ input.click();
+ break;
+ }
+ }
+ });
+
+ document.getElementById('cover-file')!.addEventListener('change', async (e) => {
+ const file = (e.target as HTMLInputElement).files?.[0];
+ if (!file) return;
+ const url = await uploadFile(file);
+ if (url) {
+ (form.elements.namedItem('cover') as HTMLInputElement).value = url;
+ document.getElementById('cover-preview')!.innerHTML = `<img src="${url}" style="max-width:200px">`;
+ }
+ });
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const id = form.dataset.id;
+ const body = {
+ title: (form.elements.namedItem('title') as HTMLInputElement).value,
+ slug: (form.elements.namedItem('slug') as HTMLInputElement).value,
+ category: (form.elements.namedItem('category') as HTMLSelectElement).value,
+ excerpt: (form.elements.namedItem('excerpt') as HTMLTextAreaElement).value,
+ cover: (form.elements.namedItem('cover') as HTMLInputElement).value || null,
+ draft: (form.elements.namedItem('draft') as HTMLInputElement).checked,
+ body_html: editor.getHTML(),
+ };
+ const res = await fetch(id ? `/api/admin/posts/${id}` : '/api/admin/posts', {
+ method: id ? 'PUT' : 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const msg = document.getElementById('save-msg')!;
+ if (res.ok) {
+ msg.textContent = 'Salvato ✓';
+ if (!id) location.href = '/admin';
+ } else {
+ msg.textContent = (await res.json()).error ?? 'Errore di salvataggio';
+ }
+ });
+</script>
+```
+
+`src/pages/admin/new.astro`:
+```astro
+---
+import Admin from '../../layouts/Admin.astro';
+import PostForm from '../../components/admin/PostForm.astro';
+export const prerender = false;
+---
+<Admin title="Nuovo articolo">
+ <h1>Nuovo articolo</h1>
+ <script id="initial-body" type="text/plain"></script>
+ <PostForm />
+</Admin>
+```
+
+`src/pages/admin/edit/[id].astro`:
+```astro
+---
+import Admin from '../../../layouts/Admin.astro';
+import PostForm from '../../../components/admin/PostForm.astro';
+import { getDb } from '../../../lib/db';
+import { getPostById } from '../../../lib/posts';
+export const prerender = false;
+const post = getPostById(getDb(), Number(Astro.params.id));
+if (!post) return Astro.redirect('/admin');
+---
+<Admin title={`Modifica: ${post.title}`}>
+ <h1>Modifica articolo</h1>
+ <script id="initial-body" type="text/plain" set:html={post.body_html}></script>
+ <PostForm post={post} />
+</Admin>
+```
+
+- [ ] **Step 6: API CRUD**
+
+`src/lib/post-body.ts` (parsing condiviso tra POST e PUT):
+```ts
+import { slugify } from './slug';
+import { sanitizeHtml } from './sanitize';
+import { CATEGORIES } from './blog-const';
+
+export function parsePostBody(data: Record<string, unknown>) {
+ const title = String(data.title ?? '').trim();
+ if (!title) return { error: 'Il titolo è obbligatorio.' } as const;
+ const category = String(data.category ?? '');
+ if (!(CATEGORIES as readonly string[]).includes(category)) return { error: 'Categoria non valida.' } as const;
+ const slug = slugify(String(data.slug ?? '') || title);
+ if (!slug) return { error: 'Slug non valido.' } as const;
+ return {
+ value: {
+ title, slug, category,
+ excerpt: String(data.excerpt ?? '').trim().slice(0, 300),
+ body_html: sanitizeHtml(String(data.body_html ?? '')),
+ cover: data.cover ? String(data.cover) : null,
+ draft: Boolean(data.draft),
+ },
+ } as const;
+}
+```
+
+`src/pages/api/admin/posts/index.ts`:
+```ts
+import type { APIRoute } from 'astro';
+import { getDb } from '../../../../lib/db';
+import { createPost } from '../../../../lib/posts';
+import { parsePostBody } from '../../../../lib/post-body';
+export const prerender = false;
+
+const json = (status: number, body: object) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+export const POST: APIRoute = async ({ request }) => {
+ let data: Record<string, unknown>;
+ try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
+ const parsed = parsePostBody(data);
+ if ('error' in parsed) return json(400, { error: parsed.error });
+ try {
+ const id = createPost(getDb(), parsed.value);
+ return json(201, { id, slug: parsed.value.slug });
+ } catch (err: any) {
+ if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' });
+ throw err;
+ }
+};
+```
+
+`src/pages/api/admin/posts/[id].ts`:
+```ts
+import type { APIRoute } from 'astro';
+import { getDb } from '../../../../lib/db';
+import { getPostById, updatePost, deletePost } from '../../../../lib/posts';
+import { parsePostBody } from '../../../../lib/post-body';
+export const prerender = false;
+
+const json = (status: number, body: object) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+export const PUT: APIRoute = async ({ params, request }) => {
+ const id = Number(params.id);
+ if (!getPostById(getDb(), id)) return json(404, { error: 'Articolo non trovato.' });
+ let data: Record<string, unknown>;
+ try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
+ const parsed = parsePostBody(data);
+ if ('error' in parsed) return json(400, { error: parsed.error });
+ try {
+ updatePost(getDb(), id, parsed.value);
+ return json(200, { ok: true });
+ } catch (err: any) {
+ if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' });
+ throw err;
+ }
+};
+
+export const DELETE: APIRoute = ({ params }) => {
+ deletePost(getDb(), Number(params.id));
+ return json(200, { ok: true });
+};
+```
+
+- [ ] **Step 7: upload e route file**
+
+`src/pages/api/admin/upload.ts`:
+```ts
+import type { APIRoute } from 'astro';
+import { writeFile, mkdir } from 'node:fs/promises';
+import { randomBytes } from 'node:crypto';
+import { join, extname } from 'node:path';
+import { getEnv } from '../../../lib/env';
+export const prerender = false;
+
+const ALLOWED: Record<string, string> = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif' };
+const MAX_BYTES = 5 * 1024 * 1024;
+
+const json = (status: number, body: object) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+export const POST: APIRoute = async ({ request }) => {
+ const form = await request.formData().catch(() => null);
+ const file = form?.get('file');
+ if (!(file instanceof File)) return json(400, { error: 'Nessun file.' });
+ const ext = extname(file.name).toLowerCase();
+ if (!ALLOWED[ext]) return json(400, { error: 'Formato non consentito (jpg, png, webp, gif).' });
+ if (file.size > MAX_BYTES) return json(400, { error: 'File troppo grande (max 5 MB).' });
+ const dir = getEnv('UPLOADS_DIR', 'uploads');
+ await mkdir(dir, { recursive: true });
+ const name = `${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
+ await writeFile(join(dir, name), Buffer.from(await file.arrayBuffer()));
+ return json(200, { url: `/uploads/${name}` });
+};
+```
+
+`src/pages/uploads/[...path].ts`:
+```ts
+import type { APIRoute } from 'astro';
+import { readFile } from 'node:fs/promises';
+import { join, normalize, extname } from 'node:path';
+import { getEnv } from '../../lib/env';
+export const prerender = false;
+
+const MIME: Record<string, string> = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif' };
+
+export const GET: APIRoute = async ({ params }) => {
+ const rel = normalize(params.path ?? '').replace(/^(\.\.[/\\])+/, '');
+ if (!rel || rel.includes('..')) return new Response('Not found', { status: 404 });
+ const mime = MIME[extname(rel).toLowerCase()];
+ if (!mime) return new Response('Not found', { status: 404 });
+ try {
+ const buf = await readFile(join(getEnv('UPLOADS_DIR', 'uploads'), rel));
+ return new Response(buf, { headers: { 'Content-Type': mime, 'Cache-Control': 'public, max-age=31536000, immutable' } });
+ } catch {
+ return new Response('Not found', { status: 404 });
+ }
+};
+```
+
+- [ ] **Step 8: verifica end-to-end manuale**
+
+Con dev server attivo e utente creato (`npm run create-user -- admin prova1234`):
+1. `curl -s -o /dev/null -w '%{http_code}' localhost:4321/admin` → `302` (redirect a login da non autenticati).
+2. `curl -s -X POST localhost:4321/api/admin/posts -H 'Content-Type: application/json' -d '{}'` → `401`.
+3. Browser: login su `/admin/login`, crea articolo con immagine nel corpo, salva come pubblicato, verifica su `/blog` e `/blog/<slug>`; modifica; elimina (con conferma).
+Expected: flusso completo senza errori console.
+
+- [ ] **Step 9: commit**
+
+```bash
+git add -A && git commit -m "feat: area admin con editor tiptap, api crud e upload immagini"
+```
+
+---
+
+### Task 16: SEO, favicon, robots e smoke test finale
+
+**Files:**
+- Create: `public/robots.txt`, `public/favicon.svg`, `scripts/smoke.sh`
+
+**Interfaces:**
+- Consumes: tutto il sito.
+
+- [ ] **Step 1: robots e favicon**
+
+`public/robots.txt`:
+```
+User-agent: *
+Disallow: /admin
+Disallow: /api
+Sitemap: https://insanitylab.tielogic.xyz/sitemap-index.xml
+```
+
+`public/favicon.svg` — battito/onda del logo InsanityLab su fondo scuro (ricava la forma dal logo estratto; in mancanza):
+```svg
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+ <rect width="32" height="32" rx="6" fill="#3a2d26"/>
+ <path d="M4 18h5l3-8 4 12 3-6h9" fill="none" stroke="#b5a48b" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
+```
+
+- [ ] **Step 2: smoke test**
+
+`scripts/smoke.sh`:
+```bash
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")/.."
+npm run build
+PORT=4399 node --env-file=.env ./dist/server/entry.mjs & SRV=$!
+trap 'kill $SRV' EXIT
+sleep 2
+fail=0
+for path in / /about /training /programs /programs/performance-class /programs/rehab /blog /contact /admin/login /robots.txt; do
+ code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:4399$path")
+ echo "$code $path"
+ [ "$code" = "200" ] || fail=1
+done
+code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/admin)
+echo "$code /admin (atteso 302)"
+[ "$code" = "302" ] || fail=1
+exit $fail
+```
+
+Run: `chmod +x scripts/smoke.sh && ./scripts/smoke.sh`
+Expected: tutti `200`, `/admin` `302`, exit 0. (Il server standalone usa `PORT`; se la porta è occupata cambiala.)
+
+- [ ] **Step 3: controllo qualità finale**
+
+- `npm test` → tutti verdi.
+- `npx astro check` → nessun errore.
+- Lighthouse rapido (facoltativo): `npx lighthouse http://localhost:4399/ --quiet --chrome-flags='--headless'` — performance e SEO ≥ 90 attesi su pagine statiche.
+
+- [ ] **Step 4: commit**
+
+```bash
+git add -A && git commit -m "chore: robots, favicon e smoke test"
+```
+
+---
+
+### Task 17: Remote GitLab e push
+
+**Files:**
+- Nessun file di progetto; solo configurazione git.
+
+- [ ] **Step 1: verifica accesso GitLab**
+
+Run: `command -v glab && glab auth status`
+- Se `glab` autenticato: `glab repo create insanitylab-website --private --source . --remote origin --push`.
+- Se `glab` assente o non autenticato: chiedi all'operatore l'URL del repo GitLab (o le credenziali/namespace) e usa `git remote add origin <url> && git push -u origin main`.
+
+- [ ] **Step 2: verifica push**
+
+Run: `git remote -v && git log origin/main --oneline | head -3`
+Expected: remote configurato, commit presenti sul remote.
+
+---
+
+## Ordine di esecuzione e note
+
+1 → 2 → 3 → 4 → 5 → 6 → 8 → 7 → 9 → 10 → 11 → 12 → 13 → 14 → 15 → 16 → 17
+(Il Task 8 precede il 7 perché Header/Footer consumano `site.ts`; i task 9–15 dipendono da 7+8.)
+
+**Punti aperti da segnalare all'operatore a fine esecuzione:**
+- Testi Storylab 2025/2026/OGGI mancanti (placeholder «Contenuto in arrivo»).
+- Orario agenda = placeholder dal template (nomi trainer finti).
+- URL social reali da confermare in `src/data/site.ts`.
+- Credenziali SMTP reali da mettere in `.env` (ora finte).
+- Testi estesi/FAQ dei programmi diversi da Performance Class: bozze ragionevoli da far validare al cliente.
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..a60ca66
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,6875 @@
+{
+ "name": "insanitylab-website",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "insanitylab-website",
+ "version": "0.1.0",
+ "dependencies": {
+ "@astrojs/node": "^11.0.1",
+ "@astrojs/sitemap": "^3.7.3",
+ "@fontsource/montserrat": "^5.2.8",
+ "@fontsource/open-sans": "^5.2.7",
+ "@tiptap/core": "^3.27.1",
+ "@tiptap/extension-image": "^3.27.1",
+ "@tiptap/extension-link": "^3.27.1",
+ "@tiptap/starter-kit": "^3.27.1",
+ "astro": "^7.0.5",
+ "bcryptjs": "^3.0.3",
+ "better-sqlite3": "^12.11.1",
+ "nodemailer": "^9.0.3",
+ "sanitize-html": "^2.17.5"
+ },
+ "devDependencies": {
+ "@astrojs/check": "^0.9.9",
+ "@types/bcryptjs": "^2.4.6",
+ "@types/better-sqlite3": "^7.6.13",
+ "@types/nodemailer": "^8.0.1",
+ "@types/sanitize-html": "^2.16.1",
+ "typescript": "^6.0.3",
+ "vitest": "^4.1.9"
+ }
+ },
+ "node_modules/@astrojs/check": {
+ "version": "0.9.9",
+ "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.9.tgz",
+ "integrity": "sha512-A5UW8uIuErLWEoRQvzgXpO1gTjUFtK8r7nU2Z7GewAMxUb7bPvpk11qaKKgxqXlHJWlAvaaxy+Xg28A6bmQ1Tg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@astrojs/language-server": "^2.16.7",
+ "chokidar": "^4.0.3",
+ "kleur": "^4.1.5",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "astro-check": "bin/astro-check.js"
+ },
+ "peerDependencies": {
+ "typescript": "^5.0.0 || ^6.0.0"
+ }
+ },
+ "node_modules/@astrojs/check/node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@astrojs/check/node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@astrojs/compiler": {
+ "version": "2.13.1",
+ "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.1.tgz",
+ "integrity": "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@astrojs/compiler-binding": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.3.0.tgz",
+ "integrity": "sha512-zlsOT5COD9hRwplJCgQhS21unxON5AKirf0vgt1ijXwuseYIaZdm2ZOpF8fsz+DY9EyXx+I/ukxtg7uoBep68A==",
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@astrojs/compiler-binding-darwin-arm64": "0.3.0",
+ "@astrojs/compiler-binding-darwin-x64": "0.3.0",
+ "@astrojs/compiler-binding-linux-arm64-gnu": "0.3.0",
+ "@astrojs/compiler-binding-linux-arm64-musl": "0.3.0",
+ "@astrojs/compiler-binding-linux-x64-gnu": "0.3.0",
+ "@astrojs/compiler-binding-linux-x64-musl": "0.3.0",
+ "@astrojs/compiler-binding-wasm32-wasi": "0.3.0",
+ "@astrojs/compiler-binding-win32-arm64-msvc": "0.3.0",
+ "@astrojs/compiler-binding-win32-x64-msvc": "0.3.0"
+ }
+ },
+ "node_modules/@astrojs/compiler-binding-darwin-arm64": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-arm64/-/compiler-binding-darwin-arm64-0.3.0.tgz",
+ "integrity": "sha512-3n0uu+uJpnCq8b4JFi3uGDsIisAvHctxSmH+cIO9Gbei1H1Y1QXaYboXyiWJugUmprr3OEYP7+LdodzpVFzLMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@astrojs/compiler-binding-darwin-x64": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-x64/-/compiler-binding-darwin-x64-0.3.0.tgz",
+ "integrity": "sha512-scxNGKjOBydMo1QR4LtK0FMgh7ubQomJDv953nz2msQFkPKke/0FpPv/cQM0T/kuZdReZQFU8Oz3iOrP/6WHEg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@astrojs/compiler-binding-linux-arm64-gnu": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-gnu/-/compiler-binding-linux-arm64-gnu-0.3.0.tgz",
+ "integrity": "sha512-NZrWLolVUANmrnl0zrFK/Sx5Sock1gEUT49ALfMTTCA5Ya2ec/BoJXMIg4KgE+wZcrdXJ8e+WyEhM7YLk/FJkA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@astrojs/compiler-binding-linux-arm64-musl": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-musl/-/compiler-binding-linux-arm64-musl-0.3.0.tgz",
+ "integrity": "sha512-PjwRmKgMFDsFhg82g0poXlIY8Qn3fMA3hXjaR0coJWJzTJsRH9ATU0j2ocigjtU1h3vL/yR7yLUxGj/lTCq73g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@astrojs/compiler-binding-linux-x64-gnu": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-gnu/-/compiler-binding-linux-x64-gnu-0.3.0.tgz",
+ "integrity": "sha512-Dr69VJYlnSfyL8gzELW6S4mE41P7TDPn1IKjwMnjdZ7+dxgJI50oMLFSk1LVe26bHmWB3ktuh8fDVK1THI9e9A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@astrojs/compiler-binding-linux-x64-musl": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-musl/-/compiler-binding-linux-x64-musl-0.3.0.tgz",
+ "integrity": "sha512-AEt+bRw8PfImCcyRH1lpXVB8CdmQ1K/wPo5u99iec4/U/XdNvQZ715YVuNzIJpbJXelgQeZ5H2+Ea7XwRyWY5g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@astrojs/compiler-binding-wasm32-wasi": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-wasm32-wasi/-/compiler-binding-wasm32-wasi-0.3.0.tgz",
+ "integrity": "sha512-U80tA1j8V6LjhiTZzVCtG4E8hrNVVNXDGV5fCgJ94q8FU9CPH+XwdDDhLzBybfWhKfyItXmQiZNRPTiPCYTpVg==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@napi-rs/wasm-runtime": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@astrojs/compiler-binding-win32-arm64-msvc": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-arm64-msvc/-/compiler-binding-win32-arm64-msvc-0.3.0.tgz",
+ "integrity": "sha512-CpY1RII2r1XMpOUVD1VR/F2wtuRsiOCkFULS10Khyj8/DFZMtxVuUCAWGw+CW2Ka0h6eP3Xc1CA+glFlvXMPxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@astrojs/compiler-binding-win32-x64-msvc": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-x64-msvc/-/compiler-binding-win32-x64-msvc-0.3.0.tgz",
+ "integrity": "sha512-qmFbs769oeeGrRebAnCW7aBk8m71vf85W/dX/jddfx5Z06/w0wf7TZCfJPOX1Fld2t+4N+iXzfGEJG+zJQ+bzg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@astrojs/compiler-rs": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@astrojs/compiler-rs/-/compiler-rs-0.3.0.tgz",
+ "integrity": "sha512-J2qEVHtIDjEM9TxwmwuebOGmZNwhKu/dR7P7qBpnJKGmBBX0vdweQ/4cEXhj8fBbWVUB5V12xWChri3CgKNULQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@astrojs/compiler-binding": "0.3.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@astrojs/internal-helpers": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.10.0.tgz",
+ "integrity": "sha512-Ry2R3VPeIN4uPCSA4xQc+e+vsJXkalKpEbDc07hV+a/o5Bs2N/s/uDcPJH/05L19DKh9tAy7e6JM3YZ6Cxfezw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.4",
+ "@types/mdast": "^4.0.4",
+ "js-yaml": "^4.1.1",
+ "picomatch": "^4.0.4",
+ "retext-smartypants": "^6.2.0",
+ "shiki": "^4.0.2",
+ "smol-toml": "^1.6.0",
+ "unified": "^11.0.5"
+ }
+ },
+ "node_modules/@astrojs/language-server": {
+ "version": "2.16.10",
+ "resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.16.10.tgz",
+ "integrity": "sha512-87VQ/5GSdHlRnUA+hGuerYyIGAj+9RbZmATyuKLEUePinUXhQ5YkRnRrHhOD9sSi5JOErLjrLkHnfZFEvGrV8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@astrojs/compiler": "^2.13.1",
+ "@astrojs/yaml2ts": "^0.2.4",
+ "@jridgewell/sourcemap-codec": "^1.5.5",
+ "@volar/kit": "~2.4.28",
+ "@volar/language-core": "~2.4.28",
+ "@volar/language-server": "~2.4.28",
+ "@volar/language-service": "~2.4.28",
+ "muggle-string": "^0.4.1",
+ "tinyglobby": "^0.2.16",
+ "volar-service-css": "0.0.70",
+ "volar-service-emmet": "0.0.70",
+ "volar-service-html": "0.0.70",
+ "volar-service-prettier": "0.0.70",
+ "volar-service-typescript": "0.0.70",
+ "volar-service-typescript-twoslash-queries": "0.0.70",
+ "volar-service-yaml": "0.0.70",
+ "vscode-html-languageservice": "^5.6.2",
+ "vscode-uri": "^3.1.0"
+ },
+ "bin": {
+ "astro-ls": "bin/nodeServer.js"
+ },
+ "peerDependencies": {
+ "prettier": "^3.0.0",
+ "prettier-plugin-astro": ">=0.11.0"
+ },
+ "peerDependenciesMeta": {
+ "prettier": {
+ "optional": true
+ },
+ "prettier-plugin-astro": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@astrojs/markdown-satteri": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.3.2.tgz",
+ "integrity": "sha512-feXuUPy41gVfeM7EHT1ciUim8ozGr+YHXab9uUBc1Hk8y60DQosO8ldL+AoPXnCAoGj1OChwHfvXmmJ6XVnY9A==",
+ "license": "MIT",
+ "dependencies": {
+ "@astrojs/internal-helpers": "0.10.0",
+ "@astrojs/prism": "4.0.2",
+ "github-slugger": "^2.0.0",
+ "satteri": "^0.9.1"
+ }
+ },
+ "node_modules/@astrojs/node": {
+ "version": "11.0.1",
+ "resolved": "https://registry.npmjs.org/@astrojs/node/-/node-11.0.1.tgz",
+ "integrity": "sha512-92s+sCSxe40xmiJfHH1Dti4uawI40/48yaL2GmVk+EbiaoztECmsIL5wBEB5xwMLI/9VBy8lZMqmfAd5u4OxhQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@astrojs/internal-helpers": "0.10.0",
+ "send": "^1.2.1",
+ "server-destroy": "^1.0.1"
+ },
+ "peerDependencies": {
+ "astro": "^7.0.0"
+ }
+ },
+ "node_modules/@astrojs/prism": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz",
+ "integrity": "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==",
+ "license": "MIT",
+ "dependencies": {
+ "prismjs": "^1.30.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@astrojs/sitemap": {
+ "version": "3.7.3",
+ "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.3.tgz",
+ "integrity": "sha512-f8euLVsyeAmAkSm/1M2Kb8sL8byQmfgbvBNaHFItCheTj/IpiJYSEWVcqDHZ/yEHxiS7+w87mQkzwZaPHmk5GA==",
+ "license": "MIT",
+ "dependencies": {
+ "sitemap": "^9.0.0",
+ "stream-replace-string": "^2.0.0",
+ "zod": "^4.3.6"
+ }
+ },
+ "node_modules/@astrojs/telemetry": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.2.tgz",
+ "integrity": "sha512-j8DNruA8ors99Al39RYZPJK4DC1bKkoNm93mAMuBhY9TCNC4R8n1q7ovFnJ5qhGh5Lsh7pa1gpQVpYpsJPeTHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ci-info": "^4.4.0",
+ "dset": "^3.1.4",
+ "is-docker": "^4.0.0",
+ "is-wsl": "^3.1.1",
+ "which-pm-runs": "^1.1.0"
+ },
+ "engines": {
+ "node": "18.20.8 || ^20.3.0 || >=22.0.0"
+ }
+ },
+ "node_modules/@astrojs/yaml2ts": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/@astrojs/yaml2ts/-/yaml2ts-0.2.4.tgz",
+ "integrity": "sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yaml": "^2.8.3"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bruits/satteri-darwin-arm64": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.9.4.tgz",
+ "integrity": "sha512-W3MSUkr2mZRR8Stoe+lqNAyzQzRuFMU8WffV9IvFSxTok0LGWR0ZZQPLELU4QTRiUbhL2Y4VUP9vV7pj8rHjgg==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@bruits/satteri-darwin-x64": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-x64/-/satteri-darwin-x64-0.9.4.tgz",
+ "integrity": "sha512-DXOuuaE1lsv7mpk2mOvGrzqoEWEvOIZEO/fXVa7zfM23Iob+CBjBkRAMwpHA4pmZ3j6Gj7WJzPKw0kQ7w741AQ==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@bruits/satteri-linux-arm64-gnu": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-gnu/-/satteri-linux-arm64-gnu-0.9.4.tgz",
+ "integrity": "sha512-gJxU9rGGoqIznSEgEzpjxkry24jeHuMpoo1tCIAhHYh7WaD3j5F8zt3jmHxEaN1Uwa+K5+wFgIR2uIGOnMzEmw==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@bruits/satteri-linux-arm64-musl": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-musl/-/satteri-linux-arm64-musl-0.9.4.tgz",
+ "integrity": "sha512-Wjzu9hmmAbfmDkBfPI1VdZygJtYz9uYZQnkEyrXi6S2JFi+2pXQ1A5irj38bqm0IZmWcTbk0cVG4NZnPdtVNJA==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@bruits/satteri-linux-x64-gnu": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-gnu/-/satteri-linux-x64-gnu-0.9.4.tgz",
+ "integrity": "sha512-MR1Q+wMx65FQlbSV7cRqWW87Knp0zkoaIV55Dt+xZl028wJABXEPEEmG3670SLq7lVZvcGIDwCgSg2kCYxvRwA==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@bruits/satteri-linux-x64-musl": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-musl/-/satteri-linux-x64-musl-0.9.4.tgz",
+ "integrity": "sha512-T4gxhXve3zyNAZesrXAd/rDZOGRkbfFIUFld4TGsw6BsjoIteCcDji6IMqeXyaWEVSykY2X8Eid2hr6aXGYAaw==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@bruits/satteri-wasm32-wasi": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/@bruits/satteri-wasm32-wasi/-/satteri-wasm32-wasi-0.9.4.tgz",
+ "integrity": "sha512-/CEG8LUlpaBEnhFnYVn0UnlHFLs51UhrkJBUPDUXLzkadzAcnR88iRA/nOl7Zwhjb4WhfBV4p3P5qeOJMtH0iA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.11.1",
+ "@emnapi/runtime": "1.11.1",
+ "@napi-rs/wasm-runtime": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@bruits/satteri-win32-arm64-msvc": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-arm64-msvc/-/satteri-win32-arm64-msvc-0.9.4.tgz",
+ "integrity": "sha512-E1ZPQbgCtFKiU7pFYVndynvY7ne4coeVDUgnVThErSFlJ2ceQCBZrfRTD1lzrIDy63Bbqo+g/cZY9duw+JYjIw==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@bruits/satteri-win32-x64-msvc": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-x64-msvc/-/satteri-win32-x64-msvc-0.9.4.tgz",
+ "integrity": "sha512-5I7SiarsNdAUuhJb50CXJPTwr/ECVrBoU+fymoLjChK5fW//+srhY4lstcNTzgFRtQSYfVtm4OQZz16CVMeTeA==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@capsizecss/unpack": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.1.tgz",
+ "integrity": "sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "fontkitten": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@clack/core": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.2.tgz",
+ "integrity": "sha512-0Ty/1Gfm+Kb07sXcuESjyKfwEhSy4Ns1AgeEisHb/bDY5fWme0tTeTkU14T1Gmcs17YIjB/teiDe4uaCghbYqQ==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-wrap-ansi": "^0.2.0",
+ "sisteransi": "^1.0.5"
+ },
+ "engines": {
+ "node": ">= 20.12.0"
+ }
+ },
+ "node_modules/@clack/prompts": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.6.0.tgz",
+ "integrity": "sha512-EYlRokl8szrP9Z25qT5aepMdBjzBvHF9ZEhzIiUBc9guz/T31EqRgvD0QSgZcpE93xiwrr+OkB4nz0BZyF6fSA==",
+ "license": "MIT",
+ "dependencies": {
+ "@clack/core": "1.4.2",
+ "fast-string-width": "^3.0.2",
+ "fast-wrap-ansi": "^0.2.0",
+ "sisteransi": "^1.0.5"
+ },
+ "engines": {
+ "node": ">= 20.12.0"
+ }
+ },
+ "node_modules/@emmetio/abbreviation": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz",
+ "integrity": "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@emmetio/scanner": "^1.0.4"
+ }
+ },
+ "node_modules/@emmetio/css-abbreviation": {
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz",
+ "integrity": "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@emmetio/scanner": "^1.0.4"
+ }
+ },
+ "node_modules/@emmetio/css-parser": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@emmetio/css-parser/-/css-parser-0.4.1.tgz",
+ "integrity": "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@emmetio/stream-reader": "^2.2.0",
+ "@emmetio/stream-reader-utils": "^0.1.0"
+ }
+ },
+ "node_modules/@emmetio/html-matcher": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@emmetio/html-matcher/-/html-matcher-1.3.0.tgz",
+ "integrity": "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@emmetio/scanner": "^1.0.0"
+ }
+ },
+ "node_modules/@emmetio/scanner": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.4.tgz",
+ "integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@emmetio/stream-reader": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@emmetio/stream-reader/-/stream-reader-2.2.0.tgz",
+ "integrity": "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@emmetio/stream-reader-utils": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@emmetio/stream-reader-utils/-/stream-reader-utils-0.1.0.tgz",
+ "integrity": "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@fontsource/montserrat": {
+ "version": "5.2.8",
+ "resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.2.8.tgz",
+ "integrity": "sha512-xTjLxSbSfCycDB0pwmNsfNvdfWPaDaRQ2LC6yt/ZI7SdvXG52zHnzNYC/09mzuAuWNJyShkteutfCoDgym56hQ==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@fontsource/open-sans": {
+ "version": "5.2.7",
+ "resolved": "https://registry.npmjs.org/@fontsource/open-sans/-/open-sans-5.2.7.tgz",
+ "integrity": "sha512-8yfgDYjE5O0vmTPdrcjV35y4yMnctsokmi9gN49Gcsr0sjzkMkR97AnKDe6OqZh2SFkYlR28fxOvi21bYEgMSw==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
+ "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
+ "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
+ "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
+ "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
+ "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
+ "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
+ "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
+ "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
+ "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
+ "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
+ "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
+ "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
+ "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
+ "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
+ "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
+ "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
+ "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
+ "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
+ "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
+ "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
+ "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
+ "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.11.1"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
+ "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
+ "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
+ "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
+ "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
+ "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@oslojs/encoding": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
+ "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
+ "license": "MIT"
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.138.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
+ "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
+ "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz",
+ "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz",
+ "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz",
+ "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz",
+ "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz",
+ "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz",
+ "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz",
+ "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz",
+ "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz",
+ "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz",
+ "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz",
+ "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz",
+ "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.11.1",
+ "@emnapi/runtime": "1.11.1",
+ "@napi-rs/wasm-runtime": "^1.1.6"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
+ "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz",
+ "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "license": "MIT"
+ },
+ "node_modules/@rollup/pluginutils": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz",
+ "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "estree-walker": "^2.0.2",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@shikijs/core": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.3.0.tgz",
+ "integrity": "sha512-EooU3i9F6IAE8kEu+AnGf9DFZWkQBZ+hJn3tLVbsH+61mtQiva5biai66fAA6nvFPXkLgvrh7BrR7YcJU83xQQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/primitive": "4.3.0",
+ "@shikijs/types": "4.3.0",
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "@types/hast": "^3.0.4",
+ "hast-util-to-html": "^9.0.5"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/engine-javascript": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.3.0.tgz",
+ "integrity": "sha512-hTv/KiFf2tpiqlACPiztGGurEARWIutB8YUhcrA1pUC7VzzwKO+g5crUocrLztrZ5ro5Z4hbXg7bYclETn3gSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "4.3.0",
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "oniguruma-to-es": "^4.3.6"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/engine-oniguruma": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.3.0.tgz",
+ "integrity": "sha512-1vMdN3gHfnKfLYwecUI2ITJI4RhHt96xEaJumVn7Heb0IlJ8WQMIH0Voak+2j22BpSNKdnOfB/pCTPnPm2gq7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "4.3.0",
+ "@shikijs/vscode-textmate": "^10.0.2"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/langs": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.3.0.tgz",
+ "integrity": "sha512-rnlqFbBRSys9bT4gl/5rw9RnS0W/I84ZldXPkO7cvlEMoV85TyF/aU01N7/NbSR776RNLjrJKjfFUXJR6wN1Cg==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "4.3.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/primitive": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.3.0.tgz",
+ "integrity": "sha512-CPkz64PTa5diRW1ggzMZH9VM/du4RNChYgVtgqrFcgruvIybmCvySv8GkiHSczUHXYuuR8TdKEwFx+UnZMpgdg==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "4.3.0",
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "@types/hast": "^3.0.4"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/themes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.3.0.tgz",
+ "integrity": "sha512-Avgt05YiT+Y3prjIc9lmQxhJzHBcCfR6cjiFW4OyaMBbt2A6trX5rfjUzx+Vj/mE9qpArYjatnqo9XPjQNW/AQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "4.3.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/types": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.3.0.tgz",
+ "integrity": "sha512-oc8b9U2SYvofKZk8e/737nIX0qwf6eV2vHFATeObAu7r+mUVpLs8Re0BmVkIjAWAYgkmG/CzLNo7rzuBzRu/wQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "@types/hast": "^3.0.4"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/vscode-textmate": {
+ "version": "10.0.2",
+ "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
+ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tiptap/core": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.27.1.tgz",
+ "integrity": "sha512-rV6Qn4wmC6BxfF+4mu6bqGWj9vA4oXXhsrpXaJL2uhjxeHAGofjwcHof2X84VYzeyXgdlsGmqKie4TAppVXZUQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/pm": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-blockquote": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.27.1.tgz",
+ "integrity": "sha512-VMF7xJx6qEGiX6DTKNiL31NLqypOcd/4sNjFSe8rb41PwejBJh/nOqVIbBvWkiT6NMGFLxMhj7zJ8/zPo1hXeg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-bold": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.27.1.tgz",
+ "integrity": "sha512-TlC5bsS+pqETTrlz4CZz9RO/cKBYtELGIxwtKeivUn3eNfnOxQbbu4WDsiwIfzRFyd0OMnKl6BPM2KnYEehoEQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-bullet-list": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.27.1.tgz",
+ "integrity": "sha512-faCUHnRP47o9Zh9VZZX6EX/569udw9Vopm2PgEKPWuKLE2qaS5WBuUVU0iItdJmKUqaWiOZkpoW4jvnDmj0dfg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-code": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.27.1.tgz",
+ "integrity": "sha512-epOUpFfEmBzjvnqvjv2qHX7NAuLo5dlOGV690lWu+sAYMjibuJBeVvAiKPyFCfRCCTUxdbDB3jbaOA1yEcEJ7w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-code-block": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.27.1.tgz",
+ "integrity": "sha512-pHlzmZx2OlHfyQ0yRlT5UL4mGokz947DthZuYefN1OleVqOkHpWBG+2JQwqoNq6bmzMne92zbH32rhcJUEYSjA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1",
+ "@tiptap/pm": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-document": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.27.1.tgz",
+ "integrity": "sha512-8FbBTkfnRP4iVaoj+2h3iWa+H0eGDD3yTyVCwrmue/sQTkqUNUoSuAZa3GDG4Sd41xdPwTJxl9nUWGgM1qDCnw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-dropcursor": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.27.1.tgz",
+ "integrity": "sha512-blFf9x9RG0Qr7P3FoAH/033ffa+mMLZn34trVs8Vi0Ppk6FmJAg5HpYFOtmYoeREdNDJ5rHJKV7SoACbOHgskQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extensions": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-gapcursor": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.27.1.tgz",
+ "integrity": "sha512-QoezN0wdvXIwLQ4ee2ccWDaX3RG0lzgQpIMpMz55oPDhpUVax1+19ApsS53LkcktpS4EbnPL4xO4DaJk0Vp7PQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extensions": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-hard-break": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.27.1.tgz",
+ "integrity": "sha512-iv/m9hzl6jfSj9Q8UEjAxONvCoUDaP7M9SRCPx3PaLNxA230TTD6RE0Ye4zFJ8ze7ZVoJJMAqg9Qpq1iYg2JOQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-heading": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.27.1.tgz",
+ "integrity": "sha512-SrC4l1kEIyv9ZXFaI/8LQqU2MyMmjczw7XXsWUQOTN4YXv0JyVgMNR3cI/wz0d2xsTfBdZ1N85Tdng+Ga1t0Sg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-horizontal-rule": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.27.1.tgz",
+ "integrity": "sha512-QlKE7qn5qMnIGVGhXQlvYedvLtNJ9z0dmit5w8vPb8tKzW4Spk6M7N2kruprrDA8GBwHfeR5wmF+njfUm34qxg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1",
+ "@tiptap/pm": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-image": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.27.1.tgz",
+ "integrity": "sha512-+JTahgQT+NxiGjduaB3qJVyhU/wh4m3pVkht1Earioku2bm/apj5Lb8rSowa/NJYP3B+oQgV/V4YLw5dtDgBoA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-italic": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.27.1.tgz",
+ "integrity": "sha512-jGGeyn9uRUnNjSTHpbqhiGsp6KaYTSbV09jDXPJI9cDwfV9hpugLvpaCZd0BMBbhU1B1W6kOfX0BE15qX/HQfA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-link": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.27.1.tgz",
+ "integrity": "sha512-/2jBfsxBZUDGJmpZifqRQPz7f1E5qpS1BckTZ39TADzUJX+feKy7RJ3DtQ02+8y6SSMzvP9loGVjrk6zEMTk4g==",
+ "license": "MIT",
+ "dependencies": {
+ "linkifyjs": "^4.3.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1",
+ "@tiptap/pm": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-list": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.27.1.tgz",
+ "integrity": "sha512-c2Upru7lj0/ZV/Ibww6cNz6sUS8m6Dp/9uygFhYcZOd3X8M0xBIEk42c6m6SQehkPziVA8QOgNJz7sMqsbz1OQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1",
+ "@tiptap/pm": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-list-item": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.27.1.tgz",
+ "integrity": "sha512-zwRl01ETfCkWUvtvK5fw9bXtAajMPkvlkE3Cq6JvH3LF7XXJwDtNj5Tj7exacMpCaSZmlNc43vFb2rAYnrnwMA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-list-keymap": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.27.1.tgz",
+ "integrity": "sha512-OIMZNlzPSO8WRd4ic73Fxckzl4N1tesjjLL2XApaNA/uMpO0LoF6WSRPAWv+Z24Wp92ARRJAnRP7iZoI5+Jxig==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-ordered-list": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.27.1.tgz",
+ "integrity": "sha512-GYrKqD//9nHJ2r80uXqbDMzRnFpGzbaEQRTSGaO/SH7DvXWFMow8evkOdjQ7PCQO07jNjJo75+A85Jwu3Ov3AA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-paragraph": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.27.1.tgz",
+ "integrity": "sha512-7K7eo1gruOgAsnbK+GCV23AUVUI0cL1bTig8HaPneoFMVbig7vddk8jNLKBWO8TXVbG7TuHdnDN4F98vdtwh5Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-strike": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.27.1.tgz",
+ "integrity": "sha512-Y3DW1jlSlCNCyMGHP3+3qBNNPS83wuFz4RTYGjZtvRRTCRh7apZme9XRWMq1rN5mJ2Cr7fKocA2/5Bs13KgN6Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-text": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.27.1.tgz",
+ "integrity": "sha512-6ZwaZwSrDh+KFFv6V1J79oO37yPs7y1bFxvk1/9Ih2rn3Xr5AWz+eMS+n8RpH3djBVVAQpdIAeYQgcn+VCSsTg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-underline": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.27.1.tgz",
+ "integrity": "sha512-N889J4nXN/TPfVt8uF9N1A0SY82E90zwc1y26lqOcw6KWNLmQrlhMh/9OD4ikLDbekmFpOBq/UicpHf/6S8hbQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extensions": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.27.1.tgz",
+ "integrity": "sha512-1Tdx9faw8k0/83V6X+xCDVhV8yElGt95JxeW3YMkKQJI56QdlPz0xOdJPlMiSGJKinPyVier+x9LJD/YZUZIaw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1",
+ "@tiptap/pm": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/pm": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.27.1.tgz",
+ "integrity": "sha512-Ffjx+vimmBU7zH/KrpXzJid3+pziCe/VL2aexSTP63cyQwKQ65LkFkCKaIsSpFdQQuakVZBGWjCA5RoBV852pw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-changeset": "^2.3.0",
+ "prosemirror-commands": "^1.6.2",
+ "prosemirror-dropcursor": "^1.8.1",
+ "prosemirror-gapcursor": "^1.3.2",
+ "prosemirror-history": "^1.4.1",
+ "prosemirror-inputrules": "^1.4.0",
+ "prosemirror-keymap": "^1.2.3",
+ "prosemirror-model": "^1.25.7",
+ "prosemirror-schema-list": "^1.5.0",
+ "prosemirror-state": "^1.4.4",
+ "prosemirror-tables": "^1.8.0",
+ "prosemirror-transform": "^1.12.0",
+ "prosemirror-view": "^1.41.8"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ }
+ },
+ "node_modules/@tiptap/starter-kit": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.27.1.tgz",
+ "integrity": "sha512-vfxRsqW8rCc0k4pzo0ilU3wobVi2wqVj88VZI2SlgZlNnUAkrDGDIAph7CTa9k9fshV+O1ivpEgPC5yC046jow==",
+ "license": "MIT",
+ "dependencies": {
+ "@tiptap/core": "^3.27.1",
+ "@tiptap/extension-blockquote": "^3.27.1",
+ "@tiptap/extension-bold": "^3.27.1",
+ "@tiptap/extension-bullet-list": "^3.27.1",
+ "@tiptap/extension-code": "^3.27.1",
+ "@tiptap/extension-code-block": "^3.27.1",
+ "@tiptap/extension-document": "^3.27.1",
+ "@tiptap/extension-dropcursor": "^3.27.1",
+ "@tiptap/extension-gapcursor": "^3.27.1",
+ "@tiptap/extension-hard-break": "^3.27.1",
+ "@tiptap/extension-heading": "^3.27.1",
+ "@tiptap/extension-horizontal-rule": "^3.27.1",
+ "@tiptap/extension-italic": "^3.27.1",
+ "@tiptap/extension-link": "^3.27.1",
+ "@tiptap/extension-list": "^3.27.1",
+ "@tiptap/extension-list-item": "^3.27.1",
+ "@tiptap/extension-list-keymap": "^3.27.1",
+ "@tiptap/extension-ordered-list": "^3.27.1",
+ "@tiptap/extension-paragraph": "^3.27.1",
+ "@tiptap/extension-strike": "^3.27.1",
+ "@tiptap/extension-text": "^3.27.1",
+ "@tiptap/extension-underline": "^3.27.1",
+ "@tiptap/extensions": "^3.27.1",
+ "@tiptap/pm": "^3.27.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ }
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/bcryptjs": {
+ "version": "2.4.6",
+ "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
+ "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/better-sqlite3": {
+ "version": "7.6.13",
+ "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
+ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree-jsx": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
+ "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/@types/hast": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
+ "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/mdast": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
+ "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/nlcst": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz",
+ "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "24.13.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
+ "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
+ },
+ "node_modules/@types/nodemailer": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
+ "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/sanitize-html": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz",
+ "integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "htmlparser2": "^10.1"
+ }
+ },
+ "node_modules/@types/sax": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz",
+ "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
+ "license": "MIT"
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz",
+ "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==",
+ "license": "ISC"
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
+ "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.9",
+ "@vitest/utils": "4.1.9",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
+ "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.9",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/mocker/node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
+ "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
+ "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.9",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
+ "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.9",
+ "@vitest/utils": "4.1.9",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
+ "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
+ "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.9",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@volar/kit": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/kit/-/kit-2.4.28.tgz",
+ "integrity": "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-service": "2.4.28",
+ "@volar/typescript": "2.4.28",
+ "typesafe-path": "^0.2.2",
+ "vscode-languageserver-textdocument": "^1.0.11",
+ "vscode-uri": "^3.0.8"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ }
+ },
+ "node_modules/@volar/language-core": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
+ "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/source-map": "2.4.28"
+ }
+ },
+ "node_modules/@volar/language-server": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/language-server/-/language-server-2.4.28.tgz",
+ "integrity": "sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.28",
+ "@volar/language-service": "2.4.28",
+ "@volar/typescript": "2.4.28",
+ "path-browserify": "^1.0.1",
+ "request-light": "^0.7.0",
+ "vscode-languageserver": "^9.0.1",
+ "vscode-languageserver-protocol": "^3.17.5",
+ "vscode-languageserver-textdocument": "^1.0.11",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@volar/language-service": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/language-service/-/language-service-2.4.28.tgz",
+ "integrity": "sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.28",
+ "vscode-languageserver-protocol": "^3.17.5",
+ "vscode-languageserver-textdocument": "^1.0.11",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@volar/source-map": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz",
+ "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@volar/typescript": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz",
+ "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.28",
+ "path-browserify": "^1.0.1",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@vscode/emmet-helper": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.11.0.tgz",
+ "integrity": "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emmet": "^2.4.3",
+ "jsonc-parser": "^2.3.0",
+ "vscode-languageserver-textdocument": "^1.0.1",
+ "vscode-languageserver-types": "^3.15.1",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@vscode/emmet-helper/node_modules/jsonc-parser": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz",
+ "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vscode/l10n": {
+ "version": "0.0.18",
+ "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz",
+ "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-draft-04": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
+ "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "ajv": "^8.5.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/am-i-vibing": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.4.0.tgz",
+ "integrity": "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg==",
+ "license": "MIT",
+ "dependencies": {
+ "process-ancestry": "^0.1.0"
+ },
+ "bin": {
+ "am-i-vibing": "dist/cli.mjs"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/anymatch/node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "license": "MIT"
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0"
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/astro": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/astro/-/astro-7.0.5.tgz",
+ "integrity": "sha512-KR/zBBgU6I+F5vDoTsuTbXbBmI565CToDOgPr0pPiL2Hgrvx4CV8Cg2wtT6VqqyOS7BIfBkdagKD+40SojGl7w==",
+ "license": "MIT",
+ "dependencies": {
+ "@astrojs/compiler-rs": "^0.3.0",
+ "@astrojs/internal-helpers": "0.10.0",
+ "@astrojs/markdown-satteri": "0.3.2",
+ "@astrojs/telemetry": "3.3.2",
+ "@capsizecss/unpack": "^4.0.0",
+ "@clack/prompts": "^1.1.0",
+ "@oslojs/encoding": "^1.1.0",
+ "@rollup/pluginutils": "^5.3.0",
+ "am-i-vibing": "^0.4.0",
+ "aria-query": "^5.3.2",
+ "axobject-query": "^4.1.0",
+ "ci-info": "^4.4.0",
+ "clsx": "^2.1.1",
+ "common-ancestor-path": "^2.0.0",
+ "cookie": "^1.1.1",
+ "devalue": "^5.8.1",
+ "diff": "^8.0.3",
+ "dset": "^3.1.4",
+ "es-module-lexer": "^2.0.0",
+ "esbuild": "^0.28.0",
+ "flattie": "^1.1.1",
+ "fontace": "~0.4.1",
+ "get-tsconfig": "5.0.0-beta.4",
+ "github-slugger": "^2.0.0",
+ "html-escaper": "3.0.3",
+ "http-cache-semantics": "^4.2.0",
+ "js-yaml": "^4.1.1",
+ "jsonc-parser": "^3.3.1",
+ "magic-string": "^0.30.21",
+ "magicast": "^0.5.2",
+ "mrmime": "^2.0.1",
+ "neotraverse": "^0.6.18",
+ "obug": "^2.1.1",
+ "p-limit": "^7.3.0",
+ "p-queue": "^9.1.0",
+ "package-manager-detector": "^1.6.0",
+ "piccolore": "^0.1.3",
+ "picomatch": "^4.0.4",
+ "semver": "^7.7.4",
+ "shiki": "^4.0.2",
+ "smol-toml": "^1.6.0",
+ "svgo": "^4.0.1",
+ "tinyclip": "^0.1.12",
+ "tinyexec": "^1.0.4",
+ "tinyglobby": "^0.2.15",
+ "ultrahtml": "^1.6.0",
+ "unifont": "~0.7.4",
+ "unstorage": "^1.17.5",
+ "vite": "^8.0.13",
+ "vitefu": "^1.1.2",
+ "xxhash-wasm": "^1.1.0",
+ "yargs-parser": "^22.0.0",
+ "zod": "^4.3.6"
+ },
+ "bin": {
+ "astro": "bin/astro.mjs"
+ },
+ "engines": {
+ "node": ">=22.12.0",
+ "npm": ">=9.6.5",
+ "pnpm": ">=7.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/astrodotbuild"
+ },
+ "optionalDependencies": {
+ "sharp": "^0.34.0 || ^0.35.0"
+ },
+ "peerDependencies": {
+ "@astrojs/markdown-remark": "7.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@astrojs/markdown-remark": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/bail": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
+ "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/bcryptjs": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
+ "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
+ "license": "BSD-3-Clause",
+ "bin": {
+ "bcrypt": "bin/bcrypt"
+ }
+ },
+ "node_modules/better-sqlite3": {
+ "version": "12.11.1",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
+ "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "prebuild-install": "^7.1.1"
+ },
+ "engines": {
+ "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
+ }
+ },
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/boolbase": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+ "license": "ISC"
+ },
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/ccount": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
+ "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/character-entities-html4": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
+ "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
+ "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
+ "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^5.0.0"
+ },
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
+ "node_modules/ci-info": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz",
+ "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/sibiraj-s"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/comma-separated-tokens": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
+ "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/commander": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
+ "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/common-ancestor-path": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz",
+ "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/cookie-es": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz",
+ "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==",
+ "license": "MIT"
+ },
+ "node_modules/crossws": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz",
+ "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==",
+ "license": "MIT",
+ "dependencies": {
+ "uncrypto": "^0.1.3"
+ }
+ },
+ "node_modules/css-select": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
+ "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-what": "^6.1.0",
+ "domhandler": "^5.0.2",
+ "domutils": "^3.0.1",
+ "nth-check": "^2.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
+ "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">= 6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/csso": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz",
+ "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==",
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "~2.2.0"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
+ "npm": ">=7.0.0"
+ }
+ },
+ "node_modules/csso/node_modules/css-tree": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz",
+ "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==",
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.0.28",
+ "source-map-js": "^1.0.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
+ "npm": ">=7.0.0"
+ }
+ },
+ "node_modules/csso/node_modules/mdn-data": {
+ "version": "2.0.28",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz",
+ "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
+ "license": "CC0-1.0"
+ },
+ "node_modules/dayjs": {
+ "version": "1.11.21",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
+ "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/defu": {
+ "version": "6.1.7",
+ "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
+ "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
+ "license": "MIT"
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/destr": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
+ "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/devalue": {
+ "version": "5.8.1",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
+ "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
+ "license": "MIT"
+ },
+ "node_modules/devlop": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
+ "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/diff": {
+ "version": "8.0.4",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
+ "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.3.1"
+ }
+ },
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/dom-serializer/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/dset": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz",
+ "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/emmet": {
+ "version": "2.4.11",
+ "resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz",
+ "integrity": "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "./packages/scanner",
+ "./packages/abbreviation",
+ "./packages/css-abbreviation",
+ "./"
+ ],
+ "dependencies": {
+ "@emmetio/abbreviation": "^2.3.3",
+ "@emmetio/css-abbreviation": "^2.1.8"
+ }
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz",
+ "integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==",
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+ "license": "MIT"
+ },
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "license": "(MIT OR WTFPL)",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-string-truncated-width": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz",
+ "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==",
+ "license": "MIT"
+ },
+ "node_modules/fast-string-width": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz",
+ "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-string-truncated-width": "^3.0.2"
+ }
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
+ "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/fast-wrap-ansi": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz",
+ "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-string-width": "^3.0.2"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "license": "MIT"
+ },
+ "node_modules/flattie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz",
+ "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fontace": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz",
+ "integrity": "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==",
+ "license": "MIT",
+ "dependencies": {
+ "fontkitten": "^1.0.2"
+ }
+ },
+ "node_modules/fontkitten": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz",
+ "integrity": "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==",
+ "license": "MIT",
+ "dependencies": {
+ "tiny-inflate": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "license": "MIT"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-tsconfig": {
+ "version": "5.0.0-beta.4",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.4.tgz",
+ "integrity": "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==",
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=20.20.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "license": "MIT"
+ },
+ "node_modules/github-slugger": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
+ "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==",
+ "license": "ISC"
+ },
+ "node_modules/h3": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz",
+ "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie-es": "^1.2.3",
+ "crossws": "^0.3.5",
+ "defu": "^6.1.6",
+ "destr": "^2.0.5",
+ "iron-webcrypto": "^1.2.1",
+ "node-mock-http": "^1.0.4",
+ "radix3": "^1.1.2",
+ "ufo": "^1.6.3",
+ "uncrypto": "^0.1.3"
+ }
+ },
+ "node_modules/hast-util-to-html": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
+ "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "html-void-elements": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "stringify-entities": "^4.0.0",
+ "zwitch": "^2.0.4"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-whitespace": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
+ "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/html-escaper": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
+ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
+ "license": "MIT"
+ },
+ "node_modules/html-void-elements": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
+ "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/htmlparser2": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/http-cache-semantics": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
+ "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
+ "node_modules/iron-webcrypto": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
+ "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/brc-dd"
+ }
+ },
+ "node_modules/is-docker": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-4.0.0.tgz",
+ "integrity": "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==",
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-inside-container": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
+ "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^3.0.0"
+ },
+ "bin": {
+ "is-inside-container": "cli.js"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-inside-container/node_modules/is-docker": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
+ "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-plain-object": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
+ "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-wsl": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
+ "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-inside-container": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/js-yaml": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
+ "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsonc-parser": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
+ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
+ "license": "MIT"
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/launder": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
+ "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
+ "license": "MIT",
+ "dependencies": {
+ "dayjs": "^1.11.7"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/linkifyjs": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
+ "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "11.5.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
+ "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/magicast": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
+ "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.3",
+ "@babel/types": "^7.29.0",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/mdast-util-to-hast": {
+ "version": "13.2.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
+ "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "trim-lines": "^3.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "license": "CC0-1.0"
+ },
+ "node_modules/micromark-util-character": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
+ "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-encode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
+ "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-sanitize-uri": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
+ "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-symbol": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
+ "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-types": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
+ "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT"
+ },
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/muggle-string": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
+ "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.15",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
+ "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "license": "MIT"
+ },
+ "node_modules/neotraverse": {
+ "version": "0.6.18",
+ "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz",
+ "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/nlcst-to-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz",
+ "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/nlcst": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/node-abi": {
+ "version": "3.93.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.93.0.tgz",
+ "integrity": "sha512-Cu6yUpX5Iavugm8BeX7c0wgU9CvOqfd1yM6A1d2q2ZMjym7GjpASv2GdRcTq3Fx+Sb5OgBkEEpw4VnAbY6Y5RA==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/node-fetch-native": {
+ "version": "1.6.7",
+ "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
+ "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
+ "license": "MIT"
+ },
+ "node_modules/node-mock-http": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz",
+ "integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==",
+ "license": "MIT"
+ },
+ "node_modules/nodemailer": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
+ "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/nth-check": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+ "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
+ "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/ofetch": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz",
+ "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==",
+ "license": "MIT",
+ "dependencies": {
+ "destr": "^2.0.5",
+ "node-fetch-native": "^1.6.7",
+ "ufo": "^1.6.1"
+ }
+ },
+ "node_modules/ohash": {
+ "version": "2.0.11",
+ "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
+ "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
+ "license": "MIT"
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/oniguruma-parser": {
+ "version": "0.12.2",
+ "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz",
+ "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==",
+ "license": "MIT"
+ },
+ "node_modules/oniguruma-to-es": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz",
+ "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==",
+ "license": "MIT",
+ "dependencies": {
+ "oniguruma-parser": "^0.12.2",
+ "regex": "^6.1.0",
+ "regex-recursion": "^6.0.2"
+ }
+ },
+ "node_modules/orderedmap": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
+ "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
+ "license": "MIT"
+ },
+ "node_modules/p-limit": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz",
+ "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==",
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-queue": {
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.0.tgz",
+ "integrity": "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==",
+ "license": "MIT",
+ "dependencies": {
+ "eventemitter3": "^5.0.4",
+ "p-timeout": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-timeout": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz",
+ "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/package-manager-detector": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.7.0.tgz",
+ "integrity": "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==",
+ "license": "MIT"
+ },
+ "node_modules/parse-srcset": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
+ "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
+ "license": "MIT"
+ },
+ "node_modules/path-browserify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/piccolore": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",
+ "integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==",
+ "license": "ISC"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.16",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
+ "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.9.4",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz",
+ "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/prismjs": {
+ "version": "1.30.0",
+ "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
+ "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/process-ancestry": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/process-ancestry/-/process-ancestry-0.1.0.tgz",
+ "integrity": "sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/property-information": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
+ "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/prosemirror-changeset": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
+ "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-transform": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-commands": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
+ "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.10.2"
+ }
+ },
+ "node_modules/prosemirror-dropcursor": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
+ "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.1.0",
+ "prosemirror-view": "^1.1.0"
+ }
+ },
+ "node_modules/prosemirror-gapcursor": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
+ "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-keymap": "^1.0.0",
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-view": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-history": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
+ "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.2.2",
+ "prosemirror-transform": "^1.0.0",
+ "prosemirror-view": "^1.31.0",
+ "rope-sequence": "^1.3.0"
+ }
+ },
+ "node_modules/prosemirror-inputrules": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
+ "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-keymap": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
+ "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "w3c-keyname": "^2.2.0"
+ }
+ },
+ "node_modules/prosemirror-model": {
+ "version": "1.25.9",
+ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.9.tgz",
+ "integrity": "sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==",
+ "license": "MIT",
+ "dependencies": {
+ "orderedmap": "^2.0.0"
+ }
+ },
+ "node_modules/prosemirror-schema-list": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
+ "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.7.3"
+ }
+ },
+ "node_modules/prosemirror-state": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
+ "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-transform": "^1.0.0",
+ "prosemirror-view": "^1.27.0"
+ }
+ },
+ "node_modules/prosemirror-tables": {
+ "version": "1.8.5",
+ "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
+ "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-keymap": "^1.2.3",
+ "prosemirror-model": "^1.25.4",
+ "prosemirror-state": "^1.4.4",
+ "prosemirror-transform": "^1.10.5",
+ "prosemirror-view": "^1.41.4"
+ }
+ },
+ "node_modules/prosemirror-transform": {
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
+ "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.21.0"
+ }
+ },
+ "node_modules/prosemirror-view": {
+ "version": "1.42.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.0.tgz",
+ "integrity": "sha512-N54DF3OXNWDuP81G1kbfCys8ZzIjuL1VnvJ2mk5STSu/fNxWIcX/EutQLA3s9KR/2wVhgDi4hzBB/1fINVxk0A==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.25.8",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.1.0"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/radix3": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz",
+ "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==",
+ "license": "MIT"
+ },
+ "node_modules/range-parser": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
+ "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
+ "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/regex": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
+ "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==",
+ "license": "MIT",
+ "dependencies": {
+ "regex-utilities": "^2.3.0"
+ }
+ },
+ "node_modules/regex-recursion": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz",
+ "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==",
+ "license": "MIT",
+ "dependencies": {
+ "regex-utilities": "^2.3.0"
+ }
+ },
+ "node_modules/regex-utilities": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz",
+ "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==",
+ "license": "MIT"
+ },
+ "node_modules/request-light": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.7.0.tgz",
+ "integrity": "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
+ "node_modules/retext-smartypants": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz",
+ "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/nlcst": "^2.0.0",
+ "nlcst-to-string": "^4.0.0",
+ "unist-util-visit": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
+ "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.138.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.1.4",
+ "@rolldown/binding-darwin-arm64": "1.1.4",
+ "@rolldown/binding-darwin-x64": "1.1.4",
+ "@rolldown/binding-freebsd-x64": "1.1.4",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.1.4",
+ "@rolldown/binding-linux-arm64-gnu": "1.1.4",
+ "@rolldown/binding-linux-arm64-musl": "1.1.4",
+ "@rolldown/binding-linux-ppc64-gnu": "1.1.4",
+ "@rolldown/binding-linux-s390x-gnu": "1.1.4",
+ "@rolldown/binding-linux-x64-gnu": "1.1.4",
+ "@rolldown/binding-linux-x64-musl": "1.1.4",
+ "@rolldown/binding-openharmony-arm64": "1.1.4",
+ "@rolldown/binding-wasm32-wasi": "1.1.4",
+ "@rolldown/binding-win32-arm64-msvc": "1.1.4",
+ "@rolldown/binding-win32-x64-msvc": "1.1.4"
+ }
+ },
+ "node_modules/rope-sequence": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
+ "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
+ "license": "MIT"
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/sanitize-html": {
+ "version": "2.17.5",
+ "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.5.tgz",
+ "integrity": "sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==",
+ "license": "MIT",
+ "dependencies": {
+ "deepmerge": "^4.2.2",
+ "escape-string-regexp": "^4.0.0",
+ "htmlparser2": "^10.1.0",
+ "is-plain-object": "^5.0.0",
+ "launder": "^1.7.1",
+ "parse-srcset": "^1.0.2",
+ "postcss": "^8.3.11"
+ }
+ },
+ "node_modules/satteri": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.9.4.tgz",
+ "integrity": "sha512-BKob126Tay84diOZsnVNH/Q/c+3njPJTCad3w5zLKa6j8bVjxskPNHDtxrMwYK4bN/RlqUSdMnPwKY4k65EMOQ==",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.5",
+ "@types/hast": "^3.0.4",
+ "@types/mdast": "^4.0.4",
+ "@types/unist": "^3.0.3"
+ },
+ "optionalDependencies": {
+ "@bruits/satteri-darwin-arm64": "0.9.4",
+ "@bruits/satteri-darwin-x64": "0.9.4",
+ "@bruits/satteri-linux-arm64-gnu": "0.9.4",
+ "@bruits/satteri-linux-arm64-musl": "0.9.4",
+ "@bruits/satteri-linux-x64-gnu": "0.9.4",
+ "@bruits/satteri-linux-x64-musl": "0.9.4",
+ "@bruits/satteri-wasm32-wasi": "0.9.4",
+ "@bruits/satteri-win32-arm64-msvc": "0.9.4",
+ "@bruits/satteri-win32-x64-msvc": "0.9.4"
+ }
+ },
+ "node_modules/sax": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
+ "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=11.0.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/server-destroy": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz",
+ "integrity": "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==",
+ "license": "ISC"
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/sharp": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
+ "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/colour": "^1.1.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.8.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.35.3",
+ "@img/sharp-darwin-x64": "0.35.3",
+ "@img/sharp-freebsd-wasm32": "0.35.3",
+ "@img/sharp-libvips-darwin-arm64": "1.3.2",
+ "@img/sharp-libvips-darwin-x64": "1.3.2",
+ "@img/sharp-libvips-linux-arm": "1.3.2",
+ "@img/sharp-libvips-linux-arm64": "1.3.2",
+ "@img/sharp-libvips-linux-ppc64": "1.3.2",
+ "@img/sharp-libvips-linux-riscv64": "1.3.2",
+ "@img/sharp-libvips-linux-s390x": "1.3.2",
+ "@img/sharp-libvips-linux-x64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
+ "@img/sharp-linux-arm": "0.35.3",
+ "@img/sharp-linux-arm64": "0.35.3",
+ "@img/sharp-linux-ppc64": "0.35.3",
+ "@img/sharp-linux-riscv64": "0.35.3",
+ "@img/sharp-linux-s390x": "0.35.3",
+ "@img/sharp-linux-x64": "0.35.3",
+ "@img/sharp-linuxmusl-arm64": "0.35.3",
+ "@img/sharp-linuxmusl-x64": "0.35.3",
+ "@img/sharp-webcontainers-wasm32": "0.35.3",
+ "@img/sharp-win32-arm64": "0.35.3",
+ "@img/sharp-win32-ia32": "0.35.3",
+ "@img/sharp-win32-x64": "0.35.3"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/shiki": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.3.0.tgz",
+ "integrity": "sha512-NKKjWzR6LIGL3sXBrWDw9sDS9cxx42/DkysaNqJEeOWE8Kix5gpak0bc00OfDVEO4oyXSyz8+aRaqKoBD1yo7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/core": "4.3.0",
+ "@shikijs/engine-javascript": "4.3.0",
+ "@shikijs/engine-oniguruma": "4.3.0",
+ "@shikijs/langs": "4.3.0",
+ "@shikijs/themes": "4.3.0",
+ "@shikijs/types": "4.3.0",
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "@types/hast": "^3.0.4"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "node_modules/sisteransi": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
+ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
+ "license": "MIT"
+ },
+ "node_modules/sitemap": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-9.0.1.tgz",
+ "integrity": "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "^24.9.2",
+ "@types/sax": "^1.2.1",
+ "arg": "^5.0.0",
+ "sax": "^1.4.1"
+ },
+ "bin": {
+ "sitemap": "dist/esm/cli.js"
+ },
+ "engines": {
+ "node": ">=20.19.5",
+ "npm": ">=10.8.2"
+ }
+ },
+ "node_modules/smol-toml": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz",
+ "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/cyyynthia"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/space-separated-tokens": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
+ "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/std-env": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
+ "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/stream-replace-string": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz",
+ "integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==",
+ "license": "MIT"
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/stringify-entities": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
+ "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities-html4": "^2.0.0",
+ "character-entities-legacy": "^3.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/svgo": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz",
+ "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==",
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^11.1.0",
+ "css-select": "^5.1.0",
+ "css-tree": "^3.0.1",
+ "css-what": "^6.1.0",
+ "csso": "^5.0.5",
+ "picocolors": "^1.1.1",
+ "sax": "^1.5.0"
+ },
+ "bin": {
+ "svgo": "bin/svgo.js"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/svgo"
+ }
+ },
+ "node_modules/tar-fs": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
+ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tiny-inflate": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
+ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyclip": {
+ "version": "0.1.15",
+ "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.15.tgz",
+ "integrity": "sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A==",
+ "license": "MIT",
+ "engines": {
+ "node": "^16.14.0 || >= 17.3.0"
+ }
+ },
+ "node_modules/tinyexec": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
+ "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/trim-lines": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
+ "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/trough": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
+ "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/typesafe-path": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz",
+ "integrity": "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-auto-import-cache": {
+ "version": "0.3.6",
+ "resolved": "https://registry.npmjs.org/typescript-auto-import-cache/-/typescript-auto-import-cache-0.3.6.tgz",
+ "integrity": "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.8"
+ }
+ },
+ "node_modules/ufo": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
+ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
+ "license": "MIT"
+ },
+ "node_modules/ultrahtml": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.6.0.tgz",
+ "integrity": "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==",
+ "license": "MIT"
+ },
+ "node_modules/uncrypto": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
+ "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
+ "license": "MIT"
+ },
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "license": "MIT"
+ },
+ "node_modules/unified": {
+ "version": "11.0.5",
+ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
+ "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "bail": "^2.0.0",
+ "devlop": "^1.0.0",
+ "extend": "^3.0.0",
+ "is-plain-obj": "^4.0.0",
+ "trough": "^2.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unifont": {
+ "version": "0.7.4",
+ "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.4.tgz",
+ "integrity": "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==",
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.1.0",
+ "ofetch": "^1.5.1",
+ "ohash": "^2.0.11"
+ }
+ },
+ "node_modules/unist-util-is": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
+ "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
+ "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-stringify-position": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
+ "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
+ "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-parents": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
+ "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unstorage": {
+ "version": "1.17.5",
+ "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz",
+ "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==",
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "^3.1.3",
+ "chokidar": "^5.0.0",
+ "destr": "^2.0.5",
+ "h3": "^1.15.10",
+ "lru-cache": "^11.2.7",
+ "node-fetch-native": "^1.6.7",
+ "ofetch": "^1.5.1",
+ "ufo": "^1.6.3"
+ },
+ "peerDependencies": {
+ "@azure/app-configuration": "^1.8.0",
+ "@azure/cosmos": "^4.2.0",
+ "@azure/data-tables": "^13.3.0",
+ "@azure/identity": "^4.6.0",
+ "@azure/keyvault-secrets": "^4.9.0",
+ "@azure/storage-blob": "^12.26.0",
+ "@capacitor/preferences": "^6 || ^7 || ^8",
+ "@deno/kv": ">=0.9.0",
+ "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0",
+ "@planetscale/database": "^1.19.0",
+ "@upstash/redis": "^1.34.3",
+ "@vercel/blob": ">=0.27.1",
+ "@vercel/functions": "^2.2.12 || ^3.0.0",
+ "@vercel/kv": "^1 || ^2 || ^3",
+ "aws4fetch": "^1.0.20",
+ "db0": ">=0.2.1",
+ "idb-keyval": "^6.2.1",
+ "ioredis": "^5.4.2",
+ "uploadthing": "^7.4.4"
+ },
+ "peerDependenciesMeta": {
+ "@azure/app-configuration": {
+ "optional": true
+ },
+ "@azure/cosmos": {
+ "optional": true
+ },
+ "@azure/data-tables": {
+ "optional": true
+ },
+ "@azure/identity": {
+ "optional": true
+ },
+ "@azure/keyvault-secrets": {
+ "optional": true
+ },
+ "@azure/storage-blob": {
+ "optional": true
+ },
+ "@capacitor/preferences": {
+ "optional": true
+ },
+ "@deno/kv": {
+ "optional": true
+ },
+ "@netlify/blobs": {
+ "optional": true
+ },
+ "@planetscale/database": {
+ "optional": true
+ },
+ "@upstash/redis": {
+ "optional": true
+ },
+ "@vercel/blob": {
+ "optional": true
+ },
+ "@vercel/functions": {
+ "optional": true
+ },
+ "@vercel/kv": {
+ "optional": true
+ },
+ "aws4fetch": {
+ "optional": true
+ },
+ "db0": {
+ "optional": true
+ },
+ "idb-keyval": {
+ "optional": true
+ },
+ "ioredis": {
+ "optional": true
+ },
+ "uploadthing": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/vfile": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+ "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-message": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
+ "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.1.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz",
+ "integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.16",
+ "rolldown": "~1.1.3",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.3.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitefu": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
+ "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==",
+ "license": "MIT",
+ "workspaces": [
+ "tests/deps/*",
+ "tests/projects/*",
+ "tests/projects/workspace/packages/*"
+ ],
+ "peerDependencies": {
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
+ "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.9",
+ "@vitest/mocker": "4.1.9",
+ "@vitest/pretty-format": "4.1.9",
+ "@vitest/runner": "4.1.9",
+ "@vitest/snapshot": "4.1.9",
+ "@vitest/spy": "4.1.9",
+ "@vitest/utils": "4.1.9",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.9",
+ "@vitest/browser-preview": "4.1.9",
+ "@vitest/browser-webdriverio": "4.1.9",
+ "@vitest/coverage-istanbul": "4.1.9",
+ "@vitest/coverage-v8": "4.1.9",
+ "@vitest/ui": "4.1.9",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/volar-service-css": {
+ "version": "0.0.70",
+ "resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.70.tgz",
+ "integrity": "sha512-K1qyOvBpE3rzdAv3e4/6Rv5yizrYPy5R/ne3IWCAzLBuMO4qBMV3kSqWzj6KUVe6S0AnN6wxF7cRkiaKfYMYJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "vscode-css-languageservice": "^6.3.0",
+ "vscode-languageserver-textdocument": "^1.0.11",
+ "vscode-uri": "^3.0.8"
+ },
+ "peerDependencies": {
+ "@volar/language-service": "~2.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@volar/language-service": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/volar-service-emmet": {
+ "version": "0.0.70",
+ "resolved": "https://registry.npmjs.org/volar-service-emmet/-/volar-service-emmet-0.0.70.tgz",
+ "integrity": "sha512-xi5bC4m/VyE3zy/n2CXspKeDZs3qA41tHLTw275/7dNWM/RqE2z3BnDICQybHIVp/6G1iOQj5c1qXMgQC08TNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@emmetio/css-parser": "^0.4.1",
+ "@emmetio/html-matcher": "^1.3.0",
+ "@vscode/emmet-helper": "^2.9.3",
+ "vscode-uri": "^3.0.8"
+ },
+ "peerDependencies": {
+ "@volar/language-service": "~2.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@volar/language-service": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/volar-service-html": {
+ "version": "0.0.70",
+ "resolved": "https://registry.npmjs.org/volar-service-html/-/volar-service-html-0.0.70.tgz",
+ "integrity": "sha512-eR6vCgMdmYAo4n+gcT7DSyBQbwB8S3HZZvSagTf0sxNaD4WppMCFfpqWnkrlGStPKMZvMiejRRVmqsX9dYcTvQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "vscode-html-languageservice": "^5.3.0",
+ "vscode-languageserver-textdocument": "^1.0.11",
+ "vscode-uri": "^3.0.8"
+ },
+ "peerDependencies": {
+ "@volar/language-service": "~2.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@volar/language-service": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/volar-service-prettier": {
+ "version": "0.0.70",
+ "resolved": "https://registry.npmjs.org/volar-service-prettier/-/volar-service-prettier-0.0.70.tgz",
+ "integrity": "sha512-Z6BCFSpGVCd8BPAsZ785Kce1BGlWd5ODqmqZGVuB14MJvrR4+CYz6cDy4F+igmE1gMifqfvMhdgT8Aud4M5ngg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "vscode-uri": "^3.0.8"
+ },
+ "peerDependencies": {
+ "@volar/language-service": "~2.4.0",
+ "prettier": "^2.2 || ^3.0"
+ },
+ "peerDependenciesMeta": {
+ "@volar/language-service": {
+ "optional": true
+ },
+ "prettier": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/volar-service-typescript": {
+ "version": "0.0.70",
+ "resolved": "https://registry.npmjs.org/volar-service-typescript/-/volar-service-typescript-0.0.70.tgz",
+ "integrity": "sha512-l46Bx4cokkUedTd74ojO5H/zqHZJ8SUuyZ0IB8JN4jfRqUM3bQFBHoOwlZCyZmOeO0A3RQNkMnFclxO4c++gsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-browserify": "^1.0.1",
+ "semver": "^7.6.2",
+ "typescript-auto-import-cache": "^0.3.5",
+ "vscode-languageserver-textdocument": "^1.0.11",
+ "vscode-nls": "^5.2.0",
+ "vscode-uri": "^3.0.8"
+ },
+ "peerDependencies": {
+ "@volar/language-service": "~2.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@volar/language-service": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/volar-service-typescript-twoslash-queries": {
+ "version": "0.0.70",
+ "resolved": "https://registry.npmjs.org/volar-service-typescript-twoslash-queries/-/volar-service-typescript-twoslash-queries-0.0.70.tgz",
+ "integrity": "sha512-IdD13Z9N2Bu8EM6CM0fDV1E69olEYGHDU25X51YXmq8Y0CmJ2LNj6gOiBJgpS5JGUqFzECVhMNBW7R0sPdRTMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "vscode-uri": "^3.0.8"
+ },
+ "peerDependencies": {
+ "@volar/language-service": "~2.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@volar/language-service": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/volar-service-yaml": {
+ "version": "0.0.70",
+ "resolved": "https://registry.npmjs.org/volar-service-yaml/-/volar-service-yaml-0.0.70.tgz",
+ "integrity": "sha512-0c8bXDBeoATF9F6iPIlOuYTuZAC4c+yi0siQo920u7eiBJk8oQmUmg9cDUbR4+Gl++bvGP4plj3fErbJuPqdcQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "vscode-uri": "^3.0.8",
+ "yaml-language-server": "~1.20.0"
+ },
+ "peerDependencies": {
+ "@volar/language-service": "~2.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@volar/language-service": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vscode-css-languageservice": {
+ "version": "6.3.10",
+ "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.10.tgz",
+ "integrity": "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vscode/l10n": "^0.0.18",
+ "vscode-languageserver-textdocument": "^1.0.12",
+ "vscode-languageserver-types": "3.17.5",
+ "vscode-uri": "^3.1.0"
+ }
+ },
+ "node_modules/vscode-css-languageservice/node_modules/vscode-languageserver-types": {
+ "version": "3.17.5",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
+ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vscode-html-languageservice": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.6.2.tgz",
+ "integrity": "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vscode/l10n": "^0.0.18",
+ "vscode-languageserver-textdocument": "^1.0.12",
+ "vscode-languageserver-types": "^3.17.5",
+ "vscode-uri": "^3.1.0"
+ }
+ },
+ "node_modules/vscode-json-languageservice": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-4.1.8.tgz",
+ "integrity": "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "jsonc-parser": "^3.0.0",
+ "vscode-languageserver-textdocument": "^1.0.1",
+ "vscode-languageserver-types": "^3.16.0",
+ "vscode-nls": "^5.0.0",
+ "vscode-uri": "^3.0.2"
+ },
+ "engines": {
+ "npm": ">=7.0.0"
+ }
+ },
+ "node_modules/vscode-jsonrpc": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.1.tgz",
+ "integrity": "sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/vscode-languageserver": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
+ "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "vscode-languageserver-protocol": "3.17.5"
+ },
+ "bin": {
+ "installServerIntoExtension": "bin/installServerIntoExtension"
+ }
+ },
+ "node_modules/vscode-languageserver-protocol": {
+ "version": "3.18.2",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.2.tgz",
+ "integrity": "sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "vscode-jsonrpc": "9.0.1",
+ "vscode-languageserver-types": "3.18.0"
+ }
+ },
+ "node_modules/vscode-languageserver-textdocument": {
+ "version": "1.0.12",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
+ "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vscode-languageserver-types": {
+ "version": "3.18.0",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.0.tgz",
+ "integrity": "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vscode-languageserver/node_modules/vscode-jsonrpc": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
+ "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/vscode-languageserver/node_modules/vscode-languageserver-protocol": {
+ "version": "3.17.5",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
+ "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "vscode-jsonrpc": "8.2.0",
+ "vscode-languageserver-types": "3.17.5"
+ }
+ },
+ "node_modules/vscode-languageserver/node_modules/vscode-languageserver-types": {
+ "version": "3.17.5",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
+ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vscode-nls": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz",
+ "integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vscode-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
+ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
+ "node_modules/which-pm-runs": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz",
+ "integrity": "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/xxhash-wasm": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz",
+ "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==",
+ "license": "MIT"
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yaml": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
+ "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
+ "devOptional": true,
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
+ "node_modules/yaml-language-server": {
+ "version": "1.20.0",
+ "resolved": "https://registry.npmjs.org/yaml-language-server/-/yaml-language-server-1.20.0.tgz",
+ "integrity": "sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vscode/l10n": "^0.0.18",
+ "ajv": "^8.17.1",
+ "ajv-draft-04": "^1.0.0",
+ "prettier": "^3.5.0",
+ "request-light": "^0.5.7",
+ "vscode-json-languageservice": "4.1.8",
+ "vscode-languageserver": "^9.0.0",
+ "vscode-languageserver-textdocument": "^1.0.1",
+ "vscode-languageserver-types": "^3.16.0",
+ "vscode-uri": "^3.0.2",
+ "yaml": "2.7.1"
+ },
+ "bin": {
+ "yaml-language-server": "bin/yaml-language-server"
+ }
+ },
+ "node_modules/yaml-language-server/node_modules/request-light": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.5.8.tgz",
+ "integrity": "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/yaml-language-server/node_modules/yaml": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz",
+ "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "17.7.3",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
+ "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "22.0.0",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
+ "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
+ "license": "ISC",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/yargs/node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
+ "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zwitch": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+ "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..57ad834
--- /dev/null
+++ b/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "insanitylab-website",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "astro dev",
+ "build": "astro build",
+ "preview": "astro preview",
+ "start": "node --env-file=.env ./dist/server/entry.mjs",
+ "test": "vitest run",
+ "create-user": "node scripts/create-user.mjs"
+ },
+ "dependencies": {
+ "@astrojs/node": "^11.0.1",
+ "@astrojs/sitemap": "^3.7.3",
+ "@fontsource/montserrat": "^5.2.8",
+ "@fontsource/open-sans": "^5.2.7",
+ "@tiptap/core": "^3.27.1",
+ "@tiptap/extension-image": "^3.27.1",
+ "@tiptap/extension-link": "^3.27.1",
+ "@tiptap/starter-kit": "^3.27.1",
+ "astro": "^7.0.5",
+ "bcryptjs": "^3.0.3",
+ "better-sqlite3": "^12.11.1",
+ "nodemailer": "^9.0.3",
+ "sanitize-html": "^2.17.5"
+ },
+ "devDependencies": {
+ "@astrojs/check": "^0.9.9",
+ "@types/bcryptjs": "^2.4.6",
+ "@types/better-sqlite3": "^7.6.13",
+ "@types/nodemailer": "^8.0.1",
+ "@types/sanitize-html": "^2.16.1",
+ "typescript": "^6.0.3",
+ "vitest": "^4.1.9"
+ }
+}
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..c6e9cb0
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+ <rect width="32" height="32" rx="6" fill="#3a2929"/>
+ <path d="M4 18h5l3-8 4 12 3-6h9" fill="none" stroke="#b8a98c" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
diff --git a/public/img/logo-dark.png b/public/img/logo-dark.png
new file mode 100644
index 0000000..5535d27
Binary files /dev/null and b/public/img/logo-dark.png differ
diff --git a/public/img/logo-light.png b/public/img/logo-light.png
new file mode 100644
index 0000000..7d5354c
Binary files /dev/null and b/public/img/logo-light.png differ
diff --git a/public/robots.txt b/public/robots.txt
new file mode 100644
index 0000000..66a96dc
--- /dev/null
+++ b/public/robots.txt
@@ -0,0 +1,4 @@
+User-agent: *
+Disallow: /admin
+Disallow: /api
+Sitemap: https://insanitylab.tielogic.xyz/sitemap-index.xml
diff --git a/scripts/apply-asset-map.mjs b/scripts/apply-asset-map.mjs
new file mode 100644
index 0000000..931b99c
--- /dev/null
+++ b/scripts/apply-asset-map.mjs
@@ -0,0 +1,42 @@
+// Pipeline asset: extract-fig-images.sh → apply-asset-map.mjs → optimize-assets.sh → extract-logos.sh
+// 1. extract-fig-images.sh: estrae hash-named images da .fig → assets-src/
+// 2. apply-asset-map.mjs: copia da assets-src/ → destinazioni (src/assets/img, public/img)
+// 3. optimize-assets.sh: converte PNG fotografici → JPG q85, ridimensiona ≤1400px
+// 4. extract-logos.sh: estrae loghi insanitylab da FigmaDoc/Homepage.pdf → public/img/logo-{dark,light}.png
+
+import { copyFileSync, mkdirSync, existsSync } from 'node:fs';
+import { readFileSync } from 'node:fs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '..');
+const map = JSON.parse(readFileSync(join(root, 'scripts/asset-map.json'), 'utf8'));
+let n = 0, missing = 0;
+
+// Sezione principale: copia da assets-src/
+for (const [destDir, entries] of Object.entries(map)) {
+ if (destDir.startsWith('_') || destDir === 'fallbacks') continue;
+ mkdirSync(join(root, destDir), { recursive: true });
+ for (const [src, dest] of Object.entries(entries)) {
+ const from = join(root, 'assets-src', src);
+ if (!existsSync(from)) { console.error(`MANCANTE: ${src}`); missing++; continue; }
+ copyFileSync(from, join(root, destDir, dest));
+ n++;
+ }
+}
+
+// Sezione fallbacks: copia da path relativo alla root del repo (es. ../Dati/)
+if (map.fallbacks) {
+ for (const [destDir, entries] of Object.entries(map.fallbacks)) {
+ mkdirSync(join(root, destDir), { recursive: true });
+ for (const [srcRelPath, dest] of Object.entries(entries)) {
+ const from = join(root, srcRelPath);
+ if (!existsSync(from)) { console.error(`MANCANTE (fallback): ${srcRelPath}`); missing++; continue; }
+ copyFileSync(from, join(root, destDir, dest));
+ n++;
+ }
+ }
+}
+
+console.log(`Copiate ${n} immagini${missing ? `, ${missing} mancanti` : ''}`);
+process.exit(missing ? 1 : 0);
diff --git a/scripts/asset-map.json b/scripts/asset-map.json
new file mode 100644
index 0000000..3155034
--- /dev/null
+++ b/scripts/asset-map.json
@@ -0,0 +1,37 @@
+{
+ "_nota": "Le destinazioni .png fotografiche vengono convertite in .jpg da optimize-assets.sh; il nome finale su disco ha estensione .jpg",
+ "src/assets/img": {
+ "8b71f158c30a0a23cc5113bfe1cd9a6c4f19e4c4.jpg": "hero-1.jpg",
+ "0f8034394657985cc4cf35eb8441ae93a7ce5302.jpg": "info-performance.jpg",
+ "432d397a342b4e8312a3a13a4fea836b3e867a64.jpg": "info-balance.jpg",
+ "d7fcf0aa827ec50f660145c0d319049b9153fefb.jpg": "info-longevity.jpg",
+ "6d6025730c3d0b73c0052fa67b1bb111c8462a08.jpg": "feature-performance.jpg",
+ "16e611de84f51bf5d01486a5e74ba0f8249850be.jpg": "feature-balance.jpg",
+ "98f1f8d257dfeffbaf35507d033e5bb51e08895e.png": "feature-longevity.png",
+ "28e8157dc88a42cffdbcd028e4cb02a6eb3de337.png": "quote-bg.png",
+ "85808a0c9aeb461c70c059b013eab47faa4fc4f1.png": "training-one-to-one.png",
+ "bb632b8da5e9e4d4b05207f04a8883163caac53a.png": "training-small-groups.png",
+ "91aa82b5dedee8ad56c6db249c9947c7d6d9c89a.png": "training-performance-class.png",
+ "86c1363b22a8fb3bb7b2bf044e52856d474d927e.jpg": "training-fit-remote.jpg",
+ "0b6cf69438af21bbca934b0b27995ac4fa967451.png": "team-donata.png",
+ "1cc33c41368d47c30d949e991c5a442ff51d1a91.png": "team-nicola.png",
+ "a75ca105e362e3c3bc5cfe0bfc7c39e69645660d.png": "team-eleonora.png",
+ "e3c84f9989df8b160d185bd40b774e460a4f6896.png": "team-antonello.png",
+ "88cfcd5fd321b5ca6e4833e3980b792a95090545.jpg": "method-step-1.jpg",
+ "d21671bb08f4f2eadde09f91a46b568788a209d7.jpg": "method-step-2.jpg",
+ "a3e84d7daf0b25c750ef694f4b9fc10bf0f2f1a8.jpg": "method-step-3.jpg",
+ "e50b6f7818b3742ddba0f4ad4a162a8aafc505e0.jpg": "method-step-4.jpg",
+ "bee22f37bba6f18d3defcd47762375e1c8ddd0f5.png": "partner-edenred.png",
+ "cf079250ea1db7f0055b76af21e04b1ed1870770.png": "partner-welbee.png",
+ "872c3679df24bd0d3fdca7c4cb9a24d1836309a0.png": "partner-howden.png",
+ "014a7708ad61b12c5765ad4a60539d2fa325f9aa.png": "partner-aon.png",
+ "8ffc5f7a2428b0d0eabd09f676ab98d3e5644f0d.png": "about-exterior.png",
+ "32ecd00a20321e58c700fbaf739e53ac3bf03876.png": "about-team-group.png",
+ "d4608b102eb67ac067e6585250b3c37963db1d34.png": "about-training-group.png",
+ "dafcc19362e3a76dd20e9a656499a8068a3a9fca.png": "about-trainer-1.png",
+ "9c50f1d3b3a0477e80e03c0849d3d4687610b461.png": "about-trainer-2.png",
+ "c51a49b9be7f1b1a31de6b2f5261c8c5b5fdab52.png": "about-trainer-3.png",
+ "3341a11b7153349a23c75b1fa2a9a62d48953330.png": "about-trainer-4.png"
+ },
+ "_loghi": "logo-dark.png e logo-light.png estratti da extract-logos.sh (FigmaDoc/Homepage.pdf), non da ../Dati"
+}
diff --git a/scripts/create-user.mjs b/scripts/create-user.mjs
new file mode 100644
index 0000000..5bd0e67
--- /dev/null
+++ b/scripts/create-user.mjs
@@ -0,0 +1,23 @@
+import Database from 'better-sqlite3';
+import bcrypt from 'bcryptjs';
+import { mkdirSync } from 'node:fs';
+import { dirname } from 'node:path';
+
+const [, , username, password] = process.argv;
+if (!username || !password) {
+ console.error('Uso: npm run create-user -- <username> <password>');
+ process.exit(1);
+}
+const path = process.env.DB_PATH ?? 'data/insanitylab.db';
+mkdirSync(dirname(path), { recursive: true });
+const db = new Database(path);
+db.exec(`CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL
+);`);
+db.prepare(
+ `INSERT INTO users (username, password_hash) VALUES (?, ?)
+ ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash`
+).run(username, bcrypt.hashSync(password, 12));
+console.log(`Utente "${username}" creato/aggiornato in ${path}`);
diff --git a/scripts/extract-fig-images.sh b/scripts/extract-fig-images.sh
new file mode 100755
index 0000000..ef3bdf1
--- /dev/null
+++ b/scripts/extract-fig-images.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")/.."
+FIG="../FigmaDoc/Web Insanity.fig"
+OUT="assets-src"
+rm -rf "$OUT" && mkdir -p "$OUT"
+unzip -o -j -q "$FIG" 'images/*' -d "$OUT"
+cd "$OUT"
+for f in *; do
+ case "$(file --brief --mime-type "$f")" in
+ image/png) mv "$f" "$f.png" ;;
+ image/jpeg) mv "$f" "$f.jpg" ;;
+ image/webp) mv "$f" "$f.webp" ;;
+ *) rm -f "$f" ;;
+ esac
+done
+echo "Estratte: $(ls | wc -l) immagini in $OUT/"
diff --git a/scripts/extract-logos.sh b/scripts/extract-logos.sh
new file mode 100755
index 0000000..a84b5bc
--- /dev/null
+++ b/scripts/extract-logos.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+# extract-logos.sh — estrae loghi insanitylab dal PDF Figma Homepage
+# Pipeline: extract-fig-images.sh → apply-asset-map.mjs → optimize-assets.sh → extract-logos.sh
+# Idempotente: stessi file ad ogni esecuzione, coordinate fisse.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+PDF="$ROOT/../FigmaDoc/Homepage.pdf"
+OUT="$ROOT/public/img"
+WORK="$(mktemp -d)"
+trap 'rm -rf "$WORK"' EXIT
+
+if [[ ! -f "$PDF" ]]; then
+ echo "ERRORE: PDF non trovato: $PDF" >&2
+ exit 1
+fi
+
+mkdir -p "$OUT"
+
+echo "Estrazione loghi da $PDF..."
+
+# ── Logo DARK (header, su fondo bianco) ─────────────────────────────────────
+# Zona: x=104, y=41, W=895, H=250 px a 300dpi (pt: x≈25, y≈10, W≈215, H≈60)
+pdftoppm -png -r 300 -f 1 -l 1 \
+ -x 104 -y 41 -W 895 -H 250 \
+ "$PDF" "$WORK/hdr"
+
+convert "$WORK/hdr-1.png" \
+ -fuzz 8% -transparent white \
+ -trim +repage \
+ -resize 600x \
+ -strip \
+ "$OUT/logo-dark.png"
+
+# ── Logo LIGHT (footer, su fondo scuro #3A2929) ─────────────────────────────
+# Zona: x=0, y=35375, W=1800, H=833 px a 300dpi (pt: x≈0, y≈8490, W≈432, H≈200)
+# poi crop interno: x=130, y=60, W=850, H=660 per includere solo icona+testo
+pdftoppm -png -r 300 -f 1 -l 1 \
+ -x 0 -y 35375 -W 1800 -H 833 \
+ "$PDF" "$WORK/ftr"
+
+convert "$WORK/ftr-1.png" \
+ -crop 850x660+130+60 +repage \
+ -fuzz 12% -transparent 'srgb(58,41,41)' \
+ -trim +repage \
+ -resize 600x \
+ -strip \
+ "$OUT/logo-light.png"
+
+echo "Logo dark: $OUT/logo-dark.png $(identify -format '%wx%h' "$OUT/logo-dark.png")"
+echo "Logo light: $OUT/logo-light.png $(identify -format '%wx%h' "$OUT/logo-light.png")"
+echo "Done."
diff --git a/scripts/optimize-assets.sh b/scripts/optimize-assets.sh
new file mode 100755
index 0000000..01bb004
--- /dev/null
+++ b/scripts/optimize-assets.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+# Passo 3 della pipeline asset (dopo apply-asset-map.mjs).
+# Converte PNG fotografici in src/assets/img/ → JPG q83 e ridimensiona a ≤1400x1600px.
+# I PNG partner-* (loghi con trasparenza) restano PNG.
+# Tutti i JPG esistenti vengono ri-codificati a q83 con vincolo 1400x1600>
+# (larghezza ≤1400 px E altezza ≤1600 px, aspect ratio preservato).
+# Idempotente se abbinato a apply-asset-map.mjs (che ripristina i sorgenti originali).
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+IMG_DIR="$ROOT/src/assets/img"
+
+cd "$IMG_DIR"
+
+# --- PNG fotografici → JPG q83 + resize ≤1400x1600 ---
+shopt -s nullglob
+declare -A just_converted
+converted=0
+for png in *.png; do
+ [[ "$png" == partner-* ]] && continue
+ jpg="${png%.png}.jpg"
+ convert "$png" -quality 83 -resize '1400x1600>' "$jpg"
+ rm "$png"
+ echo " PNG→JPG: $png → $jpg"
+ just_converted["$jpg"]=1
+ (( converted++ )) || true
+done
+
+# --- Ri-codifica JPG esistenti a q83 + vincolo 1400x1600 ---
+# Skip file appena convertiti (già a q83), elabora tutti gli altri.
+reencoded=0
+for jpg in *.jpg; do
+ [[ -n "${just_converted[$jpg]+x}" ]] && continue
+ mogrify -quality 83 -resize '1400x1600>' "$jpg"
+ echo " Ricodificato: $jpg"
+ (( reencoded++ )) || true
+done
+
+echo "Ottimizzazione completata: ${converted} PNG→JPG, ${reencoded} JPG ricodificati (q83, ≤1400x1600)."
diff --git a/scripts/smoke.sh b/scripts/smoke.sh
new file mode 100755
index 0000000..67e9a56
--- /dev/null
+++ b/scripts/smoke.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")/.."
+npm run build
+PORT=4399 node --env-file=.env ./dist/server/entry.mjs & SRV=$!
+trap 'kill $SRV' EXIT
+sleep 2
+fail=0
+for path in / /about /training /programs /programs/performance-class /programs/rehab /blog /contact /admin/login /robots.txt; do
+ code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:4399$path")
+ echo "$code $path"
+ [ "$code" = "200" ] || fail=1
+done
+code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/admin)
+echo "$code /admin (atteso 302)"
+[ "$code" = "302" ] || fail=1
+exit $fail
diff --git a/src/assets/img/about-exterior.jpg b/src/assets/img/about-exterior.jpg
new file mode 100644
index 0000000..99a44e7
Binary files /dev/null and b/src/assets/img/about-exterior.jpg differ
diff --git a/src/assets/img/about-team-group.jpg b/src/assets/img/about-team-group.jpg
new file mode 100644
index 0000000..a37d5ad
Binary files /dev/null and b/src/assets/img/about-team-group.jpg differ
diff --git a/src/assets/img/about-trainer-1.jpg b/src/assets/img/about-trainer-1.jpg
new file mode 100644
index 0000000..b958d89
Binary files /dev/null and b/src/assets/img/about-trainer-1.jpg differ
diff --git a/src/assets/img/about-trainer-2.jpg b/src/assets/img/about-trainer-2.jpg
new file mode 100644
index 0000000..76cfa77
Binary files /dev/null and b/src/assets/img/about-trainer-2.jpg differ
diff --git a/src/assets/img/about-trainer-3.jpg b/src/assets/img/about-trainer-3.jpg
new file mode 100644
index 0000000..e17f529
Binary files /dev/null and b/src/assets/img/about-trainer-3.jpg differ
diff --git a/src/assets/img/about-trainer-4.jpg b/src/assets/img/about-trainer-4.jpg
new file mode 100644
index 0000000..54c1470
Binary files /dev/null and b/src/assets/img/about-trainer-4.jpg differ
diff --git a/src/assets/img/about-training-group.jpg b/src/assets/img/about-training-group.jpg
new file mode 100644
index 0000000..6c795a3
Binary files /dev/null and b/src/assets/img/about-training-group.jpg differ
diff --git a/src/assets/img/feature-balance.jpg b/src/assets/img/feature-balance.jpg
new file mode 100644
index 0000000..416d835
Binary files /dev/null and b/src/assets/img/feature-balance.jpg differ
diff --git a/src/assets/img/feature-longevity.jpg b/src/assets/img/feature-longevity.jpg
new file mode 100644
index 0000000..06ec041
Binary files /dev/null and b/src/assets/img/feature-longevity.jpg differ
diff --git a/src/assets/img/feature-performance.jpg b/src/assets/img/feature-performance.jpg
new file mode 100644
index 0000000..f7209e7
Binary files /dev/null and b/src/assets/img/feature-performance.jpg differ
diff --git a/src/assets/img/hero-1.jpg b/src/assets/img/hero-1.jpg
new file mode 100644
index 0000000..edaa600
Binary files /dev/null and b/src/assets/img/hero-1.jpg differ
diff --git a/src/assets/img/info-balance.jpg b/src/assets/img/info-balance.jpg
new file mode 100644
index 0000000..57ffdac
Binary files /dev/null and b/src/assets/img/info-balance.jpg differ
diff --git a/src/assets/img/info-longevity.jpg b/src/assets/img/info-longevity.jpg
new file mode 100644
index 0000000..e7b194d
Binary files /dev/null and b/src/assets/img/info-longevity.jpg differ
diff --git a/src/assets/img/info-performance.jpg b/src/assets/img/info-performance.jpg
new file mode 100644
index 0000000..bdc360c
Binary files /dev/null and b/src/assets/img/info-performance.jpg differ
diff --git a/src/assets/img/method-step-1.jpg b/src/assets/img/method-step-1.jpg
new file mode 100644
index 0000000..3c3748d
Binary files /dev/null and b/src/assets/img/method-step-1.jpg differ
diff --git a/src/assets/img/method-step-2.jpg b/src/assets/img/method-step-2.jpg
new file mode 100644
index 0000000..81dfb8a
Binary files /dev/null and b/src/assets/img/method-step-2.jpg differ
diff --git a/src/assets/img/method-step-3.jpg b/src/assets/img/method-step-3.jpg
new file mode 100644
index 0000000..d8e7b96
Binary files /dev/null and b/src/assets/img/method-step-3.jpg differ
diff --git a/src/assets/img/method-step-4.jpg b/src/assets/img/method-step-4.jpg
new file mode 100644
index 0000000..ec6fc69
Binary files /dev/null and b/src/assets/img/method-step-4.jpg differ
diff --git a/src/assets/img/partner-aon.png b/src/assets/img/partner-aon.png
new file mode 100644
index 0000000..a9e8c2c
Binary files /dev/null and b/src/assets/img/partner-aon.png differ
diff --git a/src/assets/img/partner-edenred.png b/src/assets/img/partner-edenred.png
new file mode 100644
index 0000000..c409f48
Binary files /dev/null and b/src/assets/img/partner-edenred.png differ
diff --git a/src/assets/img/partner-howden.png b/src/assets/img/partner-howden.png
new file mode 100644
index 0000000..3771321
Binary files /dev/null and b/src/assets/img/partner-howden.png differ
diff --git a/src/assets/img/partner-welbee.png b/src/assets/img/partner-welbee.png
new file mode 100644
index 0000000..b1c188b
Binary files /dev/null and b/src/assets/img/partner-welbee.png differ
diff --git a/src/assets/img/quote-bg.jpg b/src/assets/img/quote-bg.jpg
new file mode 100644
index 0000000..e5ab31d
Binary files /dev/null and b/src/assets/img/quote-bg.jpg differ
diff --git a/src/assets/img/team-antonello.jpg b/src/assets/img/team-antonello.jpg
new file mode 100644
index 0000000..0c8a08c
Binary files /dev/null and b/src/assets/img/team-antonello.jpg differ
diff --git a/src/assets/img/team-donata.jpg b/src/assets/img/team-donata.jpg
new file mode 100644
index 0000000..71ddb49
Binary files /dev/null and b/src/assets/img/team-donata.jpg differ
diff --git a/src/assets/img/team-eleonora.jpg b/src/assets/img/team-eleonora.jpg
new file mode 100644
index 0000000..003469f
Binary files /dev/null and b/src/assets/img/team-eleonora.jpg differ
diff --git a/src/assets/img/team-nicola.jpg b/src/assets/img/team-nicola.jpg
new file mode 100644
index 0000000..2e0befd
Binary files /dev/null and b/src/assets/img/team-nicola.jpg differ
diff --git a/src/assets/img/training-fit-remote.jpg b/src/assets/img/training-fit-remote.jpg
new file mode 100644
index 0000000..c7602b4
Binary files /dev/null and b/src/assets/img/training-fit-remote.jpg differ
diff --git a/src/assets/img/training-one-to-one.jpg b/src/assets/img/training-one-to-one.jpg
new file mode 100644
index 0000000..6a81d1f
Binary files /dev/null and b/src/assets/img/training-one-to-one.jpg differ
diff --git a/src/assets/img/training-performance-class.jpg b/src/assets/img/training-performance-class.jpg
new file mode 100644
index 0000000..434a95f
Binary files /dev/null and b/src/assets/img/training-performance-class.jpg differ
diff --git a/src/assets/img/training-small-groups.jpg b/src/assets/img/training-small-groups.jpg
new file mode 100644
index 0000000..885e2b7
Binary files /dev/null and b/src/assets/img/training-small-groups.jpg differ
diff --git a/src/components/Agenda.astro b/src/components/Agenda.astro
new file mode 100644
index 0000000..116b0c0
--- /dev/null
+++ b/src/components/Agenda.astro
@@ -0,0 +1,54 @@
+---
+import { agenda, disciplines, days, slots } from '../data/agenda';
+---
+<section class="section section--dark agenda">
+ <div class="container">
+ <div class="section-heading"><h2>Agenda</h2></div>
+ <div class="agenda__tabs" role="tablist">
+ {disciplines.map((d, i) => (
+ <button class:list={['agenda__tab', { 'is-active': i === 0 }]} data-filter={d} role="tab" aria-selected={i === 0 ? 'true' : 'false'}>{d}</button>
+ ))}
+ </div>
+ <div class="agenda__grid">
+ <div class="agenda__corner"></div>
+ {days.map((d) => <div class="agenda__day">{d}</div>)}
+ {slots.map((slot) => (
+ <>
+ <div class="agenda__slot">{slot}</div>
+ {days.map((_, dayIdx) => {
+ const entry = agenda.find((e) => e.day === dayIdx && e.slot === slot);
+ return entry
+ ? <div class="agenda__cell" data-discipline={entry.discipline}><strong>{entry.discipline}</strong><span>{entry.time}</span><em>{entry.trainer}</em></div>
+ : <div class="agenda__cell agenda__cell--empty"></div>;
+ })}
+ </>
+ ))}
+ </div>
+ </div>
+</section>
+
+<style>
+ .agenda__tabs { display: flex; flex-wrap: wrap; gap: 4px; justify-content: center; margin-bottom: 40px; }
+ .agenda__tab { background: transparent; color: #b9aea4; border: 0; cursor: pointer; font-family: var(--font-heading); font-size: .68rem; letter-spacing: .18em; text-transform: uppercase; padding: 10px 18px; }
+ .agenda__tab.is-active { background: var(--c-accent); color: #fff; }
+ .agenda__grid { display: grid; grid-template-columns: 70px repeat(7, 1fr); gap: 2px; }
+ .agenda__day, .agenda__slot { font-family: var(--font-heading); font-size: .66rem; letter-spacing: .14em; text-transform: uppercase; color: #b9aea4; display: flex; align-items: center; justify-content: center; padding: 10px 4px; }
+ .agenda__cell { background: var(--c-accent); color: #fff; padding: 14px 8px; text-align: center; font-size: .72rem; display: flex; flex-direction: column; gap: 2px; }
+ .agenda__cell strong { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .1em; text-transform: uppercase; }
+ .agenda__cell em { font-style: normal; opacity: .85; }
+ .agenda__cell--empty { background: transparent; }
+ .agenda__cell.is-dim { opacity: .15; }
+ @media (max-width: 991px) { .agenda__grid { grid-template-columns: 60px repeat(7, minmax(90px, 1fr)); overflow-x: auto; } }
+</style>
+
+<script>
+ document.querySelectorAll<HTMLElement>('.agenda').forEach((root) => {
+ const tabs = root.querySelectorAll<HTMLButtonElement>('.agenda__tab');
+ const cells = root.querySelectorAll<HTMLElement>('.agenda__cell[data-discipline]');
+ tabs.forEach((tab) => tab.addEventListener('click', () => {
+ tabs.forEach((t) => { t.classList.toggle('is-active', t === tab); t.setAttribute('aria-selected', String(t === tab)); });
+ const f = tab.dataset.filter!;
+ cells.forEach((c) => c.classList.toggle('is-dim', f !== 'Events' && c.dataset.discipline !== f));
+ }));
+ });
+</script>
diff --git a/src/components/ContactForm.astro b/src/components/ContactForm.astro
new file mode 100644
index 0000000..fec0282
--- /dev/null
+++ b/src/components/ContactForm.astro
@@ -0,0 +1,42 @@
+---
+interface Props { variant?: 'full' | 'footer' }
+const { variant = 'full' } = Astro.props;
+---
+<form class:list={['cform', `cform--${variant}`]} method="post" action="/api/contact" novalidate>
+ <input class="field" name="firstName" placeholder="Nome" required maxlength="100" />
+ {variant === 'full' && <input class="field" name="lastName" placeholder="Cognome" maxlength="100" />}
+ {variant === 'full' && <input class="field" name="phone" placeholder="Telefono" maxlength="40" />}
+ <input class="field" type="email" name="email" placeholder="E-mail" required maxlength="200" />
+ <textarea class="field" name="message" placeholder="Messaggio" required maxlength="5000"></textarea>
+ <input class="hp" type="text" name="website" tabindex="-1" autocomplete="off" />
+ <button class="btn" type="submit">Invia messaggio</button>
+ <p class="form-msg" hidden></p>
+</form>
+
+<script>
+ document.querySelectorAll<HTMLFormElement>('.cform').forEach((form) => {
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const msg = form.querySelector<HTMLParagraphElement>('.form-msg')!;
+ const btn = form.querySelector<HTMLButtonElement>('button')!;
+ btn.disabled = true;
+ msg.hidden = true;
+ try {
+ const res = await fetch('/api/contact', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(Object.fromEntries(new FormData(form))),
+ });
+ const data = await res.json();
+ msg.textContent = res.ok ? 'Messaggio inviato, ti ricontatteremo al più presto.' : (data.error ?? 'Errore di invio, riprova più tardi.');
+ msg.className = `form-msg ${res.ok ? 'form-msg--ok' : 'form-msg--err'}`;
+ if (res.ok) form.reset();
+ } catch {
+ msg.textContent = 'Errore di rete, riprova più tardi.';
+ msg.className = 'form-msg form-msg--err';
+ }
+ msg.hidden = false;
+ btn.disabled = false;
+ });
+ });
+</script>
diff --git a/src/components/Footer.astro b/src/components/Footer.astro
new file mode 100644
index 0000000..1546466
--- /dev/null
+++ b/src/components/Footer.astro
@@ -0,0 +1,49 @@
+---
+import { site } from '../data/site';
+import ContactForm from './ContactForm.astro';
+const year = new Date().getFullYear();
+---
+<footer class="ftr">
+ <div class="container ftr__grid">
+ <div>
+ <img src="/img/logo-light.png" alt="InsanityLab" width="140" loading="lazy" />
+ <p class="ftr__tag">PERFORMANCE.&nbsp; BALANCE.&nbsp; LONGEVITY.</p>
+ <p>Promuoviamo il movimento come cura di sé. Abitudini sostenibili per benessere fisico e mentale.</p>
+ <p>{site.phone}<br />{site.address}, {site.cityLine}</p>
+ {site.hours.map((h) => <p class="ftr__hours">{h}</p>)}
+ </div>
+ <nav aria-label="training">
+ <h4>Training</h4>
+ {site.footerTraining.map((l) => <a href={l.href}>{l.label}</a>)}
+ </nav>
+ <nav aria-label="programmi">
+ <h4>Programmi</h4>
+ {site.footerPrograms.map((l) => <a href={l.href}>{l.label}</a>)}
+ </nav>
+ <div>
+ <h4>Contact</h4>
+ <ContactForm variant="footer" />
+ </div>
+ </div>
+ <div class="ftr__bar">
+ <div class="container ftr__bar-in">
+ <p>© {year} <strong>Insanitylab</strong>, All Rights Reserved</p>
+ <p>Seguici {site.socials.map((s) => <a href={s.url} rel="noopener" target="_blank">{s.label}</a>)}</p>
+ </div>
+ </div>
+</footer>
+
+<style>
+ .ftr { background: var(--c-dark); color: #b9aea4; font-size: .88rem; }
+ .ftr a { color: #b9aea4; text-decoration: none; display: block; padding: 4px 0; }
+ .ftr a:hover { color: #fff; }
+ .ftr h4 { color: #fff; text-transform: uppercase; letter-spacing: .18em; font-size: .78rem; margin-bottom: 18px; }
+ .ftr__grid { display: grid; grid-template-columns: 1.3fr 1fr 1fr 1.3fr; gap: 40px; padding-block: 70px; }
+ .ftr__tag { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .2em; color: #fff; }
+ .ftr__hours { margin: 0; }
+ .ftr__bar { border-top: 1px solid rgba(255,255,255,.08); padding-block: 18px; }
+ .ftr__bar-in { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
+ .ftr__bar-in a { display: inline; margin-left: 12px; }
+ @media (max-width: 991px) { .ftr__grid { grid-template-columns: 1fr 1fr; } }
+ @media (max-width: 600px) { .ftr__grid { grid-template-columns: 1fr; } }
+</style>
diff --git a/src/components/Header.astro b/src/components/Header.astro
new file mode 100644
index 0000000..7c62429
--- /dev/null
+++ b/src/components/Header.astro
@@ -0,0 +1,45 @@
+---
+import { site } from '../data/site';
+const path = Astro.url.pathname;
+---
+<header class="hdr" id="site-header">
+ <div class="container hdr__in">
+ <a href="/" class="hdr__logo"><img src="/img/logo-dark.png" alt="InsanityLab" width="150" /></a>
+ <nav class="hdr__nav" id="site-nav" aria-label="principale">
+ {site.navigation.map((item) => (
+ <a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{item.label}</a>
+ ))}
+ </nav>
+ <button class="hdr__burger" id="nav-toggle" aria-label="Apri menu" aria-expanded="false">
+ <span></span><span></span><span></span>
+ </button>
+ </div>
+</header>
+
+<style>
+ .hdr { position: sticky; top: 0; z-index: 100; background: #fff; transition: box-shadow .2s; }
+ .hdr.is-scrolled { box-shadow: 0 2px 14px rgba(0,0,0,.08); }
+ .hdr__in { display: flex; align-items: center; justify-content: space-between; height: var(--header-h); }
+ .hdr__nav { display: flex; gap: 34px; }
+ .hdr__link { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
+ .hdr__link:hover, .hdr__link.is-active { color: var(--c-accent-dark); }
+ .hdr__burger { display: none; background: none; border: 0; cursor: pointer; padding: 8px; }
+ .hdr__burger span { display: block; width: 22px; height: 2px; background: var(--c-heading); margin: 5px 0; }
+ @media (max-width: 991px) {
+ .hdr__nav { display: none; position: absolute; top: var(--header-h); left: 0; right: 0; background: #fff; flex-direction: column; gap: 0; padding: 10px 20px 20px; box-shadow: 0 10px 20px rgba(0,0,0,.08); }
+ .hdr__nav.is-open { display: flex; }
+ .hdr__link { padding: 12px 0; }
+ .hdr__burger { display: block; }
+ }
+</style>
+
+<script>
+ const header = document.getElementById('site-header')!;
+ const toggle = document.getElementById('nav-toggle')!;
+ const nav = document.getElementById('site-nav')!;
+ addEventListener('scroll', () => header.classList.toggle('is-scrolled', scrollY > 10), { passive: true });
+ toggle.addEventListener('click', () => {
+ const open = nav.classList.toggle('is-open');
+ toggle.setAttribute('aria-expanded', String(open));
+ });
+</script>
diff --git a/src/components/PageHero.astro b/src/components/PageHero.astro
new file mode 100644
index 0000000..7a4529f
--- /dev/null
+++ b/src/components/PageHero.astro
@@ -0,0 +1,15 @@
+---
+interface Props { title: string; eyebrow?: string }
+const { title, eyebrow } = Astro.props;
+---
+<section class="phero">
+ <div class="container">
+ {eyebrow && <p class="eyebrow">{eyebrow}</p>}
+ <h1>{title}</h1>
+ </div>
+</section>
+
+<style>
+ .phero { background: var(--c-bg-alt); padding-block: 70px; }
+ .phero h1 { text-transform: lowercase; font-weight: 500; margin: 0; }
+</style>
diff --git a/src/components/about/Timeline.astro b/src/components/about/Timeline.astro
new file mode 100644
index 0000000..2eb91dc
--- /dev/null
+++ b/src/components/about/Timeline.astro
@@ -0,0 +1,34 @@
+---
+const openText = `Il percorso di crescita di InsanityLab si è sviluppato attraverso tappe precise e coerenti con la visione iniziale. Nato nel 2021 con un piccolo team e grandi ambizioni, il progetto ha conosciuto una forte evoluzione nel 2024, anno in cui il servizio si è ampliato attraverso nuovi format come gli small group e una sempre maggiore specializzazione nella profilazione fisica del cliente, grazie a sistemi avanzati di valutazione e monitoraggio. Parallelamente, anche l'area dedicata alla riabilitazione e all'esercizio preventivo si è consolidata, ponendo le basi per un importante salto di qualità.`;
+const stages = [
+ { label: 'INSANITYLAB 2025', text: 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.' },
+ { label: 'INSANITYLAB 2026', text: 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.' },
+ { label: 'INSANITYLAB OGGI', text: 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.' },
+];
+---
+<section class="section">
+ <div class="container">
+ <div class="section-heading">
+ <h2>Storylab</h2>
+ <p>Scopri la nostra storia: come nasce InsanityLab, perché abbiamo deciso di creare qualcosa di diverso e chi sono i visionari che hanno trasformato questa idea in un nuovo modo di vivere benessere, movimento e salute.</p>
+ </div>
+ <div class="tl">
+ <h3 class="tl__label">INSANITYLAB 2021-2024</h3>
+ <p>{openText}</p>
+ {stages.map((s) => (
+ <details class="tl__stage">
+ <summary>+ {s.label}</summary>
+ <p>{s.text}</p>
+ </details>
+ ))}
+ </div>
+ </div>
+</section>
+
+<style>
+ .tl { max-width: 720px; margin-inline: auto; }
+ .tl__label { text-transform: uppercase; letter-spacing: .16em; font-size: .9rem; }
+ .tl__stage { border-top: 1px solid #e4ded6; padding-block: 14px; }
+ .tl__stage summary { cursor: pointer; font-family: var(--font-heading); font-size: .82rem; letter-spacing: .16em; text-transform: uppercase; color: var(--c-heading); list-style: none; }
+ .tl__stage summary::-webkit-details-marker { display: none; }
+</style>
diff --git a/src/components/admin/PostForm.astro b/src/components/admin/PostForm.astro
new file mode 100644
index 0000000..4ffdf2a
--- /dev/null
+++ b/src/components/admin/PostForm.astro
@@ -0,0 +1,136 @@
+---
+import { CATEGORIES } from '../../lib/blog-const';
+import type { Post } from '../../lib/posts';
+interface Props { post?: Post }
+const { post } = Astro.props;
+---
+<form id="post-form" data-id={post?.id ?? ''}>
+ <input class="afield" name="title" placeholder="Titolo" required value={post?.title ?? ''} />
+ <input class="afield" name="slug" placeholder="Slug (vuoto = generato dal titolo)" value={post?.slug ?? ''} />
+ <select class="afield" name="category">
+ {CATEGORIES.map((c) => <option value={c} selected={post?.category === c}>{c}</option>)}
+ </select>
+ <textarea class="afield" name="excerpt" placeholder="Estratto (anteprima in lista blog)" maxlength="300">{post?.excerpt ?? ''}</textarea>
+
+ <label>Copertina: <input type="file" id="cover-file" accept="image/*" /></label>
+ <input type="hidden" name="cover" value={post?.cover ?? ''} />
+ <p id="cover-preview">{post?.cover && <img src={post.cover} style="max-width:200px" />}</p>
+
+ <div class="ed-toolbar" id="ed-toolbar">
+ <button type="button" data-cmd="h2">H2</button>
+ <button type="button" data-cmd="h3">H3</button>
+ <button type="button" data-cmd="bold"><b>B</b></button>
+ <button type="button" data-cmd="italic"><i>I</i></button>
+ <button type="button" data-cmd="bullet">• Elenco</button>
+ <button type="button" data-cmd="ordered">1. Elenco</button>
+ <button type="button" data-cmd="quote">" Citazione</button>
+ <button type="button" data-cmd="link">Link</button>
+ <button type="button" data-cmd="image">Immagine</button>
+ </div>
+ <div id="editor" class="ed-body"></div>
+
+ <label><input type="checkbox" name="draft" checked={post ? post.draft === 1 : true} /> Bozza (non visibile sul sito)</label>
+ <p><button class="abtn" type="submit">Salva</button> <span id="save-msg"></span></p>
+</form>
+
+<style>
+ .ed-toolbar { display: flex; gap: 6px; flex-wrap: wrap; background: #fff; border: 1px solid #ccc; border-bottom: 0; padding: 8px; }
+ .ed-toolbar button { background: #eee; border: 1px solid #ccc; padding: 4px 10px; cursor: pointer; }
+ .ed-toolbar button.is-active { background: #b5a48b; color: #fff; }
+ .ed-body { background: #fff; border: 1px solid #ccc; min-height: 340px; padding: 12px 16px; margin-bottom: 14px; }
+ .ed-body :global(.ProseMirror) { outline: none; min-height: 320px; }
+</style>
+
+<script>
+ import { Editor } from '@tiptap/core';
+ import StarterKit from '@tiptap/starter-kit';
+ import Image from '@tiptap/extension-image';
+ import Link from '@tiptap/extension-link';
+
+ const form = document.getElementById('post-form') as HTMLFormElement;
+ const initial = (document.getElementById('initial-body') as HTMLScriptElement | null)?.textContent ?? '';
+
+ const editor = new Editor({
+ element: document.getElementById('editor')!,
+ extensions: [
+ StarterKit.configure({ heading: { levels: [2, 3] } }),
+ Image,
+ Link.configure({ openOnClick: false }),
+ ],
+ content: initial,
+ });
+
+ async function uploadFile(file: File): Promise<string | null> {
+ const fd = new FormData();
+ fd.append('file', file);
+ const res = await fetch('/api/admin/upload', { method: 'POST', body: fd });
+ if (!res.ok) { alert('Upload non riuscito.'); return null; }
+ return (await res.json()).url;
+ }
+
+ document.getElementById('ed-toolbar')!.addEventListener('click', async (e) => {
+ const btn = (e.target as HTMLElement).closest('button');
+ if (!btn) return;
+ const c = editor.chain().focus();
+ switch (btn.dataset.cmd) {
+ case 'h2': c.toggleHeading({ level: 2 }).run(); break;
+ case 'h3': c.toggleHeading({ level: 3 }).run(); break;
+ case 'bold': c.toggleBold().run(); break;
+ case 'italic': c.toggleItalic().run(); break;
+ case 'bullet': c.toggleBulletList().run(); break;
+ case 'ordered': c.toggleOrderedList().run(); break;
+ case 'quote': c.toggleBlockquote().run(); break;
+ case 'link': {
+ const url = prompt('URL del link:');
+ if (url) c.setLink({ href: url }).run();
+ break;
+ }
+ case 'image': {
+ const input = document.createElement('input');
+ input.type = 'file'; input.accept = 'image/*';
+ input.onchange = async () => {
+ const url = input.files?.[0] && await uploadFile(input.files[0]);
+ if (url) editor.chain().focus().setImage({ src: url }).run();
+ };
+ input.click();
+ break;
+ }
+ }
+ });
+
+ document.getElementById('cover-file')!.addEventListener('change', async (e) => {
+ const file = (e.target as HTMLInputElement).files?.[0];
+ if (!file) return;
+ const url = await uploadFile(file);
+ if (url) {
+ (form.elements.namedItem('cover') as HTMLInputElement).value = url;
+ document.getElementById('cover-preview')!.innerHTML = `<img src="${url}" style="max-width:200px">`;
+ }
+ });
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const id = form.dataset.id;
+ const body = {
+ title: (form.elements.namedItem('title') as HTMLInputElement).value,
+ slug: (form.elements.namedItem('slug') as HTMLInputElement).value,
+ category: (form.elements.namedItem('category') as HTMLSelectElement).value,
+ excerpt: (form.elements.namedItem('excerpt') as HTMLTextAreaElement).value,
+ cover: (form.elements.namedItem('cover') as HTMLInputElement).value || null,
+ draft: (form.elements.namedItem('draft') as HTMLInputElement).checked,
+ body_html: editor.getHTML(),
+ };
+ const res = await fetch(id ? `/api/admin/posts/${id}` : '/api/admin/posts', {
+ method: id ? 'PUT' : 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const msg = document.getElementById('save-msg')!;
+ if (res.ok) {
+ msg.textContent = 'Salvato ✓';
+ if (!id) location.href = '/admin';
+ } else {
+ msg.textContent = (await res.json()).error ?? 'Errore di salvataggio';
+ }
+ });
+</script>
diff --git a/src/components/blog/Sidebar.astro b/src/components/blog/Sidebar.astro
new file mode 100644
index 0000000..a89dcc5
--- /dev/null
+++ b/src/components/blog/Sidebar.astro
@@ -0,0 +1,26 @@
+---
+import { getDb } from '../../lib/db';
+import { listRecent, listArchiveMonths } from '../../lib/posts';
+import { CATEGORIES } from '../../lib/blog-const';
+const db = getDb();
+const recent = listRecent(db, 4);
+const months = listArchiveMonths(db);
+const monthLabel = (m: string) => new Intl.DateTimeFormat('it-IT', { month: 'long', year: 'numeric' }).format(new Date(`${m}-01`));
+---
+<aside class="bside">
+ <h3>Categorie</h3>
+ {CATEGORIES.map((c) => <p><a href={`/blog?categoria=${encodeURIComponent(c)}`}>{c}</a></p>)}
+ <h3>Ultimi articoli</h3>
+ {recent.map((p) => (
+ <p><a href={`/blog/${p.slug}`}>{p.title}</a></p>
+ ))}
+ <h3>Archivio</h3>
+ {months.map((m) => <p class="bside__month">{monthLabel(m.month)} ({m.count})</p>)}
+</aside>
+
+<style>
+ .bside h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; margin-top: 30px; }
+ .bside a { text-decoration: none; color: var(--c-text); }
+ .bside a:hover { color: var(--c-accent-dark); }
+ .bside__month { text-transform: capitalize; }
+</style>
diff --git a/src/components/home/FeatureBlocks.astro b/src/components/home/FeatureBlocks.astro
new file mode 100644
index 0000000..914f7fd
--- /dev/null
+++ b/src/components/home/FeatureBlocks.astro
@@ -0,0 +1,35 @@
+---
+import { Image } from 'astro:assets';
+import featPerformance from '../../assets/img/feature-performance.jpg';
+import featBalance from '../../assets/img/feature-balance.jpg';
+import featLongevity from '../../assets/img/feature-longevity.jpg';
+const blocks = [
+ { title: 'PERFORMANCE', image: featPerformance, reverse: false,
+ text: 'Allenamenti progettati su misura per adattarsi ai tuoi obiettivi e al tuo livello. Programmi mirati che sviluppano forza, resistenza e funzionalità in modo equilibrato. Un metodo efficace per migliorare le performance e ottenere risultati concreti e duraturi.' },
+ { title: 'BALANCE', image: featBalance, reverse: true,
+ text: 'L’equilibrio tra corpo e mente è la base di ogni performance duratura. Attraverso movimento, recupero e gestione dello stress, costruiamo stabilità e controllo. Un approccio integrato che sostiene benessere, energia e continuità nel tempo.' },
+ { title: 'LONGEVITY', image: featLongevity, reverse: false,
+ text: 'Benessere completo tra movimento, nutrizione e stile di vita. Un percorso personalizzato che unisce prevenzione, recupero ed equilibrio. Investi nella tua salute e qualità della vita nel tempo.' },
+];
+---
+<section class="section">
+ <div class="container">
+ {blocks.map((b) => (
+ <div class:list={['feat', { 'feat--reverse': b.reverse }]}>
+ <Image src={b.image} alt={b.title} class="feat__img" widths={[500, 900]} sizes="(max-width: 767px) 100vw, 45vw" />
+ <div class="feat__text">
+ <h2>{b.title}</h2>
+ <p>{b.text}</p>
+ <a class="btn" href="/programs">Scopri</a>
+ </div>
+ </div>
+ ))}
+ </div>
+</section>
+
+<style>
+ .feat { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; padding-block: 40px; }
+ .feat--reverse .feat__img { order: 2; }
+ .feat__text h2 { text-transform: uppercase; letter-spacing: .1em; }
+ @media (max-width: 767px) { .feat { grid-template-columns: 1fr; gap: 24px; } .feat--reverse .feat__img { order: 0; } }
+</style>
diff --git a/src/components/home/HeroSlider.astro b/src/components/home/HeroSlider.astro
new file mode 100644
index 0000000..75f3e9d
--- /dev/null
+++ b/src/components/home/HeroSlider.astro
@@ -0,0 +1,54 @@
+---
+import { Image } from 'astro:assets';
+import hero1 from '../../assets/img/hero-1.jpg';
+const slides = [
+ { title: ['PERFORMANCE.', 'BALANCE.', 'LONGEVITY.'], image: hero1, alt: 'Atleti InsanityLab' },
+];
+---
+<section class="hero" aria-label="presentazione">
+ {slides.map((s, i) => (
+ <div class:list={['hero__slide', { 'is-active': i === 0 }]}>
+ <div class="container hero__in">
+ <div class="hero__text">
+ <h1>{s.title.map((line) => <span class="hero__line">{line}</span>)}</h1>
+ <a class="btn" href="/programs">Scopri</a>
+ </div>
+ <Image src={s.image} alt={s.alt} class="hero__img" widths={[600, 1000, 1400]} sizes="(max-width: 991px) 100vw, 60vw" loading="eager" />
+ </div>
+ </div>
+ ))}
+ {slides.length > 1 && (
+ <>
+ <button class="hero__arrow hero__arrow--prev" aria-label="precedente">‹</button>
+ <button class="hero__arrow hero__arrow--next" aria-label="successiva">›</button>
+ </>
+ )}
+</section>
+
+<style>
+ .hero { position: relative; background:
+ repeating-linear-gradient(to bottom, transparent 0 10px, rgba(0,0,0,.05) 10px 11px) left top / 40% 60% no-repeat, #fff; }
+ .hero__slide { display: none; }
+ .hero__slide.is-active { display: block; }
+ .hero__in { display: grid; grid-template-columns: 1fr 1.2fr; align-items: center; min-height: calc(100vh - var(--header-h)); gap: 30px; }
+ .hero__line { display: block; }
+ .hero__text h1 { font-weight: 500; }
+ .hero__img { object-fit: cover; height: 100%; max-height: calc(100vh - var(--header-h)); width: 100%; }
+ .hero__arrow { position: absolute; top: 50%; transform: translateY(-50%); background: none; border: 0; font-size: 3rem; color: var(--c-heading); cursor: pointer; }
+ .hero__arrow--prev { left: 12px; } .hero__arrow--next { right: 12px; }
+ @media (max-width: 991px) { .hero__in { grid-template-columns: 1fr; padding-block: 40px; min-height: 0; } }
+</style>
+
+<script>
+ const slides = document.querySelectorAll('.hero__slide');
+ if (slides.length > 1) {
+ let cur = 0;
+ const show = (i: number) => {
+ slides[cur].classList.remove('is-active');
+ cur = (i + slides.length) % slides.length;
+ slides[cur].classList.add('is-active');
+ };
+ document.querySelector('.hero__arrow--prev')?.addEventListener('click', () => show(cur - 1));
+ document.querySelector('.hero__arrow--next')?.addEventListener('click', () => show(cur + 1));
+ }
+</script>
diff --git a/src/components/home/InfoColumns.astro b/src/components/home/InfoColumns.astro
new file mode 100644
index 0000000..763bc2b
--- /dev/null
+++ b/src/components/home/InfoColumns.astro
@@ -0,0 +1,26 @@
+---
+import infoPerformance from '../../assets/img/info-performance.jpg';
+import infoBalance from '../../assets/img/info-balance.jpg';
+import infoLongevity from '../../assets/img/info-longevity.jpg';
+const cols = [
+ { title: 'PERFORMANCE.', image: infoPerformance },
+ { title: 'BALANCE.', image: infoBalance },
+ { title: 'LONGEVITY.', image: infoLongevity },
+];
+---
+<section class="info" aria-label="pilastri">
+ {cols.map((c) => (
+ <a class="info__col" href="/programs" style={`background-image:url(${c.image.src})`}>
+ <span class="info__title">{c.title}</span>
+ </a>
+ ))}
+</section>
+
+<style>
+ .info { display: grid; grid-template-columns: repeat(3, 1fr); }
+ .info__col { position: relative; min-height: 320px; background-size: cover; background-position: center; display: flex; align-items: center; justify-content: center; text-decoration: none; }
+ .info__col::before { content: ''; position: absolute; inset: 0; background: rgba(30,24,20,.35); transition: background .2s; }
+ .info__col:hover::before { background: rgba(30,24,20,.15); }
+ .info__title { position: relative; color: #fff; font-family: var(--font-heading); font-weight: 600; letter-spacing: .1em; font-size: 1.1rem; }
+ @media (max-width: 767px) { .info { grid-template-columns: 1fr; } }
+</style>
diff --git a/src/components/home/MethodSteps.astro b/src/components/home/MethodSteps.astro
new file mode 100644
index 0000000..dfbbd1a
--- /dev/null
+++ b/src/components/home/MethodSteps.astro
@@ -0,0 +1,39 @@
+---
+import { Image } from 'astro:assets';
+import type { ImageMetadata } from 'astro';
+import { methodIntro, methodExtraNote, methodSteps } from '../../data/method';
+const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/method-*.{jpg,png}', { eager: true });
+const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
+---
+<section class="section section--alt">
+ <div class="container">
+ <div class="section-heading">
+ <h2>Il metodo<br />InsanityLab</h2>
+ <p set:html={methodIntro.replace('Metodo Insanitylab', '<strong>Metodo Insanitylab</strong>')} />
+ </div>
+ <p class="method__note">{methodExtraNote}</p>
+ <div class="method__row">
+ {methodSteps.map((s) => (
+ <div class:list={['method__step', { 'method__step--extra': s.extra }]}>
+ <div class="method__circle">
+ {imgOf(s.image) && <Image src={imgOf(s.image)!} alt="" widths={[220]} sizes="220px" />}
+ <span>{s.n}</span>
+ </div>
+ <h3>{s.title}</h3>
+ <p>{s.text}</p>
+ </div>
+ ))}
+ </div>
+ </div>
+</section>
+
+<style>
+ .method__note { text-align: right; font-size: .8rem; color: var(--c-text-light); max-width: 260px; margin-left: auto; }
+ .method__row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 30px; text-align: center; position: relative; }
+ .method__circle { position: relative; width: 150px; height: 150px; margin: 0 auto 20px; border: 1px dashed var(--c-accent-dark); border-radius: 50%; padding: 8px; }
+ .method__circle img { border-radius: 50%; width: 100%; height: 100%; object-fit: cover; }
+ .method__circle span { position: absolute; top: -6px; left: -6px; font-family: var(--font-heading); font-size: 2.4rem; color: var(--c-heading); background: var(--c-bg-alt); padding: 0 8px; }
+ .method__step h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .14em; }
+ .method__step p { font-size: .85rem; color: var(--c-text-light); }
+ @media (max-width: 767px) { .method__row { grid-template-columns: 1fr 1fr; } }
+</style>
diff --git a/src/components/home/PartnersStrip.astro b/src/components/home/PartnersStrip.astro
new file mode 100644
index 0000000..f5e8bf6
--- /dev/null
+++ b/src/components/home/PartnersStrip.astro
@@ -0,0 +1,18 @@
+---
+import { Image } from 'astro:assets';
+import type { ImageMetadata } from 'astro';
+import { partners } from '../../data/partners';
+const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/partner-*.{jpg,png}', { eager: true });
+const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
+---
+<section class="pstrip" aria-label="partner">
+ <div class="container pstrip__in">
+ {partners.map((p) => imgOf(p.image) && <Image src={imgOf(p.image)!} alt={p.name} height={44} />)}
+ </div>
+</section>
+
+<style>
+ .pstrip { background: var(--c-accent); padding-block: 40px; }
+ .pstrip__in { display: flex; align-items: center; justify-content: space-around; gap: 30px; flex-wrap: wrap; }
+ .pstrip img { height: 44px; width: auto; opacity: .9; }
+</style>
diff --git a/src/components/home/ProgramsGrid.astro b/src/components/home/ProgramsGrid.astro
new file mode 100644
index 0000000..d4c1c98
--- /dev/null
+++ b/src/components/home/ProgramsGrid.astro
@@ -0,0 +1,38 @@
+---
+import { programs } from '../../data/programs';
+const icons: Record<string, string> = {
+ 'personal-training': 'M4 12h3m10 0h3M9 8v8m6-8v8M7 10v4m10-4v4',
+ 'performance-class': 'M13 3l-2 7h4l-6 11 2-8H7l4-10z',
+ 'coaching': 'M12 3a4 4 0 110 8 4 4 0 010-8zm-7 18a7 7 0 0114 0',
+ 'pilates-flow': 'M12 4a2 2 0 110 4 2 2 0 010-4zM6 20c2-6 10-6 12 0M12 8v6',
+ 'rehab': 'M12 21C7 17 3 13 3 9a5 5 0 019-3 5 5 0 019 3c0 4-4 8-9 12z',
+ 'nutrition': 'M12 3c4 0 7 3 7 8s-3 10-7 10-7-5-7-10 3-8 7-8zm0-1v4',
+};
+---
+<section class="section">
+ <div class="container">
+ <div class="section-heading">
+ <h2>Programmi</h2>
+ <p>Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un’esperienza di allenamento completa e mirata.</p>
+ </div>
+ <div class="pgrid">
+ {programs.map((p) => (
+ <a class="pgrid__item" href={`/programs/${p.slug}`}>
+ <svg viewBox="0 0 24 24" fill="none" stroke="var(--c-accent-dark)" stroke-width="1.4" width="52" height="52" aria-hidden="true"><path d={icons[p.slug]} /></svg>
+ <h3>{p.title}</h3>
+ <p>{p.subtitle}</p>
+ </a>
+ ))}
+ </div>
+ </div>
+</section>
+
+<style>
+ .pgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; text-align: center; }
+ .pgrid__item { text-decoration: none; }
+ .pgrid__item svg { margin-inline: auto; }
+ .pgrid__item h3 { text-transform: uppercase; letter-spacing: .12em; font-size: .95rem; margin: 18px 0 6px; }
+ .pgrid__item p { font-size: .85rem; color: var(--c-text-light); margin: 0; }
+ @media (max-width: 767px) { .pgrid { grid-template-columns: 1fr 1fr; } }
+ @media (max-width: 480px) { .pgrid { grid-template-columns: 1fr; } }
+</style>
diff --git a/src/components/home/QuoteBanner.astro b/src/components/home/QuoteBanner.astro
new file mode 100644
index 0000000..09a6e06
--- /dev/null
+++ b/src/components/home/QuoteBanner.astro
@@ -0,0 +1,22 @@
+---
+import quoteBg from '../../assets/img/quote-bg.jpg';
+---
+<section class="quote" style={`background-image:url(${quoteBg.src})`}>
+ <div class="container quote__in">
+ <p class="quote__mark">“</p>
+ <blockquote>
+ <p><strong>PERFORMANCE</strong> È SPINGERE OLTRE I PROPRI LIMITI, <strong>BALANCE</strong> È SAPERLI ASCOLTARE, <strong>LONGEVITY</strong> È COSTRUIRE NEL TEMPO CIÒ CHE DAVVERO CONTA.</p>
+ <cite>Insanitylab</cite>
+ </blockquote>
+ </div>
+</section>
+
+<style>
+ .quote { position: relative; background-size: cover; background-position: center; text-align: center; }
+ .quote::before { content: ''; position: absolute; inset: 0; background: rgba(46,32,24,.82); }
+ .quote__in { position: relative; padding-block: 110px; max-width: 820px; }
+ .quote__mark { color: #fff; font-size: 4rem; font-family: var(--font-heading); margin: 0; line-height: 1; }
+ .quote blockquote { margin: 0; }
+ .quote blockquote p { color: #fff; font-family: var(--font-heading); font-size: clamp(1.1rem, 2.4vw, 1.6rem); letter-spacing: .06em; line-height: 1.6; }
+ .quote cite { display: block; margin-top: 24px; color: var(--c-accent); font-style: normal; font-family: var(--font-heading); font-size: .75rem; letter-spacing: .3em; text-transform: uppercase; }
+</style>
diff --git a/src/components/home/TeamSection.astro b/src/components/home/TeamSection.astro
new file mode 100644
index 0000000..65cb0b7
--- /dev/null
+++ b/src/components/home/TeamSection.astro
@@ -0,0 +1,34 @@
+---
+import { Image } from 'astro:assets';
+import type { ImageMetadata } from 'astro';
+import { team, homeTeam } from '../../data/team';
+const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/team-*.{jpg,png}', { eager: true });
+const members = homeTeam.map((short) => team.find((m) => m.shortName === short)!).filter((m) => m.image);
+const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
+---
+<section class="section">
+ <div class="container">
+ <div class="tteam__head">
+ <h2>Team<br />Trainer</h2>
+ <a class="btn" href="/about">Scopri team</a>
+ </div>
+ <div class="tteam__grid">
+ {members.map((m) => (
+ <figure class="tteam__card">
+ <Image src={imgOf(m.image!)!} alt={m.name} widths={[300, 500]} sizes="(max-width: 767px) 50vw, 25vw" />
+ <figcaption>{m.shortName}</figcaption>
+ </figure>
+ ))}
+ </div>
+ </div>
+</section>
+
+<style>
+ .tteam__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 40px; }
+ .tteam__head h2 { text-transform: uppercase; letter-spacing: .1em; margin: 0; }
+ .tteam__grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; }
+ .tteam__card { position: relative; margin: 0; }
+ .tteam__card img { aspect-ratio: 3/4; object-fit: cover; width: 100%; }
+ .tteam__card figcaption { position: absolute; right: 10px; bottom: 14px; background: #fff; padding: 10px 6px; writing-mode: vertical-rl; font-family: var(--font-heading); font-size: .62rem; letter-spacing: .3em; text-transform: uppercase; color: var(--c-accent-dark); }
+ @media (max-width: 767px) { .tteam__grid { grid-template-columns: 1fr 1fr; } }
+</style>
diff --git a/src/components/home/TrainingCards.astro b/src/components/home/TrainingCards.astro
new file mode 100644
index 0000000..93b2fbe
--- /dev/null
+++ b/src/components/home/TrainingCards.astro
@@ -0,0 +1,42 @@
+---
+import { Image } from 'astro:assets';
+import type { ImageMetadata } from 'astro';
+import { trainings } from '../../data/trainings';
+const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/training-*.{jpg,png}', { eager: true });
+const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
+const tones = ['taupe', 'tan', 'dark', 'charcoal'];
+// riga 1 (training 0-1): card, foto | riga 2 (training 2-3): foto, card
+const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1;
+---
+<section class="tcards">
+ {trainings.map((t, i) => {
+ const img = imgOf(t.image);
+ const card = (
+ <div class:list={['tcards__cell', `tcards__cell--${tones[i]}`]}>
+ <h3>{t.title}</h3>
+ <p>{t.caption}</p>
+ <a class="tcards__link" href={t.cta.href}>Scopri →</a>
+ </div>
+ );
+ const photo = img ? <Image src={img} alt={t.title} class="tcards__img" widths={[400, 700]} sizes="(max-width: 767px) 50vw, 25vw" /> : null;
+ return isSwapRow(i) ? <>{photo}{card}</> : <>{card}{photo}</>;
+ })}
+</section>
+
+<style>
+ .tcards { display: grid; grid-template-columns: repeat(4, 1fr); }
+ .tcards__cell { padding: 48px 36px; display: flex; flex-direction: column; justify-content: center; aspect-ratio: 1; }
+ .tcards__cell--taupe { background: #6b5d5b; }
+ .tcards__cell--dark { background: var(--c-dark); }
+ .tcards__cell--charcoal { background: #27252a; }
+ .tcards__cell--taupe h3, .tcards__cell--taupe p,
+ .tcards__cell--dark h3, .tcards__cell--dark p,
+ .tcards__cell--charcoal h3, .tcards__cell--charcoal p { color: #efe9e3; }
+ .tcards__cell--tan { background: var(--c-accent); }
+ .tcards__cell--tan h3, .tcards__cell--tan p { color: #fff; }
+ .tcards__cell h3 { text-transform: uppercase; letter-spacing: .12em; }
+ .tcards__cell p { font-size: .88rem; }
+ .tcards__link { color: #fff; font-family: var(--font-heading); font-size: .7rem; letter-spacing: .2em; text-transform: uppercase; text-decoration: none; margin-top: 18px; }
+ .tcards__img { width: 100%; height: 100%; object-fit: cover; aspect-ratio: 1; }
+ @media (max-width: 767px) { .tcards { grid-template-columns: repeat(2, 1fr); } }
+</style>
diff --git a/src/data/agenda.ts b/src/data/agenda.ts
new file mode 100644
index 0000000..3942c7e
--- /dev/null
+++ b/src/data/agenda.ts
@@ -0,0 +1,36 @@
+export const disciplines = ["Events", "Crossfit", "Cardio", "Box", "Meditation", "Yoga Classes", "Body Balance"] as const;
+export const days = ["Lunedì", "Martedì", "Mercoledì", "Giovedì", "Venerdì", "Sabato", "Domenica"] as const;
+export const slots = ["10.00", "12.00", "16.00", "18.00", "20.00"] as const;
+
+export interface AgendaEntry { day: number; slot: string; discipline: string; time: string; trainer: string }
+
+// Placeholder dal template Prowess (come nel Figma); il cliente fornirà l’orario reale.
+export const agenda: AgendaEntry[] = [
+ { day: 0, slot: "10.00", discipline: "Crossfit", time: "10.00 - 11.00", trainer: "Amanda Snow" },
+ { day: 0, slot: "12.00", discipline: "Box", time: "12.00 - 13.00", trainer: "Matthew Dunn" },
+ { day: 0, slot: "18.00", discipline: "Cardio", time: "18.00 - 19.00", trainer: "Randy Nelson" },
+ { day: 0, slot: "20.00", discipline: "Crossfit", time: "20.00 - 21.00", trainer: "Amanda Snow" },
+ { day: 1, slot: "10.00", discipline: "Cardio", time: "10.00 - 11.00", trainer: "Randy Nelson" },
+ { day: 1, slot: "12.00", discipline: "Crossfit", time: "12.00 - 13.00", trainer: "Amanda Snow" },
+ { day: 1, slot: "16.00", discipline: "Body Balance", time: "16.00 - 17.00", trainer: "Barbara Holland" },
+ { day: 1, slot: "20.00", discipline: "Box", time: "20.00 - 21.00", trainer: "Matthew Dunn" },
+ { day: 2, slot: "10.00", discipline: "Crossfit", time: "10.00 - 11.00", trainer: "Amanda Snow" },
+ { day: 2, slot: "12.00", discipline: "Meditation", time: "12.00 - 13.00", trainer: "Nicole Fuller" },
+ { day: 2, slot: "16.00", discipline: "Box", time: "16.00 - 17.00", trainer: "Matthew Dunn" },
+ { day: 2, slot: "20.00", discipline: "Body Balance", time: "20.00 - 21.00", trainer: "Barbara Holland" },
+ { day: 3, slot: "10.00", discipline: "Body Balance", time: "10.00 - 11.00", trainer: "Barbara Holland" },
+ { day: 3, slot: "12.00", discipline: "Box", time: "12.00 - 13.00", trainer: "Matthew Dunn" },
+ { day: 3, slot: "16.00", discipline: "Crossfit", time: "16.00 - 17.00", trainer: "Amanda Snow" },
+ { day: 3, slot: "20.00", discipline: "Body Balance", time: "20.00 - 21.00", trainer: "Randy Nelson" },
+ { day: 4, slot: "12.00", discipline: "Crossfit", time: "12.00 - 13.00", trainer: "Amanda Snow" },
+ { day: 4, slot: "16.00", discipline: "Body Balance", time: "16.00 - 17.00", trainer: "Barbara Holland" },
+ { day: 4, slot: "18.00", discipline: "Cardio", time: "18.00 - 19.00", trainer: "Randy Nelson" },
+ { day: 4, slot: "20.00", discipline: "Yoga Classes", time: "20.00 - 21.00", trainer: "Amanda Snow" },
+ { day: 5, slot: "10.00", discipline: "Cardio", time: "10.00 - 11.00", trainer: "Randy Nelson" },
+ { day: 5, slot: "12.00", discipline: "Body Balance", time: "12.00 - 13.00", trainer: "Barbara Holland" },
+ { day: 5, slot: "18.00", discipline: "Box", time: "18.00 - 19.00", trainer: "Matthew Dunn" },
+ { day: 6, slot: "12.00", discipline: "Crossfit", time: "12.00 - 13.00", trainer: "Amanda Snow" },
+ { day: 6, slot: "16.00", discipline: "Cardio", time: "16.00 - 17.00", trainer: "Randy Nelson" },
+ { day: 6, slot: "18.00", discipline: "Box", time: "18.00 - 19.00", trainer: "Matthew Dunn" },
+ { day: 6, slot: "20.00", discipline: "Body Balance", time: "20.00 - 21.00", trainer: "Barbara Holland" },
+];
diff --git a/src/data/method.ts b/src/data/method.ts
new file mode 100644
index 0000000..26d609b
--- /dev/null
+++ b/src/data/method.ts
@@ -0,0 +1,9 @@
+export const methodIntro = "Il Metodo Insanitylab si basa su un percorso strutturato in fasi, dove le prime due rappresentano il focus standard del lavoro: valutazione iniziale e avvio dell’allenamento personalizzato. A queste possono essere aggiunti servizi e approfondimenti extra, in base alle esigenze e agli obiettivi della persona.";
+export const methodExtraNote = "Disponibili su richiesta, non incluse nel percorso standard.";
+
+export const methodSteps = [
+ { n: 1, title: "Prima consulenza conoscitiva iniziale", text: "Dove raccogliamo un’anamnesi iniziale per comprendere il cliente, i suoi obiettivi e guidarlo verso il percorso più adatto.", extra: false, image: "method-step-1" },
+ { n: 2, title: "Prima seduta operativa", text: "In cui si effettua la profilazione iniziale e la valutazione sul campo per definire il punto di partenza e impostare un percorso personalizzato.", extra: false, image: "method-step-2" },
+ { n: 3, title: "Plicometria", text: "Test che misura il grasso sottocutaneo per stimare la massa grassa, disponibile su richiesta o come servizio aggiuntivo.", extra: true, image: "method-step-3" },
+ { n: 4, title: "Analisi antropometriche", text: "Le analisi antropometriche sono misurazioni del corpo utili a valutare la composizione fisica e lo stato di forma.", extra: true, image: "method-step-4" },
+];
diff --git a/src/data/partners.ts b/src/data/partners.ts
new file mode 100644
index 0000000..be9cba6
--- /dev/null
+++ b/src/data/partners.ts
@@ -0,0 +1,6 @@
+export const partners = [
+ { name: "Ticket Welfare Edenred", image: "partner-edenred" },
+ { name: "Welbee", image: "partner-welbee" },
+ { name: "AON", image: "partner-aon" },
+ { name: "Howden", image: "partner-howden" },
+];
diff --git a/src/data/pricing.ts b/src/data/pricing.ts
new file mode 100644
index 0000000..6673960
--- /dev/null
+++ b/src/data/pricing.ts
@@ -0,0 +1,17 @@
+export const performanceClassPricing = {
+ heading: "Acquista la tua performance class",
+ intro: "Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un’esperienza di allenamento completa e mirata.",
+ groups: [
+ {
+ label: "Programmi performance class semestrale",
+ open: true,
+ plans: [
+ { monthly: 130, entries: 1, note: "tot: 780€ · da 23€ lezione singola", installments: "2 rate da 390€ – inizio 60", best: "best price 720€" },
+ { monthly: 234, entries: 2, note: "tot: 1.400€ · da 23€ lezione singola", installments: "2 rate da 700€ – inizio 60", best: "best price 1.250€" },
+ { monthly: 300, entries: 3, note: "tot: 1.800€ · da 23€ lezione singola", installments: "2 rate da 900€ – inizio 60", best: "best price 1.650€" },
+ ],
+ },
+ { label: "Programmi performance class trimestrale", open: false, plans: [] },
+ { label: "Programmi performance class mensile", open: false, plans: [] },
+ ],
+};
diff --git a/src/data/programs.ts b/src/data/programs.ts
new file mode 100644
index 0000000..089eec9
--- /dev/null
+++ b/src/data/programs.ts
@@ -0,0 +1,104 @@
+export interface Program {
+ slug: string;
+ title: string;
+ subtitle: string;
+ excerpt: string;
+ longDescription: string;
+ features: string[];
+ faq: { q: string; a: string }[];
+ hasPricing: boolean;
+}
+
+export const programs: Program[] = [
+ {
+ slug: "personal-training",
+ title: "Personal Training",
+ subtitle: "One to One / Small Group",
+ excerpt: "Il personal training InsanityLab è un percorso one-to-one su misura, seguito da un professionista dedicato, per raggiungere in modo sicuro ed efficace obiettivi di forma fisica, performance e benessere.",
+ longDescription: "Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati, con supporto costante, attenzione totale e allenamenti personalizzati. Disponibile anche in formula small group (massimo 3 persone) per unire attenzione personalizzata, motivazione e condivisione.",
+ features: ["Trainer dedicato", "Percorso su misura", "Valutazione iniziale e monitoraggio", "Formula one to one o small group (max 3)"],
+ faq: [
+ { q: "Come si svolge la prima seduta?", a: "Con una consulenza conoscitiva iniziale: raccogliamo l’anamnesi, definiamo gli obiettivi e impostiamo il percorso più adatto." },
+ { q: "Con quale frequenza ci si allena?", a: "La frequenza viene definita insieme al trainer in base a obiettivi, disponibilità e livello di partenza." },
+ { q: "È adatto a chi parte da zero?", a: "Sì: il percorso è costruito sul tuo livello, con progressioni graduali e supervisione costante." },
+ ],
+ hasPricing: false,
+ },
+ {
+ slug: "performance-class",
+ title: "Performance Class",
+ subtitle: "Gruppo max 8 persone",
+ excerpt: "La Performance Class è un allenamento in piccoli gruppi, basato su una programmazione progressiva che combina forza e condizionamento per ottenere risultati concreti e costanti nel tempo.",
+ longDescription: "La Performance Class è un allenamento in piccoli gruppi con programmazione progressiva e scientifica, pensato per migliorare forza, composizione corporea e performance attraverso sessioni strutturate che combinano forza, ipertrofia e condizionamento metabolico. Non si improvvisa: ogni sessione ha un posto preciso all’interno di un piano più ampio.",
+ features: ["Allenamento ibrido", "Forza, ipertrofia, condizionamento metabolico e lavoro funzionale", "Sessioni da 50 minuti", "Fasi di bassa, media e alta intensità", "Programmazione mensile"],
+ faq: [
+ { q: "Che cosa si intende per Performance Class?", a: "Un allenamento in piccoli gruppi (massimo 8 persone) guidato da trainer certificati, con programmazione progressiva mensile: ogni sessione ha un posto preciso all’interno di un piano più ampio." },
+ { q: "Che tipo di allenamento viene svolto?", a: "Un allenamento ibrido che alterna forza, ipertrofia, condizionamento metabolico e lavoro funzionale. Le sessioni durano 50 minuti e prevedono fasi di bassa, media e alta intensità, calibrate sulla programmazione mensile." },
+ { q: "A chi è consigliato questo servizio?", a: "A chi vuole allenarsi con qualità e continuità in un contesto di gruppo motivante, mantenendo una programmazione seria e misurabile." },
+ { q: "Qual è la differenza rispetto a una class tradizionale?", a: "La programmazione: non è una lezione isolata ma parte di un piano mensile progressivo, con carichi e intensità calibrati." },
+ { q: "Che tipo di risultati o miglioramenti si ottengono?", a: "Miglioramento di forza, composizione corporea e performance, con risultati concreti e costanti nel tempo." },
+ { q: "Qual è l’intensità del lavoro?", a: "Alternata: fasi di bassa, media e alta intensità all’interno della stessa sessione, adattate al livello dei partecipanti." },
+ ],
+ hasPricing: true,
+ },
+ {
+ slug: "coaching",
+ title: "Coaching",
+ subtitle: "Programma di allenamento e monitoraggio",
+ excerpt: "Il coaching InsanityLab è un percorso personalizzato che, con programma su misura e supporto costante, guida la persona verso i propri obiettivi sviluppando autonomia e continuità nell’allenamento.",
+ longDescription: "Con il coaching hai un programma di allenamento su misura e un monitoraggio costante, ovunque ti alleni: in palestra, a casa o in viaggio. Il percorso sviluppa autonomia e continuità, adattandosi al tuo stile di vita (formula Fit Remote).",
+ features: ["Programma su misura", "Supporto e monitoraggio costanti", "Ovunque ti alleni (Fit Remote)", "Sviluppo di autonomia"],
+ faq: [
+ { q: "Come funziona il monitoraggio?", a: "Il trainer segue i tuoi progressi con check periodici e adatta il programma in base ai risultati." },
+ { q: "Serve attrezzatura?", a: "Il programma viene costruito sull’attrezzatura che hai a disposizione, in palestra o a casa." },
+ { q: "È adatto se viaggio spesso?", a: "Sì: la formula Fit Remote è pensata per chi si allena in luoghi diversi." },
+ ],
+ hasPricing: false,
+ },
+ {
+ slug: "pilates-flow",
+ title: "Pilates Flow",
+ subtitle: "Movimento e controllo",
+ excerpt: "Il Pilates Flow migliora postura, core e consapevolezza corporea con un movimento fluido, mentre l’osteopatia supporta recupero, prevenzione e benessere globale integrandosi con l’allenamento.",
+ longDescription: "Il Pilates Flow unisce movimento fluido e controllo: migliora postura, core e consapevolezza corporea. Integrato con l’osteopatia, supporta recupero, prevenzione e benessere globale all’interno del percorso di allenamento.",
+ features: ["Postura e core", "Movimento fluido e controllo", "Integrazione con osteopatia", "Benessere globale"],
+ faq: [
+ { q: "A chi è adatto il Pilates Flow?", a: "A chiunque voglia migliorare postura, mobilità e controllo del movimento, a ogni livello." },
+ { q: "Serve esperienza precedente?", a: "No: le progressioni sono graduali e seguite da professionisti qualificati." },
+ { q: "Come si integra con l’osteopatia?", a: "Il lavoro osteopatico supporta recupero e prevenzione, in sinergia con le sessioni di movimento." },
+ ],
+ hasPricing: false,
+ },
+ {
+ slug: "rehab",
+ title: "Rehab",
+ subtitle: "Recupero post-infortunio, patologie muscolo-scheletriche",
+ excerpt: "Il Rehab InsanityLab è un percorso supervisionato che accompagna dal recupero riabilitativo al ritorno all’attività fisica in modo sicuro, progressivo e personalizzato.",
+ longDescription: "Il Rehab accompagna dal recupero post-infortunio o da patologie muscolo-scheletriche fino al ritorno all’attività fisica. Percorso supervisionato da chinesiologi e osteopati, sicuro, progressivo e personalizzato.",
+ features: ["Recupero post-infortunio", "Patologie muscolo-scheletriche", "Supervisione di chinesiologi e osteopati", "Progressione sicura e personalizzata"],
+ faq: [
+ { q: "Chi segue il percorso Rehab?", a: "Professionisti specializzati in rieducazione funzionale ed esercizio adattato, in sinergia con l’area osteopatica." },
+ { q: "Serve una diagnosi medica?", a: "Se disponibile è utile portarla alla prima consulenza; il percorso si integra con le indicazioni dei sanitari." },
+ { q: "Quanto dura il percorso?", a: "Dipende dalla condizione di partenza: la progressione viene definita e aggiornata durante il percorso." },
+ ],
+ hasPricing: false,
+ },
+ {
+ slug: "nutrition",
+ title: "Nutrizione",
+ subtitle: "Alimentazione e stile di vita",
+ excerpt: "La nutrizione in InsanityLab è un servizio personalizzato che integra alimentazione e allenamento per migliorare energia, performance e benessere con piani su misura e sostenibili.",
+ longDescription: "Il servizio di nutrizione integra alimentazione e allenamento: piani su misura e sostenibili per migliorare energia, performance e benessere, costruiti sul tuo stile di vita.",
+ features: ["Piani alimentari su misura", "Integrazione con l’allenamento", "Sostenibilità nel tempo", "Focus su energia e performance"],
+ faq: [
+ { q: "Come inizia il percorso di nutrizione?", a: "Con una valutazione iniziale di abitudini, obiettivi e allenamento, da cui nasce il piano personalizzato." },
+ { q: "È integrato con il training?", a: "Sì: alimentazione e allenamento vengono programmati in modo coerente." },
+ { q: "Sono previsti controlli periodici?", a: "Sì, con aggiornamento del piano in base ai risultati." },
+ ],
+ hasPricing: false,
+ },
+];
+
+export function getProgram(slug: string): Program | undefined {
+ return programs.find((p) => p.slug === slug);
+}
diff --git a/src/data/site.ts b/src/data/site.ts
new file mode 100644
index 0000000..5b1c6ee
--- /dev/null
+++ b/src/data/site.ts
@@ -0,0 +1,34 @@
+export const site = {
+ name: "InsanityLab",
+ phone: "351 7535977",
+ email: "info@insanitylab.it",
+ address: "Via Leandro Alberti, 76",
+ cityLine: "40139 Bologna",
+ hours: ["Lun – Ven 6.00 – 21.00", "Sab 8.00 – 19.00 · Dom chiuso"],
+ socials: [
+ { label: "Instagram", url: "https://www.instagram.com/insanitylab" },
+ { label: "Facebook", url: "https://www.facebook.com/insanitylab" },
+ { label: "LinkedIn", url: "https://www.linkedin.com/company/insanitylab" },
+ ],
+ navigation: [
+ { label: "Insanitylab", href: "/about" },
+ { label: "Training", href: "/training" },
+ { label: "Programmi", href: "/programs" },
+ { label: "Blog & Edugo", href: "/blog" },
+ { label: "Contatti", href: "/contact" },
+ ],
+ footerTraining: [
+ { label: "One to one", href: "/training#one-to-one" },
+ { label: "Small groups", href: "/training#small-groups" },
+ { label: "Performance class", href: "/training#performance-class" },
+ { label: "Fit remote", href: "/training#fit-remote" },
+ ],
+ footerPrograms: [
+ { label: "Personal Training", href: "/programs/personal-training" },
+ { label: "Performance Class", href: "/programs/performance-class" },
+ { label: "Coaching", href: "/programs/coaching" },
+ { label: "Rehab", href: "/programs/rehab" },
+ { label: "Pilates Flow", href: "/programs/pilates-flow" },
+ { label: "Nutrizione", href: "/programs/nutrition" },
+ ],
+};
diff --git a/src/data/team.ts b/src/data/team.ts
new file mode 100644
index 0000000..9287197
--- /dev/null
+++ b/src/data/team.ts
@@ -0,0 +1,24 @@
+export interface Member { name: string; shortName: string; role: string; bio: string; image?: string; founder: boolean }
+
+export const team: Member[] = [
+ { name: "Donata Dal Pastro", shortName: "Donata", role: "Chinesiologa e Osteopata", founder: true, image: "team-donata",
+ bio: "Donata Dal Pastro è chinesiologa e osteopata specializzata in rieducazione funzionale, riatletizzazione sportiva ed esercizio adattato. Supervisiona l’area dedicata a prevenzione, recupero e benessere, con competenze specifiche anche in pediatria e gravidanza." },
+ { name: "Nicola Antonelli", shortName: "Nicola", role: "Chinesiologo e Osteopata", founder: true, image: "team-nicola",
+ bio: "Nicola Antonelli è chinesiologo e osteopata specializzato in performance sportiva, functional training e bodybuilding. Coordina lo sviluppo tecnico dei percorsi dedicati alla performance e all’allenamento avanzato." },
+ { name: "Eva Cassetta", shortName: "Eva", role: "Business Manager – Balance & Strategy", founder: true,
+ bio: "Eva Cassetta ricopre il ruolo di Business Manager – Balance & Strategy e si occupa della gestione amministrativa, economica e finanziaria del progetto, garantendone solidità, organizzazione e sostenibilità nel tempo." },
+ { name: "Alessandro Lazzari", shortName: "Alessandro", role: "Trainer – Functional & Movement", founder: false,
+ bio: "Trainer – Functional & Movement. Laureando in Scienze Motorie, specializzato in running e atletica leggera." },
+ { name: "Eleonora Cassani", shortName: "Eleonora", role: "Trainer – Planning Manager", founder: false, image: "team-eleonora",
+ bio: "Trainer – Planning Manager. Chinesiologa specializzata in attività preventiva e adattata, con focus su allenamento in gravidanza e post-gravidanza." },
+ { name: "Antonello D’Alessandro", shortName: "Antonello", role: "Trainer – Exercise & Health", founder: false, image: "team-antonello",
+ bio: "Trainer – Exercise & Health. Chinesiologo specializzato in attività preventiva e adattata, Dottore in Osteopatia." },
+ { name: "Alice Roda", shortName: "Alice", role: "Trainer – Functional & Movement", founder: false,
+ bio: "Trainer – Functional & Movement. Laureanda in Scienze Motorie e Preparatore Atletico. Scienza del movimento e massima cura della persona: un approccio al functional training empatico e accogliente, dove l’ascolto umano guida e supporta ogni tuo progresso." },
+ { name: "Davide Bisagni", shortName: "Davide", role: "Trainer – Functional & Movement", founder: false,
+ bio: "Trainer – Functional & Movement. Chinesiologo e allenamento funzionale al servizio del corpo: massima cura del gesto atletico e attenzione mirata al recupero per prestazioni sostenibili e durature." },
+ { name: "Giulia Barbàra", shortName: "Giulia", role: "Trainer – Pilates, Yoga & Mindfulness", founder: false,
+ bio: "Trainer – Pilates, Yoga & Mindfulness. Laureata in Psicologia Clinica e Riabilitazione, insegnante di Pilates, Yoga e Mindfulness, con approccio orientato al benessere e all’equilibrio mente-corpo." },
+];
+
+export const homeTeam = ["Donata", "Nicola", "Eleonora", "Antonello"];
diff --git a/src/data/trainings.ts b/src/data/trainings.ts
new file mode 100644
index 0000000..f87e364
--- /dev/null
+++ b/src/data/trainings.ts
@@ -0,0 +1,6 @@
+export const trainings = [
+ { id: "one-to-one", title: "One to One", caption: "Allenamento con un trainer dedicato", image: "training-one-to-one", description: "Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati. Supporto costante, attenzione totale e allenamenti personalizzati per dare il massimo.", cta: { label: "Contattaci", href: "/contact" } },
+ { id: "small-groups", title: "Small Groups", caption: "Mini gruppi (max 3 persone) con trainer dedicato", image: "training-small-groups", description: "Allenati in mini gruppi da massimo 3 persone con un trainer dedicato. Un’esperienza small group che unisce attenzione personalizzata, motivazione e condivisione. Programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme.", cta: { label: "Contattaci", href: "/contact" } },
+ { id: "performance-class", title: "Performance Class", caption: "Classi fino a 8 persone con trainer certificato", image: "training-performance-class", description: "Allenati in classi fino a 8 persone guidate da trainer certificati. Sessioni dinamiche ad alta intensità pensate per migliorare forza, resistenza e performance. Energia, motivazione e allenamenti strutturati per dare il massimo in ogni workout.", cta: { label: "Scopri", href: "/programs/performance-class" } },
+ { id: "fit-remote", title: "Fit Remote", caption: "Piano di allenamento su misura, ovunque ti alleni.", image: "training-fit-remote", description: "Con Fit Remote hai un piano di allenamento su misura, ovunque ti alleni. Programmi personalizzati creati sui tuoi obiettivi, da seguire in palestra, a casa o in viaggio. Supporto costante e allenamenti pensati per adattarsi al tuo stile di vita.", cta: { label: "Contattaci", href: "/contact" } },
+] as const;
diff --git a/src/env.d.ts b/src/env.d.ts
new file mode 100644
index 0000000..81f5f4a
--- /dev/null
+++ b/src/env.d.ts
@@ -0,0 +1,7 @@
+/// <reference types="astro/client" />
+
+declare namespace App {
+ interface Locals {
+ user?: { id: number; username: string };
+ }
+}
diff --git a/src/layouts/Admin.astro b/src/layouts/Admin.astro
new file mode 100644
index 0000000..0cb4bd0
--- /dev/null
+++ b/src/layouts/Admin.astro
@@ -0,0 +1,36 @@
+---
+interface Props { title: string }
+const { title } = Astro.props;
+const user = Astro.locals.user;
+---
+<!doctype html>
+<html lang="it">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>{title} — Admin InsanityLab</title>
+ <meta name="robots" content="noindex" />
+ <style is:global>
+ body { font-family: system-ui, sans-serif; margin: 0; background: #f4f2ef; color: #333; }
+ .abar { background: #3a2d26; color: #fff; padding: 12px 24px; display: flex; justify-content: space-between; align-items: center; }
+ .abar a { color: #d9cfc4; text-decoration: none; margin-left: 16px; }
+ .awrap { max-width: 960px; margin: 30px auto; padding: 0 20px; }
+ .abtn { background: #b5a48b; color: #fff; border: 0; padding: 10px 18px; cursor: pointer; font-size: .9rem; text-decoration: none; display: inline-block; }
+ .abtn--danger { background: #a33; }
+ .afield { width: 100%; padding: 10px; margin-bottom: 14px; border: 1px solid #ccc; box-sizing: border-box; font: inherit; }
+ table { width: 100%; border-collapse: collapse; background: #fff; }
+ th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #eee; font-size: .92rem; }
+ .pill { font-size: .75rem; padding: 2px 10px; border-radius: 10px; background: #e5decf; }
+ .pill--draft { background: #f3d9a4; }
+ </style>
+ </head>
+ <body>
+ <div class="abar">
+ <strong>InsanityLab · Admin</strong>
+ <span>
+ {user && <>{`Ciao, ${user.username}`} <a href="/admin">Articoli</a> <a href="/admin/new">Nuovo</a> <a href="/admin/logout">Esci</a> <a href="/blog" target="_blank">Vedi sito ↗</a></>}
+ </span>
+ </div>
+ <div class="awrap"><slot /></div>
+ </body>
+</html>
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
new file mode 100644
index 0000000..4b19758
--- /dev/null
+++ b/src/layouts/Base.astro
@@ -0,0 +1,33 @@
+---
+import '@fontsource/montserrat/400.css';
+import '@fontsource/montserrat/500.css';
+import '@fontsource/montserrat/600.css';
+import '@fontsource/montserrat/700.css';
+import '@fontsource/open-sans/400.css';
+import '@fontsource/open-sans/600.css';
+import '../styles/global.css';
+import Header from '../components/Header.astro';
+import Footer from '../components/Footer.astro';
+
+interface Props { title: string; description?: string }
+const { title, description = 'InsanityLab — Performance. Balance. Longevity. Allenamento personalizzato, piccoli gruppi e benessere a Bologna.' } = Astro.props;
+---
+<!doctype html>
+<html lang="it">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>{title} — InsanityLab</title>
+ <meta name="description" content={description} />
+ <meta property="og:title" content={`${title} — InsanityLab`} />
+ <meta property="og:description" content={description} />
+ <meta property="og:type" content="website" />
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
+ <link rel="sitemap" href="/sitemap-index.xml" />
+ </head>
+ <body>
+ <Header />
+ <main><slot /></main>
+ <Footer />
+ </body>
+</html>
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
new file mode 100644
index 0000000..1946702
--- /dev/null
+++ b/src/lib/auth.ts
@@ -0,0 +1,48 @@
+import type Database from 'better-sqlite3';
+import bcrypt from 'bcryptjs';
+import { randomBytes } from 'node:crypto';
+
+export const SESSION_COOKIE = 'session';
+const SESSION_DAYS = 7;
+
+export function hashPassword(plain: string): string {
+ return bcrypt.hashSync(plain, 12);
+}
+
+export function verifyPassword(plain: string, hash: string): boolean {
+ return bcrypt.compareSync(plain, hash);
+}
+
+export function createUser(db: Database.Database, username: string, password: string): number {
+ const res = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)')
+ .run(username, hashPassword(password));
+ return Number(res.lastInsertRowid);
+}
+
+export function login(db: Database.Database, username: string, password: string): string | null {
+ const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as
+ | { id: number; password_hash: string } | undefined;
+ if (!user || !verifyPassword(password, user.password_hash)) return null;
+ const token = randomBytes(32).toString('hex');
+ db.prepare(
+ `INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, datetime('now', '+${SESSION_DAYS} days'))`
+ ).run(token, user.id);
+ return token;
+}
+
+export function getSessionUser(db: Database.Database, token: string): { id: number; username: string } | null {
+ const row = db.prepare(
+ `SELECT s.token, s.expires_at, u.id, u.username
+ FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ?`
+ ).get(token) as { token: string; expires_at: string; id: number; username: string } | undefined;
+ if (!row) return null;
+ if (row.expires_at <= new Date().toISOString().slice(0, 19).replace('T', ' ')) {
+ db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
+ return null;
+ }
+ return { id: row.id, username: row.username };
+}
+
+export function logout(db: Database.Database, token: string): void {
+ db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
+}
diff --git a/src/lib/blog-const.ts b/src/lib/blog-const.ts
new file mode 100644
index 0000000..d1b7436
--- /dev/null
+++ b/src/lib/blog-const.ts
@@ -0,0 +1,6 @@
+export const CATEGORIES = [
+ 'Articoli tecnici & scientifici',
+ 'Contenuti educativi',
+ 'LinkedIn / Insight',
+ 'Eventi & Presidi',
+] as const;
diff --git a/src/lib/contact.ts b/src/lib/contact.ts
new file mode 100644
index 0000000..96f9a32
--- /dev/null
+++ b/src/lib/contact.ts
@@ -0,0 +1,31 @@
+export interface ContactData {
+ firstName: string; lastName: string; phone: string;
+ email: string; message: string; website: string;
+}
+
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+function str(v: unknown, max: number): string {
+ return typeof v === 'string' ? v.trim().slice(0, max + 1) : '';
+}
+
+export function validateContact(data: unknown):
+ | { ok: true; value: ContactData }
+ | { ok: false; error: string } {
+ if (typeof data !== 'object' || data === null) return { ok: false, error: 'Dati non validi.' };
+ const d = data as Record<string, unknown>;
+ const value: ContactData = {
+ firstName: str(d.firstName, 100),
+ lastName: str(d.lastName, 100),
+ phone: str(d.phone, 40),
+ email: str(d.email, 200),
+ message: str(d.message, 5000),
+ website: str(d.website, 200),
+ };
+ if (value.website) return { ok: false, error: 'Richiesta non valida.' };
+ if (!value.firstName) return { ok: false, error: 'Il nome è obbligatorio.' };
+ if (!EMAIL_RE.test(value.email)) return { ok: false, error: 'Inserisci una e-mail valida.' };
+ if (!value.message) return { ok: false, error: 'Il messaggio è obbligatorio.' };
+ if (value.message.length > 5000) return { ok: false, error: 'Messaggio troppo lungo.' };
+ return { ok: true, value };
+}
diff --git a/src/lib/db.ts b/src/lib/db.ts
new file mode 100644
index 0000000..537d0aa
--- /dev/null
+++ b/src/lib/db.ts
@@ -0,0 +1,47 @@
+import Database from 'better-sqlite3';
+import { mkdirSync } from 'node:fs';
+import { dirname } from 'node:path';
+
+const SCHEMA = `
+CREATE TABLE IF NOT EXISTS posts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL,
+ slug TEXT NOT NULL UNIQUE,
+ category TEXT NOT NULL,
+ excerpt TEXT NOT NULL DEFAULT '',
+ body_html TEXT NOT NULL DEFAULT '',
+ cover TEXT,
+ draft INTEGER NOT NULL DEFAULT 1,
+ published_at TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS sessions (
+ token TEXT PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ expires_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_posts_pub ON posts (draft, published_at DESC);
+`;
+
+export function createDb(path?: string): Database.Database {
+ const p = path ?? process.env.DB_PATH ?? 'data/insanitylab.db';
+ if (p !== ':memory:') mkdirSync(dirname(p), { recursive: true });
+ const db = new Database(p);
+ db.pragma('journal_mode = WAL');
+ db.pragma('foreign_keys = ON');
+ db.exec(SCHEMA);
+ return db;
+}
+
+let singleton: Database.Database | null = null;
+
+export function getDb(): Database.Database {
+ if (!singleton) singleton = createDb();
+ return singleton;
+}
diff --git a/src/lib/env.ts b/src/lib/env.ts
new file mode 100644
index 0000000..fe0ba40
--- /dev/null
+++ b/src/lib/env.ts
@@ -0,0 +1,5 @@
+export function getEnv(name: string, fallback?: string): string {
+ const v = process.env[name] ?? (import.meta.env ? import.meta.env[name] : undefined) ?? fallback;
+ if (v === undefined) throw new Error(`Variabile d'ambiente mancante: ${name}`);
+ return v;
+}
diff --git a/src/lib/mailer.ts b/src/lib/mailer.ts
new file mode 100644
index 0000000..a2b8b99
--- /dev/null
+++ b/src/lib/mailer.ts
@@ -0,0 +1,25 @@
+import nodemailer from 'nodemailer';
+import { getEnv } from './env';
+import type { ContactData } from './contact';
+
+export async function sendContactEmail(data: ContactData): Promise<void> {
+ const transporter = nodemailer.createTransport({
+ host: getEnv('SMTP_HOST'),
+ port: Number(getEnv('SMTP_PORT', '587')),
+ secure: Number(getEnv('SMTP_PORT', '587')) === 465,
+ auth: { user: getEnv('SMTP_USER'), pass: getEnv('SMTP_PASS') },
+ });
+ await transporter.sendMail({
+ from: getEnv('CONTACT_FROM'),
+ to: getEnv('CONTACT_TO', 'info@insanitylab.it'),
+ replyTo: data.email,
+ subject: `Nuovo contatto dal sito: ${data.firstName} ${data.lastName}`.trim(),
+ text: [
+ `Nome: ${data.firstName} ${data.lastName}`.trim(),
+ data.phone && `Telefono: ${data.phone}`,
+ `E-mail: ${data.email}`,
+ '',
+ data.message,
+ ].filter(Boolean).join('\n'),
+ });
+}
diff --git a/src/lib/post-body.ts b/src/lib/post-body.ts
new file mode 100644
index 0000000..7d34ee4
--- /dev/null
+++ b/src/lib/post-body.ts
@@ -0,0 +1,21 @@
+import { slugify } from './slug';
+import { sanitizeHtml } from './sanitize';
+import { CATEGORIES } from './blog-const';
+
+export function parsePostBody(data: Record<string, unknown>) {
+ const title = String(data.title ?? '').trim();
+ if (!title) return { error: 'Il titolo è obbligatorio.' } as const;
+ const category = String(data.category ?? '');
+ if (!(CATEGORIES as readonly string[]).includes(category)) return { error: 'Categoria non valida.' } as const;
+ const slug = slugify(String(data.slug ?? '') || title);
+ if (!slug) return { error: 'Slug non valido.' } as const;
+ return {
+ value: {
+ title, slug, category,
+ excerpt: String(data.excerpt ?? '').trim().slice(0, 300),
+ body_html: sanitizeHtml(String(data.body_html ?? '')),
+ cover: data.cover ? String(data.cover) : null,
+ draft: Boolean(data.draft),
+ },
+ } as const;
+}
diff --git a/src/lib/posts.ts b/src/lib/posts.ts
new file mode 100644
index 0000000..dad9159
--- /dev/null
+++ b/src/lib/posts.ts
@@ -0,0 +1,74 @@
+import type Database from 'better-sqlite3';
+
+export interface Post {
+ id: number; title: string; slug: string; category: string; excerpt: string;
+ body_html: string; cover: string | null; draft: number;
+ published_at: string | null; created_at: string; updated_at: string;
+}
+
+export interface PostInput {
+ title: string; slug: string; category: string; excerpt: string;
+ body_html: string; cover?: string | null; draft: boolean;
+}
+
+export function createPost(db: Database.Database, input: PostInput): number {
+ const res = db.prepare(
+ `INSERT INTO posts (title, slug, category, excerpt, body_html, cover, draft, published_at)
+ VALUES (@title, @slug, @category, @excerpt, @body_html, @cover, @draft,
+ CASE WHEN @draft = 0 THEN datetime('now') ELSE NULL END)`
+ ).run({ ...input, cover: input.cover ?? null, draft: input.draft ? 1 : 0 });
+ return Number(res.lastInsertRowid);
+}
+
+export function updatePost(db: Database.Database, id: number, input: PostInput): void {
+ db.prepare(
+ `UPDATE posts SET title = @title, slug = @slug, category = @category,
+ excerpt = @excerpt, body_html = @body_html, cover = @cover, draft = @draft,
+ published_at = CASE WHEN @draft = 0 AND published_at IS NULL THEN datetime('now') ELSE published_at END,
+ updated_at = datetime('now')
+ WHERE id = @id`
+ ).run({ ...input, id, cover: input.cover ?? null, draft: input.draft ? 1 : 0 });
+}
+
+export function deletePost(db: Database.Database, id: number): void {
+ db.prepare('DELETE FROM posts WHERE id = ?').run(id);
+}
+
+export function getPostById(db: Database.Database, id: number): Post | undefined {
+ return db.prepare('SELECT * FROM posts WHERE id = ?').get(id) as Post | undefined;
+}
+
+export function getPublishedBySlug(db: Database.Database, slug: string): Post | undefined {
+ return db.prepare('SELECT * FROM posts WHERE slug = ? AND draft = 0').get(slug) as Post | undefined;
+}
+
+export function listPublished(
+ db: Database.Database,
+ opts: { category?: string; page?: number; perPage?: number } = {},
+): { items: Post[]; total: number } {
+ const { category, page = 1, perPage = 9 } = opts;
+ const where = category ? 'WHERE draft = 0 AND category = @category' : 'WHERE draft = 0';
+ const total = (db.prepare(`SELECT COUNT(*) AS n FROM posts ${where}`)
+ .get({ category }) as { n: number }).n;
+ const items = db.prepare(
+ `SELECT * FROM posts ${where} ORDER BY published_at DESC LIMIT @limit OFFSET @offset`
+ ).all({ category, limit: perPage, offset: (page - 1) * perPage }) as Post[];
+ return { items, total };
+}
+
+export function listAllPosts(db: Database.Database): Post[] {
+ return db.prepare('SELECT * FROM posts ORDER BY updated_at DESC').all() as Post[];
+}
+
+export function listRecent(db: Database.Database, limit = 4): Post[] {
+ return db.prepare(
+ 'SELECT * FROM posts WHERE draft = 0 ORDER BY published_at DESC LIMIT ?'
+ ).all(limit) as Post[];
+}
+
+export function listArchiveMonths(db: Database.Database): { month: string; count: number }[] {
+ return db.prepare(
+ `SELECT strftime('%Y-%m', published_at) AS month, COUNT(*) AS count
+ FROM posts WHERE draft = 0 GROUP BY month ORDER BY month DESC`
+ ).all() as { month: string; count: number }[];
+}
diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts
new file mode 100644
index 0000000..9f8905c
--- /dev/null
+++ b/src/lib/rate-limit.ts
@@ -0,0 +1,17 @@
+const buckets = new Map<string, number[]>();
+
+export function rateLimit(key: string, max: number, windowMs: number): boolean {
+ const now = Date.now();
+ const recent = (buckets.get(key) ?? []).filter((t) => now - t < windowMs);
+ if (recent.length >= max) {
+ buckets.set(key, recent);
+ return false;
+ }
+ recent.push(now);
+ buckets.set(key, recent);
+ return true;
+}
+
+export function _resetBuckets(): void {
+ buckets.clear();
+}
diff --git a/src/lib/sanitize.ts b/src/lib/sanitize.ts
new file mode 100644
index 0000000..b72e440
--- /dev/null
+++ b/src/lib/sanitize.ts
@@ -0,0 +1,11 @@
+import sanitize from 'sanitize-html';
+
+export function sanitizeHtml(html: string): string {
+ return sanitize(html, {
+ allowedTags: ['h2', 'h3', 'p', 'strong', 'em', 'u', 's', 'ul', 'ol', 'li', 'a', 'img', 'blockquote', 'br', 'hr'],
+ allowedAttributes: { a: ['href', 'rel', 'target'], img: ['src', 'alt'] },
+ allowedSchemes: ['https', 'http', 'mailto'],
+ allowedSchemesAppliedToAttributes: ['href', 'src'],
+ allowProtocolRelative: false,
+ });
+}
diff --git a/src/lib/slug.ts b/src/lib/slug.ts
new file mode 100644
index 0000000..5bdd460
--- /dev/null
+++ b/src/lib/slug.ts
@@ -0,0 +1,8 @@
+export function slugify(input: string): string {
+ return input
+ .toLowerCase()
+ .normalize('NFD')
+ .replace(/[̀-ͯ]/g, '')
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+}
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..fa8dcfd
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,24 @@
+import { defineMiddleware } from 'astro:middleware';
+import { getDb } from './lib/db';
+import { getSessionUser, SESSION_COOKIE } from './lib/auth';
+
+export const onRequest = defineMiddleware((context, next) => {
+ const { pathname } = context.url;
+ const isProtected =
+ (pathname.startsWith('/admin') && pathname !== '/admin/login') ||
+ pathname.startsWith('/api/admin');
+ if (!isProtected) return next();
+
+ const token = context.cookies.get(SESSION_COOKIE)?.value;
+ const user = token ? getSessionUser(getDb(), token) : null;
+ if (!user) {
+ if (pathname.startsWith('/api/')) {
+ return new Response(JSON.stringify({ error: 'Non autorizzato' }), {
+ status: 401, headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ return context.redirect('/admin/login');
+ }
+ context.locals.user = user;
+ return next();
+});
diff --git a/src/pages/404.astro b/src/pages/404.astro
new file mode 100644
index 0000000..9aa6d78
--- /dev/null
+++ b/src/pages/404.astro
@@ -0,0 +1,14 @@
+---
+import Base from '../layouts/Base.astro';
+export const prerender = true;
+---
+<Base title="Pagina non trovata">
+ <section class="section" style="text-align:center; padding-block:140px;">
+ <div class="container">
+ <p class="eyebrow">Errore 404</p>
+ <h1>Pagina non trovata</h1>
+ <p>La pagina che cerchi non esiste o è stata spostata.</p>
+ <a class="btn" href="/">Torna alla home</a>
+ </div>
+ </section>
+</Base>
diff --git a/src/pages/about.astro b/src/pages/about.astro
new file mode 100644
index 0000000..1cfd76f
--- /dev/null
+++ b/src/pages/about.astro
@@ -0,0 +1,166 @@
+---
+import Base from '../layouts/Base.astro';
+import PageHero from '../components/PageHero.astro';
+import Timeline from '../components/about/Timeline.astro';
+import { Image } from 'astro:assets';
+import { team } from '../data/team';
+import { site } from '../data/site';
+const images = import.meta.glob<{ default: ImageMetadata }>('../assets/img/*.{jpg,png}', { eager: true });
+const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1]?.default;
+const founders = team.filter((m) => m.founder);
+const staff = team.filter((m) => !m.founder);
+export const prerender = true;
+---
+<Base title="Insanitylab" description="Mission, storia, team e metodo di InsanityLab: un laboratorio esclusivo dove movimento, prevenzione e performance convivono in un approccio personalizzato.">
+ <PageHero title="Insanitylab" />
+
+ <section class="section">
+ <div class="container">
+ <div class="about-img-row">
+ <div class="about-img-row__text section-heading">
+ <h2>Mission</h2>
+ <p>In InsanityLab crediamo che il movimento non debba essere vissuto come un obbligo o un sacrificio, ma come una forma autentica di cura di sé. La nostra missione è accompagnare ogni persona in un percorso di consapevolezza, aiutandola a riscoprire il benessere fisico e mentale attraverso un approccio sostenibile, concreto e duraturo.</p>
+ </div>
+ {imgOf('about-training-group') && (
+ <div class="about-img-row__img">
+ <Image src={imgOf('about-training-group')!} alt="Training group InsanityLab" widths={[600, 900]} sizes="(max-width:767px) 100vw, 50vw" />
+ </div>
+ )}
+ </div>
+ </div>
+ </section>
+
+ <section class="section section--alt">
+ <div class="container about-2col">
+ <div>
+ <h2>Direzione</h2>
+ <p>Non ci limitiamo ad allenare il corpo: creiamo percorsi personalizzati che uniscono movimento, equilibrio e benessere, attraverso il supporto di professionisti qualificati, programmi su misura e un approccio centrato sulla persona e su risultati duraturi.</p>
+ <a class="btn" href="/programs">Scopri</a>
+ </div>
+ <div>
+ <h2>Approccio</h2>
+ <p>InsanityLab si distingue per un approccio serio e personalizzato al benessere, basato su competenze specializzate, costanza e cura quotidiana, rivolto a chiunque voglia migliorare il proprio stile di vita in modo consapevole e duraturo.</p>
+ <a class="btn btn--dark" href="/contact">Contattaci</a>
+ </div>
+ </div>
+ </section>
+
+ <section class="section">
+ <div class="container">
+ <div class="about-img-row about-img-row--reverse">
+ <div class="about-img-row__text section-heading">
+ <h2>About</h2>
+ <p>InsanityLab nasce con l'obiettivo di unire fitness e salute in un unico percorso dedicato al benessere globale, superando il concetto tradizionale di palestra. Un laboratorio esclusivo, guidato da osteopati e chinesiologi e professionisti specializzati, dove movimento, prevenzione e performance convivono all'interno di un approccio personalizzato e consapevole.</p>
+ </div>
+ {imgOf('about-team-group') && (
+ <div class="about-img-row__img about-img-row__img--native">
+ <Image src={imgOf('about-team-group')!} alt="Team InsanityLab" widths={[270]} sizes="270px" />
+ </div>
+ )}
+ </div>
+ </div>
+ </section>
+
+ <Timeline />
+
+ <section class="section section--dark">
+ <div class="container">
+ <div class="section-heading">
+ <h2>Convenzioni</h2>
+ <p>InsanityLab collabora con aziende, enti e organizzazioni che desiderano investire concretamente nel benessere delle proprie persone, attraverso programmi dedicati alla salute, al movimento e alla qualità della vita. Le convenzioni nascono con l'obiettivo di portare il valore del benessere anche all'interno del contesto professionale, creando percorsi che favoriscano equilibrio fisico, prevenzione e performance quotidiana.</p>
+ </div>
+ <ul class="conv">
+ <li>Le realtà convenzionate possono accedere a programmi di welfare aziendale personalizzati, con percorsi individuali, small group training e classi di gruppo seguiti da professionisti qualificati.</li>
+ <li>Ogni collaborazione parte da una consulenza gratuita preliminare per analizzare esigenze e obiettivi, così da sviluppare soluzioni efficaci e sostenibili.</li>
+ <li>Le convenzioni vengono definite su misura insieme a InsanityLab, in base alle caratteristiche e alle necessità dell'azienda partner.</li>
+ </ul>
+ <p class="conv__cta">Per ricevere maggiori informazioni o attivare una convenzione: {site.email} · {site.phone}</p>
+ </div>
+ </section>
+
+ <section class="section">
+ <div class="container">
+ <div class="section-heading">
+ <h2>Team</h2>
+ <p>Il team di InsanityLab riunisce professionisti specializzati in allenamento, salute e benessere, con un approccio multidisciplinare basato su competenza, personalizzazione e attenzione alla persona.</p>
+ </div>
+ <div class="tgrid tgrid--founders">
+ {founders.map((m) => (
+ <article class="tcard">
+ {m.image && imgOf(m.image) && <Image src={imgOf(m.image)!} alt={m.name} widths={[300, 500]} sizes="(max-width:767px) 100vw, 33vw" />}
+ <h3>{m.shortName}</h3>
+ <p class="tcard__role">{m.role}</p>
+ <p>{m.bio}</p>
+ </article>
+ ))}
+ </div>
+ <p class="section-heading" style="margin-top:60px">Accanto ai fondatori opera un team in continua crescita composto da trainer e professionisti con specializzazioni complementari:</p>
+ <div class="tgrid">
+ {staff.map((m) => (
+ <article class="tcard">
+ {m.image && imgOf(m.image) && <Image src={imgOf(m.image)!} alt={m.name} widths={[300, 500]} sizes="(max-width:767px) 100vw, 33vw" />}
+ <h3>{m.shortName}</h3>
+ <p class="tcard__role">{m.role}</p>
+ <p>{m.bio}</p>
+ </article>
+ ))}
+ </div>
+ </div>
+ </section>
+
+ <section class="section section--alt">
+ <div class="container section-heading">
+ <h2>Metodo</h2>
+ <p>Il team InsanityLab lavora con un approccio empatico e personalizzato, seguendo ogni persona in un percorso continuo e su misura. Unisce competenze multidisciplinari in allenamento, prevenzione, riabilitazione e performance, supportate da formazione costante. Il metodo si basa su sinergia, condivisione e qualità, per generare risultati concreti e duraturi.</p>
+ <p>In poche parole, il team di InsanityLab è composto da professionisti che credono profondamente in ciò che fanno: un gruppo unito da valori condivisi, formato per eccellere e guidato dalla convinzione che il movimento sia uno strumento di benessere, equilibrio e qualità della vita.</p>
+ </div>
+ </section>
+
+ <section class="section">
+ <div class="container">
+ <div class="about-img-row">
+ <div class="about-img-row__text section-heading">
+ <h2>In-Sanity Studios</h2>
+ <p>IN-SANITY STUDIOS è il nuovo spazio professionale nato dall'evoluzione di InsanityLab, dedicato ai professionisti della salute, del benessere e della performance. Più che un semplice insieme di studi professionali, rappresenta un ecosistema multidisciplinare pensato per favorire collaborazione, qualità del servizio e integrazione tra competenze diverse.</p>
+ <p>Il progetto nasce con l'obiettivo di offrire a liberi professionisti uno spazio elegante, riservato e già attrezzato, in cui poter svolgere la propria attività all'interno di un contesto consolidato e altamente qualificato, senza dover affrontare i costi e le complessità di una struttura indipendente.</p>
+ <p>IN-SANITY STUDIOS si rivolge a figure specializzate nel settore salute e wellness, come fisioterapisti, osteopati, psicologi, nutrizionisti, massaggiatori e altri professionisti dedicati alla cura della persona. Ogni studio è progettato per garantire comfort, professionalità e continuità operativa, all'interno di un ambiente coerente con la filosofia di InsanityLab: mettere il benessere globale della persona al centro del percorso.</p>
+ </div>
+ {imgOf('about-exterior') && (
+ <div class="about-img-row__img">
+ <Image src={imgOf('about-exterior')!} alt="In-Sanity Studios" widths={[600, 900]} sizes="(max-width:767px) 100vw, 50vw" />
+ </div>
+ )}
+ </div>
+ </div>
+ </section>
+
+ <section class="section section--alt">
+ <div class="container section-heading">
+ <h2>Vision</h2>
+ <p>InsanityLab nasce con l'obiettivo di aiutare le persone a ritrovare energia ed equilibrio attraverso il movimento, promuovendo un approccio autentico, scientifico e umano al benessere. La sua visione va oltre l'estetica e la performance: allenarsi significa prendersi cura di sé, costruendo abitudini sane, sostenibili e orientate alla longevità, alla prevenzione e alla qualità della vita.</p>
+ <p>L'obiettivo è diventare un punto di riferimento capace di lasciare un impatto reale nella comunità, trasformando il movimento in uno strumento di salute, consapevolezza e identità.</p>
+ </div>
+ </section>
+</Base>
+
+<style>
+ .about-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; }
+ .about-img-row { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; }
+ .about-img-row--reverse { direction: rtl; }
+ .about-img-row--reverse > * { direction: ltr; }
+ .about-img-row__img img { width: 100%; height: auto; display: block; }
+ .about-img-row__img--native { max-width: 320px; margin: 0 auto; }
+ .conv { max-width: 760px; margin: 0 auto 30px; display: grid; gap: 18px; padding-left: 20px; }
+ .conv__cta { text-align: center; font-family: var(--font-heading); letter-spacing: .06em; }
+ .tgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 40px; }
+ .tcard img { aspect-ratio: 3/4; object-fit: cover; margin-bottom: 16px; }
+ .tcard h3 { text-transform: lowercase; letter-spacing: .2em; }
+ .tcard__role { font-size: .8rem; text-transform: uppercase; letter-spacing: .12em; color: var(--c-accent-dark); }
+ .tcard p { font-size: .9rem; }
+ @media (max-width: 767px) {
+ .about-2col,
+ .tgrid,
+ .about-img-row { grid-template-columns: 1fr; }
+ .about-img-row--reverse { direction: ltr; }
+ }
+</style>
diff --git a/src/pages/admin/edit/[id].astro b/src/pages/admin/edit/[id].astro
new file mode 100644
index 0000000..7789e84
--- /dev/null
+++ b/src/pages/admin/edit/[id].astro
@@ -0,0 +1,14 @@
+---
+import Admin from '../../../layouts/Admin.astro';
+import PostForm from '../../../components/admin/PostForm.astro';
+import { getDb } from '../../../lib/db';
+import { getPostById } from '../../../lib/posts';
+export const prerender = false;
+const post = getPostById(getDb(), Number(Astro.params.id));
+if (!post) return Astro.redirect('/admin');
+---
+<Admin title={`Modifica: ${post.title}`}>
+ <h1>Modifica articolo</h1>
+ <script id="initial-body" type="text/plain" set:html={post.body_html}></script>
+ <PostForm post={post} />
+</Admin>
diff --git a/src/pages/admin/index.astro b/src/pages/admin/index.astro
new file mode 100644
index 0000000..a8e0cd3
--- /dev/null
+++ b/src/pages/admin/index.astro
@@ -0,0 +1,37 @@
+---
+import Admin from '../../layouts/Admin.astro';
+import { getDb } from '../../lib/db';
+import { listAllPosts } from '../../lib/posts';
+export const prerender = false;
+const posts = listAllPosts(getDb());
+const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' });
+---
+<Admin title="Articoli">
+ <h1>Articoli</h1>
+ <p><a class="abtn" href="/admin/new">+ Nuovo articolo</a></p>
+ <table>
+ <thead><tr><th>Titolo</th><th>Categoria</th><th>Stato</th><th>Aggiornato</th><th></th></tr></thead>
+ <tbody>
+ {posts.map((p) => (
+ <tr>
+ <td><a href={`/admin/edit/${p.id}`}>{p.title}</a></td>
+ <td>{p.category}</td>
+ <td><span class:list={['pill', { 'pill--draft': p.draft }]}>{p.draft ? 'Bozza' : 'Pubblicato'}</span></td>
+ <td>{fmt.format(new Date(p.updated_at.replace(' ', 'T') + 'Z'))}</td>
+ <td><button class="abtn abtn--danger" data-del={p.id}>Elimina</button></td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ {posts.length === 0 && <p>Nessun articolo: creane uno nuovo.</p>}
+</Admin>
+
+<script>
+ document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
+ b.addEventListener('click', async () => {
+ if (!confirm('Eliminare definitivamente questo articolo?')) return;
+ const res = await fetch(`/api/admin/posts/${b.dataset.del}`, { method: 'DELETE' });
+ if (res.ok) location.reload();
+ else alert('Eliminazione non riuscita.');
+ }));
+</script>
diff --git a/src/pages/admin/login.astro b/src/pages/admin/login.astro
new file mode 100644
index 0000000..2f39dd8
--- /dev/null
+++ b/src/pages/admin/login.astro
@@ -0,0 +1,34 @@
+---
+import Admin from '../../layouts/Admin.astro';
+import { getDb } from '../../lib/db';
+import { login, SESSION_COOKIE } from '../../lib/auth';
+import { rateLimit } from '../../lib/rate-limit';
+export const prerender = false;
+
+let error = '';
+if (Astro.request.method === 'POST') {
+ if (!rateLimit(`login:${Astro.clientAddress}`, 5, 15 * 60 * 1000)) {
+ error = 'Troppi tentativi: riprova tra 15 minuti.';
+ } else {
+ const form = await Astro.request.formData();
+ const token = login(getDb(), String(form.get('username') ?? ''), String(form.get('password') ?? ''));
+ if (token) {
+ Astro.cookies.set(SESSION_COOKIE, token, {
+ httpOnly: true, sameSite: 'lax', path: '/',
+ secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
+ });
+ return Astro.redirect('/admin');
+ }
+ error = 'Credenziali non valide.';
+ }
+}
+---
+<Admin title="Login">
+ <h1>Accesso</h1>
+ {error && <p style="color:#a33">{error}</p>}
+ <form method="post" style="max-width:360px">
+ <input class="afield" name="username" placeholder="Utente" required autocomplete="username" />
+ <input class="afield" type="password" name="password" placeholder="Password" required autocomplete="current-password" />
+ <button class="abtn" type="submit">Entra</button>
+ </form>
+</Admin>
diff --git a/src/pages/admin/logout.ts b/src/pages/admin/logout.ts
new file mode 100644
index 0000000..883170b
--- /dev/null
+++ b/src/pages/admin/logout.ts
@@ -0,0 +1,11 @@
+import type { APIRoute } from 'astro';
+import { getDb } from '../../lib/db';
+import { logout, SESSION_COOKIE } from '../../lib/auth';
+export const prerender = false;
+
+export const GET: APIRoute = ({ cookies, redirect }) => {
+ const token = cookies.get(SESSION_COOKIE)?.value;
+ if (token) logout(getDb(), token);
+ cookies.delete(SESSION_COOKIE, { path: '/' });
+ return redirect('/admin/login');
+};
diff --git a/src/pages/admin/new.astro b/src/pages/admin/new.astro
new file mode 100644
index 0000000..08163ea
--- /dev/null
+++ b/src/pages/admin/new.astro
@@ -0,0 +1,10 @@
+---
+import Admin from '../../layouts/Admin.astro';
+import PostForm from '../../components/admin/PostForm.astro';
+export const prerender = false;
+---
+<Admin title="Nuovo articolo">
+ <h1>Nuovo articolo</h1>
+ <script id="initial-body" type="text/plain"></script>
+ <PostForm />
+</Admin>
diff --git a/src/pages/api/admin/posts/[id].ts b/src/pages/api/admin/posts/[id].ts
new file mode 100644
index 0000000..c7e8a33
--- /dev/null
+++ b/src/pages/api/admin/posts/[id].ts
@@ -0,0 +1,29 @@
+import type { APIRoute } from 'astro';
+import { getDb } from '../../../../lib/db';
+import { getPostById, updatePost, deletePost } from '../../../../lib/posts';
+import { parsePostBody } from '../../../../lib/post-body';
+export const prerender = false;
+
+const json = (status: number, body: object) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+export const PUT: APIRoute = async ({ params, request }) => {
+ const id = Number(params.id);
+ if (!getPostById(getDb(), id)) return json(404, { error: 'Articolo non trovato.' });
+ let data: Record<string, unknown>;
+ try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
+ const parsed = parsePostBody(data);
+ if ('error' in parsed) return json(400, { error: parsed.error });
+ try {
+ updatePost(getDb(), id, parsed.value);
+ return json(200, { ok: true });
+ } catch (err: any) {
+ if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' });
+ throw err;
+ }
+};
+
+export const DELETE: APIRoute = ({ params }) => {
+ deletePost(getDb(), Number(params.id));
+ return json(200, { ok: true });
+};
diff --git a/src/pages/api/admin/posts/index.ts b/src/pages/api/admin/posts/index.ts
new file mode 100644
index 0000000..16305d7
--- /dev/null
+++ b/src/pages/api/admin/posts/index.ts
@@ -0,0 +1,22 @@
+import type { APIRoute } from 'astro';
+import { getDb } from '../../../../lib/db';
+import { createPost } from '../../../../lib/posts';
+import { parsePostBody } from '../../../../lib/post-body';
+export const prerender = false;
+
+const json = (status: number, body: object) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+export const POST: APIRoute = async ({ request }) => {
+ let data: Record<string, unknown>;
+ try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
+ const parsed = parsePostBody(data);
+ if ('error' in parsed) return json(400, { error: parsed.error });
+ try {
+ const id = createPost(getDb(), parsed.value);
+ return json(201, { id, slug: parsed.value.slug });
+ } catch (err: any) {
+ if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' });
+ throw err;
+ }
+};
diff --git a/src/pages/api/admin/upload.ts b/src/pages/api/admin/upload.ts
new file mode 100644
index 0000000..e14487d
--- /dev/null
+++ b/src/pages/api/admin/upload.ts
@@ -0,0 +1,26 @@
+import type { APIRoute } from 'astro';
+import { writeFile, mkdir } from 'node:fs/promises';
+import { randomBytes } from 'node:crypto';
+import { join, extname } from 'node:path';
+import { getEnv } from '../../../lib/env';
+export const prerender = false;
+
+const ALLOWED: Record<string, string> = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif' };
+const MAX_BYTES = 5 * 1024 * 1024;
+
+const json = (status: number, body: object) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+export const POST: APIRoute = async ({ request }) => {
+ const form = await request.formData().catch(() => null);
+ const file = form?.get('file');
+ if (!(file instanceof File)) return json(400, { error: 'Nessun file.' });
+ const ext = extname(file.name).toLowerCase();
+ if (!ALLOWED[ext]) return json(400, { error: 'Formato non consentito (jpg, png, webp, gif).' });
+ if (file.size > MAX_BYTES) return json(400, { error: 'File troppo grande (max 5 MB).' });
+ const dir = getEnv('UPLOADS_DIR', 'uploads');
+ await mkdir(dir, { recursive: true });
+ const name = `${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
+ await writeFile(join(dir, name), Buffer.from(await file.arrayBuffer()));
+ return json(200, { url: `/uploads/${name}` });
+};
diff --git a/src/pages/api/contact.ts b/src/pages/api/contact.ts
new file mode 100644
index 0000000..74aba2f
--- /dev/null
+++ b/src/pages/api/contact.ts
@@ -0,0 +1,26 @@
+import type { APIRoute } from 'astro';
+import { validateContact } from '../../lib/contact';
+import { sendContactEmail } from '../../lib/mailer';
+import { rateLimit } from '../../lib/rate-limit';
+
+export const prerender = false;
+
+const json = (status: number, body: object) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+export const POST: APIRoute = async ({ request, clientAddress }) => {
+ if (!rateLimit(`contact:${clientAddress}`, 5, 60 * 60 * 1000)) {
+ return json(429, { error: 'Troppe richieste, riprova più tardi.' });
+ }
+ let data: unknown;
+ try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
+ const result = validateContact(data);
+ if (!result.ok) return json(400, { error: result.error });
+ try {
+ await sendContactEmail(result.value);
+ } catch (err) {
+ console.error('Invio email fallito:', err);
+ return json(502, { error: 'Invio non riuscito, riprova più tardi o scrivici direttamente.' });
+ }
+ return json(200, { ok: true });
+};
diff --git a/src/pages/blog/[slug].astro b/src/pages/blog/[slug].astro
new file mode 100644
index 0000000..fdf30f1
--- /dev/null
+++ b/src/pages/blog/[slug].astro
@@ -0,0 +1,33 @@
+---
+import Base from '../../layouts/Base.astro';
+import Sidebar from '../../components/blog/Sidebar.astro';
+import { getDb } from '../../lib/db';
+import { getPublishedBySlug } from '../../lib/posts';
+export const prerender = false;
+
+const post = getPublishedBySlug(getDb(), Astro.params.slug!);
+if (!post) return Astro.redirect('/404');
+const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' });
+---
+<Base title={post.title} description={post.excerpt}>
+ <section class="section">
+ <div class="container art">
+ <article>
+ {post.cover && <img class="art__cover" src={post.cover} alt="" />}
+ <p class="art__meta">{post.published_at && fmt.format(new Date(post.published_at))} · {post.category}</p>
+ <h1>{post.title}</h1>
+ <div class="art__body" set:html={post.body_html} />
+ </article>
+ <Sidebar />
+ </div>
+ </section>
+</Base>
+
+<style>
+ .art { display: grid; grid-template-columns: 2.4fr 1fr; gap: 60px; }
+ .art__cover { width: 100%; margin-bottom: 24px; }
+ .art__meta { font-size: .8rem; color: var(--c-text-light); }
+ .art__body :global(img) { margin-block: 20px; }
+ .art__body :global(blockquote) { border-left: 3px solid var(--c-accent); margin: 24px 0; padding: 6px 0 6px 20px; font-style: italic; color: var(--c-heading); }
+ @media (max-width: 991px) { .art { grid-template-columns: 1fr; } }
+</style>
diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro
new file mode 100644
index 0000000..dcd8a05
--- /dev/null
+++ b/src/pages/blog/index.astro
@@ -0,0 +1,81 @@
+---
+import Base from '../../layouts/Base.astro';
+import PageHero from '../../components/PageHero.astro';
+import { getDb } from '../../lib/db';
+import { listPublished } from '../../lib/posts';
+import { CATEGORIES } from '../../lib/blog-const';
+export const prerender = false;
+
+const PER_PAGE = 9;
+const url = Astro.url;
+const category = url.searchParams.get('categoria') ?? undefined;
+const page = Math.max(1, Number(url.searchParams.get('pagina') ?? '1') || 1);
+const { items, total } = listPublished(getDb(), {
+ category: category && (CATEGORIES as readonly string[]).includes(category) ? category : undefined,
+ page, perPage: PER_PAGE,
+});
+const pages = Math.max(1, Math.ceil(total / PER_PAGE));
+const fmt = (d: string | null) => d ? new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' }).format(new Date(d)) : '';
+const linkFor = (cat?: string, p = 1) => {
+ const q = new URLSearchParams();
+ if (cat) q.set('categoria', cat);
+ if (p > 1) q.set('pagina', String(p));
+ const s = q.toString();
+ return `/blog${s ? `?${s}` : ''}`;
+};
+---
+<Base title="Blog & Edugo" description="Edugo è lo spazio di InsanityLab dedicato alla conoscenza: articoli, guide e approfondimenti su allenamento, nutrizione e salute.">
+ <PageHero title="Blog & Edugo" />
+ <section class="section">
+ <div class="container">
+ <div class="section-heading">
+ <h2>Blog & Edugo</h2>
+ <p>Edugo è lo spazio di InsanityLab dedicato alla conoscenza: articoli, guide e approfondimenti su allenamento, nutrizione e salute per capire il corpo e migliorare il proprio benessere. Perché la conoscenza è il primo passo del cambiamento.</p>
+ </div>
+ <nav class="bfilters" aria-label="categorie">
+ <a class:list={['bfilters__item', { 'is-active': !category }]} href={linkFor()}>Mostra tutto</a>
+ {CATEGORIES.map((c) => (
+ <a class:list={['bfilters__item', { 'is-active': category === c }]} href={linkFor(c)}>{c}</a>
+ ))}
+ </nav>
+ {items.length === 0 ? (
+ <p style="text-align:center">Nessun articolo pubblicato{category ? ' in questa categoria' : ''}, torna a trovarci presto.</p>
+ ) : (
+ <div class="bgrid">
+ {items.map((post) => (
+ <article class="bcard">
+ {post.cover && <a href={`/blog/${post.slug}`}><img src={post.cover} alt="" loading="lazy" /></a>}
+ <p class="bcard__date">{fmt(post.published_at)}</p>
+ <h3><a href={`/blog/${post.slug}`}>{post.title}</a></h3>
+ <p>{post.excerpt}</p>
+ <a class="bcard__more" href={`/blog/${post.slug}`}>Per saperne di più →</a>
+ </article>
+ ))}
+ </div>
+ )}
+ {pages > 1 && (
+ <nav class="bpages" aria-label="paginazione">
+ {Array.from({ length: pages }, (_, i) => (
+ <a class:list={['bpages__n', { 'is-active': page === i + 1 }]} href={linkFor(category, i + 1)}>{i + 1}</a>
+ ))}
+ </nav>
+ )}
+ </div>
+ </section>
+</Base>
+
+<style>
+ .bfilters { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-bottom: 50px; }
+ .bfilters__item { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .16em; text-transform: uppercase; text-decoration: none; color: var(--c-text); padding: 10px 16px; }
+ .bfilters__item.is-active { background: var(--c-accent); color: #fff; }
+ .bgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; }
+ .bcard img { aspect-ratio: 4/3; object-fit: cover; width: 100%; margin-bottom: 14px; }
+ .bcard__date { font-size: .78rem; color: var(--c-text-light); margin-bottom: 4px; }
+ .bcard h3 a { text-decoration: none; }
+ .bcard__more { font-family: var(--font-heading); font-size: .7rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
+ .bpages { display: flex; gap: 8px; justify-content: center; margin-top: 50px; }
+ .bpages__n { font-family: var(--font-heading); text-decoration: none; padding: 8px 14px; color: var(--c-text); }
+ .bpages__n.is-active { background: var(--c-dark); color: #fff; }
+ @media (max-width: 991px) { .bgrid { grid-template-columns: 1fr 1fr; } }
+ @media (max-width: 600px) { .bgrid { grid-template-columns: 1fr; } }
+</style>
diff --git a/src/pages/contact.astro b/src/pages/contact.astro
new file mode 100644
index 0000000..f3e35dc
--- /dev/null
+++ b/src/pages/contact.astro
@@ -0,0 +1,55 @@
+---
+import Base from '../layouts/Base.astro';
+import PageHero from '../components/PageHero.astro';
+import ContactForm from '../components/ContactForm.astro';
+import { site } from '../data/site';
+export const prerender = true;
+const mapsQuery = encodeURIComponent(`${site.address}, ${site.cityLine}`);
+---
+<Base title="Contatti" description="Contatta InsanityLab: Via Leandro Alberti 76, Bologna. Compila il form o chiamaci per una consulenza iniziale.">
+ <PageHero title="Contattaci" />
+ <section class="section">
+ <div class="container cgrid">
+ <div class="cmap" id="map-box">
+ <button class="btn btn--dark" id="map-load">Carica la mappa (Google Maps)</button>
+ <p class="cmap__note">La mappa viene caricata da Google solo dopo il tuo consenso.</p>
+ </div>
+ <ContactForm variant="full" />
+ </div>
+ <div class="container cinfo">
+ <div>
+ <h3>Indirizzo</h3>
+ <p>{site.address} {site.cityLine}</p>
+ </div>
+ <div>
+ <h3>Telefono</h3>
+ <p>{site.phone}</p>
+ </div>
+ <div>
+ <h3>E-mail</h3>
+ <p><a href={`mailto:${site.email}`}>{site.email}</a></p>
+ </div>
+ </div>
+ <div class="container">
+ <p class="cinvite">Per informazioni o per richiedere una consulenza iniziale, compila il form. Un team specializzato ti accoglierà e ti ricontatterà al più presto, oppure lasciaci il tuo contatto.</p>
+ </div>
+ </section>
+</Base>
+
+<style>
+ .cgrid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 50px; margin-bottom: 50px; }
+ .cmap { background: var(--c-bg-alt); min-height: 420px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; }
+ .cmap iframe { width: 100%; height: 100%; border: 0; min-height: 420px; }
+ .cmap__note { font-size: .8rem; color: var(--c-text-light); }
+ .cinfo { display: grid; grid-template-columns: repeat(3, 1fr); gap: 30px; margin-bottom: 30px; }
+ .cinfo h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; }
+ .cinvite { max-width: 640px; }
+ @media (max-width: 991px) { .cgrid, .cinfo { grid-template-columns: 1fr; } }
+</style>
+
+<script define:vars={{ mapsQuery }}>
+ document.getElementById('map-load')?.addEventListener('click', () => {
+ const box = document.getElementById('map-box');
+ box.innerHTML = `<iframe src="https://www.google.com/maps?q=${mapsQuery}&output=embed" loading="lazy" title="Mappa InsanityLab" referrerpolicy="no-referrer-when-downgrade"></iframe>`;
+ });
+</script>
diff --git a/src/pages/index.astro b/src/pages/index.astro
new file mode 100644
index 0000000..665a928
--- /dev/null
+++ b/src/pages/index.astro
@@ -0,0 +1,32 @@
+---
+import Base from '../layouts/Base.astro';
+import HeroSlider from '../components/home/HeroSlider.astro';
+import InfoColumns from '../components/home/InfoColumns.astro';
+import FeatureBlocks from '../components/home/FeatureBlocks.astro';
+import QuoteBanner from '../components/home/QuoteBanner.astro';
+import TrainingCards from '../components/home/TrainingCards.astro';
+import ProgramsGrid from '../components/home/ProgramsGrid.astro';
+import Agenda from '../components/Agenda.astro';
+import TeamSection from '../components/home/TeamSection.astro';
+import MethodSteps from '../components/home/MethodSteps.astro';
+import PartnersStrip from '../components/home/PartnersStrip.astro';
+export const prerender = true;
+---
+<Base title="Performance. Balance. Longevity.">
+ <HeroSlider />
+ <InfoColumns />
+ <FeatureBlocks />
+ <QuoteBanner />
+ <section class="section" style="padding-bottom:0">
+ <div class="section-heading">
+ <h2>Training</h2>
+ <p>Porta i tuoi allenamenti al livello successivo: trova il percorso più adatto alle tue esigenze.</p>
+ </div>
+ </section>
+ <TrainingCards />
+ <ProgramsGrid />
+ <Agenda />
+ <TeamSection />
+ <MethodSteps />
+ <PartnersStrip />
+</Base>
diff --git a/src/pages/programs/[slug].astro b/src/pages/programs/[slug].astro
new file mode 100644
index 0000000..6f0d178
--- /dev/null
+++ b/src/pages/programs/[slug].astro
@@ -0,0 +1,98 @@
+---
+import Base from '../../layouts/Base.astro';
+import PageHero from '../../components/PageHero.astro';
+import { programs } from '../../data/programs';
+import { performanceClassPricing } from '../../data/pricing';
+export const prerender = true;
+
+export function getStaticPaths() {
+ return programs.map((p) => ({ params: { slug: p.slug }, props: { program: p } }));
+}
+const { program } = Astro.props;
+const others = programs.filter((p) => p.slug !== program.slug);
+const blogCategories = ['Fitness', 'Salute', 'Stile di vita', 'Nutrizione'];
+---
+<Base title={program.title} description={program.excerpt}>
+ <PageHero title={program.title.toUpperCase()} />
+
+ <section class="section">
+ <div class="container prog">
+ <div>
+ <h2>{program.title.toUpperCase()}</h2>
+ <p>{program.longDescription}</p>
+ <ul class="prog__features">
+ {program.features.map((f) => <li>{f}</li>)}
+ </ul>
+ <div class="prog__faq">
+ {program.faq.map((item, i) => (
+ <details open={i === 0}>
+ <summary>{item.q}</summary>
+ <p>{item.a}</p>
+ </details>
+ ))}
+ </div>
+ </div>
+ <aside class="prog__side">
+ <h3>Categorie</h3>
+ {blogCategories.map((c) => <p><a href="/blog">{c}</a></p>)}
+ <h3>Altri programmi</h3>
+ {others.map((o) => <p><a href={`/programs/${o.slug}`}>{o.title}</a></p>)}
+ </aside>
+ </div>
+ </section>
+
+ {program.hasPricing && (
+ <section class="section section--alt">
+ <div class="container">
+ <div class="section-heading">
+ <h2>{performanceClassPricing.heading}</h2>
+ <p>{performanceClassPricing.intro}</p>
+ </div>
+ {performanceClassPricing.groups.map((g) => (
+ <details class="price__group" open={g.open}>
+ <summary>{g.open ? '+' : '–'} {g.label}</summary>
+ {g.plans.length === 0 ? (
+ <p class="price__empty">Contattaci per i dettagli di questo programma. <a href="/contact">Scrivici →</a></p>
+ ) : (
+ <div class="price__row">
+ {g.plans.map((p) => (
+ <div class="price__card">
+ <p class="price__amount">{p.monthly}€</p>
+ <p class="price__per">al mese, semestrale</p>
+ <p class="price__entries">{p.entries} {p.entries === 1 ? 'ingresso settimanale' : 'ingressi settimanali'}</p>
+ <p>{p.note}</p>
+ <p>{p.installments}</p>
+ <p class="price__best">{p.best}</p>
+ <a class="btn" href="/contact">Acquista</a>
+ </div>
+ ))}
+ </div>
+ )}
+ </details>
+ ))}
+ </div>
+ </section>
+ )}
+</Base>
+
+<style>
+ .prog { display: grid; grid-template-columns: 2.2fr 1fr; gap: 60px; }
+ .prog__features { padding-left: 20px; display: grid; gap: 8px; margin-bottom: 30px; }
+ .prog__features li::marker { color: var(--c-accent); }
+ .prog__faq details { border-top: 1px solid #e4ded6; padding-block: 14px; }
+ .prog__faq summary { cursor: pointer; font-family: var(--font-heading); font-size: .85rem; letter-spacing: .08em; color: var(--c-heading); }
+ .prog__side h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; margin-top: 30px; }
+ .prog__side a { text-decoration: none; color: var(--c-text); }
+ .prog__side a:hover { color: var(--c-accent-dark); }
+ .price__group { border-top: 1px solid #ddd5ca; padding-block: 18px; }
+ .price__group summary { cursor: pointer; font-family: var(--font-heading); letter-spacing: .12em; text-transform: uppercase; font-size: .85rem; color: var(--c-heading); }
+ .price__row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 30px; margin-top: 30px; }
+ .price__card { background: #fff; padding: 36px 28px; text-align: center; }
+ .price__amount { font-family: var(--font-heading); font-size: 2.6rem; color: var(--c-heading); margin: 0; }
+ .price__per { color: var(--c-text-light); font-size: .8rem; }
+ .price__entries { font-family: var(--font-heading); text-transform: uppercase; letter-spacing: .1em; font-size: .82rem; color: var(--c-heading); }
+ .price__card p { margin-bottom: 6px; font-size: .85rem; }
+ .price__best { font-weight: 600; color: var(--c-accent-dark); }
+ .price__card .btn { margin-top: 16px; }
+ @media (max-width: 991px) { .prog { grid-template-columns: 1fr; } .price__row { grid-template-columns: 1fr; } }
+</style>
diff --git a/src/pages/programs/index.astro b/src/pages/programs/index.astro
new file mode 100644
index 0000000..d2446df
--- /dev/null
+++ b/src/pages/programs/index.astro
@@ -0,0 +1,38 @@
+---
+import Base from '../../layouts/Base.astro';
+import PageHero from '../../components/PageHero.astro';
+import { programs } from '../../data/programs';
+export const prerender = true;
+---
+<Base title="Programmi" description="I programmi InsanityLab: Personal Training, Performance Class, Coaching, Pilates Flow, Rehab e Nutrizione.">
+ <PageHero title="programmi" />
+ <section class="section">
+ <div class="container">
+ <div class="section-heading">
+ <h2>Programmi</h2>
+ <p>Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un'esperienza di allenamento completa e mirata.</p>
+ </div>
+ <div class="plist">
+ {programs.map((p) => (
+ <article class="plist__card">
+ <h3>{p.title}</h3>
+ <p class="plist__sub">{p.subtitle}</p>
+ <p>{p.excerpt}</p>
+ <a class="plist__more" href={`/programs/${p.slug}`}>Per saperne di più →</a>
+ </article>
+ ))}
+ </div>
+ </div>
+ </section>
+</Base>
+
+<style>
+ .plist { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; }
+ .plist__card h3 { letter-spacing: .08em; }
+ .plist__sub { font-size: .8rem; text-transform: uppercase; letter-spacing: .12em; color: var(--c-accent-dark); }
+ .plist__card p { font-size: .92rem; }
+ .plist__more { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
+ .plist__more:hover { color: var(--c-accent-dark); }
+ @media (max-width: 991px) { .plist { grid-template-columns: 1fr 1fr; } }
+ @media (max-width: 600px) { .plist { grid-template-columns: 1fr; } }
+</style>
diff --git a/src/pages/training.astro b/src/pages/training.astro
new file mode 100644
index 0000000..747fffb
--- /dev/null
+++ b/src/pages/training.astro
@@ -0,0 +1,57 @@
+---
+import Base from '../layouts/Base.astro';
+import PageHero from '../components/PageHero.astro';
+import TrainingCards from '../components/home/TrainingCards.astro';
+import Agenda from '../components/Agenda.astro';
+import { Image } from 'astro:assets';
+import { trainings } from '../data/trainings';
+const images = import.meta.glob<{ default: ImageMetadata }>('../assets/img/training-*.{jpg,png}', { eager: true });
+const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1]?.default;
+export const prerender = true;
+---
+<Base title="Training" description="One to One, Small Groups, Performance Class e Fit Remote: trova il training InsanityLab più adatto a te.">
+ <PageHero title="training" />
+
+ <section class="section" style="padding-bottom:0">
+ <div class="section-heading">
+ <h2>Training</h2>
+ <p>Porta i tuoi allenamenti al livello successivo: programmi completi e percorsi su misura. Trova il training più adatto alle tue esigenze.</p>
+ </div>
+ </section>
+ <TrainingCards />
+
+ <section class="section">
+ <div class="container">
+ {trainings.map((t, i) => (
+ <div class:list={['tblock', { 'tblock--reverse': i % 2 === 1 }]} id={t.id}>
+ {imgOf(t.image) && <Image src={imgOf(t.image)!} alt={t.title} widths={[500, 900]} sizes="(max-width:767px) 100vw, 45vw" />}
+ <div>
+ <h2>{t.title.toLowerCase()}</h2>
+ <p>{t.description}</p>
+ <a class="btn" href={t.cta.href}>{t.cta.label}</a>
+ </div>
+ </div>
+ ))}
+ </div>
+ </section>
+
+ <Agenda />
+
+ <section class="section section--alt">
+ <div class="container trial">
+ <div>
+ <h2>Prenota la lezione di prova</h2>
+ <p>La lezione di prova è l'occasione perfetta per conoscere il metodo, vivere l'esperienza in prima persona e capire il percorso più adatto a te. Inizia oggi il tuo percorso: prenota la tua lezione di prova.</p>
+ <a class="btn btn--dark" href="/contact">Prenota</a>
+ </div>
+ </div>
+ </section>
+</Base>
+
+<style>
+ .tblock { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; padding-block: 40px; scroll-margin-top: calc(var(--header-h) + 20px); }
+ .tblock--reverse img { order: 2; }
+ .tblock h2 { letter-spacing: .1em; }
+ .trial { max-width: 720px; text-align: center; }
+ @media (max-width: 767px) { .tblock { grid-template-columns: 1fr; gap: 24px; } .tblock--reverse img { order: 0; } }
+</style>
diff --git a/src/pages/uploads/[...path].ts b/src/pages/uploads/[...path].ts
new file mode 100644
index 0000000..1444dfa
--- /dev/null
+++ b/src/pages/uploads/[...path].ts
@@ -0,0 +1,20 @@
+import type { APIRoute } from 'astro';
+import { readFile } from 'node:fs/promises';
+import { join, normalize, extname } from 'node:path';
+import { getEnv } from '../../lib/env';
+export const prerender = false;
+
+const MIME: Record<string, string> = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif' };
+
+export const GET: APIRoute = async ({ params }) => {
+ const rel = normalize(params.path ?? '').replace(/^(\.\.[/\\])+/, '');
+ if (!rel || rel.includes('..')) return new Response('Not found', { status: 404 });
+ const mime = MIME[extname(rel).toLowerCase()];
+ if (!mime) return new Response('Not found', { status: 404 });
+ try {
+ const buf = await readFile(join(getEnv('UPLOADS_DIR', 'uploads'), rel));
+ return new Response(buf, { headers: { 'Content-Type': mime, 'Cache-Control': 'public, max-age=31536000, immutable' } });
+ } catch {
+ return new Response('Not found', { status: 404 });
+ }
+};
diff --git a/src/styles/global.css b/src/styles/global.css
new file mode 100644
index 0000000..e071d40
--- /dev/null
+++ b/src/styles/global.css
@@ -0,0 +1,54 @@
+/* === Design tokens (valori campionati da Figma) === */
+:root {
+ --c-accent: #b8a98c;
+ --c-accent-dark: #9c8b70;
+ --c-dark: #3a2929;
+ --c-dark-2: #4a3a30;
+ --c-heading: #2b2b2b;
+ --c-text: #4b4b4b;
+ --c-text-light: #8a8a8a;
+ --c-bg: #ffffff;
+ --c-bg-alt: #f5f3f0;
+ --font-heading: 'Montserrat', sans-serif;
+ --font-body: 'Open Sans', sans-serif;
+ --header-h: 70px;
+}
+
+/* === Reset === */
+*, *::before, *::after { box-sizing: border-box; }
+body { margin: 0; font-family: var(--font-body); color: var(--c-text); font-size: 16px; line-height: 1.7; background: var(--c-bg); }
+img { max-width: 100%; height: auto; display: block; }
+h1, h2, h3, h4 { font-family: var(--font-heading); color: var(--c-heading); font-weight: 500; letter-spacing: .04em; line-height: 1.2; margin: 0 0 .5em; }
+h1 { font-size: clamp(2.2rem, 5vw, 3.8rem); }
+h2 { font-size: clamp(1.8rem, 4vw, 2.6rem); }
+h3 { font-size: 1.3rem; }
+a { color: inherit; }
+p { margin: 0 0 1em; }
+
+/* === Utility === */
+.container { max-width: 1200px; margin-inline: auto; padding-inline: 20px; }
+.section { padding-block: 90px; }
+.section--alt { background: var(--c-bg-alt); }
+.section--dark { background: var(--c-dark); color: #cfc6bd; }
+.section--dark h2, .section--dark h3 { color: #fff; }
+.eyebrow { font-family: var(--font-heading); text-transform: uppercase; letter-spacing: .25em; font-size: .75rem; color: var(--c-accent-dark); }
+.section-heading { text-align: center; max-width: 640px; margin: 0 auto 56px; }
+.section-heading h2 { text-transform: uppercase; letter-spacing: .12em; }
+
+.btn { display: inline-flex; align-items: center; gap: 14px; font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; text-decoration: none; border: 0; cursor: pointer; padding: 16px 26px; background: var(--c-accent); color: #fff; transition: background .2s; }
+.btn:hover { background: var(--c-accent-dark); }
+.btn::after { content: '→'; font-size: .9rem; }
+.btn--dark { background: var(--c-dark); }
+.btn--dark:hover { background: var(--c-dark-2); }
+.btn--light { background: #fff; color: var(--c-heading); }
+
+/* === Form === */
+.field { width: 100%; border: 1px solid #ddd; background: #fff; padding: 14px 16px; font-family: var(--font-body); font-size: .95rem; color: var(--c-text); margin-bottom: 14px; }
+.field:focus { outline: 2px solid var(--c-accent); outline-offset: -1px; }
+textarea.field { min-height: 120px; resize: vertical; }
+.form-msg { font-size: .9rem; margin-top: 8px; }
+.form-msg--ok { color: #3c7a3c; }
+.form-msg--err { color: #b03030; }
+
+/* honeypot */
+.hp { position: absolute; left: -9999px; opacity: 0; }
diff --git a/tests/auth.test.ts b/tests/auth.test.ts
new file mode 100644
index 0000000..5eb2459
--- /dev/null
+++ b/tests/auth.test.ts
@@ -0,0 +1,45 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import type Database from 'better-sqlite3';
+import { createDb } from '../src/lib/db';
+import { hashPassword, verifyPassword, createUser, login, getSessionUser, logout } from '../src/lib/auth';
+
+let db: Database.Database;
+beforeEach(() => { db = createDb(':memory:'); });
+
+describe('auth', () => {
+ it('hash e verifica password', () => {
+ const h = hashPassword('segreta123');
+ expect(h).not.toBe('segreta123');
+ expect(verifyPassword('segreta123', h)).toBe(true);
+ expect(verifyPassword('sbagliata', h)).toBe(false);
+ });
+
+ it('login corretto crea sessione recuperabile', () => {
+ createUser(db, 'adriano', 'segreta123');
+ const token = login(db, 'adriano', 'segreta123');
+ expect(token).toBeTruthy();
+ const user = getSessionUser(db, token!);
+ expect(user).toMatchObject({ username: 'adriano' });
+ });
+
+ it('login errato ritorna null', () => {
+ createUser(db, 'adriano', 'segreta123');
+ expect(login(db, 'adriano', 'sbagliata')).toBeNull();
+ expect(login(db, 'inesistente', 'x')).toBeNull();
+ });
+
+ it('sessione scaduta viene rifiutata ed eliminata', () => {
+ createUser(db, 'adriano', 'segreta123');
+ const token = login(db, 'adriano', 'segreta123')!;
+ db.prepare("UPDATE sessions SET expires_at = datetime('now', '-1 day')").run();
+ expect(getSessionUser(db, token)).toBeNull();
+ expect(db.prepare('SELECT COUNT(*) AS n FROM sessions').get()).toMatchObject({ n: 0 });
+ });
+
+ it('logout elimina la sessione', () => {
+ createUser(db, 'adriano', 'segreta123');
+ const token = login(db, 'adriano', 'segreta123')!;
+ logout(db, token);
+ expect(getSessionUser(db, token)).toBeNull();
+ });
+});
diff --git a/tests/contact.test.ts b/tests/contact.test.ts
new file mode 100644
index 0000000..9fdbf67
--- /dev/null
+++ b/tests/contact.test.ts
@@ -0,0 +1,33 @@
+import { describe, it, expect } from 'vitest';
+import { validateContact } from '../src/lib/contact';
+
+describe('validateContact', () => {
+ const good = { firstName: 'Mario', email: 'mario@example.com', message: 'Ciao, vorrei informazioni.' };
+
+ it('accetta payload valido e normalizza i campi opzionali', () => {
+ const r = validateContact(good);
+ expect(r.ok).toBe(true);
+ if (r.ok) {
+ expect(r.value.lastName).toBe('');
+ expect(r.value.email).toBe('mario@example.com');
+ }
+ });
+
+ it('rifiuta email non valida', () => {
+ expect(validateContact({ ...good, email: 'non-email' }).ok).toBe(false);
+ });
+
+ it('rifiuta campi obbligatori mancanti o vuoti', () => {
+ expect(validateContact({ ...good, firstName: ' ' }).ok).toBe(false);
+ expect(validateContact({ ...good, message: '' }).ok).toBe(false);
+ expect(validateContact(null).ok).toBe(false);
+ });
+
+ it('rifiuta honeypot compilato', () => {
+ expect(validateContact({ ...good, website: 'spam.com' }).ok).toBe(false);
+ });
+
+ it('rifiuta messaggi oltre 5000 caratteri', () => {
+ expect(validateContact({ ...good, message: 'x'.repeat(5001) }).ok).toBe(false);
+ });
+});
diff --git a/tests/posts.test.ts b/tests/posts.test.ts
new file mode 100644
index 0000000..ad373fa
--- /dev/null
+++ b/tests/posts.test.ts
@@ -0,0 +1,74 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import type Database from 'better-sqlite3';
+import { createDb } from '../src/lib/db';
+import {
+ createPost, updatePost, deletePost, getPostById, getPublishedBySlug,
+ listPublished, listAllPosts, listRecent, listArchiveMonths, type PostInput,
+} from '../src/lib/posts';
+
+let db: Database.Database;
+beforeEach(() => { db = createDb(':memory:'); });
+
+const base: PostInput = {
+ title: 'Titolo', slug: 'titolo', category: 'Contenuti educativi',
+ excerpt: 'estratto', body_html: '<p>corpo</p>', cover: null, draft: false,
+};
+
+describe('posts repository', () => {
+ it('crea e rilegge un post pubblicato', () => {
+ const id = createPost(db, base);
+ const post = getPostById(db, id)!;
+ expect(post.title).toBe('Titolo');
+ expect(post.draft).toBe(0);
+ expect(post.published_at).not.toBeNull();
+ });
+
+ it('bozza non ha published_at e non appare nelle liste pubbliche', () => {
+ const id = createPost(db, { ...base, slug: 'bozza', draft: true });
+ expect(getPostById(db, id)!.published_at).toBeNull();
+ expect(getPublishedBySlug(db, 'bozza')).toBeUndefined();
+ expect(listPublished(db).total).toBe(0);
+ expect(listAllPosts(db)).toHaveLength(1);
+ });
+
+ it('slug duplicato lancia', () => {
+ createPost(db, base);
+ expect(() => createPost(db, base)).toThrow();
+ });
+
+ it('update imposta published_at alla prima pubblicazione e lo conserva', () => {
+ const id = createPost(db, { ...base, draft: true });
+ updatePost(db, id, { ...base, draft: false });
+ const first = getPostById(db, id)!.published_at;
+ expect(first).not.toBeNull();
+ updatePost(db, id, { ...base, title: 'Nuovo', draft: false });
+ expect(getPostById(db, id)!.published_at).toBe(first);
+ expect(getPostById(db, id)!.title).toBe('Nuovo');
+ });
+
+ it('listPublished filtra per categoria e pagina', () => {
+ for (let i = 0; i < 12; i++) {
+ createPost(db, { ...base, slug: `post-${i}`, category: i % 2 ? 'Contenuti educativi' : 'Eventi & Presidi' });
+ }
+ const all = listPublished(db, { page: 1, perPage: 9 });
+ expect(all.total).toBe(12);
+ expect(all.items).toHaveLength(9);
+ const edu = listPublished(db, { category: 'Contenuti educativi' });
+ expect(edu.total).toBe(6);
+ });
+
+ it('delete rimuove', () => {
+ const id = createPost(db, base);
+ deletePost(db, id);
+ expect(getPostById(db, id)).toBeUndefined();
+ });
+
+ it('listRecent e archivio mesi', () => {
+ createPost(db, base);
+ expect(listRecent(db, 4)).toHaveLength(1);
+ const months = listArchiveMonths(db);
+ expect(months).toHaveLength(1);
+ expect(months[0].count).toBe(1);
+ expect(months[0].month).toMatch(/^\d{4}-\d{2}$/);
+ });
+});
diff --git a/tests/rate-limit.test.ts b/tests/rate-limit.test.ts
new file mode 100644
index 0000000..930c2dc
--- /dev/null
+++ b/tests/rate-limit.test.ts
@@ -0,0 +1,23 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { rateLimit, _resetBuckets } from '../src/lib/rate-limit';
+
+afterEach(() => { _resetBuckets(); vi.useRealTimers(); });
+
+describe('rateLimit', () => {
+ it('consente fino a max richieste', () => {
+ expect(rateLimit('a', 2, 1000)).toBe(true);
+ expect(rateLimit('a', 2, 1000)).toBe(true);
+ expect(rateLimit('a', 2, 1000)).toBe(false);
+ });
+ it('chiavi indipendenti', () => {
+ expect(rateLimit('a', 1, 1000)).toBe(true);
+ expect(rateLimit('b', 1, 1000)).toBe(true);
+ });
+ it('la finestra scade', () => {
+ vi.useFakeTimers();
+ expect(rateLimit('a', 1, 1000)).toBe(true);
+ expect(rateLimit('a', 1, 1000)).toBe(false);
+ vi.advanceTimersByTime(1100);
+ expect(rateLimit('a', 1, 1000)).toBe(true);
+ });
+});
diff --git a/tests/sanitize.test.ts b/tests/sanitize.test.ts
new file mode 100644
index 0000000..0830958
--- /dev/null
+++ b/tests/sanitize.test.ts
@@ -0,0 +1,16 @@
+import { describe, it, expect } from 'vitest';
+import { sanitizeHtml } from '../src/lib/sanitize';
+
+describe('sanitizeHtml', () => {
+ it('mantiene la formattazione consentita', () => {
+ const html = '<h2>Titolo</h2><p>Testo <strong>forte</strong> ed <em>enfasi</em></p><ul><li>voce</li></ul><blockquote>citazione</blockquote>';
+ expect(sanitizeHtml(html)).toBe(html);
+ });
+ it('rimuove script e handler', () => {
+ expect(sanitizeHtml('<p onclick="x()">ciao</p><script>alert(1)</script>')).toBe('<p>ciao</p>');
+ });
+ it('consente immagini locali e https, blocca javascript:', () => {
+ expect(sanitizeHtml('<img src="/uploads/a.jpg">')).toContain('/uploads/a.jpg');
+ expect(sanitizeHtml('<a href="javascript:alert(1)">x</a>')).toBe('<a>x</a>');
+ });
+});
diff --git a/tests/slug.test.ts b/tests/slug.test.ts
new file mode 100644
index 0000000..5ca5bed
--- /dev/null
+++ b/tests/slug.test.ts
@@ -0,0 +1,20 @@
+import { describe, it, expect } from 'vitest';
+import { slugify } from '../src/lib/slug';
+
+describe('slugify', () => {
+ it('minuscole e trattini', () => {
+ expect(slugify('Il Metodo InsanityLab')).toBe('il-metodo-insanitylab');
+ });
+ it('rimuove accenti', () => {
+ expect(slugify('Attività fisica è già qui')).toBe('attivita-fisica-e-gia-qui');
+ });
+ it('comprime simboli e spazi multipli', () => {
+ expect(slugify('Blog & Edugo -- news!!')).toBe('blog-edugo-news');
+ });
+ it('niente trattini ai bordi', () => {
+ expect(slugify(' ciao ')).toBe('ciao');
+ });
+ it('stringa vuota resta vuota', () => {
+ expect(slugify('')).toBe('');
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..fe31721
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "astro/tsconfigs/strict",
+ "include": [".astro/types.d.ts", "src/**/*", "tests/**/*"],
+ "exclude": ["dist"]
+}
diff --git a/uploads/.gitkeep b/uploads/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..aefccb5
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,5 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: { include: ['tests/**/*.test.ts'] },
+});