@press2ai/engine 0.6.0 → 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.
@@ -1,276 +0,0 @@
1
- /**
2
- * template-catalog — referencyjna implementacja PresenceTemplate.
3
- *
4
- * Uniwersalny katalog wizytówek (trener, terapeuta, lekarz, ...).
5
- * Engine wywoła render() per request (SSR) albo per route (SSG);
6
- * template nie wie który tryb jest aktywny i nie powinien tego sprawdzać.
7
- */
8
-
9
- import { z } from 'zod';
10
- import type {
11
- PresenceTemplate,
12
- RenderRequest,
13
- RenderContext,
14
- RenderResult,
15
- SitemapEntry,
16
- } from './types';
17
-
18
- /* ─────────────── Theme contract dla tego templatu ───────────────
19
- * Każdy template deklaruje JAKIEGO theme'u potrzebuje. Engine pilnuje
20
- * przez generics że theme przekazany do createWorker() ma te same metody.
21
- */
22
- export interface CatalogTheme {
23
- layout(props: { title: string; description?: string; jsonLd?: object; headExtra?: string }, body: string): string;
24
- catalogHero(p: { badge?: string; title: string; subtitle?: string; searchAction?: string; searchPlaceholder?: string; searchValue?: string }): string;
25
- catalogGrid(p: { title: string; filters?: string; cards: string }): string;
26
- statBar(items: { value: string; label: string; icon?: string }[], summary?: string): string;
27
- categoryNav(items: { href: string; label: string }[], ariaLabel?: string): string;
28
- pagination(p: { current: number; total: number; extraParams?: string }): string;
29
- profileCard(p: CatalogProfile, href: string): string;
30
- profileArticle(p: CatalogProfile): string;
31
- esc(s: string): string;
32
- }
33
-
34
- /* ─────────────── Schema treści ─────────────── */
35
-
36
- const profileSchema = z.object({
37
- slug: z.string(),
38
- firstName: z.string(),
39
- lastName: z.string(),
40
- jobTitle: z.string().default('Specjalista'),
41
- city: z.string().optional(),
42
- bio: z.string().optional(),
43
- photo: z.string().url().optional(),
44
- specialties: z.array(z.string()).default([]),
45
- languages: z.array(z.string()).default([]),
46
- business: z
47
- .object({
48
- name: z.string().optional(),
49
- taxId: z.string().optional(),
50
- classification: z.array(z.string()).default([]),
51
- })
52
- .default({}),
53
- social: z.record(z.string(), z.string()).default({}),
54
- });
55
-
56
- export type CatalogProfile = z.infer<typeof profileSchema>;
57
-
58
- const catalogContentSchema = z.object({
59
- brand: z.object({
60
- siteName: z.string(),
61
- description: z.string(),
62
- }),
63
- copy: z.object({
64
- heroBadge: z.string().optional(),
65
- heroTitle: z.string(),
66
- heroSubtitle: z.string().optional(),
67
- searchPlaceholder: z.string().default('Szukaj...'),
68
- /** Plural label for the count in statBar (e.g. 'trenerów', 'terapeutów'). */
69
- itemsLabel: z.string(),
70
- /** Singular fallback shown on 404 (e.g. 'trenera', 'terapeuty'). */
71
- itemSingular: z.string(),
72
- }),
73
- profiles: z.array(profileSchema),
74
- cities: z.array(z.object({ name: z.string(), count: z.number() })).default([]),
75
- sections: z.object({
76
- afterHero: z.string().default(''),
77
- afterGrid: z.string().default(''),
78
- }).default({}),
79
- });
80
-
81
- export type CatalogContent = z.infer<typeof catalogContentSchema>;
82
-
83
- const PAGE_SIZE = 12;
84
-
85
- /* ─────────────── Template ─────────────── */
86
-
87
- export const catalogTemplate: PresenceTemplate<CatalogContent, CatalogTheme> = {
88
- kind: 'presence',
89
- id: 'catalog',
90
- name: 'Otwarty Katalog',
91
- version: '1.0.0',
92
-
93
- schema: catalogContentSchema,
94
-
95
- defaultContent: {
96
- brand: {
97
- siteName: 'Otwarty Katalog',
98
- description: 'Otwarta baza specjalistów w Polsce. Dane z CEIDG.',
99
- },
100
- copy: {
101
- heroBadge: 'Dane z publicznego rejestru CEIDG',
102
- heroTitle: 'Znajdź specjalistę w Twojej okolicy',
103
- heroSubtitle: 'Otwarty katalog specjalistów. Bezpłatnie i bez rejestracji.',
104
- searchPlaceholder: 'Szukaj po nazwisku, mieście lub firmie...',
105
- itemsLabel: 'wpisów',
106
- itemSingular: 'wpisu',
107
- },
108
- profiles: [],
109
- cities: [],
110
- sections: { afterHero: '', afterGrid: '' },
111
- },
112
-
113
- editor: {
114
- fields: [
115
- { key: 'brand.siteName', label: 'Nazwa serwisu', type: 'text', required: true },
116
- { key: 'brand.description', label: 'Opis serwisu', type: 'textarea', required: true },
117
- { key: 'copy.heroBadge', label: 'Badge nad nagłówkiem', type: 'text' },
118
- { key: 'copy.heroTitle', label: 'Nagłówek hero', type: 'text', required: true },
119
- { key: 'copy.heroSubtitle', label: 'Podtytuł hero', type: 'textarea' },
120
- { key: 'copy.searchPlaceholder', label: 'Placeholder wyszukiwarki', type: 'text' },
121
- ],
122
- sections: [
123
- { id: 'brand', title: 'Marka', fieldKeys: ['brand.siteName', 'brand.description'] },
124
- { id: 'hero', title: 'Sekcja hero', fieldKeys: ['copy.heroBadge', 'copy.heroTitle', 'copy.heroSubtitle', 'copy.searchPlaceholder'] },
125
- ],
126
- },
127
-
128
- dataSources: [
129
- {
130
- id: 'ceidg',
131
- refresh: 'daily',
132
- async fetch({ taxId }) {
133
- if (!taxId) return {};
134
- return { profiles: [] };
135
- },
136
- },
137
- ],
138
-
139
- routes(content) {
140
- return [
141
- { path: '/', cacheControl: 'public, s-maxage=300' },
142
- {
143
- path: '/:slug',
144
- enumerate: () => content.profiles.map((p) => ({ slug: p.slug })),
145
- cacheControl: 'public, s-maxage=300',
146
- },
147
- ];
148
- },
149
-
150
- render(req, ctx) {
151
- if (req.params.slug) return renderProfile(req, ctx);
152
- return renderIndex(req, ctx);
153
- },
154
-
155
- sitemap(content, baseUrl) {
156
- const entries: SitemapEntry[] = [
157
- { loc: `${baseUrl}/`, priority: 1.0, changefreq: 'daily' },
158
- ];
159
- for (const p of content.profiles) {
160
- entries.push({ loc: `${baseUrl}/${p.slug}`, priority: 0.8, changefreq: 'weekly' });
161
- }
162
- return entries;
163
- },
164
-
165
- meta: {
166
- locale: 'pl-PL',
167
- description: 'Katalog specjalistów',
168
- suggestedTheme: 'specialist-glossy',
169
- tags: ['katalog', 'wizytówka', 'rejestr-publiczny'],
170
- },
171
- };
172
-
173
- /* ─────────────── Render handlers (private) ─────────────── */
174
-
175
- function renderIndex(req: RenderRequest<CatalogContent>, ctx: RenderContext<CatalogTheme>): RenderResult {
176
- const { content } = req;
177
- const t = ctx.theme;
178
- const q = (req.query.q ?? '').trim();
179
- const page = Math.max(1, parseInt(req.query.p ?? '1', 10) || 1);
180
-
181
- let filtered = content.profiles;
182
- if (q) {
183
- const ql = q.toLowerCase();
184
- filtered = filtered.filter(
185
- (p) =>
186
- `${p.firstName} ${p.lastName}`.toLowerCase().includes(ql) ||
187
- (p.city ?? '').toLowerCase().includes(ql) ||
188
- (p.business?.name ?? '').toLowerCase().includes(ql),
189
- );
190
- }
191
-
192
- const total = filtered.length;
193
- const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
194
- const slice = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
195
-
196
- const hero = t.catalogHero({
197
- badge: content.copy.heroBadge,
198
- title: content.copy.heroTitle,
199
- subtitle: content.copy.heroSubtitle,
200
- searchAction: '/',
201
- searchPlaceholder: content.copy.searchPlaceholder,
202
- searchValue: q,
203
- });
204
-
205
- const stats = t.statBar([
206
- { value: total.toLocaleString('pl'), label: content.copy.itemsLabel, icon: 'people' },
207
- { value: String(content.cities.length), label: 'miast', icon: 'city' },
208
- { value: '100%', label: 'bezpłatnie', icon: 'free' },
209
- ]);
210
-
211
- const cityNav = t.categoryNav(
212
- content.cities.slice(0, 8).map((c) => ({
213
- href: `/?q=${encodeURIComponent(c.name)}`,
214
- label: `${c.name} (${c.count})`,
215
- })),
216
- 'Popularne miasta',
217
- );
218
-
219
- const cards = slice.map((p) => t.profileCard(p, `/${p.slug}`)).join('\n');
220
- const grid = t.catalogGrid({
221
- title: q ? `Wyniki dla „${t.esc(q)}" (${total})` : content.brand.siteName,
222
- cards,
223
- });
224
-
225
- const pag = t.pagination({
226
- current: page,
227
- total: totalPages,
228
- extraParams: q ? `&q=${encodeURIComponent(q)}` : '',
229
- });
230
-
231
- const body = `${hero}${stats}${content.sections.afterHero}${cityNav}${grid}${pag}${content.sections.afterGrid}`;
232
-
233
- const jsonLd = {
234
- '@context': 'https://schema.org',
235
- '@type': 'WebSite',
236
- name: content.brand.siteName,
237
- url: ctx.baseUrl,
238
- description: content.brand.description,
239
- };
240
-
241
- return {
242
- body: t.layout({ title: content.brand.siteName, description: content.brand.description, jsonLd }, body),
243
- };
244
- }
245
-
246
- function renderProfile(req: RenderRequest<CatalogContent>, ctx: RenderContext<CatalogTheme>): RenderResult {
247
- const profile = req.content.profiles.find((p) => p.slug === req.params.slug);
248
- if (!profile) {
249
- return {
250
- status: 404,
251
- body: ctx.theme.layout({ title: 'Nie znaleziono' }, `<h1>404</h1><p>Brak takiego ${req.content.copy.itemSingular}.</p>`),
252
- };
253
- }
254
-
255
- const t = ctx.theme;
256
- const fullName = `${profile.firstName} ${profile.lastName}`;
257
- const description = `${fullName} — ${profile.jobTitle}${profile.city ? ` w ${profile.city}` : ''}`;
258
-
259
- const jsonLd = {
260
- '@context': 'https://schema.org',
261
- '@type': 'Person',
262
- name: fullName,
263
- jobTitle: profile.jobTitle,
264
- url: `${ctx.baseUrl}/${profile.slug}`,
265
- ...(profile.city && {
266
- address: { '@type': 'PostalAddress', addressLocality: profile.city, addressCountry: 'PL' },
267
- }),
268
- };
269
-
270
- return {
271
- body: t.layout(
272
- { title: `${fullName} | ${req.content.brand.siteName}`, description, jsonLd },
273
- t.profileArticle(profile),
274
- ),
275
- };
276
- }
package/src/types.ts DELETED
@@ -1,232 +0,0 @@
1
- /**
2
- * @press2ai/engine — core types
3
- *
4
- * Cały design stoi na jednej zasadzie:
5
- * render = pure function (content + theme + request) → html + meta
6
- * Dzięki temu ten sam template uruchamiamy w trzech trybach:
7
- * - SSR: w CF Workerze multi-tenant (Faza 0, content z D1)
8
- * - SSG: build statyczny (Faza 1, deploy do CF Pages / Codeberg Pages)
9
- * - Browser: live preview w lokalnym edytorze
10
- * Engine jest tylko cienką warstwą routingu + ładowania content; cała
11
- * inteligencja siedzi w templatach.
12
- */
13
-
14
- import type { ZodType, ZodTypeDef } from 'zod';
15
-
16
- /**
17
- * Schema generic — `ZodType<C, ZodTypeDef, any>` zamiast `ZodType<C>` żeby
18
- * akceptować schematy z `.default()` (gdzie input-type ≠ output-type).
19
- * Bez tego template nie mógłby używać `z.array(...).default([])`.
20
- */
21
- type ContentSchema<C> = ZodType<C, ZodTypeDef, any>;
22
-
23
- /* ─────────────── Discriminator: Presence vs Commerce ─────────────── */
24
-
25
- /**
26
- * PresenceTemplate — wizytówki, blogi, portfolio, landingi.
27
- * Działa w Fazie 0 (trial multi-tenant) i Fazie 1 (sovereign).
28
- *
29
- * CommerceTemplate — sklepy. Wymaga Fazy 1 (własne konta usera u
30
- * operatorów płatności + na infrze). NIGDY nie tworzymy commerce
31
- * tenanta pod kontami platformy — gate prawny (PCI DSS, JPK, RODO).
32
- */
33
- export type OtwartyTemplate<C = unknown, T = unknown> =
34
- | PresenceTemplate<C, T>
35
- | CommerceTemplate<C, T>;
36
-
37
- interface BaseTemplate<C, T> {
38
- /** Unikalny identyfikator templatu, np. 'trener', 'lekarz', 'blog'. */
39
- id: string;
40
- /** Nazwa do wyświetlenia w wizardzie / panelu. */
41
- name: string;
42
- /** Semver paczki npm — używane w wizardzie do migracji. */
43
- version: string;
44
-
45
- /** Zod schema kształtu treści. Engine waliduje przy zapisie. */
46
- schema: ContentSchema<C>;
47
- /** Domyślna treść dla świeżego tenanta (seed). */
48
- defaultContent: C;
49
-
50
- /** Auto-generowany formularz edytora (Faza 0). */
51
- editor: EditorSchema;
52
-
53
- /** Deklaratywne źródła danych (CEIDG, RPWDL, ...). Engine wywołuje. */
54
- dataSources?: DataSource<C>[];
55
-
56
- /** Wyliczenie istniejących URL-i dla danej treści. */
57
- routes(content: C): RouteDescriptor<C>[];
58
-
59
- /** Czysta funkcja renderowania. Wywoływana per request (SSR) lub per route (SSG). */
60
- render(req: RenderRequest<C>, ctx: RenderContext<T>): RenderResult;
61
-
62
- /** SEO + maszynowa odkrywalność. */
63
- sitemap(content: C, baseUrl: string): SitemapEntry[];
64
- robots?(content: C): string;
65
- feed?(content: C, baseUrl: string): RssFeed | null;
66
-
67
- /** Metadane templatu (nie treści). */
68
- meta: TemplateMeta;
69
- }
70
-
71
- export interface PresenceTemplate<C = unknown, T = unknown> extends BaseTemplate<C, T> {
72
- kind: 'presence';
73
- }
74
-
75
- export interface CommerceTemplate<C = unknown, T = unknown> extends BaseTemplate<C, T> {
76
- kind: 'commerce';
77
- /** Type-level gate: commerce zawsze wymaga Fazy 1. */
78
- requiresPhase1: true;
79
- /** Sloty providerów wypełniane przez wizard migracji. */
80
- providers: CommerceProviders;
81
- /** Demo mode — w Fazie 0 wolno pokazać podgląd, ale checkout musi być fake. */
82
- demoMode?: { fakeCheckout: true };
83
- }
84
-
85
- /* ─────────────── Routes & rendering ─────────────── */
86
-
87
- export interface RouteDescriptor<C> {
88
- /** Wzorzec URL-a, np. '/' albo '/blog/:slug'. */
89
- path: string;
90
- /** Dla SSG: wylicz wszystkie konkretne wartości parametrów z treści. */
91
- enumerate?(content: C): Array<Record<string, string>>;
92
- /** Hint dla CDN cache. */
93
- cacheControl?: string;
94
- }
95
-
96
- export interface RenderRequest<C> {
97
- /** Konkretna ścieżka URL (już dopasowana do route'a). */
98
- path: string;
99
- /** Dopasowane parametry route'a, np. { slug: 'anna-kowalska' }. */
100
- params: Record<string, string>;
101
- /** Query string, np. { q: 'warszawa', p: '2' }. */
102
- query: Record<string, string>;
103
- /** Treść tenanta (już załadowana przez engine). */
104
- content: C;
105
- }
106
-
107
- export interface RenderContext<T> {
108
- /** Bezwzględny URL bazowy (host + scheme), np. 'https://anna.otwarty-trener.pl'. */
109
- baseUrl: string;
110
- /** Theme (theme-specialist-glossy itd.). Type T określa kontrakt per template. */
111
- theme: T;
112
- /** Lokalizacja, np. 'pl-PL'. */
113
- locale: string;
114
- /** Tryb wykonania — pozwala templatom uniknąć dynamiki w SSG. */
115
- mode: 'ssr' | 'ssg' | 'browser';
116
- }
117
-
118
- export interface RenderResult {
119
- status?: number;
120
- headers?: Record<string, string>;
121
- body: string;
122
- }
123
-
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.
129
-
130
- /* ─────────────── Editor (Faza 0) ─────────────── */
131
-
132
- export interface EditorSchema {
133
- fields: EditorField[];
134
- sections?: EditorSection[];
135
- }
136
-
137
- export interface EditorField {
138
- /** Ścieżka w content, np. 'profile.bio' (kropka = zagnieżdżenie). */
139
- key: string;
140
- label: string;
141
- type: 'text' | 'textarea' | 'email' | 'url' | 'phone' | 'image' | 'select' | 'array' | 'date';
142
- required?: boolean;
143
- placeholder?: string;
144
- help?: string;
145
- /** Dla 'select'. */
146
- options?: string[];
147
- /** Dla 'array' — schemat pojedynczego elementu. */
148
- itemSchema?: EditorField[];
149
- }
150
-
151
- export interface EditorSection {
152
- id: string;
153
- title: string;
154
- fieldKeys: string[];
155
- }
156
-
157
- /* ─────────────── Data sources ─────────────── */
158
-
159
- export interface DataSource<C> {
160
- id: string;
161
- /** Engine wywołuje z kluczami identyfikacyjnymi (NIP, email, ...). */
162
- fetch(input: DataSourceInput): Promise<Partial<C>>;
163
- /** Częstotliwość auto-refresh w trybie sovereign (manual = nigdy). */
164
- refresh?: 'manual' | 'daily' | 'weekly';
165
- }
166
-
167
- export interface DataSourceInput {
168
- taxId?: string;
169
- email?: string;
170
- slug?: string;
171
- }
172
-
173
- /* ─────────────── SEO / discovery ─────────────── */
174
-
175
- export interface SitemapEntry {
176
- loc: string;
177
- lastmod?: string;
178
- changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
179
- priority?: number;
180
- }
181
-
182
- export interface RssFeed {
183
- title: string;
184
- link: string;
185
- description: string;
186
- items: RssItem[];
187
- }
188
-
189
- export interface RssItem {
190
- title: string;
191
- link: string;
192
- guid: string;
193
- pubDate: string;
194
- description?: string;
195
- content?: string;
196
- }
197
-
198
- /* ─────────────── Commerce providers ─────────────── */
199
-
200
- export interface CommerceProviders {
201
- payment: ProviderSlot<PaymentProviderId>;
202
- shipping?: ProviderSlot<ShippingProviderId>;
203
- invoice?: ProviderSlot<InvoiceProviderId>;
204
- }
205
-
206
- export type PaymentProviderId = 'stripe' | 'p24' | 'payu' | 'blik' | 'paypal';
207
- export type ShippingProviderId = 'inpost' | 'dpd' | 'pickup' | 'manual';
208
- export type InvoiceProviderId = 'gus' | 'fakturownia' | 'manual';
209
-
210
- export interface ProviderSlot<T extends string> {
211
- required: boolean;
212
- available: T[];
213
- default?: T;
214
- }
215
-
216
- /* ─────────────── Engine outputs ─────────────── */
217
-
218
- /** Plik wyprodukowany przez SSG build. */
219
- export interface BuildFile {
220
- path: string; // np. '/index.html', '/blog/foo/index.html', '/sitemap.xml'
221
- content: string;
222
- contentType: string;
223
- }
224
-
225
- export interface TemplateMeta {
226
- locale: string;
227
- description: string;
228
- /** Sugerowany theme — engine użyje go, jeśli user nie wybierze innego. */
229
- suggestedTheme?: string;
230
- /** Tagi do wyświetlania w katalogu templatów ('wizytówka', 'blog', 'sklep'). */
231
- tags?: string[];
232
- }