@press2ai/engine 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@press2ai/engine",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Multi-tenant runtime + template contracts dla otwarty-* verticali. SSR na Cloudflare Workers, isomorphic renderer (SSG/browser w roadmapie).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,9 +22,10 @@
22
22
  * i — fix względem starego trener-landinga — `cities` query też filtruje
23
23
  * po kategorii (wcześniej trener pokazywał miasta z całej tabeli leads,
24
24
  * mieszając wszystkie wertykale).
25
- * - Opt-out jest **globalny** UPDATE leads bez filtra category. RODO
26
- * sprzeciw to usunięcie danych osobowych, nie selektywne ukrycie w jednej
27
- * kategorii. Komentarz przeniesiony z terapeuta-landinga.
25
+ * - Opt-out jest **globalny** i **hard delete**: DELETE FROM leads + wpis
26
+ * w `opted_out_nips` (blacklist NIP-ów żeby sync nie wprowadził z powrotem).
27
+ * RODO art. 17 usunięcie danych osobowych, nie soft flag. Z karty profilu
28
+ * przez slug (jeden klik), bez formularza NIP.
28
29
  *
29
30
  * Hono jest peer dependency (każdy wertykal i tak go ma). Engine core nie
30
31
  * importuje Hono — tylko ten subpath go używa.
@@ -53,7 +54,6 @@ export type CeidgLead = {
53
54
  claimed: number;
54
55
  external_site_url: string | null;
55
56
  fetched_at: number;
56
- opted_out_at: number | null;
57
57
  };
58
58
 
59
59
  export type CeidgBindings = { DB: D1Database; CACHE: KVNamespace };
@@ -108,17 +108,15 @@ export function createVerticalApp(config: VerticalConfig): Hono<{ Bindings: Ceid
108
108
  // ─────────── SQL ───────────
109
109
  // EXISTS subselect zamiast JOIN: lead z N PKDs nie multiplikuje wierszy.
110
110
  // (SELECT pkd ... LIMIT 1) wybiera jeden PKD per lead — wystarczy do
111
- // pkdToJobTitle. To jest świadomy uproszczenie: lead z mieszanymi PKDs
112
- // (np. fizjoterapia + psychologia) dostanie tylko jeden label.
111
+ // pkdToJobTitle. Świadome uproszczenie: lead z mieszanymi PKDs dostanie
112
+ // jeden label. Filtr opted-out zbędny opt-out to hard delete (0010).
113
113
  const leadsQuery = `
114
114
  SELECT l.nip, l.first_name, l.last_name, c.name as city,
115
115
  l.company_name, l.slug, l.claimed, l.external_site_url, l.fetched_at,
116
- l.opted_out_at,
117
116
  (SELECT pkd FROM lead_pkd WHERE nip = l.nip LIMIT 1) as pkd
118
117
  FROM leads l
119
118
  LEFT JOIN cities c ON l.city_id = c.id
120
- WHERE l.opted_out_at IS NULL
121
- AND EXISTS (SELECT 1 FROM lead_categories WHERE lead_nip = l.nip AND category = ?)`;
119
+ WHERE EXISTS (SELECT 1 FROM lead_categories WHERE lead_nip = l.nip AND category = ?)`;
122
120
 
123
121
  // Cities query filtruje po kategorii — fix względem starego trener landinga
124
122
  // który pokazywał miasta z całej tabeli leads (mieszane wertykale).
@@ -127,7 +125,6 @@ export function createVerticalApp(config: VerticalConfig): Hono<{ Bindings: Ceid
127
125
  FROM leads l
128
126
  JOIN cities c ON l.city_id = c.id
129
127
  INNER JOIN lead_categories lc ON l.nip = lc.lead_nip AND lc.category = ?
130
- WHERE l.opted_out_at IS NULL
131
128
  GROUP BY c.name ORDER BY count DESC LIMIT 8`;
132
129
 
133
130
  // ─────────── Content loader ───────────
@@ -261,10 +258,15 @@ export function createVerticalApp(config: VerticalConfig): Hono<{ Bindings: Ceid
261
258
  }
262
259
  app.get('/opt-out', (c) => c.html(renderStatic(pages.optOutForm)));
263
260
 
261
+ // One-click opt-out: przycisk na karcie profilu POST-uje slug. Hard delete
262
+ // z `leads` (cascade do `lead_pkd`/`lead_categories`) + insert do blacklisty
263
+ // `opted_out_nips` żeby sync nie wprowadził NIP-a z powrotem. Rate limit
264
+ // 5/h/IP przeciw abuse — slug jest publiczny, ktokolwiek może nukeować,
265
+ // ale katalog to publiczne wizytówki z CEIDG, nie konta.
264
266
  app.post('/opt-out', async (c) => {
265
267
  const form = await c.req.formData();
266
- const nip = String(form.get('nip') ?? '').replace(/\D/g, '');
267
- if (nip.length !== 10) return c.text('Nieprawidłowy NIP', 400);
268
+ const slug = String(form.get('slug') ?? '').trim();
269
+ if (!slug) return c.text('Brak slug', 400);
268
270
 
269
271
  const ip = c.req.header('cf-connecting-ip') ?? '0.0.0.0';
270
272
  const rlKey = `vertical:${category}:rl:optout:${ip}`;
@@ -272,16 +274,21 @@ export function createVerticalApp(config: VerticalConfig): Hono<{ Bindings: Ceid
272
274
  if (rlVal >= OPTOUT_LIMIT) return c.text('Zbyt wiele prób. Spróbuj ponownie za godzinę.', 429);
273
275
  await c.env.CACHE.put(rlKey, String(rlVal + 1), { expirationTtl: OPTOUT_WINDOW_SEC });
274
276
 
275
- // Opt-out jest GLOBALNY — usuwa też z innych wertykali. RODO sprzeciw to
276
- // usunięcie danych osobowych, nie selektywne ukrycie w jednej kategorii.
277
- const res = await c.env.DB
278
- .prepare(
279
- 'UPDATE leads SET opted_out_at = ?, opted_out_ip = ?, opted_out_ua = ? WHERE nip = ? AND opted_out_at IS NULL',
280
- )
281
- .bind(Math.floor(Date.now() / 1000), ip, c.req.header('user-agent') ?? '', nip)
282
- .run();
277
+ const row = await c.env.DB
278
+ .prepare('SELECT nip FROM leads WHERE slug = ?')
279
+ .bind(slug)
280
+ .first<{ nip: string }>();
281
+ if (!row) return c.text('Nie znaleziono wpisu', 404);
282
+
283
+ const now = Math.floor(Date.now() / 1000);
284
+ const ipHash = await sha256Hex(ip + ':' + slug);
285
+ await c.env.DB.batch([
286
+ c.env.DB.prepare('INSERT OR IGNORE INTO opted_out_nips (nip, opted_out_at, ip_hash) VALUES (?, ?, ?)').bind(row.nip, now, ipHash),
287
+ c.env.DB.prepare('DELETE FROM lead_categories WHERE lead_nip = ?').bind(row.nip),
288
+ c.env.DB.prepare('DELETE FROM lead_pkd WHERE nip = ?').bind(row.nip),
289
+ c.env.DB.prepare('DELETE FROM leads WHERE nip = ?').bind(row.nip),
290
+ ]);
283
291
 
284
- if (!res.meta.changes) return c.text('Nie znaleziono wpisu', 404);
285
292
  return c.html(renderStatic(pages.optOutDone));
286
293
  });
287
294
 
@@ -303,3 +310,8 @@ export function createVerticalApp(config: VerticalConfig): Hono<{ Bindings: Ceid
303
310
 
304
311
  return app;
305
312
  }
313
+
314
+ async function sha256Hex(input: string): Promise<string> {
315
+ const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
316
+ return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('');
317
+ }
package/src/index.ts CHANGED
@@ -11,7 +11,6 @@ export type {
11
11
  RenderRequest,
12
12
  RenderContext,
13
13
  RenderResult,
14
- HeadMeta,
15
14
  EditorSchema,
16
15
  EditorField,
17
16
  EditorSection,
@@ -29,6 +29,26 @@ export interface SSRWorkerOpts<C, T> {
29
29
  loadContent(host: string, env: unknown): Promise<C | null>;
30
30
  /** Override base URL (np. dla preview albo custom domains). */
31
31
  baseUrlFor?(host: string): string;
32
+ /**
33
+ * Optional KV cache rendered responses. Klucz: `{prefix}:{host}:{path}?{query}`.
34
+ * TTL bierze z `route.cacheControl` (`s-maxage=N`) albo `defaultTtlSec`.
35
+ * Cache miss trafia do D1+render, cache hit zwraca surowy body+headers
36
+ * **bez** uruchamiania `loadContent` ani `template.render` — czyli zero
37
+ * D1 query na hot path. Cachuje wyłącznie 200 OK.
38
+ *
39
+ * Świadomie poza cache'em: opcjonalność. KV ma free tier 1k writes/dzień,
40
+ * landing musi sam zdecydować czy włącza. Wbudowane endpointy
41
+ * (/sitemap.xml, /robots.txt, /feed.xml) **nie** używają tego cache'a —
42
+ * mają swój własny `cache-control` skierowany do CDN, a KV pisanie dla
43
+ * sitemap'u które trafia 1×/dobę z botów to czysty waste.
44
+ */
45
+ cache?: {
46
+ kv: KVNamespace;
47
+ /** Domyślny TTL gdy route nie podaje cacheControl. Default 300s. */
48
+ defaultTtlSec?: number;
49
+ /** Prefix klucza KV — typowo nazwa wertykalu. Default 'engine'. */
50
+ keyPrefix?: string;
51
+ };
32
52
  }
33
53
 
34
54
  export interface OtwartyWorker {
@@ -59,6 +79,18 @@ export function createSSRWorker<C, T>(opts: SSRWorkerOpts<C, T>): OtwartyWorker
59
79
  return handleFeed(opts, host, env, baseUrl);
60
80
  }
61
81
 
82
+ // KV cache lookup — kompletny bypass D1+render gdy hit. Tylko GET, tylko
83
+ // gdy cache skonfigurowany. Klucz zawiera host (multi-tenant) i query.
84
+ const cacheKey = opts.cache && request.method === 'GET'
85
+ ? buildCacheKey(opts.cache.keyPrefix ?? 'engine', host, url.pathname, url.searchParams)
86
+ : null;
87
+ if (cacheKey && opts.cache) {
88
+ const hit = await opts.cache.kv.get(cacheKey, 'json') as CachedRender | null;
89
+ if (hit) {
90
+ return new Response(hit.body, { status: hit.status, headers: hit.headers });
91
+ }
92
+ }
93
+
62
94
  // Standard render path
63
95
  const content = await opts.loadContent(host, env);
64
96
  if (content === null) {
@@ -100,14 +132,48 @@ export function createSSRWorker<C, T>(opts: SSRWorkerOpts<C, T>): OtwartyWorker
100
132
  // Nagłówki z RenderResult przesłaniają wszystko (template wie najlepiej)
101
133
  Object.assign(headers, result.headers ?? {});
102
134
 
103
- return new Response(result.body, {
104
- status: result.status ?? 200,
105
- headers,
106
- });
135
+ const status = result.status ?? 200;
136
+
137
+ // Cache write — tylko 200 OK (4xx/5xx nie chcemy cementować w KV).
138
+ if (cacheKey && opts.cache && status === 200) {
139
+ const ttl = parseSMaxAge(headers['cache-control']) ?? opts.cache.defaultTtlSec ?? 300;
140
+ if (ttl > 0) {
141
+ await opts.cache.kv.put(
142
+ cacheKey,
143
+ JSON.stringify({ body: result.body, status, headers } satisfies CachedRender),
144
+ { expirationTtl: ttl },
145
+ );
146
+ }
147
+ }
148
+
149
+ return new Response(result.body, { status, headers });
107
150
  },
108
151
  };
109
152
  }
110
153
 
154
+ /* ─────────────── KV cache helpers ─────────────── */
155
+
156
+ interface CachedRender {
157
+ body: string;
158
+ status: number;
159
+ headers: Record<string, string>;
160
+ }
161
+
162
+ function buildCacheKey(prefix: string, host: string, path: string, query: URLSearchParams): string {
163
+ // Sortowanie query żeby `?p=2&q=foo` i `?q=foo&p=2` były tym samym kluczem.
164
+ const pairs: Array<[string, string]> = [];
165
+ query.forEach((v, k) => { pairs.push([k, v]); });
166
+ pairs.sort(([a], [b]) => a.localeCompare(b));
167
+ const qs = pairs.map(([k, v]) => `${k}=${v}`).join('&');
168
+ return `${prefix}:render:${host}:${path}${qs ? '?' + qs : ''}`;
169
+ }
170
+
171
+ function parseSMaxAge(cacheControl: string | undefined): number | null {
172
+ if (!cacheControl) return null;
173
+ const m = cacheControl.match(/s-maxage=(\d+)/);
174
+ return m ? parseInt(m[1]!, 10) : null;
175
+ }
176
+
111
177
  /* ─────────────── Wbudowane endpointy ─────────────── */
112
178
 
113
179
  async function handleSitemap<C, T>(
@@ -103,16 +103,14 @@ function renderIndex(req: RenderRequest<BlogContent>, ctx: RenderContext<BlogThe
103
103
  .map((p) => `<article><h2><a href="/blog/${ctx.theme.esc(p.slug)}">${ctx.theme.esc(p.title)}</a></h2><time>${ctx.theme.esc(p.pubDate)}</time>${p.excerpt ? `<p>${ctx.theme.esc(p.excerpt)}</p>` : ''}</article>`)
104
104
  .join('\n');
105
105
  return {
106
- head: { title: content.brand.siteName, description: content.brand.description, canonical: `${ctx.baseUrl}/` },
107
106
  body: ctx.theme.layout({ title: content.brand.siteName, description: content.brand.description }, `<h1>${ctx.theme.esc(content.brand.siteName)}</h1>${items}`),
108
107
  };
109
108
  }
110
109
 
111
110
  function renderPost(req: RenderRequest<BlogContent>, ctx: RenderContext<BlogTheme>): RenderResult {
112
111
  const post = req.content.posts.find((p) => p.slug === req.params.slug);
113
- if (!post) return { status: 404, head: { title: '404' }, body: ctx.theme.layout({ title: '404' }, '<h1>404</h1>') };
112
+ if (!post) return { status: 404, body: ctx.theme.layout({ title: '404' }, '<h1>404</h1>') };
114
113
  return {
115
- head: { title: `${post.title} | ${req.content.brand.siteName}`, description: post.excerpt, canonical: `${ctx.baseUrl}/blog/${post.slug}` },
116
114
  body: ctx.theme.layout(
117
115
  { title: post.title, description: post.excerpt },
118
116
  `<article><h1>${ctx.theme.esc(post.title)}</h1><time>${ctx.theme.esc(post.pubDate)}</time>${ctx.theme.markdown(post.body)}</article>`,
@@ -128,5 +126,5 @@ function renderRssXml(req: RenderRequest<BlogContent>, ctx: RenderContext<BlogTh
128
126
  .map((i) => `<item><title>${ctx.theme.esc(i.title)}</title><link>${i.link}</link><guid>${i.guid}</guid><pubDate>${i.pubDate}</pubDate>${i.description ? `<description>${ctx.theme.esc(i.description)}</description>` : ''}</item>`)
129
127
  .join('');
130
128
  const xml = `<?xml version="1.0"?><rss version="2.0"><channel><title>${ctx.theme.esc(feed.title)}</title><link>${feed.link}</link><description>${ctx.theme.esc(feed.description)}</description>${items}</channel></rss>`;
131
- return { headers: { 'content-type': 'application/rss+xml' }, head: { title: '' }, body: xml };
129
+ return { headers: { 'content-type': 'application/rss+xml' }, body: xml };
132
130
  }
@@ -232,12 +232,6 @@ function renderIndex(req: RenderRequest<TrenerContent>, ctx: RenderContext<Trene
232
232
  };
233
233
 
234
234
  return {
235
- head: {
236
- title: q ? `${q} — ${content.brand.siteName}` : content.brand.siteName,
237
- description: content.brand.description,
238
- canonical: `${ctx.baseUrl}/`,
239
- jsonLd,
240
- },
241
235
  body: t.layout({ title: content.brand.siteName, description: content.brand.description, jsonLd }, body),
242
236
  };
243
237
  }
@@ -247,7 +241,6 @@ function renderProfile(req: RenderRequest<TrenerContent>, ctx: RenderContext<Tre
247
241
  if (!profile) {
248
242
  return {
249
243
  status: 404,
250
- head: { title: 'Nie znaleziono' },
251
244
  body: ctx.theme.layout({ title: 'Nie znaleziono' }, '<h1>404</h1><p>Brak takiego trenera.</p>'),
252
245
  };
253
246
  }
@@ -268,12 +261,6 @@ function renderProfile(req: RenderRequest<TrenerContent>, ctx: RenderContext<Tre
268
261
  };
269
262
 
270
263
  return {
271
- head: {
272
- title: `${fullName} — ${profile.city ?? ''} | ${req.content.brand.siteName}`,
273
- description,
274
- canonical: `${ctx.baseUrl}/${profile.slug}`,
275
- jsonLd,
276
- },
277
264
  body: t.layout(
278
265
  { title: `${fullName} | ${req.content.brand.siteName}`, description, jsonLd },
279
266
  t.profileArticle(profile),
package/src/types.ts CHANGED
@@ -118,19 +118,14 @@ export interface RenderContext<T> {
118
118
  export interface RenderResult {
119
119
  status?: number;
120
120
  headers?: Record<string, string>;
121
- head: HeadMeta;
122
121
  body: string;
123
122
  }
124
123
 
125
- export interface HeadMeta {
126
- title: string;
127
- description?: string;
128
- canonical?: string;
129
- og?: { image?: string; type?: string; url?: string };
130
- jsonLd?: object | object[];
131
- /** Surowy HTML do wstawienia w <head> (tylko gdy naprawdę trzeba). */
132
- extra?: string;
133
- }
124
+ // HeadMeta usunięte w v0.3.0 — engine nigdy nie odczytywał `RenderResult.head`,
125
+ // bo SEO meta-tagi i tak idą do `body` przez `theme.layout()`. Dwa źródła prawdy
126
+ // (head field vs head w body) były źródłem rozjazdów (np. `<title>` w body
127
+ // różnił się od title w deklarowanym head). Templaty teraz mają jedno źródło —
128
+ // to które naprawdę trafia do HTML.
134
129
 
135
130
  /* ─────────────── Editor (Faza 0) ─────────────── */
136
131
 
package/src/type-tests.ts DELETED
@@ -1,28 +0,0 @@
1
- /**
2
- * Compile-time type tests. Plik nigdy nie biegnie w runtime — istnieje tylko
3
- * po to żeby tsc krzyknął jeśli któryś referencyjny template przestanie
4
- * spełniać OtwartyTemplate. Drugi cel: pokazać że ten sam template wchodzi
5
- * jako input do wszystkich trzech trybów engine'u (SSR / SSG / Browser).
6
- */
7
-
8
- import type { OtwartyTemplate, PresenceTemplate } from './types';
9
- import { trenerTemplate, type TrenerContent, type TrenerTheme } from './template-trener';
10
- import { blogTemplate, type BlogContent, type BlogTheme } from './template-blog';
11
- import type { createSSRWorker } from './runtime/ssr';
12
- import type { createSSGBuild, createBrowserRenderer } from './engine-signature';
13
-
14
- // 1. Każdy template jest poprawnym OtwartyTemplate
15
- const _trener: OtwartyTemplate<TrenerContent, TrenerTheme> = trenerTemplate;
16
- const _blog: OtwartyTemplate<BlogContent, BlogTheme> = blogTemplate;
17
-
18
- // 2. Discriminator działa — TS narrowi 'const x = trenerTemplate' do PresenceTemplate
19
- // nawet jeśli annotujemy szerzej. Wystarczy że union assignment kompiluje.
20
- const _presenceCheck: PresenceTemplate<TrenerContent, TrenerTheme> = trenerTemplate;
21
-
22
- // 3. Ten sam template jako input do wszystkich trzech trybów engine'u
23
- type _SSR = ReturnType<typeof createSSRWorker<TrenerContent, TrenerTheme>>;
24
- type _SSG = ReturnType<typeof createSSGBuild<TrenerContent, TrenerTheme>>;
25
- type _BROWSER = ReturnType<typeof createBrowserRenderer<TrenerContent, TrenerTheme>>;
26
- type _SSR_BLOG = ReturnType<typeof createSSRWorker<BlogContent, BlogTheme>>;
27
-
28
- export type { _SSR, _SSG, _BROWSER, _SSR_BLOG };