@press2ai/engine 0.5.1 → 1.0.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/README.md +1 -1
- package/package.json +6 -31
- package/src/index.ts +134 -29
- package/src/ceidg-vertical.ts +0 -325
- package/src/engine-signature.ts +0 -43
- package/src/runtime/ssr.ts +0 -298
- package/src/template-blog.ts +0 -130
- package/src/template-trener.ts +0 -280
- package/src/types.ts +0 -232
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ npm install @press2ai/engine
|
|
|
13
13
|
## Subpath exports
|
|
14
14
|
|
|
15
15
|
- `@press2ai/engine` — core runtime + types
|
|
16
|
-
- `@press2ai/engine/template-
|
|
16
|
+
- `@press2ai/engine/template-catalog` — universal catalog template (trener, terapeuta, lekarz, ...)
|
|
17
17
|
- `@press2ai/engine/template-blog` — blog template
|
|
18
18
|
- `@press2ai/engine/types` — TypeScript type exports
|
|
19
19
|
|
package/package.json
CHANGED
|
@@ -1,40 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@press2ai/engine",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CEIDG data library for otwarty-* verticals. Loads leads, transforms to profiles, builds sitemap/catalog/llms.txt. Zero opinions about rendering.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"files": [
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"publishConfig": {
|
|
12
|
-
"access": "public"
|
|
13
|
-
},
|
|
14
|
-
"exports": {
|
|
15
|
-
".": "./src/index.ts",
|
|
16
|
-
"./template-trener": "./src/template-trener.ts",
|
|
17
|
-
"./template-blog": "./src/template-blog.ts",
|
|
18
|
-
"./ceidg-vertical": "./src/ceidg-vertical.ts",
|
|
19
|
-
"./types": "./src/types.ts"
|
|
20
|
-
},
|
|
21
|
-
"scripts": {
|
|
22
|
-
"check": "tsc --noEmit"
|
|
23
|
-
},
|
|
24
|
-
"dependencies": {
|
|
25
|
-
"zod": "^3.23.8"
|
|
26
|
-
},
|
|
27
|
-
"peerDependencies": {
|
|
28
|
-
"hono": "^4.0.0"
|
|
29
|
-
},
|
|
30
|
-
"peerDependenciesMeta": {
|
|
31
|
-
"hono": {
|
|
32
|
-
"optional": true
|
|
33
|
-
}
|
|
34
|
-
},
|
|
7
|
+
"files": ["src", "README.md"],
|
|
8
|
+
"publishConfig": { "access": "public" },
|
|
9
|
+
"exports": { ".": "./src/index.ts" },
|
|
10
|
+
"scripts": { "check": "tsc --noEmit" },
|
|
35
11
|
"devDependencies": {
|
|
36
12
|
"@cloudflare/workers-types": "^4.20260101.0",
|
|
37
|
-
"hono": "^4.6.0",
|
|
38
13
|
"typescript": "^5.5.0"
|
|
39
14
|
}
|
|
40
15
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,32 +1,137 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @press2ai/engine —
|
|
2
|
+
* @press2ai/engine — CEIDG data library for otwarty-* verticals.
|
|
3
|
+
*
|
|
4
|
+
* Engine is data, not opinions. It loads leads from D1, transforms them
|
|
5
|
+
* to profiles, and provides helpers for sitemap/catalog/llms.txt.
|
|
6
|
+
* How your page looks is YOUR decision — compose HTML however you want.
|
|
3
7
|
*/
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
9
|
+
/* ─────────────── Types ─────────────── */
|
|
10
|
+
|
|
11
|
+
export interface CatalogProfile {
|
|
12
|
+
slug: string;
|
|
13
|
+
firstName: string;
|
|
14
|
+
lastName: string;
|
|
15
|
+
jobTitle: string;
|
|
16
|
+
city?: string;
|
|
17
|
+
bio?: string;
|
|
18
|
+
photo?: string;
|
|
19
|
+
specialties: string[];
|
|
20
|
+
languages: string[];
|
|
21
|
+
business: { name?: string; taxId?: string; classification: string[] };
|
|
22
|
+
social: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CeidgLead {
|
|
26
|
+
nip: string;
|
|
27
|
+
first_name: string;
|
|
28
|
+
last_name: string;
|
|
29
|
+
city: string | null;
|
|
30
|
+
company_name: string;
|
|
31
|
+
pkd: string | null;
|
|
32
|
+
slug: string;
|
|
33
|
+
claimed: number;
|
|
34
|
+
external_site_url: string | null;
|
|
35
|
+
fetched_at: number;
|
|
36
|
+
opted_out_at: number | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type CeidgBindings = { DB: D1Database; CACHE: KVNamespace };
|
|
40
|
+
|
|
41
|
+
/* ─────────────── Data (D1) ─────────────── */
|
|
42
|
+
|
|
43
|
+
const LEADS_SQL = `
|
|
44
|
+
SELECT l.nip, l.first_name, l.last_name, c.name as city,
|
|
45
|
+
l.company_name, l.slug, l.claimed, l.external_site_url, l.fetched_at,
|
|
46
|
+
l.opted_out_at,
|
|
47
|
+
(SELECT pkd FROM lead_pkd WHERE nip = l.nip LIMIT 1) as pkd
|
|
48
|
+
FROM leads l
|
|
49
|
+
LEFT JOIN cities c ON l.city_id = c.id
|
|
50
|
+
WHERE l.opted_out_at IS NULL
|
|
51
|
+
AND EXISTS (SELECT 1 FROM lead_categories WHERE lead_nip = l.nip AND category = ?)`;
|
|
52
|
+
|
|
53
|
+
const CITIES_SQL = `
|
|
54
|
+
SELECT c.name, COUNT(*) as count
|
|
55
|
+
FROM leads l
|
|
56
|
+
JOIN cities c ON l.city_id = c.id
|
|
57
|
+
INNER JOIN lead_categories lc ON l.nip = lc.lead_nip AND lc.category = ?
|
|
58
|
+
WHERE l.opted_out_at IS NULL
|
|
59
|
+
GROUP BY c.name ORDER BY count DESC LIMIT 8`;
|
|
60
|
+
|
|
61
|
+
export async function loadLeads(db: D1Database, category: string): Promise<CeidgLead[]> {
|
|
62
|
+
const { results } = await db.prepare(LEADS_SQL + ' ORDER BY c.name, l.last_name').bind(category).all<CeidgLead>();
|
|
63
|
+
return results ?? [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function loadLeadBySlug(db: D1Database, category: string, slug: string): Promise<CeidgLead | null> {
|
|
67
|
+
return db.prepare(LEADS_SQL + ' AND l.slug = ?').bind(category, slug).first<CeidgLead>() ?? null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function loadCities(db: D1Database, category: string): Promise<{ name: string; count: number }[]> {
|
|
71
|
+
const { results } = await db.prepare(CITIES_SQL).bind(category).all<{ name: string; count: number }>();
|
|
72
|
+
return results ?? [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* ─────────────── Transform ─────────────── */
|
|
76
|
+
|
|
77
|
+
export function leadToProfile(l: CeidgLead, pkdToJobTitle: (pkd: string | null | undefined) => string): CatalogProfile {
|
|
78
|
+
return {
|
|
79
|
+
slug: l.slug, firstName: l.first_name, lastName: l.last_name,
|
|
80
|
+
jobTitle: pkdToJobTitle(l.pkd), city: l.city ?? undefined,
|
|
81
|
+
specialties: [], languages: [],
|
|
82
|
+
business: { name: l.company_name, taxId: l.nip, classification: l.pkd ? [l.pkd] : [] },
|
|
83
|
+
social: {},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* ─────────────── Helpers ─────────────── */
|
|
88
|
+
|
|
89
|
+
export async function kvCached<T>(kv: KVNamespace, key: string, fn: () => Promise<T>, ttl = 86400): Promise<T> {
|
|
90
|
+
const hit = await kv.get(key);
|
|
91
|
+
if (hit) return JSON.parse(hit) as T;
|
|
92
|
+
const data = await fn();
|
|
93
|
+
await kv.put(key, JSON.stringify(data), { expirationTtl: ttl });
|
|
94
|
+
return data;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function filterProfiles(profiles: CatalogProfile[], query: string): CatalogProfile[] {
|
|
98
|
+
if (!query) return profiles;
|
|
99
|
+
const ql = query.toLowerCase();
|
|
100
|
+
return profiles.filter(p =>
|
|
101
|
+
`${p.firstName} ${p.lastName}`.toLowerCase().includes(ql) ||
|
|
102
|
+
(p.city ?? '').toLowerCase().includes(ql) ||
|
|
103
|
+
(p.business?.name ?? '').toLowerCase().includes(ql),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function paginate<T>(items: T[], page: number, pageSize = 12): { slice: T[]; total: number; totalPages: number } {
|
|
108
|
+
const total = items.length;
|
|
109
|
+
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
110
|
+
const p = Math.max(1, Math.min(page, totalPages));
|
|
111
|
+
return { slice: items.slice((p - 1) * pageSize, p * pageSize), total, totalPages };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function buildSitemapXml(urls: string[]): string {
|
|
115
|
+
const items = urls.map(u => ` <url><loc>${escXml(u)}</loc></url>`).join('\n');
|
|
116
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${items}\n</urlset>`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function buildCatalogJson(leads: CeidgLead[], category: string) {
|
|
120
|
+
return {
|
|
121
|
+
version: 1, source: 'ceidg', category, count: leads.length,
|
|
122
|
+
items: leads.map(l => ({
|
|
123
|
+
slug: l.slug, firstName: l.first_name, lastName: l.last_name,
|
|
124
|
+
city: l.city, companyName: l.company_name, claimed: !!l.claimed, url: '/' + l.slug,
|
|
125
|
+
})),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function buildLlmsTxt(leads: CeidgLead[], title: string, intro: string): string {
|
|
130
|
+
return [`# ${title}`, intro, '', '## Wpisy',
|
|
131
|
+
...leads.map(l => `- ${l.first_name} ${l.last_name} (${l.city ?? '—'}) — /${l.slug}`),
|
|
132
|
+
].join('\n');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function escXml(s: string): string {
|
|
136
|
+
return s.replace(/[<>&"']/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[c]!);
|
|
137
|
+
}
|
package/src/ceidg-vertical.ts
DELETED
|
@@ -1,325 +0,0 @@
|
|
|
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. Soft flag + filtr w queries — hard delete bez proof-of-ownership
|
|
28
|
-
* jest podatny na sabotage (patrz NEXT-STEPS manifest-1/manifest-2 notatka).
|
|
29
|
-
*
|
|
30
|
-
* Hono jest peer dependency (każdy wertykal i tak go ma). Engine core nie
|
|
31
|
-
* importuje Hono — tylko ten subpath go używa.
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
import { Hono } from 'hono';
|
|
35
|
-
import { createSSRWorker } from './runtime/ssr';
|
|
36
|
-
import {
|
|
37
|
-
trenerTemplate,
|
|
38
|
-
type TrenerContent,
|
|
39
|
-
type TrenerProfile,
|
|
40
|
-
type TrenerTheme,
|
|
41
|
-
} from './template-trener';
|
|
42
|
-
|
|
43
|
-
/* ─────────────── Public types ─────────────── */
|
|
44
|
-
|
|
45
|
-
/** Kształt wiersza z `leads` JOIN `cities` JOIN (subselect) `lead_pkd`. */
|
|
46
|
-
export type CeidgLead = {
|
|
47
|
-
nip: string;
|
|
48
|
-
first_name: string;
|
|
49
|
-
last_name: string;
|
|
50
|
-
city: string | null;
|
|
51
|
-
company_name: string;
|
|
52
|
-
pkd: string | null;
|
|
53
|
-
slug: string;
|
|
54
|
-
claimed: number;
|
|
55
|
-
external_site_url: string | null;
|
|
56
|
-
fetched_at: number;
|
|
57
|
-
opted_out_at: number | null;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
export type CeidgBindings = { DB: D1Database; CACHE: KVNamespace };
|
|
61
|
-
|
|
62
|
-
/** Statyczna strona prawna/info — pełne body HTML, opcjonalny meta description. */
|
|
63
|
-
export interface StaticPage {
|
|
64
|
-
title: string;
|
|
65
|
-
body: string;
|
|
66
|
-
description?: string;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export interface VerticalConfig {
|
|
70
|
-
/** Wartość `lead_categories.category`. Używana w SQL i w prefiksach KV cache. */
|
|
71
|
-
category: string;
|
|
72
|
-
/** Wersja landinga — wyświetlana w `/health` (debug) i w stopce. */
|
|
73
|
-
version: string;
|
|
74
|
-
brand: { siteName: string; description: string };
|
|
75
|
-
copy: {
|
|
76
|
-
heroBadge?: string;
|
|
77
|
-
heroTitle: string;
|
|
78
|
-
heroSubtitle?: string;
|
|
79
|
-
searchPlaceholder?: string;
|
|
80
|
-
/** Plural label for count in statBar (e.g. 'trenerów', 'terapeutów'). Default 'wpisów'. */
|
|
81
|
-
itemsLabel?: string;
|
|
82
|
-
/** Singular fallback for 404 (e.g. 'trenera', 'terapeuty'). Default 'wpisu'. */
|
|
83
|
-
itemSingular?: string;
|
|
84
|
-
};
|
|
85
|
-
/** PKD → human-readable jobTitle. Per-wertykal mapping. */
|
|
86
|
-
pkdToJobTitle(pkd: string | null | undefined): string;
|
|
87
|
-
/** Statyczne strony — RODO/regulamin/opt-out. Body HTML wkleja się w layout. */
|
|
88
|
-
pages: {
|
|
89
|
-
zasady: StaticPage;
|
|
90
|
-
optOutForm: StaticPage;
|
|
91
|
-
optOutDone: StaticPage;
|
|
92
|
-
/** Optional — RODO art. 14 dla linków społecznościowych. */
|
|
93
|
-
linkiInfo?: StaticPage;
|
|
94
|
-
};
|
|
95
|
-
/** llms.txt — header + intro paragraph. Lista profili dopisywana automatycznie. */
|
|
96
|
-
llms: { title: string; intro: string };
|
|
97
|
-
/** Static HTML sections injected into the index page between engine-rendered blocks. */
|
|
98
|
-
sections?: {
|
|
99
|
-
afterHero?: string;
|
|
100
|
-
afterGrid?: string;
|
|
101
|
-
};
|
|
102
|
-
/** Theme bundle. Wertykal buduje adapter (Profile ↔ TrenerProfile) i podaje gotowy. */
|
|
103
|
-
theme: TrenerTheme;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/* ─────────────── Implementation ─────────────── */
|
|
107
|
-
|
|
108
|
-
const KV_TTL = 86400;
|
|
109
|
-
const OPTOUT_LIMIT = 5;
|
|
110
|
-
const OPTOUT_WINDOW_SEC = 3600;
|
|
111
|
-
|
|
112
|
-
export function createVerticalApp(config: VerticalConfig): Hono<{ Bindings: CeidgBindings }> {
|
|
113
|
-
const app = new Hono<{ Bindings: CeidgBindings }>();
|
|
114
|
-
app.onError((err, c) => c.json({ error: err.message, stack: err.stack }, 500));
|
|
115
|
-
|
|
116
|
-
const { category, version, brand, copy, pkdToJobTitle, pages, llms, theme } = config;
|
|
117
|
-
|
|
118
|
-
// ─────────── SQL ───────────
|
|
119
|
-
// EXISTS subselect zamiast JOIN: lead z N PKDs nie multiplikuje wierszy.
|
|
120
|
-
// (SELECT pkd ... LIMIT 1) wybiera jeden PKD per lead — wystarczy do
|
|
121
|
-
// pkdToJobTitle. To jest świadomy uproszczenie: lead z mieszanymi PKDs
|
|
122
|
-
// (np. fizjoterapia + psychologia) dostanie tylko jeden label.
|
|
123
|
-
const leadsQuery = `
|
|
124
|
-
SELECT l.nip, l.first_name, l.last_name, c.name as city,
|
|
125
|
-
l.company_name, l.slug, l.claimed, l.external_site_url, l.fetched_at,
|
|
126
|
-
l.opted_out_at,
|
|
127
|
-
(SELECT pkd FROM lead_pkd WHERE nip = l.nip LIMIT 1) as pkd
|
|
128
|
-
FROM leads l
|
|
129
|
-
LEFT JOIN cities c ON l.city_id = c.id
|
|
130
|
-
WHERE l.opted_out_at IS NULL
|
|
131
|
-
AND EXISTS (SELECT 1 FROM lead_categories WHERE lead_nip = l.nip AND category = ?)`;
|
|
132
|
-
|
|
133
|
-
// Cities query filtruje po kategorii — fix względem starego trener landinga
|
|
134
|
-
// który pokazywał miasta z całej tabeli leads (mieszane wertykale).
|
|
135
|
-
const citiesQuery = `
|
|
136
|
-
SELECT c.name, COUNT(*) as count
|
|
137
|
-
FROM leads l
|
|
138
|
-
JOIN cities c ON l.city_id = c.id
|
|
139
|
-
INNER JOIN lead_categories lc ON l.nip = lc.lead_nip AND lc.category = ?
|
|
140
|
-
WHERE l.opted_out_at IS NULL
|
|
141
|
-
GROUP BY c.name ORDER BY count DESC LIMIT 8`;
|
|
142
|
-
|
|
143
|
-
// ─────────── Content loader ───────────
|
|
144
|
-
function leadToProfile(l: CeidgLead): TrenerProfile {
|
|
145
|
-
return {
|
|
146
|
-
slug: l.slug,
|
|
147
|
-
firstName: l.first_name,
|
|
148
|
-
lastName: l.last_name,
|
|
149
|
-
jobTitle: pkdToJobTitle(l.pkd),
|
|
150
|
-
city: l.city ?? undefined,
|
|
151
|
-
specialties: [],
|
|
152
|
-
languages: [],
|
|
153
|
-
business: { name: l.company_name, taxId: l.nip, classification: l.pkd ? [l.pkd] : [] },
|
|
154
|
-
social: {},
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
async function loadContent(_host: string, env: unknown): Promise<TrenerContent> {
|
|
159
|
-
const e = env as CeidgBindings;
|
|
160
|
-
const [leadsRes, citiesRes] = await Promise.all([
|
|
161
|
-
e.DB.prepare(leadsQuery + ' ORDER BY c.name, l.last_name').bind(category).all<CeidgLead>(),
|
|
162
|
-
e.DB.prepare(citiesQuery).bind(category).all<{ name: string; count: number }>(),
|
|
163
|
-
]);
|
|
164
|
-
return {
|
|
165
|
-
brand: { siteName: brand.siteName, description: brand.description },
|
|
166
|
-
copy: {
|
|
167
|
-
heroBadge: copy.heroBadge,
|
|
168
|
-
heroTitle: copy.heroTitle,
|
|
169
|
-
heroSubtitle: copy.heroSubtitle,
|
|
170
|
-
searchPlaceholder: copy.searchPlaceholder ?? 'Szukaj...',
|
|
171
|
-
itemsLabel: copy.itemsLabel ?? 'wpisów',
|
|
172
|
-
itemSingular: copy.itemSingular ?? 'wpisu',
|
|
173
|
-
},
|
|
174
|
-
profiles: (leadsRes.results ?? []).map(leadToProfile),
|
|
175
|
-
cities: citiesRes.results ?? [],
|
|
176
|
-
sections: {
|
|
177
|
-
afterHero: config.sections?.afterHero ?? '',
|
|
178
|
-
afterGrid: config.sections?.afterGrid ?? '',
|
|
179
|
-
},
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const engine = createSSRWorker({ template: trenerTemplate, theme, loadContent });
|
|
184
|
-
|
|
185
|
-
// ─────────── KV cache helper ───────────
|
|
186
|
-
async function cached<T>(kv: KVNamespace, key: string, fn: () => Promise<T>): Promise<T> {
|
|
187
|
-
const hit = await kv.get(key);
|
|
188
|
-
if (hit) return JSON.parse(hit) as T;
|
|
189
|
-
const data = await fn();
|
|
190
|
-
await kv.put(key, JSON.stringify(data), { expirationTtl: KV_TTL });
|
|
191
|
-
return data;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const ck = (suffix: string) => `vertical:${category}:${suffix}`;
|
|
195
|
-
|
|
196
|
-
// ─────────── Static page renderer ───────────
|
|
197
|
-
// Wszystkie strony statyczne idą przez ten helper — theme.layout dostarcza
|
|
198
|
-
// siteName/footer/CSS, wertykal podaje tylko {title, body, description}.
|
|
199
|
-
function renderStatic(page: StaticPage): string {
|
|
200
|
-
return theme.layout({ title: page.title, description: page.description }, page.body);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// ─────────── Routes: legacy / cached / mutations ───────────
|
|
204
|
-
|
|
205
|
-
app.get('/health', async (c) => {
|
|
206
|
-
const { results } = await c.env.DB
|
|
207
|
-
.prepare('SELECT COUNT(*) as cnt FROM lead_categories WHERE category = ?')
|
|
208
|
-
.bind(category)
|
|
209
|
-
.all();
|
|
210
|
-
return c.json({ ok: true, db: results, version, category });
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
app.get('/catalog.json', async (c) => {
|
|
214
|
-
const data = await cached(c.env.CACHE, ck('catalog.json'), async () => {
|
|
215
|
-
const { results } = await c.env.DB
|
|
216
|
-
.prepare(leadsQuery + ' ORDER BY c.name, l.last_name')
|
|
217
|
-
.bind(category)
|
|
218
|
-
.all<CeidgLead>();
|
|
219
|
-
const leads = results ?? [];
|
|
220
|
-
return {
|
|
221
|
-
version: 1,
|
|
222
|
-
source: 'ceidg',
|
|
223
|
-
category,
|
|
224
|
-
count: leads.length,
|
|
225
|
-
items: leads.map((l) => ({
|
|
226
|
-
slug: l.slug,
|
|
227
|
-
firstName: l.first_name,
|
|
228
|
-
lastName: l.last_name,
|
|
229
|
-
city: l.city,
|
|
230
|
-
companyName: l.company_name,
|
|
231
|
-
claimed: !!l.claimed,
|
|
232
|
-
url: '/' + l.slug,
|
|
233
|
-
})),
|
|
234
|
-
};
|
|
235
|
-
});
|
|
236
|
-
return c.json(data);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
app.get('/llms.txt', async (c) => {
|
|
240
|
-
const text = await cached(c.env.CACHE, ck('llms.txt'), async () => {
|
|
241
|
-
const { results } = await c.env.DB
|
|
242
|
-
.prepare(leadsQuery + ' ORDER BY c.name, l.last_name')
|
|
243
|
-
.bind(category)
|
|
244
|
-
.all<CeidgLead>();
|
|
245
|
-
const leads = results ?? [];
|
|
246
|
-
return [
|
|
247
|
-
`# ${llms.title}`,
|
|
248
|
-
llms.intro,
|
|
249
|
-
'',
|
|
250
|
-
'## Wpisy',
|
|
251
|
-
...leads.map((l) => `- ${l.first_name} ${l.last_name} (${l.city ?? '—'}) — /${l.slug}`),
|
|
252
|
-
].join('\n');
|
|
253
|
-
});
|
|
254
|
-
return c.text(text);
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
app.get('/sitemap.xml', async (c) => {
|
|
258
|
-
const host = new URL(c.req.url).origin;
|
|
259
|
-
const xml = await cached(c.env.CACHE, ck(`sitemap:${host}`), async () => {
|
|
260
|
-
const { results } = await c.env.DB
|
|
261
|
-
.prepare(leadsQuery)
|
|
262
|
-
.bind(category)
|
|
263
|
-
.all<CeidgLead>();
|
|
264
|
-
const urls = [host + '/', ...(results ?? []).map((l) => host + '/' + l.slug)];
|
|
265
|
-
return (
|
|
266
|
-
'<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
|
|
267
|
-
urls.map((u) => ` <url><loc>${u}</loc></url>`).join('\n') +
|
|
268
|
-
'\n</urlset>'
|
|
269
|
-
);
|
|
270
|
-
});
|
|
271
|
-
return c.body(xml, 200, { 'content-type': 'application/xml' });
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
app.get('/zasady', (c) => c.html(renderStatic(pages.zasady)));
|
|
275
|
-
if (pages.linkiInfo) {
|
|
276
|
-
app.get('/linki-info', (c) => c.html(renderStatic(pages.linkiInfo!)));
|
|
277
|
-
}
|
|
278
|
-
app.get('/opt-out', (c) => c.html(renderStatic(pages.optOutForm)));
|
|
279
|
-
|
|
280
|
-
app.post('/opt-out', async (c) => {
|
|
281
|
-
const form = await c.req.formData();
|
|
282
|
-
const nip = String(form.get('nip') ?? '').replace(/\D/g, '');
|
|
283
|
-
if (nip.length !== 10) return c.text('Nieprawidłowy NIP', 400);
|
|
284
|
-
|
|
285
|
-
const ip = c.req.header('cf-connecting-ip') ?? '0.0.0.0';
|
|
286
|
-
const rlKey = `vertical:${category}:rl:optout:${ip}`;
|
|
287
|
-
const rlVal = parseInt((await c.env.CACHE.get(rlKey)) ?? '0', 10);
|
|
288
|
-
if (rlVal >= OPTOUT_LIMIT) return c.text('Zbyt wiele prób. Spróbuj ponownie za godzinę.', 429);
|
|
289
|
-
await c.env.CACHE.put(rlKey, String(rlVal + 1), { expirationTtl: OPTOUT_WINDOW_SEC });
|
|
290
|
-
|
|
291
|
-
// Opt-out jest GLOBALNY — usuwa też z innych wertykali. RODO sprzeciw to
|
|
292
|
-
// usunięcie danych osobowych, nie selektywne ukrycie w jednej kategorii.
|
|
293
|
-
// Soft flag (nie DELETE) — defense in depth przeciw masowej sabotage:
|
|
294
|
-
// catalog.json emituje slug+imię+miasto bez NIP, więc adversary musi
|
|
295
|
-
// zmapować slug→NIP przez CEIDG pod rate limit (6000 req/h); dodatkowo
|
|
296
|
-
// soft flag jest natychmiast odwracalny jednym UPDATE gdyby coś poszło nie tak.
|
|
297
|
-
const res = await c.env.DB
|
|
298
|
-
.prepare(
|
|
299
|
-
'UPDATE leads SET opted_out_at = ?, opted_out_ip = ?, opted_out_ua = ? WHERE nip = ? AND opted_out_at IS NULL',
|
|
300
|
-
)
|
|
301
|
-
.bind(Math.floor(Date.now() / 1000), ip, c.req.header('user-agent') ?? '', nip)
|
|
302
|
-
.run();
|
|
303
|
-
|
|
304
|
-
if (!res.meta.changes) return c.text('Nie znaleziono wpisu', 404);
|
|
305
|
-
return c.html(renderStatic(pages.optOutDone));
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
// ─────────── /:slug — claim redirect → engine fallback ───────────
|
|
309
|
-
app.get('/:slug', async (c) => {
|
|
310
|
-
const slug = c.req.param('slug');
|
|
311
|
-
const claim = await c.env.DB
|
|
312
|
-
.prepare('SELECT claimed, external_site_url FROM leads WHERE slug = ?')
|
|
313
|
-
.bind(slug)
|
|
314
|
-
.first<{ claimed: number; external_site_url: string | null }>();
|
|
315
|
-
if (claim?.claimed && claim.external_site_url) {
|
|
316
|
-
return c.redirect(claim.external_site_url);
|
|
317
|
-
}
|
|
318
|
-
return engine.fetch(c.req.raw, c.env);
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
// ─────────── / — engine ───────────
|
|
322
|
-
app.get('/', (c) => engine.fetch(c.req.raw, c.env));
|
|
323
|
-
|
|
324
|
-
return app;
|
|
325
|
-
}
|
package/src/engine-signature.ts
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Engine signatures — pozostałe TRZY tryby wykonania (SSG + Browser).
|
|
3
|
-
* SSR zostało wyciągnięte do runtime/ssr.ts (ma już prawdziwą implementację).
|
|
4
|
-
* Te dwa pozostają jako `declare` do czasu implementacji w runtime/.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { OtwartyTemplate, BuildFile, RenderResult } from './types';
|
|
8
|
-
|
|
9
|
-
/* ─────────────── Faza 1: SSG build do plików statycznych ─────────────── */
|
|
10
|
-
|
|
11
|
-
export interface SSGBuildOpts<C, T> {
|
|
12
|
-
template: OtwartyTemplate<C, T>;
|
|
13
|
-
theme: T;
|
|
14
|
-
/** Treść jest stała na czas builda — z disku, z migracji z Faza 0, z CMS. */
|
|
15
|
-
content: C;
|
|
16
|
-
baseUrl: string;
|
|
17
|
-
/** Output adapter — pisanie plików, upload do CF Pages, push do Codeberg Pages. */
|
|
18
|
-
output: SSGOutput;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface SSGOutput {
|
|
22
|
-
write(file: BuildFile): Promise<void>;
|
|
23
|
-
finalize?(): Promise<void>;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export declare function createSSGBuild<C, T>(opts: SSGBuildOpts<C, T>): Promise<{ files: BuildFile[] }>;
|
|
27
|
-
|
|
28
|
-
/* ─────────────── Browser: live preview w lokalnym edytorze ─────────────── */
|
|
29
|
-
|
|
30
|
-
export interface BrowserRendererOpts<C, T> {
|
|
31
|
-
template: OtwartyTemplate<C, T>;
|
|
32
|
-
theme: T;
|
|
33
|
-
baseUrl: string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface BrowserRenderer<C> {
|
|
37
|
-
/** Renderuje konkretną ścieżkę dla aktualnej treści — używane przez iframe preview. */
|
|
38
|
-
preview(content: C, path: string): RenderResult;
|
|
39
|
-
/** Lista wszystkich ścieżek wyliczonych z treści — używane przez side panel "Strony". */
|
|
40
|
-
listRoutes(content: C): string[];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export declare function createBrowserRenderer<C, T>(opts: BrowserRendererOpts<C, T>): BrowserRenderer<C>;
|