Files
InsanityLab-web/.superpowers/sdd/review-6f51aec..b22cc40.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

239 lines
9.4 KiB
Diff

# Review package: 6f51aec..b22cc40
## Commits
b22cc40 feat: content store con seed, cache e fallback
## Files changed
src/data/content-seed.ts | 24 +++++++++++++++++++
src/lib/content.ts | 55 +++++++++++++++++++++++++++++++++++++++++++
src/lib/db.ts | 4 ++++
src/lib/sanitize.ts | 9 +++++++
tests/content.test.ts | 61 ++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 153 insertions(+)
## Diff
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<string, string> }
+
+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<string, Entry> | null = null;
+
+ const load = (): Map<string, Entry> => {
+ 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<string, string> = {};
+ 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,13 +1,14 @@
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 (
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,
@@ -42,19 +43,22 @@ export function createDb(path?: string): Database.Database {
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);
// 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'`);
}
+ 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;
}
let singleton: Database.Database | null = null;
export function getDb(): Database.Database {
if (!singleton) singleton = createDb();
return singleton;
}
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
@@ -2,10 +2,19 @@ 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,
});
}
+
+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('');
+ });
+});