@press2ai/engine 0.1.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 +29 -0
- package/src/engine-signature.ts +43 -0
- package/src/index.ts +33 -0
- package/src/runtime/ssr.ts +232 -0
- package/src/template-blog.ts +132 -0
- package/src/template-trener.ts +282 -0
- package/src/type-tests.ts +28 -0
- package/src/types.ts +237 -0
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@press2ai/engine",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Multi-tenant runtime + template contracts dla otwarty-* verticali. SSR na Cloudflare Workers, isomorphic renderer (SSG/browser w roadmapie).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"files": [
|
|
8
|
+
"src",
|
|
9
|
+
"README.md"
|
|
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
|
+
"./types": "./src/types.ts"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"check": "tsc --noEmit"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"zod": "^3.23.8"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"typescript": "^5.5.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
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>;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @press2ai/engine — public exports.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Types — kontrakty templatów
|
|
6
|
+
export type {
|
|
7
|
+
OtwartyTemplate,
|
|
8
|
+
PresenceTemplate,
|
|
9
|
+
CommerceTemplate,
|
|
10
|
+
RouteDescriptor,
|
|
11
|
+
RenderRequest,
|
|
12
|
+
RenderContext,
|
|
13
|
+
RenderResult,
|
|
14
|
+
HeadMeta,
|
|
15
|
+
EditorSchema,
|
|
16
|
+
EditorField,
|
|
17
|
+
EditorSection,
|
|
18
|
+
DataSource,
|
|
19
|
+
DataSourceInput,
|
|
20
|
+
SitemapEntry,
|
|
21
|
+
RssFeed,
|
|
22
|
+
RssItem,
|
|
23
|
+
CommerceProviders,
|
|
24
|
+
PaymentProviderId,
|
|
25
|
+
ShippingProviderId,
|
|
26
|
+
InvoiceProviderId,
|
|
27
|
+
ProviderSlot,
|
|
28
|
+
BuildFile,
|
|
29
|
+
TemplateMeta,
|
|
30
|
+
} from './types';
|
|
31
|
+
|
|
32
|
+
// Runtime — Faza 0 SSR multi-tenant
|
|
33
|
+
export { createSSRWorker, type SSRWorkerOpts, type OtwartyWorker } from './runtime/ssr';
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createSSRWorker — Faza 0 multi-tenant runtime na CF Worker.
|
|
3
|
+
*
|
|
4
|
+
* Bierze OtwartyTemplate + theme + content loader. Per request:
|
|
5
|
+
* 1. Parsuje URL → host, path, query
|
|
6
|
+
* 2. Obsługuje wbudowane endpointy: /sitemap.xml, /robots.txt, /feed.xml
|
|
7
|
+
* 3. Ładuje content przez loadContent(host, env) — multi-tenancy hook
|
|
8
|
+
* 4. Matchuje path do template.routes() → wyciąga params
|
|
9
|
+
* 5. Wywołuje template.render(req, ctx) — czystą funkcję
|
|
10
|
+
* 6. Zwraca Response z body + nagłówkami z RenderResult
|
|
11
|
+
*
|
|
12
|
+
* Mutacje (POST/PUT/DELETE) NIE należą do engine v0.1 — landing wrapper
|
|
13
|
+
* trzyma własne handlery dla opt-out, claim, edycji. Engine jest GET-only.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
OtwartyTemplate,
|
|
18
|
+
RouteDescriptor,
|
|
19
|
+
RenderRequest,
|
|
20
|
+
RenderContext,
|
|
21
|
+
SitemapEntry,
|
|
22
|
+
RssFeed,
|
|
23
|
+
} from '../types';
|
|
24
|
+
|
|
25
|
+
export interface SSRWorkerOpts<C, T> {
|
|
26
|
+
template: OtwartyTemplate<C, T>;
|
|
27
|
+
theme: T;
|
|
28
|
+
/** Resolver host → tenant content. Engine cache'uje po stronie KV jeśli podany. */
|
|
29
|
+
loadContent(host: string, env: unknown): Promise<C | null>;
|
|
30
|
+
/** Override base URL (np. dla preview albo custom domains). */
|
|
31
|
+
baseUrlFor?(host: string): string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface OtwartyWorker {
|
|
35
|
+
fetch(request: Request, env: unknown, ctx?: unknown): Promise<Response>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createSSRWorker<C, T>(opts: SSRWorkerOpts<C, T>): OtwartyWorker {
|
|
39
|
+
return {
|
|
40
|
+
async fetch(request, env): Promise<Response> {
|
|
41
|
+
// Engine obsługuje wyłącznie GET; mutacje są poza zakresem.
|
|
42
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
43
|
+
return new Response('Method not allowed', { status: 405 });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const url = new URL(request.url);
|
|
47
|
+
const host = url.host;
|
|
48
|
+
const baseUrl = opts.baseUrlFor?.(host) ?? `${url.protocol}//${host}`;
|
|
49
|
+
|
|
50
|
+
// Wbudowane endpointy obsługiwane przez engine bezpośrednio
|
|
51
|
+
// (nie wymagają pełnego renderera, biorą dane z metod templatu).
|
|
52
|
+
if (url.pathname === '/sitemap.xml') {
|
|
53
|
+
return handleSitemap(opts, host, env, baseUrl);
|
|
54
|
+
}
|
|
55
|
+
if (url.pathname === '/robots.txt') {
|
|
56
|
+
return handleRobots(opts, host, env);
|
|
57
|
+
}
|
|
58
|
+
if (url.pathname === '/feed.xml' || url.pathname === '/rss.xml') {
|
|
59
|
+
return handleFeed(opts, host, env, baseUrl);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Standard render path
|
|
63
|
+
const content = await opts.loadContent(host, env);
|
|
64
|
+
if (content === null) {
|
|
65
|
+
return new Response('Not found', { status: 404 });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const routes = opts.template.routes(content);
|
|
69
|
+
const match = matchRoute(url.pathname, routes);
|
|
70
|
+
if (!match) {
|
|
71
|
+
return new Response('Not found', { status: 404 });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const query: Record<string, string> = {};
|
|
75
|
+
url.searchParams.forEach((v, k) => { query[k] = v; });
|
|
76
|
+
|
|
77
|
+
const req: RenderRequest<C> = {
|
|
78
|
+
path: url.pathname,
|
|
79
|
+
params: match.params,
|
|
80
|
+
query,
|
|
81
|
+
content,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const ctx: RenderContext<T> = {
|
|
85
|
+
baseUrl,
|
|
86
|
+
theme: opts.theme,
|
|
87
|
+
locale: opts.template.meta.locale,
|
|
88
|
+
mode: 'ssr',
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const result = opts.template.render(req, ctx);
|
|
92
|
+
|
|
93
|
+
const headers: Record<string, string> = {
|
|
94
|
+
'content-type': 'text/html; charset=utf-8',
|
|
95
|
+
};
|
|
96
|
+
// Cache-Control z route descriptor (jeśli podany) — pozwala CDN cachować
|
|
97
|
+
if (match.route.cacheControl) {
|
|
98
|
+
headers['cache-control'] = match.route.cacheControl;
|
|
99
|
+
}
|
|
100
|
+
// Nagłówki z RenderResult przesłaniają wszystko (template wie najlepiej)
|
|
101
|
+
Object.assign(headers, result.headers ?? {});
|
|
102
|
+
|
|
103
|
+
return new Response(result.body, {
|
|
104
|
+
status: result.status ?? 200,
|
|
105
|
+
headers,
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* ─────────────── Wbudowane endpointy ─────────────── */
|
|
112
|
+
|
|
113
|
+
async function handleSitemap<C, T>(
|
|
114
|
+
opts: SSRWorkerOpts<C, T>,
|
|
115
|
+
host: string,
|
|
116
|
+
env: unknown,
|
|
117
|
+
baseUrl: string,
|
|
118
|
+
): Promise<Response> {
|
|
119
|
+
const content = await opts.loadContent(host, env);
|
|
120
|
+
if (content === null) return new Response('Not found', { status: 404 });
|
|
121
|
+
const entries = opts.template.sitemap(content, baseUrl);
|
|
122
|
+
return new Response(renderSitemapXml(entries), {
|
|
123
|
+
headers: {
|
|
124
|
+
'content-type': 'application/xml; charset=utf-8',
|
|
125
|
+
'cache-control': 'public, s-maxage=86400',
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function handleRobots<C, T>(
|
|
131
|
+
opts: SSRWorkerOpts<C, T>,
|
|
132
|
+
host: string,
|
|
133
|
+
env: unknown,
|
|
134
|
+
): Promise<Response> {
|
|
135
|
+
if (!opts.template.robots) {
|
|
136
|
+
return new Response('User-agent: *\nAllow: /\n', { headers: { 'content-type': 'text/plain' } });
|
|
137
|
+
}
|
|
138
|
+
const content = await opts.loadContent(host, env);
|
|
139
|
+
if (content === null) return new Response('Not found', { status: 404 });
|
|
140
|
+
return new Response(opts.template.robots(content), {
|
|
141
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function handleFeed<C, T>(
|
|
146
|
+
opts: SSRWorkerOpts<C, T>,
|
|
147
|
+
host: string,
|
|
148
|
+
env: unknown,
|
|
149
|
+
baseUrl: string,
|
|
150
|
+
): Promise<Response> {
|
|
151
|
+
if (!opts.template.feed) return new Response('Not found', { status: 404 });
|
|
152
|
+
const content = await opts.loadContent(host, env);
|
|
153
|
+
if (content === null) return new Response('Not found', { status: 404 });
|
|
154
|
+
const feed = opts.template.feed(content, baseUrl);
|
|
155
|
+
if (!feed) return new Response('Not found', { status: 404 });
|
|
156
|
+
return new Response(renderRssXml(feed), {
|
|
157
|
+
headers: {
|
|
158
|
+
'content-type': 'application/rss+xml; charset=utf-8',
|
|
159
|
+
'cache-control': 'public, s-maxage=3600',
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* ─────────────── Routing ─────────────── */
|
|
165
|
+
|
|
166
|
+
interface RouteMatch<C> {
|
|
167
|
+
route: RouteDescriptor<C>;
|
|
168
|
+
params: Record<string, string>;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function matchRoute<C>(pathname: string, routes: RouteDescriptor<C>[]): RouteMatch<C> | null {
|
|
172
|
+
// Statyczne routes (bez :param) mają wyższy priorytet niż dynamiczne.
|
|
173
|
+
// Bez tego /sitemap.xml byłoby przechwycone przez /:slug.
|
|
174
|
+
const sorted = [...routes].sort((a, b) => {
|
|
175
|
+
const aDyn = a.path.includes(':');
|
|
176
|
+
const bDyn = b.path.includes(':');
|
|
177
|
+
if (aDyn === bDyn) return 0;
|
|
178
|
+
return aDyn ? 1 : -1;
|
|
179
|
+
});
|
|
180
|
+
for (const route of sorted) {
|
|
181
|
+
const params = matchPath(pathname, route.path);
|
|
182
|
+
if (params) return { route, params };
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function matchPath(pathname: string, pattern: string): Record<string, string> | null {
|
|
188
|
+
const patternParts = pattern.split('/').filter(Boolean);
|
|
189
|
+
const pathParts = pathname.split('/').filter(Boolean);
|
|
190
|
+
if (patternParts.length !== pathParts.length) return null;
|
|
191
|
+
|
|
192
|
+
const params: Record<string, string> = {};
|
|
193
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
194
|
+
const pp = patternParts[i]!;
|
|
195
|
+
const ap = pathParts[i]!;
|
|
196
|
+
if (pp.startsWith(':')) {
|
|
197
|
+
params[pp.slice(1)] = decodeURIComponent(ap);
|
|
198
|
+
} else if (pp !== ap) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return params;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/* ─────────────── XML helpers ─────────────── */
|
|
206
|
+
|
|
207
|
+
function renderSitemapXml(entries: SitemapEntry[]): string {
|
|
208
|
+
const items = entries
|
|
209
|
+
.map((e) => {
|
|
210
|
+
const lastmod = e.lastmod ? `<lastmod>${e.lastmod}</lastmod>` : '';
|
|
211
|
+
const changefreq = e.changefreq ? `<changefreq>${e.changefreq}</changefreq>` : '';
|
|
212
|
+
const priority = e.priority !== undefined ? `<priority>${e.priority}</priority>` : '';
|
|
213
|
+
return ` <url><loc>${escapeXml(e.loc)}</loc>${lastmod}${changefreq}${priority}</url>`;
|
|
214
|
+
})
|
|
215
|
+
.join('\n');
|
|
216
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${items}\n</urlset>`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function renderRssXml(feed: RssFeed): string {
|
|
220
|
+
const items = feed.items
|
|
221
|
+
.map((i) => {
|
|
222
|
+
const desc = i.description ? `<description>${escapeXml(i.description)}</description>` : '';
|
|
223
|
+
const ce = i.content ? `<content:encoded><![CDATA[${i.content}]]></content:encoded>` : '';
|
|
224
|
+
return `<item><title>${escapeXml(i.title)}</title><link>${escapeXml(i.link)}</link><guid>${escapeXml(i.guid)}</guid><pubDate>${i.pubDate}</pubDate>${desc}${ce}</item>`;
|
|
225
|
+
})
|
|
226
|
+
.join('\n');
|
|
227
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">\n<channel>\n<title>${escapeXml(feed.title)}</title>\n<link>${escapeXml(feed.link)}</link>\n<description>${escapeXml(feed.description)}</description>\n${items}\n</channel>\n</rss>`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function escapeXml(s: string): string {
|
|
231
|
+
return s.replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[c]!);
|
|
232
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* template-blog — drugi referencyjny PresenceTemplate.
|
|
3
|
+
* Jego ROLĄ jest udowodnić że abstrakcja przeżywa radikalnie inny kształt
|
|
4
|
+
* niż katalog wizytówek: enumeracja postów per :slug, RSS feed, brak
|
|
5
|
+
* data sources (treść w pełni manualna). Render jest minimalistyczny —
|
|
6
|
+
* theme contract zostawiamy płytki, bo blog może działać na dowolnym
|
|
7
|
+
* theme który ma `layout` + funkcję renderującą markdown body.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import type { PresenceTemplate, RenderRequest, RenderContext, RenderResult, RssFeed } from './types';
|
|
12
|
+
|
|
13
|
+
export interface BlogTheme {
|
|
14
|
+
layout(props: { title: string; description?: string; jsonLd?: object }, body: string): string;
|
|
15
|
+
/** Zamienia markdown na HTML — implementacja po stronie theme. */
|
|
16
|
+
markdown(md: string): string;
|
|
17
|
+
esc(s: string): string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const postSchema = z.object({
|
|
21
|
+
slug: z.string(),
|
|
22
|
+
title: z.string(),
|
|
23
|
+
pubDate: z.string(), // ISO 8601
|
|
24
|
+
author: z.string().optional(),
|
|
25
|
+
excerpt: z.string().optional(),
|
|
26
|
+
body: z.string(), // markdown
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const blogContentSchema = z.object({
|
|
30
|
+
brand: z.object({ siteName: z.string(), description: z.string(), authorEmail: z.string().email().optional() }),
|
|
31
|
+
posts: z.array(postSchema).default([]),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export type BlogPost = z.infer<typeof postSchema>;
|
|
35
|
+
export type BlogContent = z.infer<typeof blogContentSchema>;
|
|
36
|
+
|
|
37
|
+
export const blogTemplate: PresenceTemplate<BlogContent, BlogTheme> = {
|
|
38
|
+
kind: 'presence',
|
|
39
|
+
id: 'blog',
|
|
40
|
+
name: 'Otwarty Blog',
|
|
41
|
+
version: '1.0.0',
|
|
42
|
+
schema: blogContentSchema,
|
|
43
|
+
defaultContent: {
|
|
44
|
+
brand: { siteName: 'Mój blog', description: 'Notatki, eseje, myśli.' },
|
|
45
|
+
posts: [],
|
|
46
|
+
},
|
|
47
|
+
editor: {
|
|
48
|
+
fields: [
|
|
49
|
+
{ key: 'brand.siteName', label: 'Tytuł bloga', type: 'text', required: true },
|
|
50
|
+
{ key: 'brand.description', label: 'Opis bloga', type: 'textarea', required: true },
|
|
51
|
+
{
|
|
52
|
+
key: 'posts',
|
|
53
|
+
label: 'Posty',
|
|
54
|
+
type: 'array',
|
|
55
|
+
itemSchema: [
|
|
56
|
+
{ key: 'title', label: 'Tytuł', type: 'text', required: true },
|
|
57
|
+
{ key: 'slug', label: 'Slug URL', type: 'text', required: true },
|
|
58
|
+
{ key: 'pubDate', label: 'Data publikacji', type: 'date', required: true },
|
|
59
|
+
{ key: 'excerpt', label: 'Zajawka', type: 'textarea' },
|
|
60
|
+
{ key: 'body', label: 'Treść (markdown)', type: 'textarea', required: true },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
routes(content) {
|
|
66
|
+
return [
|
|
67
|
+
{ path: '/' },
|
|
68
|
+
{ path: '/blog/:slug', enumerate: () => content.posts.map((p) => ({ slug: p.slug })) },
|
|
69
|
+
{ path: '/rss.xml' },
|
|
70
|
+
];
|
|
71
|
+
},
|
|
72
|
+
render(req, ctx) {
|
|
73
|
+
if (req.path === '/rss.xml') return renderRssXml(req, ctx);
|
|
74
|
+
if (req.params.slug) return renderPost(req, ctx);
|
|
75
|
+
return renderIndex(req, ctx);
|
|
76
|
+
},
|
|
77
|
+
sitemap(content, baseUrl) {
|
|
78
|
+
return [
|
|
79
|
+
{ loc: `${baseUrl}/`, priority: 1.0, changefreq: 'weekly' },
|
|
80
|
+
...content.posts.map((p) => ({ loc: `${baseUrl}/blog/${p.slug}`, lastmod: p.pubDate, priority: 0.7 })),
|
|
81
|
+
];
|
|
82
|
+
},
|
|
83
|
+
feed(content, baseUrl): RssFeed {
|
|
84
|
+
return {
|
|
85
|
+
title: content.brand.siteName,
|
|
86
|
+
link: baseUrl,
|
|
87
|
+
description: content.brand.description,
|
|
88
|
+
items: content.posts.map((p) => ({
|
|
89
|
+
title: p.title,
|
|
90
|
+
link: `${baseUrl}/blog/${p.slug}`,
|
|
91
|
+
guid: `${baseUrl}/blog/${p.slug}`,
|
|
92
|
+
pubDate: p.pubDate,
|
|
93
|
+
description: p.excerpt,
|
|
94
|
+
})),
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
meta: { locale: 'pl-PL', description: 'Personal blog', suggestedTheme: 'specialist-glossy', tags: ['blog', 'markdown'] },
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function renderIndex(req: RenderRequest<BlogContent>, ctx: RenderContext<BlogTheme>): RenderResult {
|
|
101
|
+
const { content } = req;
|
|
102
|
+
const items = content.posts
|
|
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
|
+
.join('\n');
|
|
105
|
+
return {
|
|
106
|
+
head: { title: content.brand.siteName, description: content.brand.description, canonical: `${ctx.baseUrl}/` },
|
|
107
|
+
body: ctx.theme.layout({ title: content.brand.siteName, description: content.brand.description }, `<h1>${ctx.theme.esc(content.brand.siteName)}</h1>${items}`),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function renderPost(req: RenderRequest<BlogContent>, ctx: RenderContext<BlogTheme>): RenderResult {
|
|
112
|
+
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>') };
|
|
114
|
+
return {
|
|
115
|
+
head: { title: `${post.title} | ${req.content.brand.siteName}`, description: post.excerpt, canonical: `${ctx.baseUrl}/blog/${post.slug}` },
|
|
116
|
+
body: ctx.theme.layout(
|
|
117
|
+
{ title: post.title, description: post.excerpt },
|
|
118
|
+
`<article><h1>${ctx.theme.esc(post.title)}</h1><time>${ctx.theme.esc(post.pubDate)}</time>${ctx.theme.markdown(post.body)}</article>`,
|
|
119
|
+
),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function renderRssXml(req: RenderRequest<BlogContent>, ctx: RenderContext<BlogTheme>): RenderResult {
|
|
124
|
+
// Engine zazwyczaj wywoła template.feed() i sam zbuduje XML — to fallback
|
|
125
|
+
// dla prostej ścieżki SSR gdzie chcemy odpowiedzieć bez warstwy engine'u.
|
|
126
|
+
const feed = blogTemplate.feed!(req.content, ctx.baseUrl)!;
|
|
127
|
+
const items = feed.items
|
|
128
|
+
.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
|
+
.join('');
|
|
130
|
+
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 };
|
|
132
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* template-trener — referencyjna implementacja PresenceTemplate.
|
|
3
|
+
*
|
|
4
|
+
* Zawiera pełen flow który dziś jest w OTWARTY-TRENER/landing/src/index.tsx,
|
|
5
|
+
* tylko jako czyste funkcje renderujące zamiast handlerów Hono. Engine
|
|
6
|
+
* wywoła render() per request (SSR) albo per route (SSG); template nie
|
|
7
|
+
* wie który tryb jest aktywny i nie powinien tego sprawdzać.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import type {
|
|
12
|
+
PresenceTemplate,
|
|
13
|
+
RenderRequest,
|
|
14
|
+
RenderContext,
|
|
15
|
+
RenderResult,
|
|
16
|
+
SitemapEntry,
|
|
17
|
+
} from './types';
|
|
18
|
+
|
|
19
|
+
/* ─────────────── Theme contract dla tego templatu ───────────────
|
|
20
|
+
* Każdy template deklaruje JAKIEGO theme'u potrzebuje. Engine pilnuje
|
|
21
|
+
* przez generics że theme przekazany do createWorker() ma te same metody.
|
|
22
|
+
* Trener używa zestawu z @press2ai/theme-specialist-glossy.
|
|
23
|
+
*/
|
|
24
|
+
export interface TrenerTheme {
|
|
25
|
+
layout(props: { title: string; description?: string; jsonLd?: object; headExtra?: string }, body: string): string;
|
|
26
|
+
catalogHero(p: { badge?: string; title: string; subtitle?: string; searchAction?: string; searchPlaceholder?: string; searchValue?: string }): string;
|
|
27
|
+
catalogGrid(p: { title: string; filters?: string; cards: string }): string;
|
|
28
|
+
statBar(items: { value: string; label: string; icon?: string }[], summary?: string): string;
|
|
29
|
+
categoryNav(items: { href: string; label: string }[], ariaLabel?: string): string;
|
|
30
|
+
pagination(p: { current: number; total: number; extraParams?: string }): string;
|
|
31
|
+
profileCard(p: TrenerProfile, href: string): string;
|
|
32
|
+
profileArticle(p: TrenerProfile): string;
|
|
33
|
+
esc(s: string): string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* ─────────────── Schema treści ─────────────── */
|
|
37
|
+
|
|
38
|
+
const profileSchema = z.object({
|
|
39
|
+
slug: z.string(),
|
|
40
|
+
firstName: z.string(),
|
|
41
|
+
lastName: z.string(),
|
|
42
|
+
jobTitle: z.string().default('Trener personalny'),
|
|
43
|
+
city: z.string().optional(),
|
|
44
|
+
bio: z.string().optional(),
|
|
45
|
+
photo: z.string().url().optional(),
|
|
46
|
+
specialties: z.array(z.string()).default([]),
|
|
47
|
+
languages: z.array(z.string()).default([]),
|
|
48
|
+
business: z
|
|
49
|
+
.object({
|
|
50
|
+
name: z.string().optional(),
|
|
51
|
+
taxId: z.string().optional(),
|
|
52
|
+
classification: z.array(z.string()).default([]),
|
|
53
|
+
})
|
|
54
|
+
.default({}),
|
|
55
|
+
social: z.record(z.string(), z.string()).default({}),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export type TrenerProfile = z.infer<typeof profileSchema>;
|
|
59
|
+
|
|
60
|
+
const trenerContentSchema = z.object({
|
|
61
|
+
brand: z.object({
|
|
62
|
+
siteName: z.string(),
|
|
63
|
+
description: z.string(),
|
|
64
|
+
}),
|
|
65
|
+
copy: z.object({
|
|
66
|
+
heroBadge: z.string().optional(),
|
|
67
|
+
heroTitle: z.string(),
|
|
68
|
+
heroSubtitle: z.string().optional(),
|
|
69
|
+
searchPlaceholder: z.string().default('Szukaj...'),
|
|
70
|
+
}),
|
|
71
|
+
profiles: z.array(profileSchema),
|
|
72
|
+
cities: z.array(z.object({ name: z.string(), count: z.number() })).default([]),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export type TrenerContent = z.infer<typeof trenerContentSchema>;
|
|
76
|
+
|
|
77
|
+
const PAGE_SIZE = 12;
|
|
78
|
+
|
|
79
|
+
/* ─────────────── Template ─────────────── */
|
|
80
|
+
|
|
81
|
+
export const trenerTemplate: PresenceTemplate<TrenerContent, TrenerTheme> = {
|
|
82
|
+
kind: 'presence',
|
|
83
|
+
id: 'trener',
|
|
84
|
+
name: 'Otwarty Trener',
|
|
85
|
+
version: '1.0.0',
|
|
86
|
+
|
|
87
|
+
schema: trenerContentSchema,
|
|
88
|
+
|
|
89
|
+
defaultContent: {
|
|
90
|
+
brand: {
|
|
91
|
+
siteName: 'Otwarty Trener',
|
|
92
|
+
description: 'Otwarta baza trenerów personalnych w Polsce. Dane z CEIDG.',
|
|
93
|
+
},
|
|
94
|
+
copy: {
|
|
95
|
+
heroBadge: 'Dane z publicznego rejestru CEIDG',
|
|
96
|
+
heroTitle: 'Znajdź trenera w Twojej okolicy',
|
|
97
|
+
heroSubtitle: 'Otwarty katalog trenerów. Bezpłatnie i bez rejestracji.',
|
|
98
|
+
searchPlaceholder: 'Szukaj po nazwisku, mieście lub firmie...',
|
|
99
|
+
},
|
|
100
|
+
profiles: [],
|
|
101
|
+
cities: [],
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
editor: {
|
|
105
|
+
fields: [
|
|
106
|
+
{ key: 'brand.siteName', label: 'Nazwa serwisu', type: 'text', required: true },
|
|
107
|
+
{ key: 'brand.description', label: 'Opis serwisu', type: 'textarea', required: true },
|
|
108
|
+
{ key: 'copy.heroBadge', label: 'Badge nad nagłówkiem', type: 'text' },
|
|
109
|
+
{ key: 'copy.heroTitle', label: 'Nagłówek hero', type: 'text', required: true },
|
|
110
|
+
{ key: 'copy.heroSubtitle', label: 'Podtytuł hero', type: 'textarea' },
|
|
111
|
+
{ key: 'copy.searchPlaceholder', label: 'Placeholder wyszukiwarki', type: 'text' },
|
|
112
|
+
],
|
|
113
|
+
sections: [
|
|
114
|
+
{ id: 'brand', title: 'Marka', fieldKeys: ['brand.siteName', 'brand.description'] },
|
|
115
|
+
{ id: 'hero', title: 'Sekcja hero', fieldKeys: ['copy.heroBadge', 'copy.heroTitle', 'copy.heroSubtitle', 'copy.searchPlaceholder'] },
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
dataSources: [
|
|
120
|
+
{
|
|
121
|
+
// CEIDG fetcher — w produkcji bije do dane.biznes.gov.pl, mapuje do
|
|
122
|
+
// profiles[]. Tu STUB — kontrakt: dostaje NIP, zwraca Partial<TrenerContent>.
|
|
123
|
+
id: 'ceidg',
|
|
124
|
+
refresh: 'daily',
|
|
125
|
+
async fetch({ taxId }) {
|
|
126
|
+
if (!taxId) return {};
|
|
127
|
+
return { profiles: [] };
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
|
|
132
|
+
routes(content) {
|
|
133
|
+
return [
|
|
134
|
+
{ path: '/', cacheControl: 'public, s-maxage=300' },
|
|
135
|
+
{
|
|
136
|
+
path: '/:slug',
|
|
137
|
+
enumerate: () => content.profiles.map((p) => ({ slug: p.slug })),
|
|
138
|
+
cacheControl: 'public, s-maxage=300',
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
render(req, ctx) {
|
|
144
|
+
if (req.params.slug) return renderProfile(req, ctx);
|
|
145
|
+
return renderIndex(req, ctx);
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
sitemap(content, baseUrl) {
|
|
149
|
+
const entries: SitemapEntry[] = [
|
|
150
|
+
{ loc: `${baseUrl}/`, priority: 1.0, changefreq: 'daily' },
|
|
151
|
+
];
|
|
152
|
+
for (const p of content.profiles) {
|
|
153
|
+
entries.push({ loc: `${baseUrl}/${p.slug}`, priority: 0.8, changefreq: 'weekly' });
|
|
154
|
+
}
|
|
155
|
+
return entries;
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
meta: {
|
|
159
|
+
locale: 'pl-PL',
|
|
160
|
+
description: 'Katalog trenerów personalnych',
|
|
161
|
+
suggestedTheme: 'specialist-glossy',
|
|
162
|
+
tags: ['katalog', 'wizytówka', 'rejestr-publiczny'],
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/* ─────────────── Render handlers (private) ─────────────── */
|
|
167
|
+
|
|
168
|
+
function renderIndex(req: RenderRequest<TrenerContent>, ctx: RenderContext<TrenerTheme>): RenderResult {
|
|
169
|
+
const { content } = req;
|
|
170
|
+
const t = ctx.theme;
|
|
171
|
+
const q = (req.query.q ?? '').trim();
|
|
172
|
+
const page = Math.max(1, parseInt(req.query.p ?? '1', 10) || 1);
|
|
173
|
+
|
|
174
|
+
let filtered = content.profiles;
|
|
175
|
+
if (q) {
|
|
176
|
+
const ql = q.toLowerCase();
|
|
177
|
+
filtered = filtered.filter(
|
|
178
|
+
(p) =>
|
|
179
|
+
`${p.firstName} ${p.lastName}`.toLowerCase().includes(ql) ||
|
|
180
|
+
(p.city ?? '').toLowerCase().includes(ql) ||
|
|
181
|
+
(p.business?.name ?? '').toLowerCase().includes(ql),
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const total = filtered.length;
|
|
186
|
+
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
187
|
+
const slice = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
|
188
|
+
|
|
189
|
+
const hero = t.catalogHero({
|
|
190
|
+
badge: content.copy.heroBadge,
|
|
191
|
+
title: content.copy.heroTitle,
|
|
192
|
+
subtitle: content.copy.heroSubtitle,
|
|
193
|
+
searchAction: '/',
|
|
194
|
+
searchPlaceholder: content.copy.searchPlaceholder,
|
|
195
|
+
searchValue: q,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const stats = t.statBar([
|
|
199
|
+
{ value: total.toLocaleString('pl'), label: 'trenerów', icon: 'people' },
|
|
200
|
+
{ value: String(content.cities.length), label: 'miast', icon: 'city' },
|
|
201
|
+
{ value: '100%', label: 'bezpłatnie', icon: 'free' },
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
const cityNav = t.categoryNav(
|
|
205
|
+
content.cities.slice(0, 8).map((c) => ({
|
|
206
|
+
href: `/?q=${encodeURIComponent(c.name)}`,
|
|
207
|
+
label: `${c.name} (${c.count})`,
|
|
208
|
+
})),
|
|
209
|
+
'Popularne miasta',
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const cards = slice.map((p) => t.profileCard(p, `/${p.slug}`)).join('\n');
|
|
213
|
+
const grid = t.catalogGrid({
|
|
214
|
+
title: q ? `Wyniki dla „${t.esc(q)}" (${total})` : 'Trenerzy',
|
|
215
|
+
cards,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const pag = t.pagination({
|
|
219
|
+
current: page,
|
|
220
|
+
total: totalPages,
|
|
221
|
+
extraParams: q ? `&q=${encodeURIComponent(q)}` : '',
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const body = `${hero}${stats}${cityNav}${grid}${pag}`;
|
|
225
|
+
|
|
226
|
+
const jsonLd = {
|
|
227
|
+
'@context': 'https://schema.org',
|
|
228
|
+
'@type': 'WebSite',
|
|
229
|
+
name: content.brand.siteName,
|
|
230
|
+
url: ctx.baseUrl,
|
|
231
|
+
description: content.brand.description,
|
|
232
|
+
};
|
|
233
|
+
|
|
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
|
+
body: t.layout({ title: content.brand.siteName, description: content.brand.description, jsonLd }, body),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function renderProfile(req: RenderRequest<TrenerContent>, ctx: RenderContext<TrenerTheme>): RenderResult {
|
|
246
|
+
const profile = req.content.profiles.find((p) => p.slug === req.params.slug);
|
|
247
|
+
if (!profile) {
|
|
248
|
+
return {
|
|
249
|
+
status: 404,
|
|
250
|
+
head: { title: 'Nie znaleziono' },
|
|
251
|
+
body: ctx.theme.layout({ title: 'Nie znaleziono' }, '<h1>404</h1><p>Brak takiego trenera.</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
|
+
head: {
|
|
272
|
+
title: `${fullName} — ${profile.city ?? ''} | ${req.content.brand.siteName}`,
|
|
273
|
+
description,
|
|
274
|
+
canonical: `${ctx.baseUrl}/${profile.slug}`,
|
|
275
|
+
jsonLd,
|
|
276
|
+
},
|
|
277
|
+
body: t.layout(
|
|
278
|
+
{ title: `${fullName} | ${req.content.brand.siteName}`, description, jsonLd },
|
|
279
|
+
t.profileArticle(profile),
|
|
280
|
+
),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
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 };
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
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
|
+
head: HeadMeta;
|
|
122
|
+
body: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
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
|
+
}
|
|
134
|
+
|
|
135
|
+
/* ─────────────── Editor (Faza 0) ─────────────── */
|
|
136
|
+
|
|
137
|
+
export interface EditorSchema {
|
|
138
|
+
fields: EditorField[];
|
|
139
|
+
sections?: EditorSection[];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface EditorField {
|
|
143
|
+
/** Ścieżka w content, np. 'profile.bio' (kropka = zagnieżdżenie). */
|
|
144
|
+
key: string;
|
|
145
|
+
label: string;
|
|
146
|
+
type: 'text' | 'textarea' | 'email' | 'url' | 'phone' | 'image' | 'select' | 'array' | 'date';
|
|
147
|
+
required?: boolean;
|
|
148
|
+
placeholder?: string;
|
|
149
|
+
help?: string;
|
|
150
|
+
/** Dla 'select'. */
|
|
151
|
+
options?: string[];
|
|
152
|
+
/** Dla 'array' — schemat pojedynczego elementu. */
|
|
153
|
+
itemSchema?: EditorField[];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface EditorSection {
|
|
157
|
+
id: string;
|
|
158
|
+
title: string;
|
|
159
|
+
fieldKeys: string[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* ─────────────── Data sources ─────────────── */
|
|
163
|
+
|
|
164
|
+
export interface DataSource<C> {
|
|
165
|
+
id: string;
|
|
166
|
+
/** Engine wywołuje z kluczami identyfikacyjnymi (NIP, email, ...). */
|
|
167
|
+
fetch(input: DataSourceInput): Promise<Partial<C>>;
|
|
168
|
+
/** Częstotliwość auto-refresh w trybie sovereign (manual = nigdy). */
|
|
169
|
+
refresh?: 'manual' | 'daily' | 'weekly';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface DataSourceInput {
|
|
173
|
+
taxId?: string;
|
|
174
|
+
email?: string;
|
|
175
|
+
slug?: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* ─────────────── SEO / discovery ─────────────── */
|
|
179
|
+
|
|
180
|
+
export interface SitemapEntry {
|
|
181
|
+
loc: string;
|
|
182
|
+
lastmod?: string;
|
|
183
|
+
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
|
184
|
+
priority?: number;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface RssFeed {
|
|
188
|
+
title: string;
|
|
189
|
+
link: string;
|
|
190
|
+
description: string;
|
|
191
|
+
items: RssItem[];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface RssItem {
|
|
195
|
+
title: string;
|
|
196
|
+
link: string;
|
|
197
|
+
guid: string;
|
|
198
|
+
pubDate: string;
|
|
199
|
+
description?: string;
|
|
200
|
+
content?: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/* ─────────────── Commerce providers ─────────────── */
|
|
204
|
+
|
|
205
|
+
export interface CommerceProviders {
|
|
206
|
+
payment: ProviderSlot<PaymentProviderId>;
|
|
207
|
+
shipping?: ProviderSlot<ShippingProviderId>;
|
|
208
|
+
invoice?: ProviderSlot<InvoiceProviderId>;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export type PaymentProviderId = 'stripe' | 'p24' | 'payu' | 'blik' | 'paypal';
|
|
212
|
+
export type ShippingProviderId = 'inpost' | 'dpd' | 'pickup' | 'manual';
|
|
213
|
+
export type InvoiceProviderId = 'gus' | 'fakturownia' | 'manual';
|
|
214
|
+
|
|
215
|
+
export interface ProviderSlot<T extends string> {
|
|
216
|
+
required: boolean;
|
|
217
|
+
available: T[];
|
|
218
|
+
default?: T;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/* ─────────────── Engine outputs ─────────────── */
|
|
222
|
+
|
|
223
|
+
/** Plik wyprodukowany przez SSG build. */
|
|
224
|
+
export interface BuildFile {
|
|
225
|
+
path: string; // np. '/index.html', '/blog/foo/index.html', '/sitemap.xml'
|
|
226
|
+
content: string;
|
|
227
|
+
contentType: string;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface TemplateMeta {
|
|
231
|
+
locale: string;
|
|
232
|
+
description: string;
|
|
233
|
+
/** Sugerowany theme — engine użyje go, jeśli user nie wybierze innego. */
|
|
234
|
+
suggestedTheme?: string;
|
|
235
|
+
/** Tagi do wyświetlania w katalogu templatów ('wizytówka', 'blog', 'sklep'). */
|
|
236
|
+
tags?: string[];
|
|
237
|
+
}
|