@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.
Files changed (70) hide show
  1. package/README.md +265 -0
  2. package/dist/chunk-33R4URZV.js +59 -0
  3. package/dist/chunk-33R4URZV.js.map +1 -0
  4. package/dist/chunk-64USRLVP.js +85 -0
  5. package/dist/chunk-64USRLVP.js.map +1 -0
  6. package/dist/chunk-WLOFOVCL.js +210 -0
  7. package/dist/chunk-WLOFOVCL.js.map +1 -0
  8. package/dist/chunk-WNMPTDCR.js +73 -0
  9. package/dist/chunk-WNMPTDCR.js.map +1 -0
  10. package/dist/chunk-Z2ZAL7KX.js +9 -0
  11. package/dist/chunk-Z2ZAL7KX.js.map +1 -0
  12. package/dist/chunk-ZQ4XMJH7.js +1 -0
  13. package/dist/chunk-ZQ4XMJH7.js.map +1 -0
  14. package/dist/htlkg/config.js +7 -0
  15. package/dist/htlkg/config.js.map +1 -0
  16. package/dist/htlkg/index.js +7 -0
  17. package/dist/htlkg/index.js.map +1 -0
  18. package/dist/index.js +64 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/middleware/index.js +168 -0
  21. package/dist/middleware/index.js.map +1 -0
  22. package/dist/utils/hydration.js +21 -0
  23. package/dist/utils/hydration.js.map +1 -0
  24. package/dist/utils/index.js +56 -0
  25. package/dist/utils/index.js.map +1 -0
  26. package/dist/utils/ssr.js +21 -0
  27. package/dist/utils/ssr.js.map +1 -0
  28. package/dist/utils/static.js +19 -0
  29. package/dist/utils/static.js.map +1 -0
  30. package/package.json +53 -0
  31. package/src/__mocks__/astro-middleware.ts +19 -0
  32. package/src/__mocks__/virtual-htlkg-config.ts +14 -0
  33. package/src/auth/LoginForm.vue +482 -0
  34. package/src/auth/LoginPage.astro +70 -0
  35. package/src/components/PageHeader.astro +145 -0
  36. package/src/components/Sidebar.astro +157 -0
  37. package/src/components/Topbar.astro +167 -0
  38. package/src/components/index.ts +9 -0
  39. package/src/htlkg/config.test.ts +165 -0
  40. package/src/htlkg/config.ts +242 -0
  41. package/src/htlkg/index.ts +245 -0
  42. package/src/htlkg/virtual-modules.test.ts +158 -0
  43. package/src/htlkg/virtual-modules.ts +81 -0
  44. package/src/index.ts +37 -0
  45. package/src/layouts/AdminLayout.astro +184 -0
  46. package/src/layouts/AuthLayout.astro +164 -0
  47. package/src/layouts/BrandLayout.astro +309 -0
  48. package/src/layouts/DefaultLayout.astro +25 -0
  49. package/src/layouts/PublicLayout.astro +153 -0
  50. package/src/layouts/index.ts +10 -0
  51. package/src/middleware/auth.ts +53 -0
  52. package/src/middleware/index.ts +31 -0
  53. package/src/middleware/route-guards.test.ts +182 -0
  54. package/src/middleware/route-guards.ts +218 -0
  55. package/src/patterns/admin/DetailPage.astro +195 -0
  56. package/src/patterns/admin/FormPage.astro +203 -0
  57. package/src/patterns/admin/ListPage.astro +178 -0
  58. package/src/patterns/admin/index.ts +9 -0
  59. package/src/patterns/brand/ConfigPage.astro +128 -0
  60. package/src/patterns/brand/PortalPage.astro +161 -0
  61. package/src/patterns/brand/index.ts +8 -0
  62. package/src/patterns/index.ts +8 -0
  63. package/src/utils/hydration.test.ts +154 -0
  64. package/src/utils/hydration.ts +151 -0
  65. package/src/utils/index.ts +9 -0
  66. package/src/utils/ssr.test.ts +235 -0
  67. package/src/utils/ssr.ts +139 -0
  68. package/src/utils/static.test.ts +144 -0
  69. package/src/utils/static.ts +144 -0
  70. 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,9 @@
1
+ /**
2
+ * @htlkg/pages - Utilities
3
+ *
4
+ * Helper functions for Astro pages.
5
+ */
6
+
7
+ export * from './ssr';
8
+ export * from './static';
9
+ export * from './hydration';
@@ -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
+ });
@@ -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
+ }