feat: content store con seed, cache e fallback
This commit is contained in:
@@ -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]));
|
||||||
@@ -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;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import { dirname } from 'node:path';
|
import { dirname } from 'node:path';
|
||||||
|
import { contentSeed } from '../data/content-seed';
|
||||||
|
|
||||||
const SCHEMA = `
|
const SCHEMA = `
|
||||||
CREATE TABLE IF NOT EXISTS posts (
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
@@ -49,6 +50,9 @@ export function createDb(path?: string): Database.Database {
|
|||||||
if (!userCols.some((c) => c.name === 'role')) {
|
if (!userCols.some((c) => c.name === 'role')) {
|
||||||
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`);
|
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;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,3 +9,12 @@ export function sanitizeHtml(html: string): string {
|
|||||||
allowProtocolRelative: false,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user