@xosen/site-sdk 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.
@@ -0,0 +1,61 @@
1
+ import { onMounted } from 'vue';
2
+
3
+ import type { Skin } from '../types/skin.js';
4
+ import type { SiteData } from '../types/config.js';
5
+
6
+ /**
7
+ * Apply skin CSS custom properties to :root.
8
+ * Reads skin from __SITE_DATA__.skin or accepts explicit skin object.
9
+ */
10
+ export function useSkin(skinOverride?: Skin) {
11
+ function applySkin(skin: Skin) {
12
+ const root = document.documentElement;
13
+
14
+ // Colors
15
+ if (skin.colors) {
16
+ for (const [key, value] of Object.entries(skin.colors)) {
17
+ if (value) root.style.setProperty(`--x-color-${camelToKebab(key)}`, value);
18
+ }
19
+ }
20
+
21
+ // Typography
22
+ if (skin.typography?.fontFamily) {
23
+ root.style.setProperty('--x-font-family', skin.typography.fontFamily);
24
+ }
25
+ if (skin.typography?.headingFont) {
26
+ root.style.setProperty('--x-font-heading', skin.typography.headingFont);
27
+ }
28
+ if (skin.typography?.baseFontSize) {
29
+ root.style.setProperty('--x-font-size', skin.typography.baseFontSize);
30
+ }
31
+
32
+ // Shape
33
+ if (skin.shape?.borderRadius) {
34
+ root.style.setProperty('--x-border-radius', skin.shape.borderRadius);
35
+ }
36
+ if (skin.shape?.maxWidth) {
37
+ root.style.setProperty('--x-max-width', skin.shape.maxWidth);
38
+ }
39
+ }
40
+
41
+ onMounted(() => {
42
+ if (skinOverride) {
43
+ applySkin(skinOverride);
44
+ return;
45
+ }
46
+
47
+ const siteData = (window as any).__SITE_DATA__ as SiteData | undefined;
48
+ const skin = siteData?.skin as Partial<Skin> | undefined;
49
+ if (skin && (skin.colors || skin.typography || skin.shape)) {
50
+ applySkin({
51
+ colors: skin.colors || {},
52
+ typography: skin.typography || {},
53
+ shape: skin.shape || {},
54
+ } as Skin);
55
+ }
56
+ });
57
+ }
58
+
59
+ function camelToKebab(str: string): string {
60
+ return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
61
+ }
package/src/index.ts ADDED
@@ -0,0 +1,42 @@
1
+ // Layout
2
+ export { default as XSiteLayout } from './components/layout/XSiteLayout.vue';
3
+ export { default as XSiteNav } from './components/layout/XSiteNav.vue';
4
+ export { default as XSiteFooter } from './components/layout/XSiteFooter.vue';
5
+
6
+ // Blocks
7
+ export { default as XBlockRenderer } from './components/blocks/XBlockRenderer.vue';
8
+ export { default as XHeroBlock } from './components/blocks/XHeroBlock.vue';
9
+ export { default as XHtmlBlock } from './components/blocks/XHtmlBlock.vue';
10
+ export { default as XCardsBlock } from './components/blocks/XCardsBlock.vue';
11
+ export { default as XImageTextBlock } from './components/blocks/XImageTextBlock.vue';
12
+ export { default as XPricingBlock } from './components/blocks/XPricingBlock.vue';
13
+ export { default as XContactsBlock } from './components/blocks/XContactsBlock.vue';
14
+
15
+ // Common
16
+ export { default as XLocaleSwitcher } from './components/common/XLocaleSwitcher.vue';
17
+
18
+ // Composables
19
+ export { useSiteData } from './composables/useSiteData.js';
20
+ export { useSiteConfig } from './composables/useSiteConfig.js';
21
+ export { useSkin } from './composables/useSkin.js';
22
+ export { useSiteApi } from './composables/useSiteApi.js';
23
+
24
+ // Worker
25
+ export { createSiteWorker } from './worker/index.js';
26
+ export type { WorkerEnv, WorkerConfig, PageData } from './worker/index.js';
27
+
28
+ // Types
29
+ export type {
30
+ Block,
31
+ BlockSettings,
32
+ HeroBlockData,
33
+ HtmlBlockData,
34
+ CardsBlockData,
35
+ ImageTextBlockData,
36
+ PricingBlockData,
37
+ ContactsBlockData,
38
+ GalleryBlockData,
39
+ MapBlockData,
40
+ } from './types/blocks.js';
41
+ export type { SiteConfig, NavItem, PageConfig, SiteData } from './types/config.js';
42
+ export type { Skin } from './types/skin.js';
@@ -0,0 +1,67 @@
1
+ export interface BlockSettings {
2
+ visible?: boolean;
3
+ className?: string;
4
+ spacing?: 'none' | 'sm' | 'md' | 'lg';
5
+ }
6
+
7
+ export interface Block<T = any> {
8
+ type: string;
9
+ data?: T;
10
+ /** Alias for data — accepted for convenience */
11
+ props?: T;
12
+ settings?: BlockSettings & Record<string, any>;
13
+ }
14
+
15
+ export interface HeroBlockData {
16
+ title: string;
17
+ subtitle?: string;
18
+ image?: string;
19
+ overlay?: boolean;
20
+ buttons?: Array<{ text: string; url: string; variant?: 'primary' | 'outline' | 'white' }>;
21
+ stats?: Array<{ value: string; label: string }>;
22
+ }
23
+
24
+ export interface HtmlBlockData {
25
+ content: string;
26
+ }
27
+
28
+ export interface CardsBlockData {
29
+ title?: string;
30
+ subtitle?: string;
31
+ cards: Array<{
32
+ title: string;
33
+ desc: string;
34
+ icon?: string;
35
+ image?: string;
36
+ link?: string;
37
+ }>;
38
+ }
39
+
40
+ export interface ImageTextBlockData {
41
+ title?: string;
42
+ content?: string;
43
+ image?: string;
44
+ items?: Array<{ text: string; icon?: string }>;
45
+ }
46
+
47
+ export interface PricingBlockData {
48
+ title?: string;
49
+ subtitle?: string;
50
+ }
51
+
52
+ export interface ContactsBlockData {
53
+ title?: string;
54
+ showBankDetails?: boolean;
55
+ }
56
+
57
+ export interface GalleryBlockData {
58
+ title?: string;
59
+ images: Array<{ src: string; alt?: string; caption?: string }>;
60
+ }
61
+
62
+ export interface MapBlockData {
63
+ title?: string;
64
+ center: { lat: number; lng: number };
65
+ zoom?: number;
66
+ markers?: Array<{ lat: number; lng: number; label?: string }>;
67
+ }
@@ -0,0 +1,52 @@
1
+ import type { Block } from './blocks.js';
2
+
3
+ export interface SiteConfig {
4
+ template: string;
5
+ version: string;
6
+ skin: string;
7
+ autoUpdate?: boolean;
8
+
9
+ /** KV key prefix for tenant data (e.g. 'tenant:swi') */
10
+ tenantPrefix?: string;
11
+
12
+ branding: {
13
+ logo?: string;
14
+ favicon?: string;
15
+ siteName?: string;
16
+ };
17
+
18
+ navigation: NavItem[];
19
+
20
+ locales: string[];
21
+ defaultLocale: string;
22
+
23
+ footer?: {
24
+ copyright?: string;
25
+ links?: Array<{ text: string; url: string }>;
26
+ };
27
+
28
+ features?: Record<string, boolean | Record<string, any>>;
29
+ }
30
+
31
+ export interface NavItem {
32
+ text: string;
33
+ url: string;
34
+ external?: boolean;
35
+ children?: NavItem[];
36
+ }
37
+
38
+ export interface PageConfig {
39
+ title: string;
40
+ blocks: Block[];
41
+ meta?: {
42
+ description?: string;
43
+ ogTitle?: string;
44
+ ogImage?: string;
45
+ };
46
+ }
47
+
48
+ export interface SiteData {
49
+ config: SiteConfig;
50
+ locale: string;
51
+ [key: string]: any;
52
+ }
@@ -0,0 +1,37 @@
1
+ export interface Skin {
2
+ name: string;
3
+
4
+ colors: {
5
+ primary: string;
6
+ primaryDark?: string;
7
+ primaryLight?: string;
8
+ secondary?: string;
9
+ accent?: string;
10
+ background: string;
11
+ backgroundAlt?: string;
12
+ surface: string;
13
+ text: string;
14
+ textLight?: string;
15
+ border?: string;
16
+ success?: string;
17
+ error?: string;
18
+ };
19
+
20
+ typography: {
21
+ fontFamily: string;
22
+ headingFont?: string;
23
+ baseFontSize?: string;
24
+ };
25
+
26
+ shape: {
27
+ borderRadius: string;
28
+ cardElevation?: number;
29
+ maxWidth?: string;
30
+ };
31
+
32
+ components?: {
33
+ navbar?: { variant?: 'fixed' | 'static'; transparent?: boolean };
34
+ footer?: { variant?: 'dark' | 'light' };
35
+ hero?: { variant?: 'fullscreen' | 'compact'; overlay?: boolean };
36
+ };
37
+ }
@@ -0,0 +1,149 @@
1
+ import type { WorkerEnv, WorkerConfig, PageData } from './types.js';
2
+
3
+ const STATIC_EXT = /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|webp|avif|map|json|txt|xml|webmanifest)$/;
4
+
5
+ function detectLocale(request: Request, config: WorkerConfig): string {
6
+ const url = new URL(request.url);
7
+ const lang = url.searchParams.get('lang');
8
+ if (lang && config.supportedLocales?.includes(lang)) return lang;
9
+
10
+ if (config.supportedLocales?.length) {
11
+ const accept = request.headers.get('Accept-Language') || '';
12
+ for (const locale of config.supportedLocales) {
13
+ if (accept.includes(locale)) return locale;
14
+ }
15
+ }
16
+
17
+ return config.defaultLocale;
18
+ }
19
+
20
+ function handleContentApi(url: URL, env: WorkerEnv): Promise<Response> | null {
21
+ if (!url.pathname.startsWith('/api/content/')) return null;
22
+
23
+ const key = decodeURIComponent(url.pathname.replace('/api/content/', ''));
24
+ if (!key) {
25
+ return Promise.resolve(Response.json({ error: 'Key is required' }, { status: 400 }));
26
+ }
27
+
28
+ return (async () => {
29
+ const value = await env.SITE_CONTENT.get(key);
30
+ if (!value) {
31
+ return Response.json({ error: 'Not found' }, { status: 404 });
32
+ }
33
+ return new Response(value, {
34
+ headers: {
35
+ 'Content-Type': 'application/json',
36
+ 'Cache-Control': 'public, max-age=60',
37
+ 'Access-Control-Allow-Origin': '*',
38
+ },
39
+ });
40
+ })();
41
+ }
42
+
43
+ function injectMetaTags(html: string, pageData: PageData, siteName?: string): string {
44
+ let result = html;
45
+
46
+ if (pageData.title) {
47
+ const suffix = siteName ? ` — ${siteName}` : '';
48
+ result = result.replace(/<title>[^<]*<\/title>/, `<title>${pageData.title}${suffix}</title>`);
49
+ }
50
+
51
+ if (pageData.meta?.description) {
52
+ result = result.replace(/(<meta\s+name="description"\s+content=")[^"]*(")/, `$1${pageData.meta.description}$2`);
53
+ }
54
+
55
+ if (pageData.meta?.ogTitle) {
56
+ result = result.replace(/(<meta\s+property="og:title"\s+content=")[^"]*(")/, `$1${pageData.meta.ogTitle}$2`);
57
+ }
58
+
59
+ if (pageData.html) {
60
+ result = result.replace('<div id="app"></div>', `<div id="app">${pageData.html}</div>`);
61
+ }
62
+
63
+ return result;
64
+ }
65
+
66
+ /**
67
+ * Create a Cloudflare Worker handler for an Xosen site template.
68
+ *
69
+ * The worker:
70
+ * - Serves static assets from ASSETS binding
71
+ * - Provides /api/content/:key endpoint for KV reads
72
+ * - Injects preloaded KV data as window.__SITE_DATA__ into HTML pages
73
+ * - Detects locale from query param or Accept-Language header
74
+ * - Preloads page-specific data for /p/:slug routes
75
+ */
76
+ export function createSiteWorker(config: WorkerConfig): { fetch: (request: Request, env: WorkerEnv) => Promise<Response> } {
77
+ const { tenantPrefix, globalKeys = [] } = config;
78
+
79
+ return {
80
+ async fetch(request: Request, env: WorkerEnv): Promise<Response> {
81
+ const url = new URL(request.url);
82
+
83
+ // API route: /api/content/:key
84
+ const apiResponse = handleContentApi(url, env);
85
+ if (apiResponse) return apiResponse;
86
+
87
+ // Static assets
88
+ if (STATIC_EXT.test(url.pathname)) {
89
+ return env.ASSETS.fetch(request);
90
+ }
91
+
92
+ // HTML pages — inject KV data
93
+ const locale = detectLocale(request, config);
94
+ const indexUrl = new URL('/index.html', request.url);
95
+ const assetResponse = await env.ASSETS.fetch(new Request(indexUrl));
96
+ let html = await assetResponse.text();
97
+
98
+ // Preload global KV data
99
+ const kvPromises: Promise<readonly [string, unknown]>[] = globalKeys.map(async (key) => {
100
+ const value = await env.SITE_CONTENT.get(`${tenantPrefix}:${key}:${locale}`);
101
+ return [key, value ? JSON.parse(value) : null] as const;
102
+ });
103
+
104
+ // Page-specific data for /p/:slug
105
+ const pageMatch = url.pathname.match(/^\/p\/(.+)$/);
106
+ if (pageMatch) {
107
+ const slug = pageMatch[1];
108
+ kvPromises.push(
109
+ (async () => {
110
+ const value = await env.SITE_CONTENT.get(`${tenantPrefix}:page:${slug}:${locale}`);
111
+ return [`page:${slug}`, value ? JSON.parse(value) : null] as const;
112
+ })(),
113
+ );
114
+ }
115
+
116
+ const entries = await Promise.all(kvPromises);
117
+
118
+ const siteData: Record<string, unknown> = {
119
+ locale,
120
+ config: { tenantPrefix },
121
+ };
122
+ for (const [key, value] of entries) {
123
+ if (value !== null) {
124
+ siteData[key] = value;
125
+ }
126
+ }
127
+
128
+ // Inject meta tags for page routes
129
+ if (pageMatch) {
130
+ const slug = pageMatch[1];
131
+ const pageData = siteData[`page:${slug}`] as PageData | undefined;
132
+ if (pageData) {
133
+ html = injectMetaTags(html, pageData, config.siteName);
134
+ }
135
+ }
136
+
137
+ // Inject __SITE_DATA__ before </head>
138
+ const injection = `<script>window.__SITE_DATA__ = ${JSON.stringify(siteData)};</script>`;
139
+ html = html.replace('</head>', `${injection}\n</head>`);
140
+
141
+ return new Response(html, {
142
+ headers: {
143
+ 'Content-Type': 'text/html;charset=utf-8',
144
+ 'Cache-Control': 'public, max-age=60',
145
+ },
146
+ });
147
+ },
148
+ };
149
+ }
@@ -0,0 +1,2 @@
1
+ export { createSiteWorker } from './create-site-worker.js';
2
+ export type { WorkerEnv, WorkerConfig, PageData } from './types.js';
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Minimal CF Workers type declarations to avoid requiring @cloudflare/workers-types.
3
+ * These match the Cloudflare Workers runtime API.
4
+ */
5
+ export interface CfKVNamespace {
6
+ get(key: string, options?: any): Promise<string | null>;
7
+ put(key: string, value: string | ArrayBuffer | ReadableStream, options?: any): Promise<void>;
8
+ delete(key: string): Promise<void>;
9
+ list(options?: any): Promise<any>;
10
+ }
11
+
12
+ export interface CfFetcher {
13
+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
14
+ }
15
+
16
+ export interface WorkerEnv {
17
+ SITE_CONTENT: CfKVNamespace;
18
+ ASSETS: CfFetcher;
19
+ }
20
+
21
+ export interface WorkerConfig {
22
+ /** KV key prefix, e.g. 'tenant:swi' */
23
+ tenantPrefix: string;
24
+ /** Default locale for the site */
25
+ defaultLocale: string;
26
+ /** Supported locales for Accept-Language detection */
27
+ supportedLocales?: string[];
28
+ /** Site name for meta title suffix, e.g. 'SWI' */
29
+ siteName?: string;
30
+ /** Global KV keys to preload on every page */
31
+ globalKeys?: string[];
32
+ }
33
+
34
+ export interface PageData {
35
+ title?: string;
36
+ blocks?: unknown[];
37
+ html?: string;
38
+ meta?: {
39
+ description?: string;
40
+ ogTitle?: string;
41
+ ogImage?: string;
42
+ };
43
+ }