diff --git a/src/data/content-seed.ts b/src/data/content-seed.ts new file mode 100644 index 0000000..428ef76 --- /dev/null +++ b/src/data/content-seed.ts @@ -0,0 +1,24 @@ +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])); diff --git a/src/lib/content.ts b/src/lib/content.ts new file mode 100644 index 0000000..b873685 --- /dev/null +++ b/src/lib/content.ts @@ -0,0 +1,55 @@ +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; diff --git a/src/lib/db.ts b/src/lib/db.ts index 0b64cb7..2821981 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,6 +1,7 @@ import Database from 'better-sqlite3'; import { mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; +import { contentSeed } from '../data/content-seed'; const SCHEMA = ` CREATE TABLE IF NOT EXISTS posts ( @@ -49,6 +50,9 @@ export function createDb(path?: string): Database.Database { if (!userCols.some((c) => c.name === 'role')) { db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`); } + 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(); return db; } diff --git a/src/lib/sanitize.ts b/src/lib/sanitize.ts index b72e440..53fa750 100644 --- a/src/lib/sanitize.ts +++ b/src/lib/sanitize.ts @@ -9,3 +9,12 @@ export function sanitizeHtml(html: string): string { allowProtocolRelative: false, }); } + +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, + }); +} diff --git a/tests/content.test.ts b/tests/content.test.ts new file mode 100644 index 0000000..a5e8e53 --- /dev/null +++ b/tests/content.test.ts @@ -0,0 +1,61 @@ +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(''); + }); +});