@leadertechie/personal-site-kit 0.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/LICENSE +21 -0
- package/README.md +60 -0
- package/dist/api/handlers/aboutme.d.ts +3 -0
- package/dist/api/handlers/aboutme.d.ts.map +1 -0
- package/dist/api/handlers/content-api.d.ts +5 -0
- package/dist/api/handlers/content-api.d.ts.map +1 -0
- package/dist/api/handlers/content.d.ts +2 -0
- package/dist/api/handlers/content.d.ts.map +1 -0
- package/dist/api/handlers/home.d.ts +3 -0
- package/dist/api/handlers/home.d.ts.map +1 -0
- package/dist/api/handlers/info.d.ts +2 -0
- package/dist/api/handlers/info.d.ts.map +1 -0
- package/dist/api/handlers/logo.d.ts +2 -0
- package/dist/api/handlers/logo.d.ts.map +1 -0
- package/dist/api/handlers/staticdetails.d.ts +2 -0
- package/dist/api/handlers/staticdetails.d.ts.map +1 -0
- package/dist/api/index.d.ts +9 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/utils.d.ts +8 -0
- package/dist/api/utils.d.ts.map +1 -0
- package/dist/api.d.ts +4 -0
- package/dist/api.js +591 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +354 -0
- package/dist/prerender/index.d.ts +5 -0
- package/dist/prerender/index.d.ts.map +1 -0
- package/dist/prerender/pageContent.d.ts +16 -0
- package/dist/prerender/pageContent.d.ts.map +1 -0
- package/dist/prerender/prerender.d.ts +3 -0
- package/dist/prerender/prerender.d.ts.map +1 -0
- package/dist/prerender/template.d.ts +10 -0
- package/dist/prerender/template.d.ts.map +1 -0
- package/dist/prerender.d.ts +4 -0
- package/dist/prerender.js +399 -0
- package/dist/shared/config/api.d.ts +2 -0
- package/dist/shared/config/api.d.ts.map +1 -0
- package/dist/shared/config/index.d.ts +5 -0
- package/dist/shared/config/index.d.ts.map +1 -0
- package/dist/shared/config/types.d.ts +16 -0
- package/dist/shared/config/types.d.ts.map +1 -0
- package/dist/shared/core/site-store.d.ts +16 -0
- package/dist/shared/core/site-store.d.ts.map +1 -0
- package/dist/shared/core/theme-toggle.d.ts +13 -0
- package/dist/shared/core/theme-toggle.d.ts.map +1 -0
- package/dist/shared/index.d.ts +9 -0
- package/dist/shared/index.d.ts.map +1 -0
- package/dist/shared/interfaces/iFooterLink.d.ts +5 -0
- package/dist/shared/interfaces/iFooterLink.d.ts.map +1 -0
- package/dist/shared/interfaces/iRoute.d.ts +5 -0
- package/dist/shared/interfaces/iRoute.d.ts.map +1 -0
- package/dist/shared/pageContent.d.ts +35 -0
- package/dist/shared/pageContent.d.ts.map +1 -0
- package/dist/shared/runtime.d.ts +7 -0
- package/dist/shared/runtime.d.ts.map +1 -0
- package/dist/shared/template.d.ts +8 -0
- package/dist/shared/template.d.ts.map +1 -0
- package/dist/shared.d.ts +2 -0
- package/dist/shared.js +10 -0
- package/dist/ui/aboutme/api.d.ts +11 -0
- package/dist/ui/aboutme/api.d.ts.map +1 -0
- package/dist/ui/aboutme/index.d.ts +26 -0
- package/dist/ui/aboutme/index.d.ts.map +1 -0
- package/dist/ui/aboutme/renderer.d.ts +5 -0
- package/dist/ui/aboutme/renderer.d.ts.map +1 -0
- package/dist/ui/aboutme/styles.d.ts +2 -0
- package/dist/ui/aboutme/styles.d.ts.map +1 -0
- package/dist/ui/admin/index.d.ts +42 -0
- package/dist/ui/admin/index.d.ts.map +1 -0
- package/dist/ui/admin/styles.d.ts +2 -0
- package/dist/ui/admin/styles.d.ts.map +1 -0
- package/dist/ui/banner/index.d.ts +17 -0
- package/dist/ui/banner/index.d.ts.map +1 -0
- package/dist/ui/banner/styles.d.ts +2 -0
- package/dist/ui/banner/styles.d.ts.map +1 -0
- package/dist/ui/footer/index.d.ts +9 -0
- package/dist/ui/footer/index.d.ts.map +1 -0
- package/dist/ui/footer/styles.d.ts +2 -0
- package/dist/ui/footer/styles.d.ts.map +1 -0
- package/dist/ui.d.ts +2 -0
- package/dist/ui.js +820 -0
- package/package.json +41 -0
- package/src/api/__tests__/info.test.ts +44 -0
- package/src/api/__tests__/utils.test.ts +78 -0
- package/src/api/handlers/aboutme.ts +99 -0
- package/src/api/handlers/content-api.ts +268 -0
- package/src/api/handlers/content.ts +72 -0
- package/src/api/handlers/home.ts +79 -0
- package/src/api/handlers/info.ts +12 -0
- package/src/api/handlers/logo.ts +55 -0
- package/src/api/handlers/staticdetails.ts +48 -0
- package/src/api/index.ts +125 -0
- package/src/api/utils.ts +16 -0
- package/src/prerender/__tests__/pageContent.test.ts +54 -0
- package/src/prerender/__tests__/template.test.ts +54 -0
- package/src/prerender/index.ts +138 -0
- package/src/prerender/pageContent.ts +263 -0
- package/src/prerender/prerender.ts +25 -0
- package/src/prerender/template.ts +65 -0
- package/src/shared/config/api.ts +16 -0
- package/src/shared/config/index.ts +41 -0
- package/src/shared/config/types.ts +16 -0
- package/src/shared/core/__tests__/theme-toggle.test.ts +204 -0
- package/src/shared/core/site-store.ts +38 -0
- package/src/shared/core/theme-toggle.ts +118 -0
- package/src/shared/index.ts +15 -0
- package/src/shared/interfaces/iFooterLink.ts +4 -0
- package/src/shared/interfaces/iRoute.ts +4 -0
- package/src/shared/models/theme-variables.css +25 -0
- package/src/shared/pageContent.ts +209 -0
- package/src/shared/runtime.ts +11 -0
- package/src/shared/styles/markdown.css +129 -0
- package/src/shared/template.ts +35 -0
- package/src/styles/markdown.css +129 -0
- package/src/styles/theme.css +432 -0
- package/src/ui/aboutme/api.ts +12 -0
- package/src/ui/aboutme/index.ts +155 -0
- package/src/ui/aboutme/renderer.ts +7 -0
- package/src/ui/aboutme/styles.ts +10 -0
- package/src/ui/admin/index.ts +492 -0
- package/src/ui/admin/styles.ts +317 -0
- package/src/ui/banner/index.ts +38 -0
- package/src/ui/banner/styles.ts +10 -0
- package/src/ui/footer/index.ts +37 -0
- package/src/ui/footer/styles.ts +9 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const PLACEHOLDER_LOGO = `<svg width="600" height="320" viewBox="0 0 600 320" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<style>
|
|
3
|
+
.text-main {
|
|
4
|
+
fill: light-dark(#4A5568, #E2E8F0);
|
|
5
|
+
font-family: 'Segoe UI', 'Arial Black', sans-serif;
|
|
6
|
+
font-weight: 900;
|
|
7
|
+
font-size: 46px;
|
|
8
|
+
letter-spacing: -0.01em;
|
|
9
|
+
text-anchor: middle;
|
|
10
|
+
}
|
|
11
|
+
</style>
|
|
12
|
+
|
|
13
|
+
<!-- Big circle placeholder -->
|
|
14
|
+
<circle cx="300" cy="160" r="100" fill="none" stroke="light-dark(#A0AEC0, #718096)" stroke-width="2" stroke-dasharray="8 4" opacity="0.5" />
|
|
15
|
+
|
|
16
|
+
<!-- Plus sign in circle -->
|
|
17
|
+
<line x1="300" y1="100" x2="300" y2="220" stroke="light-dark(#A0AEC0, #718096)" stroke-width="2" opacity="0.3" />
|
|
18
|
+
<line x1="240" y1="160" x2="360" y2="160" stroke="light-dark(#A0AEC0, #718096)" stroke-width="2" opacity="0.3" />
|
|
19
|
+
|
|
20
|
+
<text x="300" y="290" class="text-main">YOUR LOGO</text>
|
|
21
|
+
</svg>`;
|
|
22
|
+
|
|
23
|
+
export async function handleLogo(env?: any): Promise<Response> {
|
|
24
|
+
try {
|
|
25
|
+
// Try to get logo from R2
|
|
26
|
+
if (env?.CONTENT_BUCKET) {
|
|
27
|
+
const logo = await env.CONTENT_BUCKET.get('logo.svg');
|
|
28
|
+
if (logo) {
|
|
29
|
+
const headers = new Headers();
|
|
30
|
+
logo.writeHttpMetadata(headers);
|
|
31
|
+
headers.set('Content-Type', 'image/svg+xml');
|
|
32
|
+
headers.set('Cache-Control', 'public, max-age=3600');
|
|
33
|
+
headers.set('Access-Control-Allow-Origin', '*');
|
|
34
|
+
return new Response(logo.body, { headers });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Return placeholder logo
|
|
39
|
+
return new Response(PLACEHOLDER_LOGO, {
|
|
40
|
+
headers: {
|
|
41
|
+
'Content-Type': 'image/svg+xml',
|
|
42
|
+
'Cache-Control': 'public, max-age=3600',
|
|
43
|
+
'Access-Control-Allow-Origin': '*',
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error('Error serving logo:', error);
|
|
48
|
+
return new Response(PLACEHOLDER_LOGO, {
|
|
49
|
+
headers: {
|
|
50
|
+
'Content-Type': 'image/svg+xml',
|
|
51
|
+
'Access-Control-Allow-Origin': '*',
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const DEFAULT_STATIC_DETAILS = {
|
|
2
|
+
siteTitle: "My Personal Website",
|
|
3
|
+
copyright: "2026 My Personal Website",
|
|
4
|
+
linkedin: "https://linkedin.com/in/yourname",
|
|
5
|
+
github: "https://github.com/yourname",
|
|
6
|
+
email: "yourname@domain.com"
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export async function handleStaticDetails(env?: any, method?: string, body?: any): Promise<Response> {
|
|
10
|
+
try {
|
|
11
|
+
if (!env?.CONTENT_BUCKET) {
|
|
12
|
+
return new Response(JSON.stringify(DEFAULT_STATIC_DETAILS), {
|
|
13
|
+
headers: { 'Content-Type': 'application/json' },
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// GET - return static details
|
|
18
|
+
if (!method || method === 'GET') {
|
|
19
|
+
const staticDetails = await env.CONTENT_BUCKET.get('staticdetails.json');
|
|
20
|
+
if (staticDetails) {
|
|
21
|
+
const data = await staticDetails.json();
|
|
22
|
+
return new Response(JSON.stringify({ ...DEFAULT_STATIC_DETAILS, ...data }), {
|
|
23
|
+
headers: {
|
|
24
|
+
'Content-Type': 'application/json',
|
|
25
|
+
'Access-Control-Allow-Origin': '*'
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return new Response(JSON.stringify(DEFAULT_STATIC_DETAILS), {
|
|
30
|
+
headers: {
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
'Access-Control-Allow-Origin': '*'
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// PUT - save static details (handled in content handler)
|
|
38
|
+
return new Response(JSON.stringify({ error: 'Use content endpoint for PUT' }), {
|
|
39
|
+
status: 400,
|
|
40
|
+
headers: { 'Content-Type': 'application/json' },
|
|
41
|
+
});
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error('Error serving static details:', error);
|
|
44
|
+
return new Response(JSON.stringify(DEFAULT_STATIC_DETAILS), {
|
|
45
|
+
headers: { 'Content-Type': 'application/json' },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/api/index.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
async fetch(request: Request, env?: Env): Promise<Response> {
|
|
3
|
+
const url = new URL(request.url);
|
|
4
|
+
|
|
5
|
+
// Handle CORS preflight requests
|
|
6
|
+
if (request.method === 'OPTIONS') {
|
|
7
|
+
return handleCORS();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Extract route from pathname, removing leading/trailing slashes and /api prefix
|
|
11
|
+
const pathname = url.pathname;
|
|
12
|
+
const route = pathname
|
|
13
|
+
.replace(/^\/api\//, '') // Remove /api/ prefix if present
|
|
14
|
+
.replace(/^\//, '') // Remove leading slash
|
|
15
|
+
.replace(/\/+$/, ''); // Remove trailing slashes
|
|
16
|
+
|
|
17
|
+
return handleAPIRoute(route, request, env);
|
|
18
|
+
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function handleCORS(): Response {
|
|
23
|
+
return new Response(null, {
|
|
24
|
+
status: 200,
|
|
25
|
+
headers: {
|
|
26
|
+
'Access-Control-Allow-Origin': '*',
|
|
27
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
28
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
29
|
+
'Access-Control-Max-Age': '86400',
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function addCORSHeaders(response: Response): Response {
|
|
35
|
+
response.headers.set('Access-Control-Allow-Origin', '*');
|
|
36
|
+
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
37
|
+
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
38
|
+
return response;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
import { createErrorResponse } from './utils';
|
|
42
|
+
|
|
43
|
+
import { handleAboutMe, clearContentCache } from './handlers/aboutme';
|
|
44
|
+
import { handleHome } from './handlers/home';
|
|
45
|
+
import { handleInfo } from './handlers/info';
|
|
46
|
+
import { handleContent } from './handlers/content';
|
|
47
|
+
import { handleBlogs, handleStories, handleSearch } from './handlers/content-api';
|
|
48
|
+
import { handleLogo } from './handlers/logo';
|
|
49
|
+
import { handleStaticDetails } from './handlers/staticdetails';
|
|
50
|
+
|
|
51
|
+
function requireAuth(request: Request, env?: Env): Response | null {
|
|
52
|
+
const authHeader = request.headers.get('Authorization');
|
|
53
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
54
|
+
return createErrorResponse('Unauthorized', 401);
|
|
55
|
+
}
|
|
56
|
+
const token = authHeader.slice(7);
|
|
57
|
+
if (token !== env?.ADMIN_API_KEY) {
|
|
58
|
+
return createErrorResponse('Unauthorized', 401);
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function handleAPIRoute(route: string, request: Request, env?: Env): Promise<Response> {
|
|
64
|
+
try {
|
|
65
|
+
// Check for content route first (content/*)
|
|
66
|
+
if (route === 'content' || route.startsWith('content/')) {
|
|
67
|
+
const subpath = route.replace(/^content\/?/, '');
|
|
68
|
+
return addCORSHeaders(await handleContent(request, env, subpath));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
switch (route) {
|
|
72
|
+
case 'info':
|
|
73
|
+
return addCORSHeaders(await handleInfo());
|
|
74
|
+
case 'home':
|
|
75
|
+
return addCORSHeaders(await handleHome(env));
|
|
76
|
+
case 'cache-clear':
|
|
77
|
+
const authError = requireAuth(request, env);
|
|
78
|
+
if (authError) return addCORSHeaders(authError);
|
|
79
|
+
clearContentCache();
|
|
80
|
+
return addCORSHeaders(new Response(JSON.stringify({ success: true, message: 'Cache cleared' }), { status: 200 }));
|
|
81
|
+
case 'aboutme':
|
|
82
|
+
return addCORSHeaders(await handleAboutMe(env));
|
|
83
|
+
case 'logo':
|
|
84
|
+
return addCORSHeaders(await handleLogo(env));
|
|
85
|
+
case 'static':
|
|
86
|
+
return addCORSHeaders(await handleStaticDetails(env));
|
|
87
|
+
case 'blogs':
|
|
88
|
+
return addCORSHeaders(await handleBlogs(env));
|
|
89
|
+
case 'blogs/latest':
|
|
90
|
+
const url = new URL(request.url);
|
|
91
|
+
const latestCount = url.searchParams.get('count');
|
|
92
|
+
return addCORSHeaders(await handleBlogs(env, undefined, latestCount ? parseInt(latestCount) : 5));
|
|
93
|
+
default:
|
|
94
|
+
if (route.startsWith('blogs/')) {
|
|
95
|
+
const slug = route.replace('blogs/', '');
|
|
96
|
+
return addCORSHeaders(await handleBlogs(env, slug));
|
|
97
|
+
}
|
|
98
|
+
if (route.startsWith('stories')) {
|
|
99
|
+
if (route === 'stories') {
|
|
100
|
+
return addCORSHeaders(await handleStories(env));
|
|
101
|
+
}
|
|
102
|
+
if (route === 'stories/latest') {
|
|
103
|
+
const url = new URL(request.url);
|
|
104
|
+
const latestCount = url.searchParams.get('count');
|
|
105
|
+
return addCORSHeaders(await handleStories(env, undefined, latestCount ? parseInt(latestCount) : 5));
|
|
106
|
+
}
|
|
107
|
+
const slug = route.replace('stories/', '');
|
|
108
|
+
return addCORSHeaders(await handleStories(env, slug));
|
|
109
|
+
}
|
|
110
|
+
if (route === 'search') {
|
|
111
|
+
const url = new URL(request.url);
|
|
112
|
+
const query = url.searchParams.get('q');
|
|
113
|
+
return addCORSHeaders(await handleSearch(env, query || undefined));
|
|
114
|
+
}
|
|
115
|
+
return addCORSHeaders(createErrorResponse('Route not found', 404));
|
|
116
|
+
}
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return addCORSHeaders(createErrorResponse('Internal server error', 500));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface Env {
|
|
123
|
+
CONTENT_BUCKET?: any; // R2Bucket type not strictly enforced here to avoid large diff
|
|
124
|
+
ADMIN_API_KEY?: string;
|
|
125
|
+
}
|
package/src/api/utils.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface APIResponse {
|
|
2
|
+
data?: any;
|
|
3
|
+
error?: string;
|
|
4
|
+
status: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function createJSONResponse(data: any, status: number = 200): Response {
|
|
8
|
+
return new Response(JSON.stringify(data), {
|
|
9
|
+
status,
|
|
10
|
+
headers: { 'Content-Type': 'application/json' },
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createErrorResponse(message: string, status: number = 500): Response {
|
|
15
|
+
return createJSONResponse({ error: message }, status);
|
|
16
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { generatePageContent } from '../pageContent';
|
|
3
|
+
|
|
4
|
+
describe('generatePageContent', () => {
|
|
5
|
+
const mockRoutes = [
|
|
6
|
+
{ link: '/', text: 'Home' },
|
|
7
|
+
{ link: '/about-me', text: 'About' }
|
|
8
|
+
];
|
|
9
|
+
const mockFooterLinks = [
|
|
10
|
+
{ text: 'Link', href: '#' }
|
|
11
|
+
];
|
|
12
|
+
const mockEnv = {
|
|
13
|
+
CONTENT_BUCKET: {
|
|
14
|
+
get: vi.fn().mockResolvedValue({
|
|
15
|
+
json: () => Promise.resolve({ name: 'User', title: 'Professional', experience: 'some' })
|
|
16
|
+
}),
|
|
17
|
+
list: vi.fn().mockResolvedValue({ objects: [] })
|
|
18
|
+
},
|
|
19
|
+
apiUrl: 'https://api.example.com',
|
|
20
|
+
baseUrl: 'https://www.example.com'
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.clearAllMocks();
|
|
25
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
26
|
+
ok: true,
|
|
27
|
+
json: () => Promise.resolve({ siteTitle: 'My Site', copyright: '2026' })
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should generate home page content', async () => {
|
|
32
|
+
const result = await generatePageContent('/', mockRoutes, mockFooterLinks, mockEnv);
|
|
33
|
+
|
|
34
|
+
expect(result.title).toContain('User');
|
|
35
|
+
expect(result.canonicalUrl).toBe('https://www.example.com/');
|
|
36
|
+
expect(result.content).toContain('<my-banner');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should generate about-me page content', async () => {
|
|
40
|
+
const result = await generatePageContent('/about-me', mockRoutes, mockFooterLinks, mockEnv);
|
|
41
|
+
|
|
42
|
+
expect(result.title).toContain('About');
|
|
43
|
+
expect(result.canonicalUrl).toBe('https://www.example.com/about-me');
|
|
44
|
+
expect(result.content).toContain('<my-aboutme');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should generate 404 page content', async () => {
|
|
48
|
+
const result = await generatePageContent('/non-existent', mockRoutes, mockFooterLinks, mockEnv);
|
|
49
|
+
|
|
50
|
+
expect(result.title).toContain('Not Found');
|
|
51
|
+
expect(result.canonicalUrl).toBe('https://www.example.com/non-existent');
|
|
52
|
+
expect(result.content).toContain('Page Not Found');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createHtmlTemplate } from '../template';
|
|
3
|
+
|
|
4
|
+
describe('createHtmlTemplate', () => {
|
|
5
|
+
it('should generate complete HTML template', async () => {
|
|
6
|
+
const props = {
|
|
7
|
+
title: 'Test Title',
|
|
8
|
+
description: 'Test Description',
|
|
9
|
+
canonicalUrl: 'https://example.com/test',
|
|
10
|
+
content: '<main>Test Content</main>'
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const html = await createHtmlTemplate(props);
|
|
14
|
+
|
|
15
|
+
expect(html).toContain('<!doctype html>');
|
|
16
|
+
expect(html).toContain('<title>Test Title</title>');
|
|
17
|
+
expect(html).toContain('<meta name="description" content="Test Description" />');
|
|
18
|
+
expect(html).toContain('<meta property="og:title" content="Test Title" />');
|
|
19
|
+
expect(html).toContain('<link rel="canonical" href="https://example.com/test" />');
|
|
20
|
+
expect(html).toContain('<main>Test Content</main>');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should escape HTML entities properly', async () => {
|
|
24
|
+
const props = {
|
|
25
|
+
title: 'Test & Title',
|
|
26
|
+
description: 'Test "Description"',
|
|
27
|
+
canonicalUrl: 'https://example.com/test',
|
|
28
|
+
content: '<main>Test Content</main>'
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const html = await createHtmlTemplate(props);
|
|
32
|
+
|
|
33
|
+
expect(html).toContain('<title>Test & Title</title>');
|
|
34
|
+
expect(html).toContain('<meta name="description" content="Test "Description"" />');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should have proper HTML structure', async () => {
|
|
38
|
+
const props = {
|
|
39
|
+
title: 'Test',
|
|
40
|
+
description: 'Test',
|
|
41
|
+
canonicalUrl: 'https://example.com',
|
|
42
|
+
content: '<div>Content</div>'
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const html = await createHtmlTemplate(props);
|
|
46
|
+
|
|
47
|
+
expect(html).toMatch(/^<!doctype html>/);
|
|
48
|
+
expect(html).toContain('<html lang="en" data-theme="light">');
|
|
49
|
+
expect(html).toContain('<head>');
|
|
50
|
+
expect(html).toContain('<body>');
|
|
51
|
+
expect(html).toContain('<div id="app">');
|
|
52
|
+
expect(html).toContain('</html>');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createHtmlTemplate, TemplateProps } from "./template";
|
|
2
|
+
|
|
3
|
+
import { IFooterLink, IRoute, generatePageContent } from "./pageContent";
|
|
4
|
+
|
|
5
|
+
const routes: IRoute[] = [
|
|
6
|
+
{ link: "/", text: "Home" },
|
|
7
|
+
{ link: "/blogs", text: "Blogs" },
|
|
8
|
+
{ link: "/stories", text: "Stories" },
|
|
9
|
+
{ link: "/about-me", text: "About Me" },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
// Default footer links (fallback)
|
|
13
|
+
const defaultFooterLinks: IFooterLink[] = [
|
|
14
|
+
{ text: "LinkedIn", link: "https://linkedin.com/in/yourname" },
|
|
15
|
+
{ text: "GitHub", link: "https://github.com/yourname" },
|
|
16
|
+
{ text: "Email", link: "mailto:yourname@domain.com" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
let footerLinks: IFooterLink[] = defaultFooterLinks;
|
|
20
|
+
let siteTitle = "My Personal Website";
|
|
21
|
+
let copyright = "2026 My Personal Website";
|
|
22
|
+
|
|
23
|
+
async function fetchStaticDetails(apiUrl: string) {
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(`${apiUrl}/api/static`);
|
|
26
|
+
if (res.ok) {
|
|
27
|
+
const data = await res.json();
|
|
28
|
+
siteTitle = data.siteTitle || siteTitle;
|
|
29
|
+
copyright = data.copyright || copyright;
|
|
30
|
+
|
|
31
|
+
const normalizeUrl = (url?: string) => {
|
|
32
|
+
if (!url) return "";
|
|
33
|
+
if (url.startsWith("http://") || url.startsWith("https://")) return url;
|
|
34
|
+
if (url.startsWith("www.")) return `https://${url}`;
|
|
35
|
+
return url;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
footerLinks = [
|
|
39
|
+
{ text: "LinkedIn", link: normalizeUrl(data.linkedin) || defaultFooterLinks[0].link },
|
|
40
|
+
{ text: "GitHub", link: normalizeUrl(data.github) || defaultFooterLinks[1].link },
|
|
41
|
+
{ text: "Email", link: data.email ? `mailto:${data.email}` : defaultFooterLinks[2].link },
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
} catch (e) {}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function fetchAboutMeData(apiUrl: string): Promise<any> {
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(`${apiUrl}/api/aboutme`);
|
|
50
|
+
if (res.ok) return await res.json();
|
|
51
|
+
} catch (e) {}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default {
|
|
56
|
+
async fetch(request: Request, env: any, ctx: any) {
|
|
57
|
+
const apiUrl = env?.API_URL || "https://api.example.com";
|
|
58
|
+
const baseSiteUrl = env?.BASE_SITE_URL || "https://site.example.com";
|
|
59
|
+
|
|
60
|
+
await fetchStaticDetails(apiUrl);
|
|
61
|
+
|
|
62
|
+
const url = new URL(request.url);
|
|
63
|
+
|
|
64
|
+
if (url.pathname.startsWith("/api/")) {
|
|
65
|
+
return fetch(`${apiUrl}${url.pathname}${url.search}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (url.pathname.startsWith("/images/")) {
|
|
69
|
+
const imageKey = url.pathname.slice(1);
|
|
70
|
+
try {
|
|
71
|
+
const image = await env.CONTENT_BUCKET.get(imageKey);
|
|
72
|
+
if (image) {
|
|
73
|
+
return new Response(image.body, {
|
|
74
|
+
headers: {
|
|
75
|
+
"content-type": image.httpMetadata?.contentType || "image/jpeg",
|
|
76
|
+
"cache-control": "public, max-age=86400",
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
} catch (e) {}
|
|
81
|
+
return new Response("Not found", { status: 404 });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (url.pathname.startsWith("/assets/") || url.pathname === "/logo.png" || url.pathname === "/favicon.ico") {
|
|
85
|
+
const path = url.pathname;
|
|
86
|
+
const ext = path.split(".").pop()?.toLowerCase();
|
|
87
|
+
const contentTypes: Record<string, string> = {
|
|
88
|
+
js: "application/javascript",
|
|
89
|
+
css: "text/css",
|
|
90
|
+
png: "image/png",
|
|
91
|
+
jpg: "image/jpeg",
|
|
92
|
+
jpeg: "image/jpeg",
|
|
93
|
+
gif: "image/gif",
|
|
94
|
+
svg: "image/svg+xml",
|
|
95
|
+
webp: "image/webp",
|
|
96
|
+
ico: "image/x-icon",
|
|
97
|
+
};
|
|
98
|
+
const contentType = contentTypes[ext || ""] || "application/octet-stream";
|
|
99
|
+
|
|
100
|
+
const response = await fetch(`${baseSiteUrl}${path}`);
|
|
101
|
+
if (response.ok) {
|
|
102
|
+
return new Response(response.body, {
|
|
103
|
+
headers: {
|
|
104
|
+
"content-type": contentType,
|
|
105
|
+
"cache-control": "public, max-age=31536000",
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return new Response("Not found", { status: 404 });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const PRERENDERED_DOMAINS = [
|
|
113
|
+
url.hostname
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
if (!PRERENDERED_DOMAINS.includes(url.hostname) && !url.hostname.includes("localhost")) {
|
|
117
|
+
return fetch(request);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let hydrationScript = "";
|
|
121
|
+
if (url.pathname === "/about-me" || url.pathname === "/about-me/") {
|
|
122
|
+
const aboutMeData = await fetchAboutMeData(apiUrl);
|
|
123
|
+
if (aboutMeData) {
|
|
124
|
+
hydrationScript = `<script>window.__HYDRATION_DATA__ = ${JSON.stringify(aboutMeData)};</script>`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const pageContent = await generatePageContent(url.pathname, routes, footerLinks, { ...env, apiUrl });
|
|
129
|
+
const html = await createHtmlTemplate({ ...pageContent, hydrationData: hydrationScript });
|
|
130
|
+
|
|
131
|
+
return new Response(html, {
|
|
132
|
+
headers: {
|
|
133
|
+
"content-type": "text/html",
|
|
134
|
+
"cache-control": "public, max-age=60",
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
};
|