Portfolio personale di Samuele Cieri, Software Developer in transizione verso la Data Engineering. È una Single Page Application minimalista costruita con Next.js: il layout è bloccato a 100dvh, le viste cambiano senza ricaricare la pagina e un campo di particelle 3D resta attivo in background. Ogni vista ha comunque un URL proprio, pre-renderizzato e indicizzabile, in inglese, italiano, francese e tedesco.
- Caratteristiche
- Stack
- Requisiti
- Avvio rapido
- Script npm
- Variabili d'ambiente
- Viste e URL
- Navigazione e interazione
- Architettura
- SEO
- Accessibilità
- Selezione del testo
- Dati e contenuti
- Estendere il progetto
- Struttura del progetto
- Qualità del codice
- Flusso di lavoro
- Deploy su Vercel
- Sito precedente
- Risoluzione dei problemi
- Contatti
- Licenza
- SPA con URL reali: ogni vista è una pagina statica (SSG) con contenuti e metadata propri. Dopo il primo caricamento il cambio vista avviene sul client, senza ricaricare, e aggiorna URL e titolo con la History API.
- Cinque lingue (EN/IT/FR/DE/ES): slug tradotti, rilevamento automatico della lingua e cambio lingua istantaneo che conserva vista, canvas e stato.
- Scena 3D persistente: una griglia di 5.376 punti (WebGL) che cambia forma e inquadratura a ogni vista e reagisce al puntatore. Viene caricata solo sul client e si adatta alle prestazioni del dispositivo.
- Tema chiaro, scuro o automatico con next-themes, senza flash al caricamento.
- Animazioni curate: intro in CSS al primo paint (prima dell'idratazione), poi Framer Motion per le transizioni tra viste, le line mask e la sottolineatura animata della navigazione.
- SEO completa: canonical, hreflang, Open Graph con immagini per lingua e vista, JSON-LD, sitemap, robots, manifest e redirect 301 dai vecchi URL PHP.
- Accessibilità: link reali, un solo
h1per URL, attributi ARIA, focus visibile e rispetto diprefers-reduced-motionin CSS, in Framer Motion e nella scena 3D. - Contenuti centralizzati e validati: un file JSON condiviso più uno per lingua, controllati da tipi, schema zod e verifiche incrociate: un contenuto incoerente fa fallire la build.
- CV su richiesta: il PDF viene generato a ogni download dagli stessi contenuti del sito, con i recapiti privati letti da variabili d'ambiente. Non è pubblico: chi visita lascia un'email e riceve un link firmato e a scadenza; il proprietario lo scarica subito con una password.
- Casi di studio: yourFinance e aria-er hanno una pagina propria sotto la vista dei progetti (
/it/sistemi/yourfinance,/it/sistemi/ariaer): problema, vincoli, architettura, scelte, misure e schermate. Il codice di yourFinance resta privato e la sua pagina non lo collega mai; quella di aria-er porta al repository pubblico e confronta i suoi risultati con i dati ARPAE.
| Layer | Tecnologia |
|---|---|
| Framework | Next.js 16 (App Router, React Server Components, SSG) · React 19 |
| Linguaggio | TypeScript 6 (strict) |
| Styling | Tailwind CSS v4 (@tailwindcss/postcss) · next-themes (light / dark / system) |
| Tipografia | Geist · Geist Mono · Instrument Serif (next/font/google) |
| Motion | Framer Motion (AnimatePresence, layoutId, MotionConfig, mask reveal) |
| 3D | WebGL senza librerie (lazy, solo client) |
| Stato | Zustand (store della vista per istanza via context + store della scena transiente) |
| i18n | next-intl (/en, /it, /fr, rilevamento lingua via proxy.ts, cambio lingua lato client) |
| Dati | data/shared.json + data/locales/*.json (unica sorgente: contenuti, stringhe UI, metadata SEO), validati con zod |
| Qualità | ESLint 9 (eslint-config-next: core-web-vitals + typescript) · Prettier · tsc --noEmit · Vitest · Playwright |
| pdf-lib (CV generato su richiesta, solo lato server) | |
| Hosting | Vercel |
I test end-to-end girano con Playwright su Chromium, WebKit e un profilo mobile, sulla build di produzione. I test unitari usano Vitest. La CI di GitHub Actions esegue formattazione, lint, typecheck, test unitari, build e test end-to-end su ogni pull request.
- Node.js ≥ 22.12.0 (richiesto da Vitest) (campo
enginesdipackage.json) - npm (il repository include
package-lock.json)
git clone https://github.com/CieriS/portfolio.git
cd portfolio
npm install
npm run devIl sito è su http://localhost:3000. La radice / reindirizza a /en o a /it in base alla lingua del browser.
Per provarlo da smartphone sulla stessa rete, apri l'indirizzo Network stampato da npm run dev (per esempio http://192.168.1.69:3000). Gli host 192.168.*.*, 10.* e *.local sono già ammessi da allowedDevOrigins in next.config.ts: senza questa voce la pagina viene mostrata ma non si idrata.
| Comando | Descrizione |
|---|---|
npm run dev |
Server di sviluppo con hot reload su http://localhost:3000 |
npm run build |
Build di produzione: pre-renderizza pagine, immagini Open Graph, sitemap e robots |
npm start |
Avvia la build di produzione in locale |
npm run lint |
ESLint su tutto il progetto (esclusi gli output di build) |
npm run format |
Formatta il progetto con Prettier (.prettierrc.json, con ordinamento delle classi Tailwind) |
npm run format:check |
Verifica la formattazione senza modificare i file, come in CI |
npm run typecheck |
Genera i tipi delle route (next typegen) e verifica i tipi con tsc --noEmit |
npm run test:unit |
Test unitari Vitest della logica pura (lib/**/*.test.ts), in meno di un secondo |
npm test |
Suite end-to-end Playwright; costruisce e avvia da sé la build di produzione |
npm run test:smoke |
Smoke test Playwright su un sito già distribuito: SMOKE_BASE_URL (URL del deploy), SMOKE_ENV (production o preview), SMOKE_SITE_URL (il SITE_URL del deploy, per difetto la base) |
npm run test:ui |
Stessa suite nella modalità interattiva di Playwright |
Prima di una push conviene eseguire npm run format:check, npm run lint, npm run typecheck, npm run test:unit e npm test. Sono gli stessi comandi della CI.
Nessuna variabile è obbligatoria: in locale il progetto funziona senza configurazione.
| Variabile | Uso |
|---|---|
SITE_URL |
Opzionale. Origine canonica per canonical, hreflang, sitemap, Open Graph e JSON-LD; gli slash finali vengono rimossi. Serve solo con un dominio personalizzato. Se assente si usa l'URL di produzione Vercel (VERCEL_PROJECT_PRODUCTION_URL), in locale http://localhost:3000. |
GOOGLE_SITE_VERIFICATION |
Opzionale. Token di verifica di Google Search Console, pubblicato come meta tag. Accetta il solo token oppure l'intero tag <meta> copiato da Search Console (il token viene estratto da lib/verification.ts); qualsiasi altro valore fa fallire la build. Il sito è statico: una modifica alla variabile ha effetto solo dopo un nuovo deploy. |
VERCEL_PROJECT_PRODUCTION_URL |
Impostata da Vercel. Usata quando SITE_URL manca. |
VERCEL_ENV |
Impostata da Vercel. Solo production è indicizzabile: le anteprime ricevono noindex e un robots.txt con Disallow: /. Fuori da Vercel, dove la variabile non esiste, il sito è indicizzabile. |
CV_ADMIN_PASSWORD |
Opzionale. Password del proprietario: scarica il CV senza altri passaggi. Almeno 16 caratteri, altrimenti il download con password resta spento (lo dice il log del server). |
CV_LINK_SECRET |
Opzionale. Segreto (almeno 32 caratteri, es. openssl rand -base64 48) che firma i link mandati per email. Cambiarlo invalida tutti i link già inviati. |
RESEND_API_KEY · CV_MAIL_FROM |
Opzionali. Chiave di Resend e mittente verificato (es. Samuele Cieri <cv@dominio.it>). Servono insieme a CV_LINK_SECRET: con uno solo dei tre i link via email restano spenti. |
CV_NOTIFY_EMAIL |
Opzionale. Indirizzo che riceve un avviso a ogni richiesta del CV, con l'email di chi l'ha chiesto. |
CV_EMAIL · CV_PHONE |
Opzionali. Recapiti stampati solo nel PDF: sono il motivo per cui il CV non è pubblico e non stanno nel repository. |
Le variabili del CV sono lette dalla route /api/cv all'avvio del server, non in build: le pagine statiche non le contengono. Senza nessuna di esse il sito funziona e il modulo risponde "non disponibile".
In locale puoi creare un file .env.local, già escluso da Git:
SITE_URL=https://www.example.com
GOOGLE_SITE_VERIFICATION=il-tuo-token| # | Vista (ID) | EN | IT | FR | DE | ES | Contenuto |
|---|---|---|---|---|---|---|---|
| 01 | Indice (hero) |
/en |
/it |
/fr |
/de |
/es |
Nome in grande formato, ruolo, presentazione, un paragrafo con datore di lavoro, studi, stack e progetti (i fatti che un motore di ricerca deve leggere già nella home) e invito a esplorare. |
| 02 | Identità (identity) |
/en/identity |
/it/identita |
/fr/identite |
/de/identitaet |
/es/identidad |
Titolo che è la prima frase della dichiarazione d'intenti (il resto ne è il sottotitolo), quattro principi di ingegneria e contatti (GitHub, LinkedIn, Instagram) e il link per sostenermi su GitHub Sponsors. |
| 03 | Esecuzione (timeline) |
/en/execution |
/it/esecuzione |
/fr/execution |
/de/ausfuehrung |
/es/ejecucion |
Timeline a tre corsie (industria, percorso accademico e studio autonomo del Data Engineering) su un asse temporale condiviso, con uptime in tempo reale e fasi con il relativo stack. |
| 04 | Sistemi (projects) |
/en/systems |
/it/sistemi |
/fr/systemes |
/de/systeme |
/es/sistemas |
Progetti in un accordion, tutti chiusi all'apertura della vista: sintesi, scelte di ingegneria, architettura a livelli, legame con la Data Engineering e link al repository (o ai contatti, se il codice è privato). Oggi: yourFinance (privato) e aria-er (pubblico). |
| — | /en/optimization |
/it/ottimizzazione |
/fr/optimisation |
/de/optimierung |
/es/optimizacion |
Allenamento e musica: vista tolta con la issue #50. I vecchi indirizzi fanno un redirect permanente (308) alla vista Identità della stessa lingua (RETIRED_VIEW_SLUGS in lib/routes.ts); allenamento e chitarra restano in una riga di Identità ("Fuori dal lavoro"). L'ultimo stato della vista (codice, testi in cinque lingue, scena) è nel tag git vista-ottimizzazione: git checkout vista-ottimizzazione per rivederla, git diff vista-ottimizzazione -- data components lib per ripartire da lì. |
Ogni URL è pre-renderizzato con i propri contenuti. Un indirizzo che non corrisponde a nessuna vista mostra una pagina 404 localizzata e non indicizzabile.
Sotto la vista Sistemi c'è una pagina in più per i progetti che hanno un caso di studio: /en/systems/yourfinance, /it/sistemi/yourfinance, /fr/systemes/yourfinance, /de/systeme/yourfinance. È un documento con link veri, non una sesta vista: ci si arriva dal progetto aperto nell'accordion e uscirne è una navigazione. Il link per tornare alla vista dei progetti sta nell'intestazione, che non scorre con la pagina, oltre che in fondo.
- Link in basso (
01–05): sono veri<a href>, quindi funzionano anche con il clic centrale, con "apri in nuova scheda" e per i crawler. Il clic principale cambia vista sul posto. Accanto alla voce attiva compare la parola di uso comune per quella vista ("Esecuzione · Esperienza"), che fa parte anche del nome accessibile di ogni link: il nome viene dal testo del link (l'indice è nascosto ai lettori di schermo, la parola comune delle voci non attive è solo per loro), così ciò che si legge coincide con ciò che si vede. - Progetti dalla home: nel paragrafo della home i nomi dei progetti sono link (
ProjectLink, ricavati dashared.projectsconsplitByNames). Portano al caso di studio se esiste, altrimenti alla vista dei progetti con il pannello già aperto: l'indirizzo è/it/sistemi#<id>, che funziona anche incollato o condiviso. Senza frammento i progetti partono tutti chiusi. - Tastiera:
1–4saltano a una vista,←e→scorrono in modo circolare. I tasti vengono ignorati se è premuto un modificatore o se il focus è in un campo di testo. - Touch: uno swipe orizzontale (oltre 70 px e prevalentemente orizzontale) passa alla vista precedente o successiva.
- Trackpad: lo scorrimento laterale a due dita non fa tornare indietro né andare avanti nella cronologia (
overscroll-behavior: nonesulla radice), perché partiva per sbaglio mentre si scorreva una vista. Vale per Chromium e Firefox; Safari gestisce quel gesto per conto suo e può ignorare la regola. Pulsanti del browser e scorciatoie da tastiera restano invariati. - Frecce e contatore nel footer, su schermi medi e grandi.
- Indicatore di scorrimento: le scrollbar sono nascoste, quindi una vista più alta dello schermo mostra sul bordo destro una linea sottile con un cursore che segue lo scorrimento. Compare solo dove c'è contenuto sotto la piega (misurato con
ResizeObserver; geometria inlib/scroll.ts) ed è nascosto alle tecnologie assistive. - Cronologia: ogni cambio vista esegue
pushState, quindi avanti e indietro del browser funzionano. Il titolo del documento segue la vista attiva. - Lingua: l'header mostra solo la lingua attiva; il pulsante apre un menu animato (
listbox) con tutte le lingue, ciascuna col proprio nome nativo, usabile con mouse, touch e tastiera (frecce, Home/End, Invio/Spazio, Esc, clic fuori; il focus torna al pulsante). Scegliere una lingua sostituisce l'URL con lo slug tradotto (replaceState), aggiornalange titolo e salva il cookieNEXT_LOCALEper un anno, senza navigare. Canvas e vista attiva restano intatti. - Rilevamento lingua: su
/e sui percorsi senza prefisso,proxy.ts(middleware di next-intl) sceglie la lingua dal cookieNEXT_LOCALEo dall'headerAccept-Language. La lingua predefinita è l'inglese e il prefisso è sempre presente. - Tema: il pulsante alterna Auto → Chiaro → Scuro; l'icona è rispettivamente piena a metà, vuota o piena.
- Scena: il puntatore sposta leggermente la camera e "scalda" i nodi vicini. L'effetto si spegne quando il puntatore esce dalla finestra.
- Layout e pagine usano
generateStaticParams: tutte le combinazioni lingua × vista vengono generate in build. app/[locale]/page.tsx(indice) eapp/[locale]/[view]/page.tsx(viste interne) convergono incomponents/seo/PortfolioPage.tsx, un Server Component che inserisce il JSON-LD e avvia l'app sulla vista richiesta.lib/routes.tsè la fonte unica di lingue, ID, slug localizzati e conversioni URL ↔ vista. Non dipende da Next né da next-intl, così la suite Playwright importa lo stesso modulo e non ne tiene una copia.- Tutte le lingue vengono inviate al client (
getPortfolioBundle), così il cambio lingua non richiede una navigazione. - I testi delle due pagine di errore stanno in
data/errors.jsone si leggono conlib/errorCopy.ts: le pagine di errore sono componenti client, e leggerli dal modulo dei contenuti portava nel browser lo schema zod e i quattro file di lingua (circa 100 KB compressi su ogni pagina). Un test fallisce se un componente client importa a runtimelib/portfolio.ts,lib/content/*ozod.
AppRootgestisce la lingua corrente e il provider di next-intl.AppShellcompone l'header (nome, ruolo, lingua, tema), la vista attiva e il footer (navigazione, frecce, contatore).AnimatePresence mode="wait", con chiavevista:lingua, gestisce le transizioni.ViewFrameè il contenitore scrollabile di ogni vista, con bordi sfumati e scrollbar nascosta.useViewNavigationgestisce tastiera e swipe;useViewUrlSyncsincronizza URL, cronologia, titolo e modalità della scena.
store/viewStore.tsx: store Zustand creato per ogni istanza e condiviso via React context. La vista iniziale arriva dall'URL, e un singleton a livello di modulo condividerebbe lo stato tra render concorrenti sul server.store/useSceneStore.ts: store globale della scena (modalità, palette, puntatore). Il puntatore viene modificato sul posto e letto inuseFramecongetState(), quindi il movimento del mouse non provoca re-render di React.
SceneLayervive nel layout, sopra ogni cambio vista, e non viene mai smontato.DataFieldè caricato connext/dynamicessr: falsee disegna con WebGL senza librerie (components/scene/fieldRenderer.ts): un programma, due buffer statici e una sola chiamata di disegno. Fino alla 2.4 la scena usava three.js e react-three-fiber: ~230 KB gzip e un task lungo all'avvio, la causa quasi intera del Total Blocking Time su desktop (issue #64). Tone mapping ACES e conversione sRGB sono quelli di three, copiati nello shader, così i colori non sono cambiati.- La griglia ha 96 × 56 punti; una riga ogni cinque trasporta "pacchetti di dati" in movimento.
- La griglia è caricata sulla GPU una volta sola (
lib/sceneField.ts). Altezza, onda attorno al puntatore, pacchetti e colore di ogni punto li calcola il vertex shader (components/scene/fieldShader.ts); a ogni framestepSim(lib/sceneSim.ts, logica pura e testata, con la camera dilib/sceneMath.ts) interpola i parametri e passa allo shader una dozzina di numeri, qualunque sia il numero di punti. Le fasi di onda e flusso arrivano già riportate entro il loro periodo, perché sulla GPU i numeri sono a 32 bit. - Ogni vista ha una modalità in
lib/sceneModes.ts(ampiezza, frequenza, velocità, flusso, raggio e forza del puntatore, presenza, posizione della camera). Al cambio vista i parametri vengono interpolati in modo graduale. lib/sceneMonitor.ts(stesse soglie delPerformanceMonitordi drei che sostituisce) riduce il device pixel ratio a 1 quando il frame rate cala e lo riporta fino a 1,75 quando migliora.- Con
prefers-reduced-motion: reduceil canvas non ha un ciclo di frame e ridisegna solo quando cambiano vista o tema. - La palette segue il tema risolto (chiaro o scuro) e porta con sé anche la dimensione dei punti: nel tema chiaro il punto è più scuro e più grande del 25% circa, perché punti scuri su fondo chiaro appaiono più sottili di punti chiari su fondo scuro a parità di contrasto. Nelle viste interne, dove il campo è attenuato per lasciar leggere il testo, il tema chiaro ne conserva di più (
presenceScale), altrimenti sulla carta panna quasi sparirebbe.
- Al primo paint le entrate sono in CSS (
.intro-linee.intro-fadesottohtml:not([data-booted])). Così i contenuti non restano nascosti in attesa dell'idratazione e il titolo dell'indice, elemento LCP, inizia subito a comparire. - Al primo cambio di vista o di lingua,
markBooted()passa il controllo a Framer Motion. MotionConfig reducedMotion="user"rispetta le preferenze di sistema.
- Token CSS in
app/globals.css(--paper,--ink,--muted,--line,--accent), ridefiniti sotto.darked esposti a Tailwind v4 con@theme inline. - Il tema chiaro è una carta color panna (
#ede7db), non un bianco da schermo: meno abbagliante, e lascia leggere il campo di punti sullo sfondo. lib/theme.tsripete carta e inchiostro per ciò che non può leggere le custom property (scena WebGL,theme-color, manifest, immagini Open Graph).lib/theme.test.tsfa fallire i test se CSS e TypeScript divergono, se il testo scende sotto i contrasti AA/AAA o se i punti della scena diventano invisibili o più forti del testo.- Utility personalizzate:
px-frame,no-scrollbar,fade-edges,link-underline,bg-dashed. - Font caricati con
next/fontedisplay: swap: Geist per il testo, Geist Mono per le etichette, Instrument Serif corsivo per le parole in enfasi.
app/api/cv/route.tsè l'unica parte resa a ogni richiesta: tutto il resto resta prerenderizzato. È un adattatore sottile: legge la configurazione, chiamalib/cv/service.tse traduce l'esito in una risposta HTTP.lib/cv/service.tscontiene le due regole, senza HTTP né PDF: password corretta → PDF; email valida → link firmato inviato a quell'indirizzo (e avviso al proprietario). Un campo nascosto (honeypot) ferma i bot senza farglielo capire.lib/cv/token.ts: il link porta con sé email, lingua e scadenza (24 ore,lib/cv/common.ts), firmati con HMAC-SHA256. Non serve un database e un link modificato o scaduto viene rifiutato. Le password si confrontano a tempo costante.lib/cv/document.tscostruisce il CV come dati puri dadata/shared.jsone dalla lingua scelta;lib/cv/pdf.tslo impagina in A4 con i font standard (nessun file di font da distribuire). I testi del PDF e dell'email stanno indata/cv.json, fuori dai file di lingua che arrivano al browser.- Nel modulo, il campo della password ha un pulsante per mostrarla o nasconderla: il confronto è esatto, quindi uno spazio incollato per sbaglio la fa rifiutare. Cambiando metodo torna nascosta.
- Il modulo non usa la validazione del browser (
noValidate): il suo messaggio generico è nella lingua del browser, non in quella della pagina.checkCvInput(lib/cv/common.ts) controlla il campo prima dell'invio e la pagina spiega cosa manca con parole sue (indirizzo assente, indirizzo che non sembra un'email, password assente), segna il campo conaria-invalide ci riporta il focus. - Il CV aggiunge ciò che il sito non mostra, da
data/cv-profile.json(lib/cv/profile.ts): competenze raggruppate, lingue parlate con il livello e quali profili elencare. Le certificazioni stanno inshared.json, dove le legge anche il sito (fase "Metodo" della corsia Tas ePerson.hasCredentialnel JSON-LD): una sola fonte, nessun disallineamento possibile. L'ordine delle sezioni è competenze, esperienza, formazione, certificazioni, progetti, lingue; in fondo c'è l'autorizzazione al trattamento dei dati. Resta a una colonna e senza immagini, perché i sistemi di selezione automatica (ATS) lo leggano in ordine: l'unico colore è quello dei titoli di sezione. I nomi degli esami sono tradotti tramitetermsindata/cv.json; un test fallisce se una tecnologia del lavoro o dei progetti manca dalle competenze. lib/cv/mailer.tsè la porta di uscita per l'email, con un adattatore per l'API REST di Resend viafetch(nessun SDK). Un invio via SMS sarebbe un altro adattatore.- Limiti noti: non c'è un limite di richieste per indirizzo, perché servirebbe uno stato condiviso; su Vercel si imposta una regola di rate limiting del Firewall su
/api/cv. La password del proprietario deve essere lunga proprio perché i tentativi non sono contati.
app/[locale]/[view]/[study]/page.tsxprerenderizza la pagina solo sotto lo slug della vista dei progetti e solo per gli id inCASE_STUDY_IDS(lib/routes.ts); il resto è un 404. Un segmento di vista in un'altra lingua (/it/systems/ariaer) non è un 404 ma un redirect 308 a quello giusto (localizedPath, applicato daproxy.ts): così/systems/ariaer, senza lingua, apre il caso di studio nella lingua del browser ed è l'indirizzo da usare fuori dal sito.components/case-study/CaseStudyPage.tsxè un componente server: tutto il testo è nell'HTML e la pagina non carica la libreria delle animazioni. Usa le primitive di testo dicomponents/ui/text.tsx, condivise con le viste.- I contenuti stanno in
data/case-studies/<id>.json(struttura più testi in ogni lingua), fuori dai file di lingua che arrivano al browser.lib/caseStudy.tsli valida con zod e con verifiche incrociate: un id senza testo in una lingua, unaltvuoto o una description oltre 160 caratteri fanno fallire la build. - Risultati dal vivo (facoltativi, campo
livedel JSON): un componente client (components/case-study/LiveResults.tsx) legge l'export che il progetto pubblica (aria-er: JSON giornaliero su GitHub Pages) e disegna i grafici in SVG con i colori del sito. La logica è pura inlib/liveResults.ts(schema del contratto, serie dei grafici, dati vecchi oltre 48 ore); le origini permesse stanno inDATA_ORIGINS, da cuinext.config.tsricavaconnect-src. Se l'export non risponde, la pagina resta intera con un messaggio. I test E2E simulano l'export conpage.route. - Le schermate sono import statici (
lib/caseStudyImages.ts) serviti danext/image. Per un progetto privato vanno prese solo dal profilo demo; per aria-er vengono dalla sua dashboard, su dati pubblici. - Ogni pagina ha canonical, hreflang, immagine Open Graph, voce nella sitemap e un JSON-LD
TechArticleconBreadcrumbList. Per un progetto privatocodeRepositorynon viene mai emesso, e un test E2E verifica che la pagina non contenga link al repository. Per un progetto pubblico il link in fondo porta al repository (data-source="public") e il JSON-LD hacodeRepository.
- Metadata per vista (
lib/seo.ts): title, description, canonical, hreflang (en,it,fr,de,x-default), Open Graph di tipoprofilee Twitter cardsummary_large_image. Per l'indicex-defaultpunta a/, che rileva la lingua; per le altre viste punta alla versione inglese. - Immagini Open Graph 1200 × 630 generate in build per ogni lingua e vista (
opengraph-image.tsx,lib/og.tsx), con l'icona storica del sito. L'icona è una copia a 56 px (lib/og-icon.png, circa 3 KB), la misura a cui viene disegnata: va rigenerata daapp/icon.pngse l'icona cambia. - JSON-LD
@graph(lib/structuredData.ts):WebSite,Person(conalternateName,address,knowsAboutesameAs;urlè la radice del sito in ogni lingua),ProfilePage,BreadcrumbListnelle viste interne eItemListdiSoftwareSourceCodenella vista dei progetti. sitemap.xmlcon alternate hreflang,robots.txt,manifest.webmanifest, favicon e icone ricavate dall'icona del sito precedente.- Indicizzazione controllata: solo la produzione è indicizzabile (vedi Variabili d'ambiente). Il meta
robotsè impostato per pagina, non nel layout, così la 404 porta solo ilnoindexdi Next. - Contenuto dei progetti sempre nell'HTML: i pannelli chiusi dell'accordion restano montati (altezza zero,
inert), quindi testi e link al repository di ogni progetto sono nella pagina prerenderizzata anche senza interazione. - Struttura della pagina: un solo
h1per URL, link reali nella navigazione,rel="me"sui profili social, headerX-Powered-Bydisattivato. - Redirect 301 dai vecchi URL PHP:
| Vecchio URL | Destinazione |
|---|---|
/index.php |
/ |
/error |
/ |
/projDev/* |
/it/sistemi |
/projProd/* |
/it/sistemi |
- Un solo host indicizzato: dei tre host
*.vercel.appdel progetto (cieri,cierisamuele,samuelecieri, elenco inlib/aliases.ts) risponde solo quello diSITE_URL; gli altri rispondono con un redirect permanente (308) allo stesso percorso suSITE_URL. Il canonical da solo non bastava: Google aveva indicizzato un alias. Gli host sono elencati uno per uno inlib/aliases.ts, così le anteprime, localhost e il server dei test non vengono toccati. Un alias nuovo aggiunto su Vercel va aggiunto anche lì.
- Aggiungi una proprietà di tipo "Prefisso URL" e scegli la verifica con tag HTML.
- Imposta
GOOGLE_SITE_VERIFICATIONsu Vercel (token o tag intero) e rifai il deploy: la variabile è letta in build. Controlla il risultato concurl -s https://<sito>/en | grep google-site-verification, poi premi "Verifica". - In "Sitemap" invia
sitemap.xml. Su una proprietà nuova lo stato "Impossibile recuperare" con "Ultima lettura" vuota significa solo che Google non l'ha ancora letta: può volerci un paio di giorni. - Le richieste manuali di indicizzazione sono poche al giorno. Vanno fatte sulle URL con la lingua (
/en,/it,/en/systems, …): la radice/risponde con un redirect e non viene indicizzata come pagina.
Con un dominio personale conviene una proprietà di tipo "Dominio", verificata via DNS.
- Navigazione con link reali e
aria-current="page"sulla vista attiva; ogni vista è unasectionconaria-label. - Accordion dei progetti con
aria-expandedearia-controls; i pannelli chiusi sonoinert, fuori dal focus e dalle tecnologie assistive; pulsanti e controlli con etichette localizzate. - Focus sempre visibile (
:focus-visible), attributolangaggiornato al cambio lingua; il menu delle lingue esponearia-haspopup="listbox",aria-expandedearia-selected, e ogni opzione ha il propriolang. - Elementi decorativi (canvas, frecce, indici) marcati con
aria-hidden. - Movimento ridotto rispettato su tre livelli: intro CSS, Framer Motion e frame loop della scena.
La selezione è disabilitata solo sull'interfaccia: navigazione, bottoni, header e footer, etichette, indici, titoli display e canvas. Lì una selezione è sempre accidentale (doppio clic, swipe). Testi, descrizioni e contatti restano selezionabili e Cmd/Ctrl+A non viene intercettato: bloccarli non protegge il contenuto (è nell'HTML e nei risultati di ricerca) e peggiorerebbe accessibilità e usabilità.
I contenuti stanno in data/shared.json e in un file per lingua in data/locales/, caricati da lib/portfolio.ts.
data/shared.json dati indipendenti dalla lingua
├── name, handle, alternateNames, address
├── knowsLanguage[] lingue che parlo (JSON-LD), non le lingue del sito
├── contacts[] id, label, handle, url
├── certifications[] id, name, issuer, entity, issued, expires, url (CV, fase "Metodo", hasCredential)
├── sponsor label, url (GitHub Sponsors: fuori da contacts, quindi niente sameAs né CV)
├── timeline.threads[] corsie: id, kind (work | education), entity, segments[], fasi
└── projects[] id, name, source, stack, layers
data/locales/<lingua>.json en · it · fr · de
├── ui messages di next-intl: meta (SEO), notFound, nav, navPlain, theme, locale, shell
├── hero
├── identity
├── timeline
└── projects items[id]: summary, bridge, highlights, layers
shared.jsoncontiene i dati indipendenti dalla lingua (link, date, stack, metriche);locales/<lingua>.jsoncontiene il copy.- Il blocco
uidi ogni lingua è usato come messages di next-intl e contiene anche title e description SEO per vista (ui.meta.views). - Il copy è collegato ai dati condivisi tramite
id(per esempioshared.projects[].id→projects.items[id]di ogni lingua). - I campi
*Emphasisindicano la parola resa in corsivo serif e devono comparire nel testo a cui si riferiscono. - Le date sono in formato ISO
YYYY-MM-DDe vengono mostrate comeDD.MM.YYYY. - I contenuti passano quattro controlli prima del prerender, e ogni errore fa fallire la build:
- tipi: ogni lingua è assegnata alla forma di
en.json, quindi una chiave mancante o rinominata fa fallirenpm run typecheck; - schema (
lib/content/schema.ts):shared.jsonè validato con zod (enum, date ISO esistenti, segmenti che finiscono dopo l'inizio, URL, unionesource); i tipi del dominio sono derivati dallo schema; - valori dei testi (
lib/content/copy.ts): ogni lingua è validata con zod contro la forma dien.json(stesse chiavi, stesse liste, stessi segnaposto{…}); nessun testo vuoto o con spazi ai bordi, meta description ≤ 160 caratteri, titoli ≤ 65 come li mostra la scheda (con il nome del sito), nessun testo di 60 caratteri o più identico all'inglese (non tradotto); - verifiche incrociate (
lib/content/validate.ts): ogni id di corsia, fase, progetto e livello ha il suo copy in ogni lingua, ogni lingua ha l'etichetta inui.localee ogni*Emphasiscompare nel suo testo. Tutti i problemi vengono elencati insieme, con il percorso.
- tipi: ogni lingua è assegnata alla forma di
alternateNames (altre grafie e handle con cui la stessa persona viene cercata) e address (locality e country, codice ISO a due lettere) alimentano alternateName e address del Person nel JSON-LD; un codice paese non valido fa fallire la build.
Campi che accettano null (mostrati come —): timeline.threads[].entity accetta null: la corsia mostra allora solo la sua entityLabel (es. "Studio autonomo") e resta fuori da worksFor / alumniOf. projects[].source vale { "visibility": "public", "url": "…" } (link al repository, pubblicato anche come codeRepository nel JSON-LD) oppure { "visibility": "private" }: il codice non viene linkato e il progetto rimanda alla vista Identità, da cui chiedere una demo. Una visibilità sconosciuta o un progetto pubblico senza url fa fallire la build.
Una corsia è una sequenza di segments, non un intervallo unico: { "start": "YYYY-MM-DD", "end": null } con end: null per il segmento ancora in corso. Più segmenti producono un vuoto sull'asse, e le date dei periodi attivi vengono scritte anche in chiaro nel dettaglio della corsia. Corsie e colonne derivano dall'array, quindi aggiungerne una terza non richiede modifiche al codice.
Il campo kind vale work o education e guida il JSON-LD: le corsie work alimentano worksFor e knowsAbout, quelle education alimentano alumniOf. Un valore diverso fa fallire la build. L'uptime in alto segue la prima corsia work e somma solo il tempo attivo, escludendo le interruzioni.
Ogni fase accetta uno start facoltativo. Senza, le fasi sono distribuite sul tempo attivo della corsia e scavalcano i vuoti; con uno start, la fase viene ancorata a quella data. Se sull'asse le etichette delle fasi non hanno spazio (corsia giovane: meno del 12% dell'asse fra un'etichetta e la successiva, o il bordo), la riga resta vuota e le fasi si leggono solo nel dettaglio: lo decide phaseMarksFit in lib/timeline.ts.
- Aggiungi un elemento a
shared.projectsconid,name,source(pubblico conurlo privato),stackelayers(id,tech).versionè facoltativo (0.9.0, senzav): compare nel pannello del progetto, nel CV e nel JSON-LD, e per un progetto pubblico porta alla pagina del tag su GitHub. Insieme areleased(la data di pubblicazione) lo tiene aggiornato un workflow: vedi Versioni dei progetti. - In ogni lingua aggiungi
projects.items[<id>]consummary,bridge,highlights(elenco delle scelte di ingegneria) elayers(una descrizione per ogniiddi livello). - Se necessario, aggiorna title e description in
ui.meta.views.projects.
La vista e il JSON-LD ItemList si aggiornano automaticamente.
- Aggiungi l'id del progetto a
CASE_STUDY_IDSinlib/routes.ts. - Crea
data/case-studies/<id>.jsonsul modello diyourfinance.jsone importalo inlib/caseStudy.ts. - Metti le schermate in
data/case-studies/<id>/e importale inlib/caseStudyImages.ts, con le stesse chiavi discreens.
Link dall'accordion, sitemap, metadata e immagine Open Graph seguono da soli; TypeScript segnala i punti rimasti da toccare.
version e released di ogni progetto in data/shared.json seguono le release dei progetti stessi, senza scriverle a mano.
.github/workflows/versions.ymlgira ogni giorno, e a richiesta dalla scheda Actions.scripts/sync-versions.mjslegge l'ultima release di ogni progetto che dichiara una versione e, se è più recente, riscrive le due righe; la logica è inlib/versions.ts, pura e testata.- Il workflow non scrive mai su
development: apre una pull request dal branchfeatures/versioni-dei-progetti(o aggiorna quella già aperta), i controlli girano come per ogni altra e il merge resta una decisione. - Contano anche le pre-release; le bozze no. Non si torna mai indietro: una versione uguale o più vecchia viene ignorata.
- Se nella pull request un controllo fallisce, un testo dipende dalla versione (per esempio la scheda di aria-er, che fino alla 1.0.0 deve dire cosa manca): si corregge il testo in quel branch e poi si unisce.
- Un progetto pubblico è cercato nel repository del suo
source.url. Uno privato non ha indirizzo nei dati, di proposito: il suo repository arriva dal secretVERSION_SOURCESe non compare né nei file né nei log; un test fallisce se il nome finisce in un file.
Configurazione, una volta sola, in Settings → Secrets and variables → Actions:
| Secret | Valore |
|---|---|
VERSIONS_TOKEN |
Token personale fine-grained limitato a questo repository. Permessi: Contents e Pull requests in lettura e scrittura. |
VERSION_SOURCES_TOKEN |
Solo per i progetti privati: un secondo token fine-grained, limitato ai loro repository, con Contents in sola lettura. Sono due token perché un token fine-grained dà gli stessi permessi su tutti i repository che include: uno solo avrebbe potuto scrivere anche nei progetti privati. |
VERSION_SOURCES |
Solo per i progetti privati: {"<id del progetto>":"proprietario/repository"}. |
Serve un token personale e non quello del workflow: una pull request aperta con GITHUB_TOKEN non fa partire altri workflow, quindi i controlli obbligatori non girerebbero mai. Il token scade: quando succede il workflow fallisce con un messaggio che lo dice, e va rigenerato. Senza il secret il workflow si ferma subito con un errore chiaro.
Aggiungi { id, label, handle, url } a shared.contacts: il contatto compare nella vista Identità e nel campo sameAs del JSON-LD.
lib/routes.ts: aggiungi l'ID aVIEW_IDSe lo slug di ogni lingua inVIEW_SLUGS.lib/sceneModes.ts: aggiungi la modalità della scena inSCENE_MODES.components/views/: crea il componente e registralo inRENDERincomponents/shell/AppShell.tsx.data/locales/*.json: aggiungiui.nav.<id>,ui.meta.views.<id>e il copy della vista in ogni lingua.
Pagine, immagini Open Graph, sitemap, navigazione e scorciatoie numeriche derivano da VIEW_IDS (le scorciatoie coprono fino a 9 viste).
lib/routes.ts: aggiungi il codice aLOCALES, il nome nativo inLOCALE_NAMESe gli slug inVIEW_SLUGS. Il menu delle lingue si genera da qui.i18n/routing.ts,viewFromPathe la suite E2E leggono da qui e non vanno toccati.lib/seo.ts: aggiungi il locale Open Graph inOG_LOCALE(per esempiopt: 'pt_PT').data/locales/<codice>.json: crea il file con la stessa struttura dien.json, importalo inlib/portfolio.tse ine2e/content.ts, e aggiungi l'etichetta della nuova lingua inui.localedi ogni lingua.
TypeScript segnala ogni punto dimenticato, perché tutte queste mappe sono tipizzate su Locale.
.
├── app/
│ ├── [locale]/
│ │ ├── layout.tsx html, font, tema, SceneLayer persistente, metadata di base
│ │ ├── page.tsx vista Indice
│ │ ├── not-found.tsx pagina 404 localizzata
│ │ ├── opengraph-image.tsx immagine Open Graph dell'indice
│ │ └── [view]/
│ │ ├── page.tsx viste interne (slug localizzati)
│ │ ├── opengraph-image.tsx immagine Open Graph per vista
│ │ └── [study]/ caso di studio di un progetto (pagina + immagine Open Graph)
│ ├── api/cv/route.ts download del CV (unica route dinamica)
│ ├── globals.css token di colore, utility Tailwind, intro CSS
│ ├── sitemap.ts · robots.ts · manifest.ts
│ └── favicon.ico · icon.png · apple-icon.png
├── components/
│ ├── motion/ ViewFrame (contenitore della vista), Reveal (varianti, line mask, intro)
│ ├── providers/ ThemeProvider (next-themes)
│ ├── scene/ SceneLayer → DataField (lazy) → fieldRenderer (WebGL) · fieldShader
│ ├── case-study/ CaseStudyPage (componente server), SceneMode
│ ├── seo/ PortfolioPage (JSON-LD + app)
│ ├── shell/ AppRoot, AppShell, NavBar, ViewLink, LocaleSwitch, ThemeToggle,
│ │ useViewNavigation, useViewUrlSync
│ ├── ui/ text (Meta, Emphasis: primitive senza codice client)
│ └── views/ HeroView, IdentityView, TimelineView, ProjectsView, CvRequest, atoms
├── data/
│ ├── shared.json dati indipendenti dalla lingua
│ ├── cv.json testi del PDF e dell'email del CV (solo server)
│ ├── cv-profile.json competenze e lingue del CV (solo server)
│ ├── case-studies/ un JSON per caso di studio + cartella con le schermate (solo server)
│ └── locales/ en.json · it.json · fr.json · de.json: copy, stringhe UI, metadata SEO
├── i18n/ routing.ts (lingue) · request.ts (messages di next-intl)
├── lib/ routes, views, portfolio, timeline, scroll, seo, site, verification, errorCopy, structuredData, theme, og, caseStudy, caseStudyImages, sceneField, projects, versions, format, hooks, cn
│ ├── content/ schema (zod) · validate (verifiche incrociate)
│ └── cv/ service, token, config, document, pdf, mailer, copy, profile, common (CV su richiesta)
├── store/ viewStore (per istanza) · useSceneStore (scena, transiente)
├── docs/audit-2026-10/ audit di design e revisione del copy (2026-10-24), con gli screenshot: origine delle issue #95–#102 e seguenti
├── e2e/ suite Playwright · helpers.ts (lingue, viste, attese)
├── .github/workflows/ci.yml lint, typecheck, build e test su ogni PR
├── .github/workflows/versions.yml versioni dei progetti allineate alle loro release (PR automatica)
├── .github/workflows/smoke.yml smoke test su ogni deploy di Vercel (anteprime e produzione)
├── scripts/ sync-versions.mjs (usato dal workflow delle versioni)
├── proxy.ts rilevamento lingua (middleware di next-intl)
├── next.config.ts plugin next-intl, redirect 301, origini ammesse in sviluppo
├── eslint.config.mjs · postcss.config.mjs · tsconfig.json · playwright.config.ts
└── package.json
- TypeScript in modalità
strict, con alias@/*sulla radice del progetto. - Prettier per TypeScript, JSON, CSS e YAML (i Markdown sono esclusi per non riallineare tabelle e alberi). I commit di sola formattazione vanno elencati in
.git-blame-ignore-revs; per usarlo in locale:git config blame.ignoreRevsFile .git-blame-ignore-revs. - ESLint con le configurazioni
core-web-vitalsetypescriptdi Next.js. npm run typecheckesegue primanext typegen, che genera i tipi globali delle route (PageProps,LayoutProps).- Test unitari con Vitest accanto ai moduli (
lib/**/*.test.ts): slug e URL, aritmetica della timeline (segmenti, vuoti, fasi, asse), formattazione, schema e verifiche dei contenuti, inclusi i file reali. - Test end-to-end in
e2e/con Playwright, eseguiti sulla build di produzione su Chromium, WebKit e un profilo mobile. Coprono URL e metadata di ogni lingua, navigazione e cronologia, cambio lingua, SEO, header di sicurezza, accessibilità (axe) e la tenuta del sito a un fallimento WebGL. - CI in
.github/workflows/ci.yml: formattazione, lint, typecheck, test unitari, build e test end-to-end su ogni pull request e a ogni push sudevelopment. I suoi tre job (questo, i test end-to-end e il budget di prestazioni) sono controlli obbligatori per il merge (vedi Flusso di lavoro). - Regressione visiva (
e2e/visual.spec.ts): otto screenshot di riferimento ine2e/__screenshots__/(le quattro viste nel tema chiaro, due nel tema scuro, due su telefono) confrontati a ogni esecuzione. Per essere ripetibili fermano l'orologio e nascondono la scena WebGL. Valgono per Chromium su Linux, cioè per la CI: in locale su macOS i test sono saltati, perché fallirebbero per il solo antialiasing. Dopo una modifica visiva voluta la CI fallisce, rigenera i riferimenti sullo stesso runner e li pubblica nell'artefattovisual-references:gh run download <id> -n visual-references -D e2e/__screenshots__, si guardano le immagini e si committano. - Budget di prestazioni (
lighthouserc.json): un terzo job della CI esegue Lighthouse tre volte, in emulazione mobile, su home, timeline e progetti della build di produzione e fallisce se la mediana esce dalle soglie: accessibilità e SEO a 100, buone pratiche ≥ 95, LCP ≤ 4 s, CLS ≤ 0,02, peso della pagina ≤ 450 KB (era 700 prima di togliere three.js). Punteggio di prestazioni (≥ 80) e TBT (≤ 300 ms) sono solo segnalati: dipendono dalla potenza del runner, che cambia da un'esecuzione all'altra (TBT da 360 a 750 ms sullo stesso codice), e come errori farebbero fallire la CI a caso. Le soglie fotografano lo stato attuale sui runner della CI, più lenti di un portatile e senza GPU (lì la scena WebGL pesa di più), con un piccolo margine: servono a bloccare i regressi e vanno strette a ogni miglioramento. In locale:npm run build && npx @lhci/cli@0.15.1 autorun. - Dependabot (
.github/dependabot.yml) apre ogni settimana una PR raggruppata versodevelopmentper le dipendenze npm e una per le GitHub Actions. Le major dieslintetypescriptsono ignorate finchéeslint-config-nextnon le supporta. - Rilasci: una milestone per versione; ogni PR ha un'etichetta
semver:major|minor|patch, e.github/release.ymlraggruppa per etichetta le note generate della release.versioninpackage.jsonsi alza solo nella PR di rilascio. - Le GitHub Actions sono fissate allo SHA del commit, con la versione in un commento (
uses: actions/checkout@<sha> # v7.0.1): un tag può essere spostato, uno SHA no. Dependabot legge il commento e continua a proporre gli aggiornamenti;lib/ci.test.tsfallisce se un workflow torna a puntare a un tag o a un branch. .mailmapunifica sotto un'unica identità i commit iniziali firmati con l'email generata dal nome host, senza riscrivere la cronologia.- Source map pubblicate anche in produzione (
productionBrowserSourceMaps): il repository è pubblico, quindi non espongono nulla di nuovo e rendono leggibili stack trace e audit di prestazioni. Il browser le scarica solo con gli strumenti di sviluppo aperti. reactStrictModeattivo e indicatore di sviluppo di Next disattivato, perché si sovrapporrebbe alla navigazione.
development è il branch di produzione ed è protetto: ci si arriva solo con una pull request.
- Un branch per ogni lavoro, creato da
development:features/<nome-parlante>. - Prima del push, in locale:
npm run format:check && npm run lint && npm run typecheck && npm run test:unit && npm test. - Push del branch e pull request verso
development. La PR fa partire la CI (gli stessi comandi) e un deploy di anteprima su Vercel, non indicizzabile. - Il merge è possibile solo con i tre controlli della CI verdi, ed è il deploy in produzione. Si usa "Create a merge commit", così la cronologia conserva i commit del branch.
- Dopo il merge il branch viene cancellato.
Le issue e le pull request sono in inglese; i messaggi di commit in italiano.
- Su vercel.com → Add New → Project → importa
CieriS/portfolio. Il framework Next.js viene rilevato automaticamente (buildnpm run build, installnpm install). - Settings → Git → Production Branch:
development. - Variabili opzionali in Settings → Environment Variables:
GOOGLE_SITE_VERIFICATIONeSITE_URL(solo con un dominio personalizzato). Per il CV:CV_ADMIN_PASSWORD,CV_LINK_SECRET,RESEND_API_KEY,CV_MAIL_FROM,CV_NOTIFY_EMAIL,CV_EMAIL,CV_PHONE(vedi Variabili d'ambiente). - Il sito è su
https://<progetto>.vercel.app. Ogni merge sudevelopmentviene distribuito automaticamente; gli altri branch e le PR generano anteprime non indicizzabili.
Dopo ogni deploy riuscito Vercel lo segnala a GitHub, e .github/workflows/smoke.yml lancia npm run test:smoke sull'URL distribuito, con il codice di quel commit: tutte le pagine (200, idratazione senza errori, canonical, robots dell'ambiente), redirect della radice, 404, intestazioni, robots.txt e sitemap, endpoint del CV (in produzione un token falso deve dare 403: 503 vuol dire variabili mancanti su Vercel) e, solo in produzione, il 308 degli altri alias. Le attese per ambiente stanno in lib/smoke.ts. Non è un controllo obbligatorio: la produzione si verifica dopo il merge.
In alternativa, da terminale: npx vercel login, poi npx vercel (anteprima) e npx vercel --prod (produzione).
npm run build
npm startIn precedenza il portfolio era un sito PHP e MySQL pubblicato su Altervista. Il suo codice non è più in questo repository (resta nella cronologia git); di quel sito oggi restano:
- i redirect 301 dei suoi URL principali verso le viste nuove (vedi SEO);
- l'icona, da cui derivano favicon e icone di questo sito.
| Problema | Soluzione |
|---|---|
| Da un altro dispositivo la pagina compare ma non risponde a clic e tastiera | L'host non è in allowedDevOrigins (next.config.ts): aggiungilo e riavvia npm run dev. |
Canonical e sitemap puntano a localhost in produzione |
Imposta SITE_URL oppure pubblica su Vercel, che fornisce VERCEL_PROJECT_PRODUCTION_URL. |
Errori di tipo su PageProps o LayoutProps |
Esegui npm run typecheck: next typegen rigenera i tipi delle route. |
| La build fallisce dopo una modifica ai contenuti | Il messaggio elenca ogni problema con il suo percorso (per esempio it.projects.items.app.layers has no copy for "api"). npm run test:unit lo mostra in meno di un secondo. |
| Un deploy di anteprima non compare su Google | È voluto: solo VERCEL_ENV=production è indicizzabile. |
- GitHub: @CieriS
- LinkedIn: in/samuelecieri
.githooks/pre-push parte prima di ogni push: cerca segreti nei commit in uscita (gitleaks, o la sua immagine Docker se manca il binario) e poi lancia i controlli veloci (format:check, lint, typecheck, test:unit). Si attiva con npm install (script prepare); per saltarlo una volta: git push --no-verify. Sulla CI, .github/workflows/secret-scan.yml controlla con gitleaks tutta la cronologia di ogni branch e tag. Il segreto finto del test del CV è dichiarato in .gitleaksignore.
Codice e contenuti sono di proprietà dell'autore, tutti i diritti riservati: vedi LICENSE.