@press2ai/engine 0.1.0 → 0.3.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/LICENSE +21 -0
- package/README.md +28 -0
- package/package.json +12 -1
- package/src/ceidg-vertical.ts +305 -0
- package/src/index.ts +0 -1
- package/src/runtime/ssr.ts +70 -4
- package/src/template-blog.ts +2 -4
- package/src/template-trener.ts +0 -13
- package/src/types.ts +5 -10
- package/src/type-tests.ts +0 -28
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Grzegorz Durtan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# @press2ai/engine
|
|
2
|
+
|
|
3
|
+
Multi-tenant runtime + template contracts for Press2AI vertical landings. SSR on Cloudflare Workers, isomorphic renderer (SSG/browser on roadmap).
|
|
4
|
+
|
|
5
|
+
Used by the `otwarty-*` verticals. See [press2ai](https://codeberg.org/press2ai) for the full ecosystem.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
npm install @press2ai/engine
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Subpath exports
|
|
14
|
+
|
|
15
|
+
- `@press2ai/engine` — core runtime + types
|
|
16
|
+
- `@press2ai/engine/template-trener` — fitness/trainer vertical template
|
|
17
|
+
- `@press2ai/engine/template-blog` — blog template
|
|
18
|
+
- `@press2ai/engine/types` — TypeScript type exports
|
|
19
|
+
|
|
20
|
+
## Contract
|
|
21
|
+
|
|
22
|
+
`OtwartyTemplate<C, T>` discriminated union (`Presence | Commerce`). Render = pure function, isomorphic (SSR / SSG / browser). Templates are bundled as subpath exports — adding a vertical does not require engine changes.
|
|
23
|
+
|
|
24
|
+
Engine v0.1 is GET-only. Mutations (POST `/opt-out`, claim flow) live in per-vertical Hono wrappers.
|
|
25
|
+
|
|
26
|
+
## License
|
|
27
|
+
|
|
28
|
+
MIT — see `LICENSE`. Part of the [Press2AI ecosystem](https://codeberg.org/press2ai).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@press2ai/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
".": "./src/index.ts",
|
|
16
16
|
"./template-trener": "./src/template-trener.ts",
|
|
17
17
|
"./template-blog": "./src/template-blog.ts",
|
|
18
|
+
"./ceidg-vertical": "./src/ceidg-vertical.ts",
|
|
18
19
|
"./types": "./src/types.ts"
|
|
19
20
|
},
|
|
20
21
|
"scripts": {
|
|
@@ -23,7 +24,17 @@
|
|
|
23
24
|
"dependencies": {
|
|
24
25
|
"zod": "^3.23.8"
|
|
25
26
|
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"hono": "^4.0.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependenciesMeta": {
|
|
31
|
+
"hono": {
|
|
32
|
+
"optional": true
|
|
33
|
+
}
|
|
34
|
+
},
|
|
26
35
|
"devDependencies": {
|
|
36
|
+
"@cloudflare/workers-types": "^4.20260101.0",
|
|
37
|
+
"hono": "^4.6.0",
|
|
27
38
|
"typescript": "^5.5.0"
|
|
28
39
|
}
|
|
29
40
|
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createVerticalApp — opinionated helper dla rodziny otwarty-* (CEIDG verticals).
|
|
3
|
+
*
|
|
4
|
+
* Każdy wertykal (`otwarty-trener`, `otwarty-terapeuta`, `otwarty-lekarz`, ...)
|
|
5
|
+
* dzielił dotąd ~230 LOC boilerplate'u: te same Hono routy, ten sam content
|
|
6
|
+
* loader, te same KV-cache helpery, te same opt-out handlery — różnica tylko
|
|
7
|
+
* w stałych. Helper wciąga to całe wnętrze do engine'u; wertykal staje się
|
|
8
|
+
* cienkim configiem (brand, copy, footer, kategoria SQL, pages, theme bundle).
|
|
9
|
+
*
|
|
10
|
+
* Co tu siedzi i dlaczego:
|
|
11
|
+
* - Engine v0.1 jest GET-only (czysty SSR). Mutacje (`POST /opt-out`)
|
|
12
|
+
* żyją tutaj, w warstwie `vertical`, bo to są handlery konkretnej rodziny
|
|
13
|
+
* wertykali, nie generycznego runtime'u. Engine core (`createSSRWorker`)
|
|
14
|
+
* pozostaje agnostyczny.
|
|
15
|
+
* - Schema CEIDG (`leads`, `lead_pkd`, `lead_categories`, `cities`) jest
|
|
16
|
+
* hard-coded w queries — to jest *cel* tego helpera. „Generic vertical
|
|
17
|
+
* framework" nie istnieje, dopóki nie ma trzeciej rodziny niż otwarty-*.
|
|
18
|
+
* - Theme bundle (`TrenerTheme`) jest **input**, nie tworzony tutaj. Engine
|
|
19
|
+
* nie zna theme-specialist-glossy. Wertykal podaje gotowy bundle (~10 LOC
|
|
20
|
+
* adaptera w landingu), helper tylko go wywołuje.
|
|
21
|
+
* - `loadContent` filtruje po `EXISTS lead_categories WHERE category=?`
|
|
22
|
+
* i — fix względem starego trener-landinga — `cities` query też filtruje
|
|
23
|
+
* po kategorii (wcześniej trener pokazywał miasta z całej tabeli leads,
|
|
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.
|
|
28
|
+
*
|
|
29
|
+
* Hono jest peer dependency (każdy wertykal i tak go ma). Engine core nie
|
|
30
|
+
* importuje Hono — tylko ten subpath go używa.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { Hono } from 'hono';
|
|
34
|
+
import { createSSRWorker } from './runtime/ssr';
|
|
35
|
+
import {
|
|
36
|
+
trenerTemplate,
|
|
37
|
+
type TrenerContent,
|
|
38
|
+
type TrenerProfile,
|
|
39
|
+
type TrenerTheme,
|
|
40
|
+
} from './template-trener';
|
|
41
|
+
|
|
42
|
+
/* ─────────────── Public types ─────────────── */
|
|
43
|
+
|
|
44
|
+
/** Kształt wiersza z `leads` JOIN `cities` JOIN (subselect) `lead_pkd`. */
|
|
45
|
+
export type CeidgLead = {
|
|
46
|
+
nip: string;
|
|
47
|
+
first_name: string;
|
|
48
|
+
last_name: string;
|
|
49
|
+
city: string | null;
|
|
50
|
+
company_name: string;
|
|
51
|
+
pkd: string | null;
|
|
52
|
+
slug: string;
|
|
53
|
+
claimed: number;
|
|
54
|
+
external_site_url: string | null;
|
|
55
|
+
fetched_at: number;
|
|
56
|
+
opted_out_at: number | null;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type CeidgBindings = { DB: D1Database; CACHE: KVNamespace };
|
|
60
|
+
|
|
61
|
+
/** Statyczna strona prawna/info — pełne body HTML, opcjonalny meta description. */
|
|
62
|
+
export interface StaticPage {
|
|
63
|
+
title: string;
|
|
64
|
+
body: string;
|
|
65
|
+
description?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface VerticalConfig {
|
|
69
|
+
/** Wartość `lead_categories.category`. Używana w SQL i w prefiksach KV cache. */
|
|
70
|
+
category: string;
|
|
71
|
+
/** Wersja landinga — wyświetlana w `/health` (debug) i w stopce. */
|
|
72
|
+
version: string;
|
|
73
|
+
brand: { siteName: string; description: string };
|
|
74
|
+
copy: {
|
|
75
|
+
heroBadge?: string;
|
|
76
|
+
heroTitle: string;
|
|
77
|
+
heroSubtitle?: string;
|
|
78
|
+
searchPlaceholder?: string;
|
|
79
|
+
};
|
|
80
|
+
/** PKD → human-readable jobTitle. Per-wertykal mapping. */
|
|
81
|
+
pkdToJobTitle(pkd: string | null | undefined): string;
|
|
82
|
+
/** Statyczne strony — RODO/regulamin/opt-out. Body HTML wkleja się w layout. */
|
|
83
|
+
pages: {
|
|
84
|
+
zasady: StaticPage;
|
|
85
|
+
optOutForm: StaticPage;
|
|
86
|
+
optOutDone: StaticPage;
|
|
87
|
+
/** Optional — RODO art. 14 dla linków społecznościowych. */
|
|
88
|
+
linkiInfo?: StaticPage;
|
|
89
|
+
};
|
|
90
|
+
/** llms.txt — header + intro paragraph. Lista profili dopisywana automatycznie. */
|
|
91
|
+
llms: { title: string; intro: string };
|
|
92
|
+
/** Theme bundle. Wertykal buduje adapter (Profile ↔ TrenerProfile) i podaje gotowy. */
|
|
93
|
+
theme: TrenerTheme;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* ─────────────── Implementation ─────────────── */
|
|
97
|
+
|
|
98
|
+
const KV_TTL = 86400;
|
|
99
|
+
const OPTOUT_LIMIT = 5;
|
|
100
|
+
const OPTOUT_WINDOW_SEC = 3600;
|
|
101
|
+
|
|
102
|
+
export function createVerticalApp(config: VerticalConfig): Hono<{ Bindings: CeidgBindings }> {
|
|
103
|
+
const app = new Hono<{ Bindings: CeidgBindings }>();
|
|
104
|
+
app.onError((err, c) => c.json({ error: err.message, stack: err.stack }, 500));
|
|
105
|
+
|
|
106
|
+
const { category, version, brand, copy, pkdToJobTitle, pages, llms, theme } = config;
|
|
107
|
+
|
|
108
|
+
// ─────────── SQL ───────────
|
|
109
|
+
// EXISTS subselect zamiast JOIN: lead z N PKDs nie multiplikuje wierszy.
|
|
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.
|
|
113
|
+
const leadsQuery = `
|
|
114
|
+
SELECT l.nip, l.first_name, l.last_name, c.name as city,
|
|
115
|
+
l.company_name, l.slug, l.claimed, l.external_site_url, l.fetched_at,
|
|
116
|
+
l.opted_out_at,
|
|
117
|
+
(SELECT pkd FROM lead_pkd WHERE nip = l.nip LIMIT 1) as pkd
|
|
118
|
+
FROM leads l
|
|
119
|
+
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 = ?)`;
|
|
122
|
+
|
|
123
|
+
// Cities query filtruje po kategorii — fix względem starego trener landinga
|
|
124
|
+
// który pokazywał miasta z całej tabeli leads (mieszane wertykale).
|
|
125
|
+
const citiesQuery = `
|
|
126
|
+
SELECT c.name, COUNT(*) as count
|
|
127
|
+
FROM leads l
|
|
128
|
+
JOIN cities c ON l.city_id = c.id
|
|
129
|
+
INNER JOIN lead_categories lc ON l.nip = lc.lead_nip AND lc.category = ?
|
|
130
|
+
WHERE l.opted_out_at IS NULL
|
|
131
|
+
GROUP BY c.name ORDER BY count DESC LIMIT 8`;
|
|
132
|
+
|
|
133
|
+
// ─────────── Content loader ───────────
|
|
134
|
+
function leadToProfile(l: CeidgLead): TrenerProfile {
|
|
135
|
+
return {
|
|
136
|
+
slug: l.slug,
|
|
137
|
+
firstName: l.first_name,
|
|
138
|
+
lastName: l.last_name,
|
|
139
|
+
jobTitle: pkdToJobTitle(l.pkd),
|
|
140
|
+
city: l.city ?? undefined,
|
|
141
|
+
specialties: [],
|
|
142
|
+
languages: [],
|
|
143
|
+
business: { name: l.company_name, taxId: l.nip, classification: l.pkd ? [l.pkd] : [] },
|
|
144
|
+
social: {},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function loadContent(_host: string, env: unknown): Promise<TrenerContent> {
|
|
149
|
+
const e = env as CeidgBindings;
|
|
150
|
+
const [leadsRes, citiesRes] = await Promise.all([
|
|
151
|
+
e.DB.prepare(leadsQuery + ' ORDER BY c.name, l.last_name').bind(category).all<CeidgLead>(),
|
|
152
|
+
e.DB.prepare(citiesQuery).bind(category).all<{ name: string; count: number }>(),
|
|
153
|
+
]);
|
|
154
|
+
return {
|
|
155
|
+
brand: { siteName: brand.siteName, description: brand.description },
|
|
156
|
+
copy: {
|
|
157
|
+
heroBadge: copy.heroBadge,
|
|
158
|
+
heroTitle: copy.heroTitle,
|
|
159
|
+
heroSubtitle: copy.heroSubtitle,
|
|
160
|
+
searchPlaceholder: copy.searchPlaceholder ?? 'Szukaj...',
|
|
161
|
+
},
|
|
162
|
+
profiles: (leadsRes.results ?? []).map(leadToProfile),
|
|
163
|
+
cities: citiesRes.results ?? [],
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const engine = createSSRWorker({ template: trenerTemplate, theme, loadContent });
|
|
168
|
+
|
|
169
|
+
// ─────────── KV cache helper ───────────
|
|
170
|
+
async function cached<T>(kv: KVNamespace, key: string, fn: () => Promise<T>): Promise<T> {
|
|
171
|
+
const hit = await kv.get(key);
|
|
172
|
+
if (hit) return JSON.parse(hit) as T;
|
|
173
|
+
const data = await fn();
|
|
174
|
+
await kv.put(key, JSON.stringify(data), { expirationTtl: KV_TTL });
|
|
175
|
+
return data;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const ck = (suffix: string) => `vertical:${category}:${suffix}`;
|
|
179
|
+
|
|
180
|
+
// ─────────── Static page renderer ───────────
|
|
181
|
+
// Wszystkie strony statyczne idą przez ten helper — theme.layout dostarcza
|
|
182
|
+
// siteName/footer/CSS, wertykal podaje tylko {title, body, description}.
|
|
183
|
+
function renderStatic(page: StaticPage): string {
|
|
184
|
+
return theme.layout({ title: page.title, description: page.description }, page.body);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─────────── Routes: legacy / cached / mutations ───────────
|
|
188
|
+
|
|
189
|
+
app.get('/health', async (c) => {
|
|
190
|
+
const { results } = await c.env.DB
|
|
191
|
+
.prepare('SELECT COUNT(*) as cnt FROM lead_categories WHERE category = ?')
|
|
192
|
+
.bind(category)
|
|
193
|
+
.all();
|
|
194
|
+
return c.json({ ok: true, db: results, version, category });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
app.get('/catalog.json', async (c) => {
|
|
198
|
+
const data = await cached(c.env.CACHE, ck('catalog.json'), async () => {
|
|
199
|
+
const { results } = await c.env.DB
|
|
200
|
+
.prepare(leadsQuery + ' ORDER BY c.name, l.last_name')
|
|
201
|
+
.bind(category)
|
|
202
|
+
.all<CeidgLead>();
|
|
203
|
+
const leads = results ?? [];
|
|
204
|
+
return {
|
|
205
|
+
version: 1,
|
|
206
|
+
source: 'ceidg',
|
|
207
|
+
category,
|
|
208
|
+
count: leads.length,
|
|
209
|
+
items: leads.map((l) => ({
|
|
210
|
+
slug: l.slug,
|
|
211
|
+
firstName: l.first_name,
|
|
212
|
+
lastName: l.last_name,
|
|
213
|
+
city: l.city,
|
|
214
|
+
companyName: l.company_name,
|
|
215
|
+
claimed: !!l.claimed,
|
|
216
|
+
url: '/' + l.slug,
|
|
217
|
+
})),
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
return c.json(data);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
app.get('/llms.txt', async (c) => {
|
|
224
|
+
const text = await cached(c.env.CACHE, ck('llms.txt'), async () => {
|
|
225
|
+
const { results } = await c.env.DB
|
|
226
|
+
.prepare(leadsQuery + ' ORDER BY c.name, l.last_name')
|
|
227
|
+
.bind(category)
|
|
228
|
+
.all<CeidgLead>();
|
|
229
|
+
const leads = results ?? [];
|
|
230
|
+
return [
|
|
231
|
+
`# ${llms.title}`,
|
|
232
|
+
llms.intro,
|
|
233
|
+
'',
|
|
234
|
+
'## Wpisy',
|
|
235
|
+
...leads.map((l) => `- ${l.first_name} ${l.last_name} (${l.city ?? '—'}) — /${l.slug}`),
|
|
236
|
+
].join('\n');
|
|
237
|
+
});
|
|
238
|
+
return c.text(text);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
app.get('/sitemap.xml', async (c) => {
|
|
242
|
+
const host = new URL(c.req.url).origin;
|
|
243
|
+
const xml = await cached(c.env.CACHE, ck(`sitemap:${host}`), async () => {
|
|
244
|
+
const { results } = await c.env.DB
|
|
245
|
+
.prepare(leadsQuery)
|
|
246
|
+
.bind(category)
|
|
247
|
+
.all<CeidgLead>();
|
|
248
|
+
const urls = [host + '/', ...(results ?? []).map((l) => host + '/' + l.slug)];
|
|
249
|
+
return (
|
|
250
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
|
|
251
|
+
urls.map((u) => ` <url><loc>${u}</loc></url>`).join('\n') +
|
|
252
|
+
'\n</urlset>'
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
return c.body(xml, 200, { 'content-type': 'application/xml' });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
app.get('/zasady', (c) => c.html(renderStatic(pages.zasady)));
|
|
259
|
+
if (pages.linkiInfo) {
|
|
260
|
+
app.get('/linki-info', (c) => c.html(renderStatic(pages.linkiInfo!)));
|
|
261
|
+
}
|
|
262
|
+
app.get('/opt-out', (c) => c.html(renderStatic(pages.optOutForm)));
|
|
263
|
+
|
|
264
|
+
app.post('/opt-out', async (c) => {
|
|
265
|
+
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
|
+
|
|
269
|
+
const ip = c.req.header('cf-connecting-ip') ?? '0.0.0.0';
|
|
270
|
+
const rlKey = `vertical:${category}:rl:optout:${ip}`;
|
|
271
|
+
const rlVal = parseInt((await c.env.CACHE.get(rlKey)) ?? '0', 10);
|
|
272
|
+
if (rlVal >= OPTOUT_LIMIT) return c.text('Zbyt wiele prób. Spróbuj ponownie za godzinę.', 429);
|
|
273
|
+
await c.env.CACHE.put(rlKey, String(rlVal + 1), { expirationTtl: OPTOUT_WINDOW_SEC });
|
|
274
|
+
|
|
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();
|
|
283
|
+
|
|
284
|
+
if (!res.meta.changes) return c.text('Nie znaleziono wpisu', 404);
|
|
285
|
+
return c.html(renderStatic(pages.optOutDone));
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ─────────── /:slug — claim redirect → engine fallback ───────────
|
|
289
|
+
app.get('/:slug', async (c) => {
|
|
290
|
+
const slug = c.req.param('slug');
|
|
291
|
+
const claim = await c.env.DB
|
|
292
|
+
.prepare('SELECT claimed, external_site_url FROM leads WHERE slug = ?')
|
|
293
|
+
.bind(slug)
|
|
294
|
+
.first<{ claimed: number; external_site_url: string | null }>();
|
|
295
|
+
if (claim?.claimed && claim.external_site_url) {
|
|
296
|
+
return c.redirect(claim.external_site_url);
|
|
297
|
+
}
|
|
298
|
+
return engine.fetch(c.req.raw, c.env);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// ─────────── / — engine ───────────
|
|
302
|
+
app.get('/', (c) => engine.fetch(c.req.raw, c.env));
|
|
303
|
+
|
|
304
|
+
return app;
|
|
305
|
+
}
|
package/src/index.ts
CHANGED
package/src/runtime/ssr.ts
CHANGED
|
@@ -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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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>(
|
package/src/template-blog.ts
CHANGED
|
@@ -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,
|
|
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' },
|
|
129
|
+
return { headers: { 'content-type': 'application/rss+xml' }, body: xml };
|
|
132
130
|
}
|
package/src/template-trener.ts
CHANGED
|
@@ -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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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 };
|