diff --git a/docs/superpowers/plans/2026-07-05-contenuti-taggati-pannello-editor.md b/docs/superpowers/plans/2026-07-05-contenuti-taggati-pannello-editor.md new file mode 100644 index 0000000..96b3271 --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-contenuti-taggati-pannello-editor.md @@ -0,0 +1,1082 @@ +# Piano implementazione: contenuti taggati + pannello editor + +> **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:** Ogni testo e immagine delle pagine vetrina riceve un tag stabile, i contenuti vivono in SQLite modificabili da un pannello `/admin/content` con ruoli admin/editor, stili tramite preset chiusi e overlay annotate sul sito. + +**Architecture:** Tabella `content_blocks` nel DB SQLite esistente, seed versionato che importa i data file attuali, lib `content.ts` con cache in memoria invalidata al salvataggio, componenti Astro ``/``, pagine vetrina flip a SSR, API REST sotto `/api/admin/content`, pannello SSR con JS vanilla. + +**Tech Stack:** Astro 7 (adapter node standalone, `output: 'server'`), better-sqlite3, bcryptjs, sanitize-html, vitest. Zero librerie frontend nuove (vanilla JS, coerente col progetto). + +**Spec:** `docs/superpowers/specs/2026-07-05-contenuti-taggati-pannello-editor-design.md` + +## Global Constraints + +- Rispondere a tutti i vincoli della spec; UI e messaggi in italiano. +- Tag: formato `pagina.sezione.elemento`, minuscolo, separatore `.`, segmenti kebab-case. Regex di validazione: `/^[a-z0-9-]+(\.[a-z0-9-]+)+$/`. +- Nessun CSS arbitrario dal pannello: solo chiavi preset whitelisted. +- Stile codice: come esistente (2 spazi, singoli apici, commenti italiani solo per vincoli non ovvi). +- Test: `npm test` (vitest, file in `tests/*.test.ts`, DB `:memory:` via `createDb(':memory:')`). +- Commit frequenti, messaggi brevi in italiano stile repo (`feat:`, `fix:`, `docs:`, `test:`). +- Il sito renderizzato NON deve cambiare visivamente dopo l'estrazione (stessi testi, stesse classi, stesse immagini). + +## Deviazioni consapevoli dalla spec + +1. **Celle agenda escluse dal tagging**: le 28 celle di `agenda.ts` sono dati placeholder del template Prowess (trainer fittizi); il cliente consegnerà l'orario reale. Taggare 84 valori destinati a essere buttati è spreco. Si taggano titolo sezione, discipline, giorni e slot. Le celle si affronteranno quando arriva l'orario reale. +2. **Aria-label e attributi tecnici esclusi** (`aria-label="Apri menu"`, title iframe): non sono contenuto editoriale. +3. **Alt text**: restano prop nel codice (derivati da name/title taggati dove data-driven); non diventano tag separati. Eccezione: nessuna — gli alt hardcoded seguono il testo del tag corrispondente dove esiste. + +## Inventario tag + +L'inventario completo (tag → valore attuale → file sorgente) è nel report allegato in coda a questo piano (sezione "Appendice: inventario"). I task di estrazione vi fanno riferimento. + +--- + +### Task 1: Tabella content_blocks + colonna role + +**Files:** +- Modify: `src/lib/db.ts` (SCHEMA + migrazione additiva) +- Test: `tests/content-db.test.ts` + +**Interfaces:** +- Produces: tabella `content_blocks(tag PK, type, value, styles, updated_at, updated_by)`; colonna `users.role` DEFAULT `'admin'`. + +- [ ] **Step 1: test fallente** + +```ts +// tests/content-db.test.ts +import { describe, it, expect } from 'vitest'; +import { createDb } from '../src/lib/db'; + +describe('schema content_blocks e ruoli', () => { + it('crea la tabella content_blocks', () => { + const db = createDb(':memory:'); + const cols = db.prepare(`PRAGMA table_info(content_blocks)`).all() as { name: string }[]; + expect(cols.map((c) => c.name)).toEqual( + expect.arrayContaining(['tag', 'type', 'value', 'styles', 'updated_at', 'updated_by']) + ); + }); + + it('rifiuta type non valido', () => { + const db = createDb(':memory:'); + expect(() => + db.prepare(`INSERT INTO content_blocks (tag, type, value) VALUES ('x.y', 'video', '')`).run() + ).toThrow(); + }); + + it('users ha colonna role con default admin', () => { + const db = createDb(':memory:'); + db.prepare(`INSERT INTO users (username, password_hash) VALUES ('a', 'h')`).run(); + const row = db.prepare(`SELECT role FROM users WHERE username = 'a'`).get() as { role: string }; + expect(row.role).toBe('admin'); + }); + + it('migra un DB esistente senza colonna role', () => { + // simula DB vecchio: createDb due volte sullo stesso path in-memory non è possibile, + // quindi si verifica che la migrazione sia idempotente (doppia esecuzione non lancia) + const db = createDb(':memory:'); + expect(() => createDb(':memory:')).not.toThrow(); + expect(db.prepare(`PRAGMA table_info(users)`).all().length).toBeGreaterThanOrEqual(4); + }); +}); +``` + +- [ ] **Step 2: eseguire** `npx vitest run tests/content-db.test.ts` — atteso: FAIL (tabella/colonna assenti). + +- [ ] **Step 3: implementazione** — in `src/lib/db.ts` aggiungere in coda alla costante `SCHEMA`: + +```sql +CREATE TABLE IF NOT EXISTS content_blocks ( + tag TEXT PRIMARY KEY, + type TEXT NOT NULL CHECK (type IN ('text','html','image')), + value TEXT NOT NULL DEFAULT '', + styles TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_by TEXT +); +``` + +e in `createDb`, dopo `db.exec(SCHEMA)`, la migrazione additiva: + +```ts + // Migrazione additiva: DB creati prima dell'introduzione dei ruoli non hanno users.role. + const userCols = db.prepare(`PRAGMA table_info(users)`).all() as { name: string }[]; + if (!userCols.some((c) => c.name === 'role')) { + db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`); + } +``` + +- [ ] **Step 4: eseguire** `npx vitest run tests/content-db.test.ts` — atteso: PASS. Poi `npm test` completo — atteso: tutto PASS. + +- [ ] **Step 5: commit** `git add src/lib/db.ts tests/content-db.test.ts && git commit -m "feat: tabella content_blocks e colonna role utenti"` + +--- + +### Task 2: Ruoli in auth + policy percorsi admin + +**Files:** +- Modify: `src/lib/auth.ts` +- Modify: `src/env.d.ts` +- Modify: `scripts/create-user.mjs` +- Test: `tests/roles.test.ts` + +**Interfaces:** +- Consumes: colonna `users.role` (Task 1). +- Produces: `createUser(db, username, password, role?: 'admin'|'editor'): number`; `getSessionUser(db, token): { id: number; username: string; role: string } | null`; `canAccessAdminPath(role: string, pathname: string): boolean`. + +- [ ] **Step 1: test fallente** + +```ts +// tests/roles.test.ts +import { describe, it, expect, beforeEach } from 'vitest'; +import type Database from 'better-sqlite3'; +import { createDb } from '../src/lib/db'; +import { createUser, login, getSessionUser, canAccessAdminPath } from '../src/lib/auth'; + +let db: Database.Database; +beforeEach(() => { db = createDb(':memory:'); }); + +describe('ruoli', () => { + it('createUser accetta ruolo editor e getSessionUser lo ritorna', () => { + createUser(db, 'cliente', 'segreta123', 'editor'); + const token = login(db, 'cliente', 'segreta123')!; + expect(getSessionUser(db, token)).toMatchObject({ username: 'cliente', role: 'editor' }); + }); + + it('createUser senza ruolo crea admin', () => { + createUser(db, 'adriano', 'segreta123'); + const token = login(db, 'adriano', 'segreta123')!; + expect(getSessionUser(db, token)).toMatchObject({ role: 'admin' }); + }); + + it('admin accede a tutto, editor solo a contenuti e logout', () => { + expect(canAccessAdminPath('admin', '/admin')).toBe(true); + expect(canAccessAdminPath('admin', '/api/admin/posts')).toBe(true); + expect(canAccessAdminPath('editor', '/admin/content')).toBe(true); + expect(canAccessAdminPath('editor', '/api/admin/content/home.hero.title')).toBe(true); + expect(canAccessAdminPath('editor', '/admin/logout')).toBe(true); + expect(canAccessAdminPath('editor', '/admin')).toBe(false); + expect(canAccessAdminPath('editor', '/admin/new')).toBe(false); + expect(canAccessAdminPath('editor', '/api/admin/posts')).toBe(false); + expect(canAccessAdminPath('editor', '/api/admin/upload')).toBe(false); + }); +}); +``` + +- [ ] **Step 2: eseguire** `npx vitest run tests/roles.test.ts` — atteso: FAIL. + +- [ ] **Step 3: implementazione** in `src/lib/auth.ts`: + +```ts +export type Role = 'admin' | 'editor'; + +export function createUser(db: Database.Database, username: string, password: string, role: Role = 'admin'): number { + const res = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)') + .run(username, hashPassword(password), role); + return Number(res.lastInsertRowid); +} + +// Percorsi admin consentiti anche al ruolo editor. +const EDITOR_ALLOWED = [/^\/admin\/content(\/|$)?/, /^\/api\/admin\/content(\/|$)/, /^\/admin\/logout$/]; + +export function canAccessAdminPath(role: string, pathname: string): boolean { + if (role === 'admin') return true; + return EDITOR_ALLOWED.some((re) => re.test(pathname)); +} +``` + +`getSessionUser`: aggiungere `u.role` alla SELECT e al tipo di ritorno `{ id, username, role }`. + +`src/env.d.ts`: `user?: { id: number; username: string; role: string }`. + +`scripts/create-user.mjs`: terzo argomento opzionale `role` (default `admin`, valori ammessi `admin|editor`, errore altrimenti); la CREATE TABLE inline nello script aggiunge `role TEXT NOT NULL DEFAULT 'admin'` e l'INSERT include role con `ON CONFLICT ... SET role = excluded.role`. Uso: `npm run create-user -- [role]`. + +- [ ] **Step 4: eseguire** `npm test` — atteso: PASS (incluso `auth.test.ts` esistente, invariato). + +- [ ] **Step 5: commit** `git add -u tests/roles.test.ts && git commit -m "feat: ruoli admin/editor in auth e create-user"` + +--- + +### Task 3: Middleware con enforcement ruoli + +**Files:** +- Modify: `src/middleware.ts` + +**Interfaces:** +- Consumes: `canAccessAdminPath`, `getSessionUser` con role (Task 2). + +- [ ] **Step 1: implementazione** — in `src/middleware.ts`, dopo aver ottenuto `user` valido, aggiungere: + +```ts + if (!canAccessAdminPath(user.role, pathname)) { + if (pathname.startsWith('/api/')) { + return new Response(JSON.stringify({ error: 'Permessi insufficienti' }), { + status: 403, headers: { 'Content-Type': 'application/json' }, + }); + } + return context.redirect('/admin/content'); + } +``` + +(import di `canAccessAdminPath` da `./lib/auth`). La logica di policy è già coperta dai test del Task 2; il middleware resta un guscio sottile. + +- [ ] **Step 2: verifica manuale** — `npm run dev` in background, poi: + +```bash +curl -s -o /dev/null -w '%{http_code}' http://localhost:4321/api/admin/posts # atteso 401 (anonimo) +``` + +Login admin da browser o curl con cookie: comportamento invariato. (La verifica editor completa avviene al Task 13 quando esiste `/admin/content`.) + +- [ ] **Step 3: commit** `git add src/middleware.ts && git commit -m "feat: middleware limita editor ai percorsi contenuti"` + +--- + +### Task 4: Preset stili + +**Files:** +- Create: `src/lib/style-presets.ts` +- Modify: `src/styles/global.css` (classi `.cs-*` in coda) +- Test: `tests/style-presets.test.ts` + +**Interfaces:** +- Produces: `STYLE_GROUPS: Record`; `type StyleGroup = 'size'|'color'|'weight'|'style'|'align'`; `validateStyles(input: unknown): { ok: true; styles: Record } | { ok: false; error: string }`; `stylesToClasses(styles: Record): string`. + +- [ ] **Step 1: test fallente** + +```ts +// tests/style-presets.test.ts +import { describe, it, expect } from 'vitest'; +import { validateStyles, stylesToClasses, STYLE_GROUPS } from '../src/lib/style-presets'; + +describe('style presets', () => { + it('accetta chiavi e valori in whitelist', () => { + const r = validateStyles({ size: 'l', color: 'accent', align: 'center' }); + expect(r).toEqual({ ok: true, styles: { size: 'l', color: 'accent', align: 'center' } }); + }); + + it('rifiuta gruppo sconosciuto e valore fuori lista', () => { + expect(validateStyles({ font: 'comic' }).ok).toBe(false); + expect(validateStyles({ size: 'xxl' }).ok).toBe(false); + expect(validateStyles('non-oggetto').ok).toBe(false); + }); + + it('mappa stili in classi cs-*', () => { + expect(stylesToClasses({ size: 's', weight: 'bold' })).toBe('cs-size-s cs-weight-bold'); + expect(stylesToClasses({})).toBe(''); + }); + + it('espone i gruppi attesi', () => { + expect(Object.keys(STYLE_GROUPS)).toEqual(['size', 'color', 'weight', 'style', 'align']); + }); +}); +``` + +- [ ] **Step 2: eseguire** `npx vitest run tests/style-presets.test.ts` — atteso: FAIL. + +- [ ] **Step 3: implementazione** + +```ts +// src/lib/style-presets.ts +export const STYLE_GROUPS = { + size: ['s', 'm', 'l'], + color: ['accent', 'accent-dark', 'dark', 'heading', 'text', 'text-light', 'white'], + weight: ['normal', 'bold'], + style: ['normal', 'italic'], + align: ['left', 'center', 'right'], +} as const; + +export type StyleGroup = keyof typeof STYLE_GROUPS; + +export function validateStyles(input: unknown): + | { ok: true; styles: Record } + | { ok: false; error: string } { + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + return { ok: false, error: 'Stili non validi.' }; + } + const out: Record = {}; + for (const [k, v] of Object.entries(input)) { + const allowed = (STYLE_GROUPS as Record)[k]; + if (!allowed) return { ok: false, error: `Gruppo stile sconosciuto: ${k}` }; + if (typeof v !== 'string' || !allowed.includes(v)) { + return { ok: false, error: `Valore non ammesso per ${k}: ${String(v)}` }; + } + out[k] = v; + } + return { ok: true, styles: out }; +} + +export function stylesToClasses(styles: Record): string { + return Object.entries(styles).map(([k, v]) => `cs-${k}-${v}`).join(' '); +} +``` + +In `src/styles/global.css`, in coda: + +```css +/* === Stili contenuto (preset pannello /admin/content) === */ +.cs-size-s { font-size: .85em; } +.cs-size-m { font-size: 1em; } +.cs-size-l { font-size: 1.25em; } +.cs-color-accent { color: var(--c-accent); } +.cs-color-accent-dark { color: var(--c-accent-dark); } +.cs-color-dark { color: var(--c-dark); } +.cs-color-heading { color: var(--c-heading); } +.cs-color-text { color: var(--c-text); } +.cs-color-text-light { color: var(--c-text-light); } +.cs-color-white { color: #fff; } +.cs-weight-normal { font-weight: 400; } +.cs-weight-bold { font-weight: 700; } +.cs-style-normal { font-style: normal; } +.cs-style-italic { font-style: italic; } +.cs-align-left { text-align: left; } +.cs-align-center { text-align: center; } +.cs-align-right { text-align: right; } +``` + +- [ ] **Step 4: eseguire** `npm test` — atteso: PASS. + +- [ ] **Step 5: commit** `git add src/lib/style-presets.ts src/styles/global.css tests/style-presets.test.ts && git commit -m "feat: preset stili contenuto con whitelist"` + +--- + +### Task 5: Seed contenuti + content lib con cache + +**Files:** +- Create: `src/data/content-seed.ts` +- Create: `src/lib/content.ts` +- Modify: `src/lib/db.ts` (sync seed in `createDb`) +- Modify: `src/lib/sanitize.ts` (aggiunta `sanitizeInline`) +- Test: `tests/content.test.ts` + +**Interfaces:** +- Consumes: `content_blocks` (Task 1), `stylesToClasses` (Task 4). +- Produces: + - Seed: `type ContentType = 'text'|'html'|'image'`; `interface SeedEntry { tag: string; type: ContentType; value: string; styleOptions?: StyleGroup[] }`; `const contentSeed: SeedEntry[]`; `const seedByTag: Map`. + - Lib: `resolveContent(tag: string): { value: string; type: ContentType; classes: string }`; `t(tag: string): string` (solo il valore); `getImageOverride(tag: string): string | null` (URL `/uploads/...` o null); `contentImageUrl(tag: string, fallback: string): string`; `invalidateContentCache(): void`; `syncSeed(db: Database): void`. + - Sanitize: `sanitizeInline(html: string): string` (solo `strong, em, u, s, br, a`). + +- [ ] **Step 1: test fallente** + +```ts +// tests/content.test.ts +import { describe, it, expect, beforeEach } from 'vitest'; +import type Database from 'better-sqlite3'; +import { createDb } from '../src/lib/db'; +import { contentSeed } from '../src/data/content-seed'; +import { makeContentStore, syncSeed } from '../src/lib/content'; + +let db: Database.Database; +beforeEach(() => { db = createDb(':memory:'); }); + +describe('seed', () => { + it('tag univoci, validi e valori non vuoti', () => { + const tags = contentSeed.map((e) => e.tag); + expect(new Set(tags).size).toBe(tags.length); + for (const e of contentSeed) { + expect(e.tag).toMatch(/^[a-z0-9-]+(\.[a-z0-9-]+)+$/); + if (e.type !== 'image') expect(e.value.length).toBeGreaterThan(0); + } + }); + + it('createDb sincronizza il seed senza sovrascrivere modifiche', () => { + const n = (db.prepare('SELECT COUNT(*) AS n FROM content_blocks').get() as { n: number }).n; + expect(n).toBe(contentSeed.length); + db.prepare(`UPDATE content_blocks SET value = 'MODIFICATO' WHERE tag = ?`).run(contentSeed[0].tag); + // ri-sync (come a un nuovo avvio) non deve toccare la modifica + syncSeed(db); + const v = db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(contentSeed[0].tag) as { value: string }; + expect(v.value).toBe('MODIFICATO'); + }); +}); + +describe('content store', () => { + it('legge dal DB con classi preset e cache invalidabile', () => { + const store = makeContentStore(() => db); + const tag = contentSeed[0].tag; + db.prepare(`UPDATE content_blocks SET value = 'Nuovo', styles = '{"size":"l"}' WHERE tag = ?`).run(tag); + store.invalidate(); + const r = store.resolve(tag); + expect(r.value).toBe('Nuovo'); + expect(r.classes).toBe('cs-size-l'); + // senza invalidate la cache resta + db.prepare(`UPDATE content_blocks SET value = 'Altro' WHERE tag = ?`).run(tag); + expect(store.resolve(tag).value).toBe('Nuovo'); + store.invalidate(); + expect(store.resolve(tag).value).toBe('Altro'); + }); + + it('fallback: tag assente nel DB usa il seed, sconosciuto stringa vuota', () => { + const store = makeContentStore(() => db); + db.prepare('DELETE FROM content_blocks WHERE tag = ?').run(contentSeed[0].tag); + store.invalidate(); + expect(store.resolve(contentSeed[0].tag).value).toBe(contentSeed[0].value); + expect(store.resolve('tag.inesistente').value).toBe(''); + }); + + it('styles corrotto nel DB viene ignorato', () => { + const store = makeContentStore(() => db); + db.prepare(`UPDATE content_blocks SET styles = 'non-json' WHERE tag = ?`).run(contentSeed[0].tag); + store.invalidate(); + expect(store.resolve(contentSeed[0].tag).classes).toBe(''); + }); +}); +``` + +- [ ] **Step 2: eseguire** `npx vitest run tests/content.test.ts` — atteso: FAIL. + +- [ ] **Step 3: implementazione seed (nucleo iniziale)** — `src/data/content-seed.ts` con tipi, helper e le sole entry GLOBALI per partire (i task di estrazione lo ampliano): + +```ts +import type { StyleGroup } from '../lib/style-presets'; +import { site } from './site'; + +export type ContentType = 'text' | 'html' | 'image'; +export interface SeedEntry { tag: string; type: ContentType; value: string; styleOptions?: StyleGroup[] } + +const T = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color', 'weight', 'style']): SeedEntry => + ({ tag, type: 'text', value, styleOptions }); +const H = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color']): SeedEntry => + ({ tag, type: 'html', value, styleOptions }); +const I = (tag: string): SeedEntry => ({ tag, type: 'image', value: '' }); + +export const contentSeed: SeedEntry[] = [ + // --- Globali (site.ts) --- + T('global.contact.phone', site.phone, []), + T('global.contact.email', site.email, []), + T('global.contact.address', site.address, []), + T('global.contact.city-line', site.cityLine, []), + T('global.hours.1', site.hours[0], []), + T('global.hours.2', site.hours[1], []), + // I task di estrazione aggiungono qui le entry di header/footer/pagine/data file. +]; + +export const seedByTag = new Map(contentSeed.map((e) => [e.tag, e])); +``` + +- [ ] **Step 4: implementazione lib** — `src/lib/content.ts`: + +```ts +import type Database from 'better-sqlite3'; +import { getDb } from './db'; +import { contentSeed, seedByTag, type ContentType } from '../data/content-seed'; +import { stylesToClasses } from './style-presets'; + +interface Entry { value: string; type: ContentType; styles: Record } + +export function syncSeed(db: Database.Database): void { + const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)'); + const tx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value); }); + tx(); +} + +export function makeContentStore(dbGetter: () => Database.Database) { + let cache: Map | null = null; + + const load = (): Map => { + if (cache) return cache; + const rows = dbGetter().prepare('SELECT tag, type, value, styles FROM content_blocks').all() as + { tag: string; type: ContentType; value: string; styles: string }[]; + cache = new Map(rows.map((r) => { + let styles: Record = {}; + try { styles = JSON.parse(r.styles); } catch { /* styles corrotto: si ignora */ } + if (typeof styles !== 'object' || styles === null) styles = {}; + return [r.tag, { value: r.value, type: r.type, styles }]; + })); + return cache; + }; + + return { + resolve(tag: string): { value: string; type: ContentType; classes: string } { + const hit = load().get(tag); + if (hit) return { value: hit.value, type: hit.type, classes: stylesToClasses(hit.styles) }; + const seed = seedByTag.get(tag); + if (seed) return { value: seed.value, type: seed.type, classes: '' }; + console.warn(`[content] tag sconosciuto: ${tag}`); + return { value: '', type: 'text', classes: '' }; + }, + invalidate(): void { cache = null; }, + }; +} + +const store = makeContentStore(getDb); + +export const resolveContent = (tag: string) => store.resolve(tag); +export const t = (tag: string): string => store.resolve(tag).value; +export const invalidateContentCache = (): void => store.invalidate(); + +export function getImageOverride(tag: string): string | null { + const r = store.resolve(tag); + return r.type === 'image' && r.value ? `/uploads/${r.value}` : null; +} + +export const contentImageUrl = (tag: string, fallback: string): string => + getImageOverride(tag) ?? fallback; +``` + +In `src/lib/db.ts`, `createDb` chiama il sync dopo schema e migrazioni. Per evitare import circolare (content.ts importa db.ts), il sync si fa con import dinamico differito NON praticabile in sync — soluzione: `syncSeed` vive in content.ts ma non importa db.ts a livello di modulo per la funzione; siccome `content.ts` importa `getDb` solo per lo store singleton, spostare `syncSeed` in un modulo dedicato è più pulito. **Scelta: crearlo in content.ts ma importare in db.ts SOLO il seed**, e replicare l'insert lì: + +```ts +// in src/lib/db.ts (import in testa: nessun ciclo — content-seed non importa db) +import { contentSeed } from '../data/content-seed'; +// ...in createDb, dopo migrazione role: + const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)'); + const syncTx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value); }); + syncTx(); +``` + +e in content.ts `syncSeed` resta esportata per i test (stessa logica). In `src/lib/sanitize.ts` aggiungere: + +```ts +export function sanitizeInline(html: string): string { + return sanitize(html, { + allowedTags: ['strong', 'em', 'u', 's', 'br', 'a'], + allowedAttributes: { a: ['href', 'rel', 'target'] }, + allowedSchemes: ['https', 'http', 'mailto'], + allowProtocolRelative: false, + }); +} +``` + +- [ ] **Step 5: eseguire** `npm test` — atteso: PASS. + +- [ ] **Step 6: commit** `git add src/data/content-seed.ts src/lib/content.ts src/lib/db.ts src/lib/sanitize.ts tests/content.test.ts && git commit -m "feat: content store con seed, cache e fallback"` + +--- + +### Task 6: Componenti `` e `` + +**Files:** +- Create: `src/components/content/T.astro` +- Create: `src/components/content/TImg.astro` + +**Interfaces:** +- Consumes: `resolveContent`, `getImageOverride` (Task 5). +- Produces: `` (default `as="span"`; emette `data-tag`, classi preset accodate); `` (`src: ImageMetadata`; override → ``). + +- [ ] **Step 1: implementazione T.astro** + +```astro +--- +import { resolveContent } from '../../lib/content'; +interface Props { tag: string; as?: string; class?: string } +const { tag, as = 'span', class: cls } = Astro.props; +const Tag = as; +const c = resolveContent(tag); +const classes = [cls, c.classes].filter(Boolean).join(' ') || undefined; +--- +{c.type === 'html' + ? + : {c.value}} +``` + +- [ ] **Step 2: implementazione TImg.astro** + +```astro +--- +import { Image } from 'astro:assets'; +import type { ImageMetadata } from 'astro'; +import { getImageOverride } from '../../lib/content'; +interface Props { + tag: string; src: ImageMetadata; alt: string; + class?: string; loading?: 'lazy' | 'eager'; widths?: number[]; sizes?: string; +} +const { tag, src, alt, class: cls, ...rest } = Astro.props; +const override = getImageOverride(tag); +--- +{override + ? {alt} + : {alt}} +``` + +- [ ] **Step 3: verifica sintassi** — `npx astro check` (accettare solo errori preesistenti, nessun nuovo errore nei due componenti). + +- [ ] **Step 4: commit** `git add src/components/content && git commit -m "feat: componenti T e TImg per contenuti taggati"` + +--- + +### Task 7: Estrazione — Header, Footer, ContactForm, globali + +**Files:** +- Modify: `src/data/content-seed.ts` (entry global/header/footer/form) +- Modify: `src/components/Header.astro`, `src/components/Footer.astro`, `src/components/ContactForm.astro` + +**Interfaces:** +- Consumes: ``, `t()` (Task 5-6). +- Produces: tag `global.nav..label` (1-5), `header.logo.alt`, `footer.tagline`, `footer.about`, `footer.col.training-title`, `footer.col.programs-title`, `footer.col.contact-title`, `footer.copyright`, `footer.social-label`, `footer.training..label` (1-4), `footer.programs..label` (1-6), `form.field.first-name`, `form.field.last-name`, `form.field.phone`, `form.field.email`, `form.field.message`, `form.submit`, `form.msg.success`, `form.msg.error-generic`, `form.msg.error-network`. + +Pattern di estrazione (vale per tutti i task 7-12): + +1. Aggiungere le entry al seed copiando ESATTAMENTE il testo dal componente (helper `T()`/`H()`/`I()`). +2. Nel template sostituire il testo con `` quando il testo occupa un intero elemento; con `{t('...')}` quando è un frammento (placeholder, attributo, interno di elemento con altro markup). +3. `type: 'html'` SOLO dove il valore contiene markup (`
`, ` `, ``, link) — renderizzato con `set:html` via ``. +4. Testi nei `ok' }, 'a'); + const row = db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(htmlTag) as any; + expect(row.value).not.toContain(' +)} +``` + +- [ ] **Step 2: verifica** — browser: `/?annotate=1` da loggato → badge visibili, click badge apre il form giusto; stessa URL in finestra anonima → nessun badge, nessun outline; pagina normale senza parametro → invariata (curl diff: `curl -s localhost:4321/ | grep -c tag-badge` → 0). +- [ ] **Step 3: commit** `git add -u && git commit -m "feat: overlay annotate per utenti loggati"` + +--- + +### Task 17: Utente editor, typecheck, smoke completo + +**Files:** +- Modify: `scripts/smoke.sh` (aggiunte route/asserzioni contenuti) + +- [ ] **Step 1**: creare utente editor locale: `npm run create-user -- editor prova1234 editor`. Verifica browser: login editor → atterra/limitato a `/admin/content`; `/admin` → redirect a `/admin/content`; PUT contenuto ok; `curl -b -X POST /api/admin/posts` → 403. +- [ ] **Step 2**: estendere `scripts/smoke.sh`: check `GET /sitemap.xml` 200 + ``, `GET /` contiene `data-tag=`, `GET /?annotate=1` anonimo NON contiene `tag-badge`, tutte le route vetrina 200. +- [ ] **Step 3**: `npx astro check && npm test && npm run build && ./scripts/smoke.sh` (contro build di produzione via `npm run start`). Tutto verde. +- [ ] **Step 4: commit** `git add -u && git commit -m "test: smoke contenuti taggati e utente editor"` + +--- + +### Task 18: Deploy produzione e verifica + +- [ ] **Step 1**: push al remote Gitea (`git push`). +- [ ] **Step 2**: sul VPS: `cd /opt/docker/insanitylab/src && git pull && docker compose up -d --build` (pattern consolidato del progetto). +- [ ] **Step 3**: creare utente editor in produzione dentro il container (`docker compose exec npm run create-user -- editor`); consegnare credenziali all'operatore (Vaultwarden). +- [ ] **Step 4: verifica produzione**: route 200 (home, about, programs, training, contact, sitemap.xml); login admin → `/admin/content` funziona; modifica di prova su un tag → visibile sul sito → ripristino; `/?annotate=1` da loggato mostra badge, da anonimo no; blog invariato. +- [ ] **Step 5**: aggiornare memoria progetto (`insanitylab-stato-progetto.md`) con stato nuovo sistema. + +--- + +## Appendice: inventario + +Riferimento completo tag → valore → file: `docs/superpowers/plans/2026-07-05-inventario-contenuti.md`. I valori esatti si copiano SEMPRE dal file sorgente al momento dell'estrazione, non da quel documento. Conteggi: ~150 tag testo hardcoded nei template, ~200 stringhe da data file, ~60 slot immagine. Esclusioni motivate in "Deviazioni consapevoli". diff --git a/docs/superpowers/plans/2026-07-05-inventario-contenuti.md b/docs/superpowers/plans/2026-07-05-inventario-contenuti.md new file mode 100644 index 0000000..8a53dd6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-inventario-contenuti.md @@ -0,0 +1,101 @@ +# Inventario contenuti — pagine vetrina (2026-07-05) + +Documento di riferimento per il piano `2026-07-05-contenuti-taggati-pannello-editor.md`. Elenca, file per file, i testi visibili e le immagini da estrarre nel sistema di tag. I valori esatti si copiano SEMPRE dal file sorgente al momento dell'estrazione; qui compaiono abbreviati per identificazione. + +**Nota nomenclatura**: i nomi tag canonici sono quelli nella sezione "Interfaces → Produces" dei task del piano (segmenti kebab-case, es. `home.hero.title-line-1`); dove questo inventario usa camelCase va convertito. + +## Pagine + +### `src/pages/index.astro` (home) +Solo orchestrazione componenti. Hardcoded: `home.meta.title` ("Performance. Balance. Longevity.", prop title di Base), `home.training.title` ("Training"), `home.training.subtitle` ("Porta i tuoi allenamenti al livello successivo: trova il percorso…"). Nessuna immagine diretta. + +### `src/pages/about.astro` +Meta (2): `about.meta.title` ("Insanitylab"), `about.meta.description` ("Mission, storia, team e metodo…"). Hero: `about.hero.title` ("Insanitylab", via PageHero). +Sezioni testo: mission (title + body), direzione (title + body + cta "Scopri"), approccio (title + body + cta "Contattaci"), about (title + body), convenzioni (title + body + item1-3 in `
    ` + riga finale con `{site.email}` · `{site.phone}` interpolati → spezzare in `about.convenzioni.cta-text` + tag global), team (title + body + staff-intro), metodo (title + body + closing), studios (title + body1-3), vision (title + body1-2). +Immagini (glob, alt hardcoded): `about.mission.image` (about-training-group.jpg), `about.about.image` (about-pushup.jpg), `about.convenzioni.image` (about-conventions.jpg), `about.metodo.image` (about-team-group.jpg), `about.studios.image` (about-studios.jpg), `about.vision.image` (about-vision.jpg). Griglia team: 9 foto da `imageAbout` (tag `team..image-about`). + +### `src/pages/contact.astro` +`contact.meta.title` ("Contatti"), `contact.meta.description`, `contact.hero.title` ("Contattaci"), `contact.hero.image` (contact-hero.jpg, background PageHero), `contact.map.button` ("Carica la mappa (Google Maps)"), `contact.map.note` ("La mappa viene caricata da Google solo dopo il tuo consenso."), label info (address-label "Indirizzo", phone-label "Telefono", email-label "E-mail" — i valori sono i tag `global.contact.*`), `contact.invite` ("Per informazioni o per richiedere una consulenza iniziale…"). Iframe Maps generato via JS (title tecnico, escluso). + +### `src/pages/training.astro` +`training.meta.title`, `training.meta.description`, `training.hero.title` ("training"), `training.intro.title` ("Training"), `training.intro.subtitle` ("Porta i tuoi allenamenti al livello successivo: programmi completi…" — variante diversa dalla home), `training.trial.title` ("Prenota la lezione di prova"), `training.trial.body`, `training.trial.cta` ("Prenota"), `training.trial.image` (trial-class.jpg). Blocchi per-training: testi dai tag `training..*` (vedi trainings.ts); nota `

    {t.title.toLowerCase()}

    ` (lowercase a runtime, conservare). + +### `src/pages/404.astro` +`notfound.meta.title`, `notfound.eyebrow` ("Errore 404"), `notfound.title` ("Pagina non trovata"), `notfound.body`, `notfound.cta` ("Torna alla home"). + +### `src/pages/programs/index.astro` +`programs.meta.title`, `programs.meta.description`, `programs.hero.title` ("Programmi"), `programs.intro.title`, `programs.intro.body` ("Questi sono i programmi delle classi di Insanity Lab…"), `programs.card.more-label` ("Per saperne di più →", ripetuto). Card e immagini (`program-.jpg`, alt = title) da programs.ts. + +### `src/pages/programs/[slug].astro` +Hardcoded: `program.side.categories-label` ("Categorie"), `program.side.category.1-4` ("Fitness", "Salute", "Stile di vita", "Nutrizione"), `program.side.others-label` ("Altri programmi"), `program.pricing.empty-note` (html con link "Contattaci per i dettagli… Scrivici →"), `program.pricing.per` ("al mese, semestrale"), `program.pricing.entries-singular/-plural` ("ingresso settimanale"/"ingressi settimanali"), `program.pricing.buy-cta` ("Acquista"). +Dinamici: meta = title/excerpt del programma; hero = `title.toUpperCase()` (conservare la trasformazione); corpo/features/faq/pricing da data. Immagine `program-.jpg` come hero bg e inline. + +## Componenti condivisi + +### `Header.astro` +Logo `/img/logo-dark.png` (public) con alt "InsanityLab" (`header.logo.alt`). Nav: label da `site.navigation` → tag `global.nav..label` (5 voci: "Insanitylab", "Training", "Programmi", "Blog & Edugo", "Contatti"). Aria-label esclusi. + +### `Footer.astro` +Logo light + alt; `footer.tagline` (html, "PERFORMANCE.  BALANCE.  LONGEVITY."); `footer.about` ("Promuoviamo il movimento come cura di sé…"); riga contatti con `
    ` (comporre dai tag `global.contact.*`); orari (`global.hours.1/2`); titoli colonne training/programs/contact; `footer.copyright` ("Insanitylab, All Rights Reserved" — `` e anno dinamico restano fuori dal tag); `footer.social-label` ("Seguici"); label link da `site.footerTraining` (4) e `site.footerPrograms` (6) → `footer.training..label`, `footer.programs..label`. + +### `ContactForm.astro` +Placeholder: first-name "Nome", last-name "Cognome" (solo full), phone "Telefono" (solo full), email "E-mail", message "Messaggio". Submit "Invia messaggio". Messaggi nello `