@htlkg/astro 0.0.1
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 +265 -0
- package/dist/chunk-33R4URZV.js +59 -0
- package/dist/chunk-33R4URZV.js.map +1 -0
- package/dist/chunk-64USRLVP.js +85 -0
- package/dist/chunk-64USRLVP.js.map +1 -0
- package/dist/chunk-WLOFOVCL.js +210 -0
- package/dist/chunk-WLOFOVCL.js.map +1 -0
- package/dist/chunk-WNMPTDCR.js +73 -0
- package/dist/chunk-WNMPTDCR.js.map +1 -0
- package/dist/chunk-Z2ZAL7KX.js +9 -0
- package/dist/chunk-Z2ZAL7KX.js.map +1 -0
- package/dist/chunk-ZQ4XMJH7.js +1 -0
- package/dist/chunk-ZQ4XMJH7.js.map +1 -0
- package/dist/htlkg/config.js +7 -0
- package/dist/htlkg/config.js.map +1 -0
- package/dist/htlkg/index.js +7 -0
- package/dist/htlkg/index.js.map +1 -0
- package/dist/index.js +64 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/index.js +168 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/utils/hydration.js +21 -0
- package/dist/utils/hydration.js.map +1 -0
- package/dist/utils/index.js +56 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/ssr.js +21 -0
- package/dist/utils/ssr.js.map +1 -0
- package/dist/utils/static.js +19 -0
- package/dist/utils/static.js.map +1 -0
- package/package.json +53 -0
- package/src/__mocks__/astro-middleware.ts +19 -0
- package/src/__mocks__/virtual-htlkg-config.ts +14 -0
- package/src/auth/LoginForm.vue +482 -0
- package/src/auth/LoginPage.astro +70 -0
- package/src/components/PageHeader.astro +145 -0
- package/src/components/Sidebar.astro +157 -0
- package/src/components/Topbar.astro +167 -0
- package/src/components/index.ts +9 -0
- package/src/htlkg/config.test.ts +165 -0
- package/src/htlkg/config.ts +242 -0
- package/src/htlkg/index.ts +245 -0
- package/src/htlkg/virtual-modules.test.ts +158 -0
- package/src/htlkg/virtual-modules.ts +81 -0
- package/src/index.ts +37 -0
- package/src/layouts/AdminLayout.astro +184 -0
- package/src/layouts/AuthLayout.astro +164 -0
- package/src/layouts/BrandLayout.astro +309 -0
- package/src/layouts/DefaultLayout.astro +25 -0
- package/src/layouts/PublicLayout.astro +153 -0
- package/src/layouts/index.ts +10 -0
- package/src/middleware/auth.ts +53 -0
- package/src/middleware/index.ts +31 -0
- package/src/middleware/route-guards.test.ts +182 -0
- package/src/middleware/route-guards.ts +218 -0
- package/src/patterns/admin/DetailPage.astro +195 -0
- package/src/patterns/admin/FormPage.astro +203 -0
- package/src/patterns/admin/ListPage.astro +178 -0
- package/src/patterns/admin/index.ts +9 -0
- package/src/patterns/brand/ConfigPage.astro +128 -0
- package/src/patterns/brand/PortalPage.astro +161 -0
- package/src/patterns/brand/index.ts +8 -0
- package/src/patterns/index.ts +8 -0
- package/src/utils/hydration.test.ts +154 -0
- package/src/utils/hydration.ts +151 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/ssr.test.ts +235 -0
- package/src/utils/ssr.ts +139 -0
- package/src/utils/static.test.ts +144 -0
- package/src/utils/static.ts +144 -0
- package/src/vue-app-setup.ts +88 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
serializeForHydration,
|
|
4
|
+
deserializeFromHydration,
|
|
5
|
+
createHydrationScript,
|
|
6
|
+
createHydrationScripts,
|
|
7
|
+
createIslandProps,
|
|
8
|
+
mergeProps,
|
|
9
|
+
} from './hydration';
|
|
10
|
+
|
|
11
|
+
describe('Hydration Utilities', () => {
|
|
12
|
+
describe('serializeForHydration', () => {
|
|
13
|
+
it('should serialize basic data', () => {
|
|
14
|
+
const data = { id: 1, name: 'Test' };
|
|
15
|
+
const serialized = serializeForHydration(data);
|
|
16
|
+
|
|
17
|
+
expect(serialized).toBe('{"id":1,"name":"Test"}');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should handle dates', () => {
|
|
21
|
+
const date = new Date('2024-01-01T00:00:00.000Z');
|
|
22
|
+
const data = { date };
|
|
23
|
+
const serialized = serializeForHydration(data);
|
|
24
|
+
|
|
25
|
+
// JSON.stringify converts dates to ISO strings by default
|
|
26
|
+
expect(serialized).toContain('"2024-01-01T00:00:00.000Z"');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should handle undefined', () => {
|
|
30
|
+
const data = { value: undefined };
|
|
31
|
+
const serialized = serializeForHydration(data);
|
|
32
|
+
|
|
33
|
+
expect(serialized).toContain('"__type":"undefined"');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should skip functions', () => {
|
|
37
|
+
const data = { fn: () => {}, value: 'test' };
|
|
38
|
+
const serialized = serializeForHydration(data);
|
|
39
|
+
|
|
40
|
+
expect(serialized).not.toContain('fn');
|
|
41
|
+
expect(serialized).toContain('"value":"test"');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('deserializeFromHydration', () => {
|
|
46
|
+
it('should deserialize basic data', () => {
|
|
47
|
+
const json = '{"id":1,"name":"Test"}';
|
|
48
|
+
const data = deserializeFromHydration(json);
|
|
49
|
+
|
|
50
|
+
expect(data).toEqual({ id: 1, name: 'Test' });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should handle date strings', () => {
|
|
54
|
+
const json = '{"date":"2024-01-01T00:00:00.000Z"}';
|
|
55
|
+
const data = deserializeFromHydration<{ date: string }>(json);
|
|
56
|
+
|
|
57
|
+
// Dates are serialized as ISO strings
|
|
58
|
+
expect(data.date).toBe('2024-01-01T00:00:00.000Z');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should restore undefined', () => {
|
|
62
|
+
const json = '{"value":{"__type":"undefined"}}';
|
|
63
|
+
const data = deserializeFromHydration<{ value: undefined }>(json);
|
|
64
|
+
|
|
65
|
+
expect(data.value).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('createHydrationScript', () => {
|
|
70
|
+
it('should create hydration script', () => {
|
|
71
|
+
const data = { id: 1, name: 'Test' };
|
|
72
|
+
const script = createHydrationScript('myData', data);
|
|
73
|
+
|
|
74
|
+
expect(script).toBe('window.myData = {"id":1,"name":"Test"};');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('createHydrationScripts', () => {
|
|
79
|
+
it('should create multiple hydration scripts', () => {
|
|
80
|
+
const data = {
|
|
81
|
+
user: { id: 1, name: 'User' },
|
|
82
|
+
config: { theme: 'dark' },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const scripts = createHydrationScripts(data);
|
|
86
|
+
|
|
87
|
+
expect(scripts).toContain('window.user = {"id":1,"name":"User"};');
|
|
88
|
+
expect(scripts).toContain('window.config = {"theme":"dark"};');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('createIslandProps', () => {
|
|
93
|
+
it('should remove functions from props', () => {
|
|
94
|
+
const props = {
|
|
95
|
+
id: 1,
|
|
96
|
+
name: 'Test',
|
|
97
|
+
onClick: () => {},
|
|
98
|
+
onSubmit: () => {},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const cleanProps = createIslandProps(props);
|
|
102
|
+
|
|
103
|
+
expect(cleanProps).toEqual({ id: 1, name: 'Test' });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should remove undefined values', () => {
|
|
107
|
+
const props = {
|
|
108
|
+
id: 1,
|
|
109
|
+
name: 'Test',
|
|
110
|
+
optional: undefined,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const cleanProps = createIslandProps(props);
|
|
114
|
+
|
|
115
|
+
expect(cleanProps).toEqual({ id: 1, name: 'Test' });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should keep null values', () => {
|
|
119
|
+
const props = {
|
|
120
|
+
id: 1,
|
|
121
|
+
value: null,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const cleanProps = createIslandProps(props);
|
|
125
|
+
|
|
126
|
+
expect(cleanProps).toEqual({ id: 1, value: null });
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('mergeProps', () => {
|
|
131
|
+
it('should merge server and client props', () => {
|
|
132
|
+
const serverProps = { id: 1, name: 'Server', value: 'old' };
|
|
133
|
+
const clientProps = { value: 'new', extra: 'data' };
|
|
134
|
+
|
|
135
|
+
const merged = mergeProps(serverProps, clientProps);
|
|
136
|
+
|
|
137
|
+
expect(merged).toEqual({
|
|
138
|
+
id: 1,
|
|
139
|
+
name: 'Server',
|
|
140
|
+
value: 'new',
|
|
141
|
+
extra: 'data',
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should not mutate original props', () => {
|
|
146
|
+
const serverProps = { id: 1, name: 'Server' };
|
|
147
|
+
const clientProps = { name: 'Client' };
|
|
148
|
+
|
|
149
|
+
mergeProps(serverProps, clientProps);
|
|
150
|
+
|
|
151
|
+
expect(serverProps.name).toBe('Server');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hydration Utilities
|
|
3
|
+
*
|
|
4
|
+
* Helper functions for Vue component hydration in Astro.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Serialize data for client-side hydration
|
|
9
|
+
* Safely handles dates, functions, and circular references
|
|
10
|
+
*/
|
|
11
|
+
export function serializeForHydration<T>(data: T): string {
|
|
12
|
+
return JSON.stringify(data, (key, value) => {
|
|
13
|
+
// Skip functions
|
|
14
|
+
if (typeof value === 'function') {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Handle undefined
|
|
19
|
+
if (value === undefined) {
|
|
20
|
+
return { __type: 'undefined' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return value;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Deserialize data on client-side
|
|
29
|
+
*/
|
|
30
|
+
export function deserializeFromHydration<T>(json: string): T {
|
|
31
|
+
return JSON.parse(json, (key, value) => {
|
|
32
|
+
// Handle undefined
|
|
33
|
+
if (value && value.__type === 'undefined') {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return value;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create hydration script for Vue components
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```astro
|
|
46
|
+
* <script set:html={createHydrationScript('userData', user)} />
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function createHydrationScript(
|
|
50
|
+
variableName: string,
|
|
51
|
+
data: any
|
|
52
|
+
): string {
|
|
53
|
+
const serialized = serializeForHydration(data);
|
|
54
|
+
return `window.${variableName} = ${serialized};`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create multiple hydration scripts
|
|
59
|
+
*/
|
|
60
|
+
export function createHydrationScripts(
|
|
61
|
+
data: Record<string, any>
|
|
62
|
+
): string {
|
|
63
|
+
return Object.entries(data)
|
|
64
|
+
.map(([key, value]) => createHydrationScript(key, value))
|
|
65
|
+
.join('\n');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get hydrated data on client-side
|
|
70
|
+
*/
|
|
71
|
+
export function getHydratedData<T>(variableName: string): T | null {
|
|
72
|
+
if (typeof window === 'undefined') {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const data = (window as any)[variableName];
|
|
77
|
+
return data ? deserializeFromHydration(JSON.stringify(data)) : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if component should hydrate
|
|
82
|
+
*/
|
|
83
|
+
export function shouldHydrate(
|
|
84
|
+
strategy: 'load' | 'idle' | 'visible' | 'media' | 'only',
|
|
85
|
+
options?: {
|
|
86
|
+
mediaQuery?: string;
|
|
87
|
+
}
|
|
88
|
+
): boolean {
|
|
89
|
+
if (typeof window === 'undefined') {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
switch (strategy) {
|
|
94
|
+
case 'load':
|
|
95
|
+
return true;
|
|
96
|
+
|
|
97
|
+
case 'idle':
|
|
98
|
+
return 'requestIdleCallback' in window;
|
|
99
|
+
|
|
100
|
+
case 'visible':
|
|
101
|
+
return 'IntersectionObserver' in window;
|
|
102
|
+
|
|
103
|
+
case 'media':
|
|
104
|
+
if (!options?.mediaQuery) return false;
|
|
105
|
+
return window.matchMedia(options.mediaQuery).matches;
|
|
106
|
+
|
|
107
|
+
case 'only':
|
|
108
|
+
return false;
|
|
109
|
+
|
|
110
|
+
default:
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Create Vue island props
|
|
117
|
+
* Prepares props for Vue component islands
|
|
118
|
+
*/
|
|
119
|
+
export function createIslandProps<T extends Record<string, any>>(
|
|
120
|
+
props: T
|
|
121
|
+
): T {
|
|
122
|
+
// Remove functions and non-serializable values
|
|
123
|
+
const cleanProps: any = {};
|
|
124
|
+
|
|
125
|
+
for (const [key, value] of Object.entries(props)) {
|
|
126
|
+
if (typeof value === 'function') {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (value === undefined) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
cleanProps[key] = value;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return cleanProps;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Merge server and client props
|
|
142
|
+
*/
|
|
143
|
+
export function mergeProps<T extends Record<string, any>>(
|
|
144
|
+
serverProps: T,
|
|
145
|
+
clientProps: Partial<T>
|
|
146
|
+
): T {
|
|
147
|
+
return {
|
|
148
|
+
...serverProps,
|
|
149
|
+
...clientProps,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
getServerData,
|
|
4
|
+
isServerSide,
|
|
5
|
+
getRequestHeaders,
|
|
6
|
+
getQueryParams,
|
|
7
|
+
setResponseHeaders,
|
|
8
|
+
setCacheControl,
|
|
9
|
+
getClientIP,
|
|
10
|
+
isMobileDevice,
|
|
11
|
+
} from './ssr';
|
|
12
|
+
|
|
13
|
+
describe('SSR Utilities', () => {
|
|
14
|
+
describe('getServerData', () => {
|
|
15
|
+
it('should return data from successful fetch', async () => {
|
|
16
|
+
const mockAstro = {} as any;
|
|
17
|
+
const fetcher = vi.fn().mockResolvedValue({ id: 1, name: 'Test' });
|
|
18
|
+
|
|
19
|
+
const result = await getServerData(mockAstro, fetcher);
|
|
20
|
+
|
|
21
|
+
expect(result).toEqual({ id: 1, name: 'Test' });
|
|
22
|
+
expect(fetcher).toHaveBeenCalledOnce();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should return null on error without redirect', async () => {
|
|
26
|
+
const mockAstro = {} as any;
|
|
27
|
+
const fetcher = vi.fn().mockRejectedValue(new Error('Fetch failed'));
|
|
28
|
+
|
|
29
|
+
const result = await getServerData(mockAstro, fetcher);
|
|
30
|
+
|
|
31
|
+
expect(result).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should return default value on error', async () => {
|
|
35
|
+
const mockAstro = {} as any;
|
|
36
|
+
const fetcher = vi.fn().mockRejectedValue(new Error('Fetch failed'));
|
|
37
|
+
const defaultValue = { id: 0, name: 'Default' };
|
|
38
|
+
|
|
39
|
+
const result = await getServerData(mockAstro, fetcher, { defaultValue });
|
|
40
|
+
|
|
41
|
+
expect(result).toEqual(defaultValue);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('isServerSide', () => {
|
|
46
|
+
it('should return true when no client header', () => {
|
|
47
|
+
const mockAstro = {
|
|
48
|
+
request: {
|
|
49
|
+
headers: new Map(),
|
|
50
|
+
},
|
|
51
|
+
} as any;
|
|
52
|
+
|
|
53
|
+
expect(isServerSide(mockAstro)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should return false when client header present', () => {
|
|
57
|
+
const headers = new Map();
|
|
58
|
+
headers.set('x-astro-client', 'true');
|
|
59
|
+
|
|
60
|
+
const mockAstro = {
|
|
61
|
+
request: { headers },
|
|
62
|
+
} as any;
|
|
63
|
+
|
|
64
|
+
expect(isServerSide(mockAstro)).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('getRequestHeaders', () => {
|
|
69
|
+
it('should convert headers to object', () => {
|
|
70
|
+
const headers = new Map();
|
|
71
|
+
headers.set('content-type', 'application/json');
|
|
72
|
+
headers.set('authorization', 'Bearer token');
|
|
73
|
+
|
|
74
|
+
const mockAstro = {
|
|
75
|
+
request: { headers },
|
|
76
|
+
} as any;
|
|
77
|
+
|
|
78
|
+
const result = getRequestHeaders(mockAstro);
|
|
79
|
+
|
|
80
|
+
expect(result).toEqual({
|
|
81
|
+
'content-type': 'application/json',
|
|
82
|
+
'authorization': 'Bearer token',
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('getQueryParams', () => {
|
|
88
|
+
it('should extract query parameters', () => {
|
|
89
|
+
const mockAstro = {
|
|
90
|
+
request: {
|
|
91
|
+
url: 'https://example.com/page?foo=bar&baz=qux',
|
|
92
|
+
},
|
|
93
|
+
} as any;
|
|
94
|
+
|
|
95
|
+
const result = getQueryParams(mockAstro);
|
|
96
|
+
|
|
97
|
+
expect(result).toEqual({
|
|
98
|
+
foo: 'bar',
|
|
99
|
+
baz: 'qux',
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should return empty object when no params', () => {
|
|
104
|
+
const mockAstro = {
|
|
105
|
+
request: {
|
|
106
|
+
url: 'https://example.com/page',
|
|
107
|
+
},
|
|
108
|
+
} as any;
|
|
109
|
+
|
|
110
|
+
const result = getQueryParams(mockAstro);
|
|
111
|
+
|
|
112
|
+
expect(result).toEqual({});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('setResponseHeaders', () => {
|
|
117
|
+
it('should set response headers', () => {
|
|
118
|
+
const headers = new Map();
|
|
119
|
+
const mockAstro = {
|
|
120
|
+
response: {
|
|
121
|
+
headers: {
|
|
122
|
+
set: vi.fn((key, value) => headers.set(key, value)),
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
} as any;
|
|
126
|
+
|
|
127
|
+
setResponseHeaders(mockAstro, {
|
|
128
|
+
'X-Custom': 'value',
|
|
129
|
+
'X-Another': 'test',
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(mockAstro.response.headers.set).toHaveBeenCalledWith('X-Custom', 'value');
|
|
133
|
+
expect(mockAstro.response.headers.set).toHaveBeenCalledWith('X-Another', 'test');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('setCacheControl', () => {
|
|
138
|
+
it('should set cache control with max-age', () => {
|
|
139
|
+
const mockAstro = {
|
|
140
|
+
response: {
|
|
141
|
+
headers: {
|
|
142
|
+
set: vi.fn(),
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
} as any;
|
|
146
|
+
|
|
147
|
+
setCacheControl(mockAstro, { maxAge: 3600, public: true });
|
|
148
|
+
|
|
149
|
+
expect(mockAstro.response.headers.set).toHaveBeenCalledWith(
|
|
150
|
+
'Cache-Control',
|
|
151
|
+
'public, max-age=3600'
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should set cache control with all options', () => {
|
|
156
|
+
const mockAstro = {
|
|
157
|
+
response: {
|
|
158
|
+
headers: {
|
|
159
|
+
set: vi.fn(),
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
} as any;
|
|
163
|
+
|
|
164
|
+
setCacheControl(mockAstro, {
|
|
165
|
+
maxAge: 3600,
|
|
166
|
+
sMaxAge: 7200,
|
|
167
|
+
staleWhileRevalidate: 86400,
|
|
168
|
+
public: true,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(mockAstro.response.headers.set).toHaveBeenCalledWith(
|
|
172
|
+
'Cache-Control',
|
|
173
|
+
'public, max-age=3600, s-maxage=7200, stale-while-revalidate=86400'
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('getClientIP', () => {
|
|
179
|
+
it('should get IP from x-forwarded-for', () => {
|
|
180
|
+
const headers = new Map();
|
|
181
|
+
headers.set('x-forwarded-for', '192.168.1.1, 10.0.0.1');
|
|
182
|
+
|
|
183
|
+
const mockAstro = {
|
|
184
|
+
request: { headers },
|
|
185
|
+
} as any;
|
|
186
|
+
|
|
187
|
+
expect(getClientIP(mockAstro)).toBe('192.168.1.1');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should get IP from x-real-ip', () => {
|
|
191
|
+
const headers = new Map();
|
|
192
|
+
headers.set('x-real-ip', '192.168.1.1');
|
|
193
|
+
|
|
194
|
+
const mockAstro = {
|
|
195
|
+
request: { headers },
|
|
196
|
+
} as any;
|
|
197
|
+
|
|
198
|
+
expect(getClientIP(mockAstro)).toBe('192.168.1.1');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should return null when no IP headers', () => {
|
|
202
|
+
const mockAstro = {
|
|
203
|
+
request: {
|
|
204
|
+
headers: new Map(),
|
|
205
|
+
},
|
|
206
|
+
} as any;
|
|
207
|
+
|
|
208
|
+
expect(getClientIP(mockAstro)).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe('isMobileDevice', () => {
|
|
213
|
+
it('should detect mobile user agent', () => {
|
|
214
|
+
const headers = new Map();
|
|
215
|
+
headers.set('user-agent', 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)');
|
|
216
|
+
|
|
217
|
+
const mockAstro = {
|
|
218
|
+
request: { headers },
|
|
219
|
+
} as any;
|
|
220
|
+
|
|
221
|
+
expect(isMobileDevice(mockAstro)).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should detect desktop user agent', () => {
|
|
225
|
+
const headers = new Map();
|
|
226
|
+
headers.set('user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)');
|
|
227
|
+
|
|
228
|
+
const mockAstro = {
|
|
229
|
+
request: { headers },
|
|
230
|
+
} as any;
|
|
231
|
+
|
|
232
|
+
expect(isMobileDevice(mockAstro)).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
});
|
package/src/utils/ssr.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSR Utilities
|
|
3
|
+
*
|
|
4
|
+
* Helper functions for server-side rendering in Astro pages.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { AstroGlobal } from 'astro';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get server-side data with error handling
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* const data = await getServerData(Astro, async () => {
|
|
15
|
+
* return await fetchBrand(brandId);
|
|
16
|
+
* });
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export async function getServerData<T>(
|
|
20
|
+
astro: AstroGlobal,
|
|
21
|
+
fetcher: () => Promise<T>,
|
|
22
|
+
options: {
|
|
23
|
+
redirectOnError?: string;
|
|
24
|
+
defaultValue?: T;
|
|
25
|
+
} = {}
|
|
26
|
+
): Promise<T | null> {
|
|
27
|
+
try {
|
|
28
|
+
return await fetcher();
|
|
29
|
+
} catch (error) {
|
|
30
|
+
console.error('[SSR] Error fetching data:', error);
|
|
31
|
+
|
|
32
|
+
if (options.redirectOnError) {
|
|
33
|
+
return astro.redirect(options.redirectOnError) as any;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return options.defaultValue ?? null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Check if request is from server-side
|
|
42
|
+
*/
|
|
43
|
+
export function isServerSide(astro: AstroGlobal): boolean {
|
|
44
|
+
return !astro.request.headers.get('x-astro-client');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get request headers as object
|
|
49
|
+
*/
|
|
50
|
+
export function getRequestHeaders(astro: AstroGlobal): Record<string, string> {
|
|
51
|
+
const headers: Record<string, string> = {};
|
|
52
|
+
astro.request.headers.forEach((value, key) => {
|
|
53
|
+
headers[key] = value;
|
|
54
|
+
});
|
|
55
|
+
return headers;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get query parameters from URL
|
|
60
|
+
*/
|
|
61
|
+
export function getQueryParams(astro: AstroGlobal): Record<string, string> {
|
|
62
|
+
const params: Record<string, string> = {};
|
|
63
|
+
const searchParams = new URL(astro.request.url).searchParams;
|
|
64
|
+
|
|
65
|
+
searchParams.forEach((value, key) => {
|
|
66
|
+
params[key] = value;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return params;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Set response headers
|
|
74
|
+
*/
|
|
75
|
+
export function setResponseHeaders(
|
|
76
|
+
astro: AstroGlobal,
|
|
77
|
+
headers: Record<string, string>
|
|
78
|
+
): void {
|
|
79
|
+
Object.entries(headers).forEach(([key, value]) => {
|
|
80
|
+
astro.response.headers.set(key, value);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Set cache control headers
|
|
86
|
+
*/
|
|
87
|
+
export function setCacheControl(
|
|
88
|
+
astro: AstroGlobal,
|
|
89
|
+
options: {
|
|
90
|
+
maxAge?: number;
|
|
91
|
+
sMaxAge?: number;
|
|
92
|
+
staleWhileRevalidate?: number;
|
|
93
|
+
public?: boolean;
|
|
94
|
+
}
|
|
95
|
+
): void {
|
|
96
|
+
const directives: string[] = [];
|
|
97
|
+
|
|
98
|
+
if (options.public) {
|
|
99
|
+
directives.push('public');
|
|
100
|
+
} else {
|
|
101
|
+
directives.push('private');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (options.maxAge !== undefined) {
|
|
105
|
+
directives.push(`max-age=${options.maxAge}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (options.sMaxAge !== undefined) {
|
|
109
|
+
directives.push(`s-maxage=${options.sMaxAge}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (options.staleWhileRevalidate !== undefined) {
|
|
113
|
+
directives.push(`stale-while-revalidate=${options.staleWhileRevalidate}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
astro.response.headers.set('Cache-Control', directives.join(', '));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get client IP address
|
|
121
|
+
*/
|
|
122
|
+
export function getClientIP(astro: AstroGlobal): string | null {
|
|
123
|
+
const headers = astro.request.headers;
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
headers.get('x-forwarded-for')?.split(',')[0].trim() ||
|
|
127
|
+
headers.get('x-real-ip') ||
|
|
128
|
+
headers.get('cf-connecting-ip') ||
|
|
129
|
+
null
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Check if request is from mobile device
|
|
135
|
+
*/
|
|
136
|
+
export function isMobileDevice(astro: AstroGlobal): boolean {
|
|
137
|
+
const userAgent = astro.request.headers.get('user-agent') || '';
|
|
138
|
+
return /mobile|android|iphone|ipad|phone/i.test(userAgent);
|
|
139
|
+
}
|