@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/src/runtime/ssr.ts
DELETED
|
@@ -1,298 +0,0 @@
|
|
|
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
|
-
* 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
|
-
};
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface OtwartyWorker {
|
|
55
|
-
fetch(request: Request, env: unknown, ctx?: unknown): Promise<Response>;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function createSSRWorker<C, T>(opts: SSRWorkerOpts<C, T>): OtwartyWorker {
|
|
59
|
-
return {
|
|
60
|
-
async fetch(request, env): Promise<Response> {
|
|
61
|
-
// Engine obsługuje wyłącznie GET; mutacje są poza zakresem.
|
|
62
|
-
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
63
|
-
return new Response('Method not allowed', { status: 405 });
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const url = new URL(request.url);
|
|
67
|
-
const host = url.host;
|
|
68
|
-
const baseUrl = opts.baseUrlFor?.(host) ?? `${url.protocol}//${host}`;
|
|
69
|
-
|
|
70
|
-
// Wbudowane endpointy obsługiwane przez engine bezpośrednio
|
|
71
|
-
// (nie wymagają pełnego renderera, biorą dane z metod templatu).
|
|
72
|
-
if (url.pathname === '/sitemap.xml') {
|
|
73
|
-
return handleSitemap(opts, host, env, baseUrl);
|
|
74
|
-
}
|
|
75
|
-
if (url.pathname === '/robots.txt') {
|
|
76
|
-
return handleRobots(opts, host, env);
|
|
77
|
-
}
|
|
78
|
-
if (url.pathname === '/feed.xml' || url.pathname === '/rss.xml') {
|
|
79
|
-
return handleFeed(opts, host, env, baseUrl);
|
|
80
|
-
}
|
|
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
|
-
|
|
94
|
-
// Standard render path
|
|
95
|
-
const content = await opts.loadContent(host, env);
|
|
96
|
-
if (content === null) {
|
|
97
|
-
return new Response('Not found', { status: 404 });
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const routes = opts.template.routes(content);
|
|
101
|
-
const match = matchRoute(url.pathname, routes);
|
|
102
|
-
if (!match) {
|
|
103
|
-
return new Response('Not found', { status: 404 });
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const query: Record<string, string> = {};
|
|
107
|
-
url.searchParams.forEach((v, k) => { query[k] = v; });
|
|
108
|
-
|
|
109
|
-
const req: RenderRequest<C> = {
|
|
110
|
-
path: url.pathname,
|
|
111
|
-
params: match.params,
|
|
112
|
-
query,
|
|
113
|
-
content,
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
const ctx: RenderContext<T> = {
|
|
117
|
-
baseUrl,
|
|
118
|
-
theme: opts.theme,
|
|
119
|
-
locale: opts.template.meta.locale,
|
|
120
|
-
mode: 'ssr',
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
const result = opts.template.render(req, ctx);
|
|
124
|
-
|
|
125
|
-
const headers: Record<string, string> = {
|
|
126
|
-
'content-type': 'text/html; charset=utf-8',
|
|
127
|
-
};
|
|
128
|
-
// Cache-Control z route descriptor (jeśli podany) — pozwala CDN cachować
|
|
129
|
-
if (match.route.cacheControl) {
|
|
130
|
-
headers['cache-control'] = match.route.cacheControl;
|
|
131
|
-
}
|
|
132
|
-
// Nagłówki z RenderResult przesłaniają wszystko (template wie najlepiej)
|
|
133
|
-
Object.assign(headers, result.headers ?? {});
|
|
134
|
-
|
|
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 });
|
|
150
|
-
},
|
|
151
|
-
};
|
|
152
|
-
}
|
|
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
|
-
|
|
177
|
-
/* ─────────────── Wbudowane endpointy ─────────────── */
|
|
178
|
-
|
|
179
|
-
async function handleSitemap<C, T>(
|
|
180
|
-
opts: SSRWorkerOpts<C, T>,
|
|
181
|
-
host: string,
|
|
182
|
-
env: unknown,
|
|
183
|
-
baseUrl: string,
|
|
184
|
-
): Promise<Response> {
|
|
185
|
-
const content = await opts.loadContent(host, env);
|
|
186
|
-
if (content === null) return new Response('Not found', { status: 404 });
|
|
187
|
-
const entries = opts.template.sitemap(content, baseUrl);
|
|
188
|
-
return new Response(renderSitemapXml(entries), {
|
|
189
|
-
headers: {
|
|
190
|
-
'content-type': 'application/xml; charset=utf-8',
|
|
191
|
-
'cache-control': 'public, s-maxage=86400',
|
|
192
|
-
},
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
async function handleRobots<C, T>(
|
|
197
|
-
opts: SSRWorkerOpts<C, T>,
|
|
198
|
-
host: string,
|
|
199
|
-
env: unknown,
|
|
200
|
-
): Promise<Response> {
|
|
201
|
-
if (!opts.template.robots) {
|
|
202
|
-
return new Response('User-agent: *\nAllow: /\n', { headers: { 'content-type': 'text/plain' } });
|
|
203
|
-
}
|
|
204
|
-
const content = await opts.loadContent(host, env);
|
|
205
|
-
if (content === null) return new Response('Not found', { status: 404 });
|
|
206
|
-
return new Response(opts.template.robots(content), {
|
|
207
|
-
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
async function handleFeed<C, T>(
|
|
212
|
-
opts: SSRWorkerOpts<C, T>,
|
|
213
|
-
host: string,
|
|
214
|
-
env: unknown,
|
|
215
|
-
baseUrl: string,
|
|
216
|
-
): Promise<Response> {
|
|
217
|
-
if (!opts.template.feed) return new Response('Not found', { status: 404 });
|
|
218
|
-
const content = await opts.loadContent(host, env);
|
|
219
|
-
if (content === null) return new Response('Not found', { status: 404 });
|
|
220
|
-
const feed = opts.template.feed(content, baseUrl);
|
|
221
|
-
if (!feed) return new Response('Not found', { status: 404 });
|
|
222
|
-
return new Response(renderRssXml(feed), {
|
|
223
|
-
headers: {
|
|
224
|
-
'content-type': 'application/rss+xml; charset=utf-8',
|
|
225
|
-
'cache-control': 'public, s-maxage=3600',
|
|
226
|
-
},
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/* ─────────────── Routing ─────────────── */
|
|
231
|
-
|
|
232
|
-
interface RouteMatch<C> {
|
|
233
|
-
route: RouteDescriptor<C>;
|
|
234
|
-
params: Record<string, string>;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function matchRoute<C>(pathname: string, routes: RouteDescriptor<C>[]): RouteMatch<C> | null {
|
|
238
|
-
// Statyczne routes (bez :param) mają wyższy priorytet niż dynamiczne.
|
|
239
|
-
// Bez tego /sitemap.xml byłoby przechwycone przez /:slug.
|
|
240
|
-
const sorted = [...routes].sort((a, b) => {
|
|
241
|
-
const aDyn = a.path.includes(':');
|
|
242
|
-
const bDyn = b.path.includes(':');
|
|
243
|
-
if (aDyn === bDyn) return 0;
|
|
244
|
-
return aDyn ? 1 : -1;
|
|
245
|
-
});
|
|
246
|
-
for (const route of sorted) {
|
|
247
|
-
const params = matchPath(pathname, route.path);
|
|
248
|
-
if (params) return { route, params };
|
|
249
|
-
}
|
|
250
|
-
return null;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function matchPath(pathname: string, pattern: string): Record<string, string> | null {
|
|
254
|
-
const patternParts = pattern.split('/').filter(Boolean);
|
|
255
|
-
const pathParts = pathname.split('/').filter(Boolean);
|
|
256
|
-
if (patternParts.length !== pathParts.length) return null;
|
|
257
|
-
|
|
258
|
-
const params: Record<string, string> = {};
|
|
259
|
-
for (let i = 0; i < patternParts.length; i++) {
|
|
260
|
-
const pp = patternParts[i]!;
|
|
261
|
-
const ap = pathParts[i]!;
|
|
262
|
-
if (pp.startsWith(':')) {
|
|
263
|
-
params[pp.slice(1)] = decodeURIComponent(ap);
|
|
264
|
-
} else if (pp !== ap) {
|
|
265
|
-
return null;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
return params;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/* ─────────────── XML helpers ─────────────── */
|
|
272
|
-
|
|
273
|
-
function renderSitemapXml(entries: SitemapEntry[]): string {
|
|
274
|
-
const items = entries
|
|
275
|
-
.map((e) => {
|
|
276
|
-
const lastmod = e.lastmod ? `<lastmod>${e.lastmod}</lastmod>` : '';
|
|
277
|
-
const changefreq = e.changefreq ? `<changefreq>${e.changefreq}</changefreq>` : '';
|
|
278
|
-
const priority = e.priority !== undefined ? `<priority>${e.priority}</priority>` : '';
|
|
279
|
-
return ` <url><loc>${escapeXml(e.loc)}</loc>${lastmod}${changefreq}${priority}</url>`;
|
|
280
|
-
})
|
|
281
|
-
.join('\n');
|
|
282
|
-
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${items}\n</urlset>`;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
function renderRssXml(feed: RssFeed): string {
|
|
286
|
-
const items = feed.items
|
|
287
|
-
.map((i) => {
|
|
288
|
-
const desc = i.description ? `<description>${escapeXml(i.description)}</description>` : '';
|
|
289
|
-
const ce = i.content ? `<content:encoded><![CDATA[${i.content}]]></content:encoded>` : '';
|
|
290
|
-
return `<item><title>${escapeXml(i.title)}</title><link>${escapeXml(i.link)}</link><guid>${escapeXml(i.guid)}</guid><pubDate>${i.pubDate}</pubDate>${desc}${ce}</item>`;
|
|
291
|
-
})
|
|
292
|
-
.join('\n');
|
|
293
|
-
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>`;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function escapeXml(s: string): string {
|
|
297
|
-
return s.replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[c]!);
|
|
298
|
-
}
|
package/src/template-blog.ts
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
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
|
-
body: ctx.theme.layout({ title: content.brand.siteName, description: content.brand.description }, `<h1>${ctx.theme.esc(content.brand.siteName)}</h1>${items}`),
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function renderPost(req: RenderRequest<BlogContent>, ctx: RenderContext<BlogTheme>): RenderResult {
|
|
111
|
-
const post = req.content.posts.find((p) => p.slug === req.params.slug);
|
|
112
|
-
if (!post) return { status: 404, body: ctx.theme.layout({ title: '404' }, '<h1>404</h1>') };
|
|
113
|
-
return {
|
|
114
|
-
body: ctx.theme.layout(
|
|
115
|
-
{ title: post.title, description: post.excerpt },
|
|
116
|
-
`<article><h1>${ctx.theme.esc(post.title)}</h1><time>${ctx.theme.esc(post.pubDate)}</time>${ctx.theme.markdown(post.body)}</article>`,
|
|
117
|
-
),
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function renderRssXml(req: RenderRequest<BlogContent>, ctx: RenderContext<BlogTheme>): RenderResult {
|
|
122
|
-
// Engine zazwyczaj wywoła template.feed() i sam zbuduje XML — to fallback
|
|
123
|
-
// dla prostej ścieżki SSR gdzie chcemy odpowiedzieć bez warstwy engine'u.
|
|
124
|
-
const feed = blogTemplate.feed!(req.content, ctx.baseUrl)!;
|
|
125
|
-
const items = feed.items
|
|
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>`)
|
|
127
|
-
.join('');
|
|
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>`;
|
|
129
|
-
return { headers: { 'content-type': 'application/rss+xml' }, body: xml };
|
|
130
|
-
}
|