yaxa-svelte 1.5.0 → 1.5.2

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,3 @@
1
+ export { default as AuthCard } from '../components/saas/AuthCard.svelte';
2
+ export { useAuth } from '../composables/useAuth.svelte';
3
+ export type { UseAuthOptions, SocialProvider } from '../composables/useAuth.svelte';
@@ -0,0 +1,3 @@
1
+ // Yaxa SaaS Auth Suite (Better-Auth integration for Svelte 5 & SvelteKit)
2
+ export { default as AuthCard } from '../components/saas/AuthCard.svelte';
3
+ export { useAuth } from '../composables/useAuth.svelte';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { tokenizeCode } from './CodeBlock.svelte';
3
+ describe('tokenizeCode', () => {
4
+ it('tokenizes markdown headings correctly', () => {
5
+ const tokens = tokenizeCode('# Hello World', 'markdown');
6
+ expect(tokens).toHaveLength(1);
7
+ expect(tokens[0]).toEqual([
8
+ { text: '# ', type: 'punctuation' },
9
+ { text: 'Hello World', type: 'heading' }
10
+ ]);
11
+ });
12
+ it('tokenizes typescript keywords and identifiers', () => {
13
+ const tokens = tokenizeCode('const x = 42;', 'typescript');
14
+ expect(tokens).toHaveLength(1);
15
+ const texts = tokens[0].map((t) => t.text);
16
+ expect(texts).toContain('const');
17
+ expect(texts).toContain('x');
18
+ expect(texts).toContain('42');
19
+ });
20
+ it('handles multiline code and empty lines', () => {
21
+ const code = 'line 1\n\nline 3';
22
+ const tokens = tokenizeCode(code, 'plain');
23
+ expect(tokens).toHaveLength(3);
24
+ expect(tokens[1]).toEqual([{ text: '', type: 'plain' }]);
25
+ });
26
+ });
@@ -3,8 +3,15 @@
3
3
  import type { ZodSchema } from 'zod';
4
4
  import Alert from '../overlays/Alert.svelte';
5
5
 
6
+ export interface FormSchema<T = any> {
7
+ parse?: (data: unknown) => T;
8
+ safeParse?: (data: unknown) => { success: boolean; data?: T; error?: any };
9
+ validate?: (data: unknown) => T;
10
+ [key: string]: any;
11
+ }
12
+
6
13
  interface Props {
7
- schema?: ZodSchema;
14
+ schema?: FormSchema | ZodSchema | any;
8
15
  values?: Record<string, any>;
9
16
  loading?: boolean;
10
17
  errorSummary?: boolean;
@@ -30,8 +37,27 @@
30
37
  function validate() {
31
38
  if (!schema) return true;
32
39
  try {
33
- schema.parse(values);
34
- errors = {};
40
+ if (typeof schema.safeParse === 'function') {
41
+ const res = schema.safeParse(values);
42
+ if (!res.success) {
43
+ const fieldErrors: Record<string, string> = {};
44
+ const issues = res.error?.issues || res.error?.errors || [];
45
+ for (const e of issues) {
46
+ const field = Array.isArray(e.path) ? e.path.join('.') : String(e.path || 'form');
47
+ if (!fieldErrors[field]) {
48
+ fieldErrors[field] = e.message;
49
+ }
50
+ }
51
+ errors = fieldErrors;
52
+ return false;
53
+ }
54
+ errors = {};
55
+ return true;
56
+ } else if (typeof schema.parse === 'function') {
57
+ schema.parse(values);
58
+ errors = {};
59
+ return true;
60
+ }
35
61
  return true;
36
62
  } catch (err: any) {
37
63
  const fieldErrors: Record<string, string> = {};
@@ -1,7 +1,17 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { ZodSchema } from 'zod';
3
+ export interface FormSchema<T = any> {
4
+ parse?: (data: unknown) => T;
5
+ safeParse?: (data: unknown) => {
6
+ success: boolean;
7
+ data?: T;
8
+ error?: any;
9
+ };
10
+ validate?: (data: unknown) => T;
11
+ [key: string]: any;
12
+ }
3
13
  interface Props {
4
- schema?: ZodSchema;
14
+ schema?: FormSchema | ZodSchema | any;
5
15
  values?: Record<string, any>;
6
16
  loading?: boolean;
7
17
  errorSummary?: boolean;
@@ -3,24 +3,16 @@
3
3
  import Badge from '../elements/Badge.svelte';
4
4
  import Icon from '../elements/Icon.svelte';
5
5
  import DropdownMenu, { type MenuItem } from '../navigation/DropdownMenu.svelte';
6
- import { useAuth } from '../../composables/useAuth.svelte';
7
-
8
- interface UserInfo {
9
- name?: string | null;
10
- email?: string | null;
11
- image?: string | null;
12
- role?: string | null;
13
- tier?: string | null;
14
- }
6
+ import { getAuthUserContext, type AuthUserContext } from '../../site/context';
15
7
 
16
8
  interface Props {
17
- user?: UserInfo | null;
9
+ user?: AuthUserContext | null;
18
10
  tier?: string;
19
11
  dashboardUrl?: string;
20
12
  settingsUrl?: string;
21
13
  billingUrl?: string;
22
14
  class?: string;
23
- onsignout?: () => void;
15
+ onsignout?: () => void | Promise<void>;
24
16
  }
25
17
 
26
18
  let {
@@ -33,16 +25,17 @@
33
25
  onsignout
34
26
  }: Props = $props();
35
27
 
36
- const auth = useAuth();
28
+ const contextUser = $derived(getAuthUserContext());
37
29
 
38
30
  const currentUser = $derived(
39
- propUser ||
40
- auth.user || {
41
- name: 'Solo Developer',
42
- email: 'dev@yaxa.dev',
43
- image: null,
44
- tier
45
- }
31
+ propUser !== undefined
32
+ ? propUser
33
+ : contextUser || {
34
+ name: 'Solo Developer',
35
+ email: 'dev@yaxa.dev',
36
+ image: null,
37
+ tier
38
+ }
46
39
  );
47
40
 
48
41
  const initials = $derived.by(() => {
@@ -91,9 +84,11 @@
91
84
  icon: 'log-out',
92
85
  destructive: true,
93
86
  onSelect: async () => {
94
- await auth.signOut();
95
- if (onsignout) onsignout();
96
- if (typeof window !== 'undefined') window.location.href = '/login';
87
+ if (onsignout) {
88
+ await onsignout();
89
+ } else if (typeof window !== 'undefined') {
90
+ window.location.href = '/login';
91
+ }
97
92
  }
98
93
  }
99
94
  ]);
@@ -1,18 +1,12 @@
1
- interface UserInfo {
2
- name?: string | null;
3
- email?: string | null;
4
- image?: string | null;
5
- role?: string | null;
6
- tier?: string | null;
7
- }
1
+ import { type AuthUserContext } from '../../site/context';
8
2
  interface Props {
9
- user?: UserInfo | null;
3
+ user?: AuthUserContext | null;
10
4
  tier?: string;
11
5
  dashboardUrl?: string;
12
6
  settingsUrl?: string;
13
7
  billingUrl?: string;
14
8
  class?: string;
15
- onsignout?: () => void;
9
+ onsignout?: () => void | Promise<void>;
16
10
  }
17
11
  declare const UserMenu: import("svelte").Component<Props, {}, "">;
18
12
  type UserMenu = ReturnType<typeof UserMenu>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { useGate } from './useGate.svelte';
3
+ describe('useGate', () => {
4
+ it('evaluates plan hierarchy correctly', () => {
5
+ const gateStarter = useGate({
6
+ user: { id: '1', role: 'user', plan: 'starter' }
7
+ });
8
+ expect(gateStarter.allowsPlan('free')).toBe(true);
9
+ expect(gateStarter.allowsPlan('starter')).toBe(true);
10
+ expect(gateStarter.allowsPlan('pro')).toBe(false);
11
+ expect(gateStarter.allowsPlan('enterprise')).toBe(false);
12
+ const gatePro = useGate({
13
+ user: { id: '2', role: 'user', plan: 'pro' }
14
+ });
15
+ expect(gatePro.allowsPlan('starter')).toBe(true);
16
+ expect(gatePro.allowsPlan('pro')).toBe(true);
17
+ expect(gatePro.allowsPlan('enterprise')).toBe(false);
18
+ });
19
+ it('allows plan arrays (exact match within allowed array)', () => {
20
+ const gate = useGate({
21
+ user: { id: '1', role: 'user', plan: 'pro' }
22
+ });
23
+ expect(gate.allowsPlan(['starter', 'pro'])).toBe(true);
24
+ expect(gate.allowsPlan(['starter', 'enterprise'])).toBe(false);
25
+ });
26
+ it('checks user roles correctly', () => {
27
+ const gateUser = useGate({
28
+ user: { id: '1', role: 'member', plan: 'free' }
29
+ });
30
+ expect(gateUser.hasRole('member')).toBe(true);
31
+ expect(gateUser.hasRole('editor')).toBe(false);
32
+ expect(gateUser.hasRole(['editor', 'member'])).toBe(true);
33
+ });
34
+ it('allows admin bypass by default', () => {
35
+ const gateAdmin = useGate({
36
+ user: { id: 'admin-1', role: 'admin', plan: 'free' }
37
+ });
38
+ expect(gateAdmin.isAdmin).toBe(true);
39
+ expect(gateAdmin.allowsPlan('enterprise')).toBe(true);
40
+ expect(gateAdmin.hasRole('billing_manager')).toBe(true);
41
+ expect(gateAdmin.can(() => false)).toBe(true);
42
+ });
43
+ it('respects allowAdminBypass = false', () => {
44
+ const gateAdminRestricted = useGate({
45
+ user: { id: 'admin-1', role: 'admin', plan: 'free' },
46
+ allowAdminBypass: false
47
+ });
48
+ expect(gateAdminRestricted.isAdmin).toBe(false);
49
+ expect(gateAdminRestricted.allowsPlan('pro')).toBe(false);
50
+ });
51
+ it('evaluates custom can() predicate correctly', () => {
52
+ const gate = useGate({
53
+ user: { id: 'custom-1', role: 'user', plan: 'pro' }
54
+ });
55
+ expect(gate.can((u) => u?.id === 'custom-1')).toBe(true);
56
+ expect(gate.can((u) => u?.id === 'other')).toBe(false);
57
+ });
58
+ });
package/dist/index.d.ts CHANGED
@@ -24,6 +24,7 @@ export type { CodeBlockProps } from './components/elements/CodeBlock.svelte';
24
24
  export { default as SortableList } from './components/elements/SortableList.svelte';
25
25
  export type { SortableListProps } from './components/elements/SortableList.svelte';
26
26
  export { default as Form } from './components/forms/Form.svelte';
27
+ export type { FormSchema } from './components/forms/Form.svelte';
27
28
  export { default as FormField } from './components/forms/FormField.svelte';
28
29
  export { default as Input } from './components/forms/Input.svelte';
29
30
  export type { InputProps } from './components/forms/Input.svelte';
@@ -123,13 +124,10 @@ export { yaxa } from './vite/index';
123
124
  export type { YaxaPluginOptions } from './vite/index';
124
125
  export { default as Gate } from './components/saas/Gate.svelte';
125
126
  export type { GateProps } from './components/saas/Gate.svelte';
126
- export { default as AuthCard } from './components/saas/AuthCard.svelte';
127
127
  export { default as UserMenu } from './components/saas/UserMenu.svelte';
128
128
  export { default as PricingCard } from './components/saas/PricingCard.svelte';
129
129
  export { default as PricingTable } from './components/saas/PricingTable.svelte';
130
130
  export { default as SubscriptionCard } from './components/saas/SubscriptionCard.svelte';
131
- export { useAuth } from './composables/useAuth.svelte';
132
- export type { UseAuthOptions, SocialProvider } from './composables/useAuth.svelte';
133
131
  export { useGate } from './composables/useGate.svelte';
134
132
  export type { UseGateOptions } from './composables/useGate.svelte';
135
133
  export { useUpload } from './composables/useUpload.svelte';
package/dist/index.js CHANGED
@@ -89,14 +89,12 @@ export { createManifestHandler } from './site/manifest';
89
89
  export { generateWebSiteSchema, generateOrganizationSchema, generateSoftwareApplicationSchema, generateSoftwareSourceCodeSchema, generatePersonSchema, generateArticleSchema, generateBreadcrumbSchema } from './site/schema';
90
90
  export { definePageSeo } from './site/seo-helpers';
91
91
  export { yaxa } from './vite/index';
92
- // SaaS Suite: UI Components & Composables
92
+ // SaaS Suite: UI Components & Composables (Pure UI & Context Runes)
93
93
  export { default as Gate } from './components/saas/Gate.svelte';
94
- export { default as AuthCard } from './components/saas/AuthCard.svelte';
95
94
  export { default as UserMenu } from './components/saas/UserMenu.svelte';
96
95
  export { default as PricingCard } from './components/saas/PricingCard.svelte';
97
96
  export { default as PricingTable } from './components/saas/PricingTable.svelte';
98
97
  export { default as SubscriptionCard } from './components/saas/SubscriptionCard.svelte';
99
- export { useAuth } from './composables/useAuth.svelte';
100
98
  export { useGate } from './composables/useGate.svelte';
101
99
  export { useUpload } from './composables/useUpload.svelte';
102
100
  // Utilities
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createYaxaAuthHook } from './hook';
3
+ describe('createYaxaAuthHook', () => {
4
+ const mockAuth = {
5
+ handler: vi.fn(() => new Response('auth handler response')),
6
+ api: {
7
+ getSession: vi.fn()
8
+ }
9
+ };
10
+ it('routes /api/auth/* requests directly to Better-Auth handler', async () => {
11
+ const hook = createYaxaAuthHook({ auth: mockAuth });
12
+ const event = {
13
+ url: new URL('https://example.com/api/auth/sign-in'),
14
+ request: new Request('https://example.com/api/auth/sign-in'),
15
+ locals: {}
16
+ };
17
+ const resolve = vi.fn();
18
+ const response = await hook({ event: event, resolve });
19
+ expect(mockAuth.handler).toHaveBeenCalledWith(event.request);
20
+ expect(await response.text()).toBe('auth handler response');
21
+ expect(resolve).not.toHaveBeenCalled();
22
+ });
23
+ it('allows public routes and resolves successfully', async () => {
24
+ mockAuth.api.getSession.mockResolvedValueOnce(null);
25
+ const hook = createYaxaAuthHook({ auth: mockAuth });
26
+ const event = {
27
+ url: new URL('https://example.com/'),
28
+ request: new Request('https://example.com/'),
29
+ locals: {}
30
+ };
31
+ const resolve = vi.fn().mockResolvedValue(new Response('home page'));
32
+ const response = await hook({ event: event, resolve });
33
+ expect(resolve).toHaveBeenCalledWith(event);
34
+ expect(event.locals.user).toBeNull();
35
+ expect(await response.text()).toBe('home page');
36
+ });
37
+ it('throws 303 redirect when unauthenticated user accesses protected path', async () => {
38
+ mockAuth.api.getSession.mockResolvedValueOnce(null);
39
+ const hook = createYaxaAuthHook({
40
+ auth: mockAuth,
41
+ protectedPaths: ['/dashboard'],
42
+ loginPath: '/auth/login'
43
+ });
44
+ const event = {
45
+ url: new URL('https://example.com/dashboard/billing?tab=invoices'),
46
+ request: new Request('https://example.com/dashboard/billing?tab=invoices'),
47
+ locals: {}
48
+ };
49
+ const resolve = vi.fn();
50
+ try {
51
+ await hook({ event: event, resolve });
52
+ expect.unreachable('Should have thrown redirect');
53
+ }
54
+ catch (err) {
55
+ expect(err.status).toBe(303);
56
+ expect(err.location).toBe('/auth/login?returnTo=%2Fdashboard%2Fbilling%3Ftab%3Dinvoices');
57
+ }
58
+ });
59
+ it('allows access to protected path when user is authenticated', async () => {
60
+ const mockUser = { id: 'user-123', email: 'user@example.com' };
61
+ const mockSession = { id: 'session-456' };
62
+ mockAuth.api.getSession.mockResolvedValueOnce({
63
+ user: mockUser,
64
+ session: mockSession
65
+ });
66
+ const hook = createYaxaAuthHook({
67
+ auth: mockAuth,
68
+ protectedPaths: ['/dashboard']
69
+ });
70
+ const event = {
71
+ url: new URL('https://example.com/dashboard'),
72
+ request: new Request('https://example.com/dashboard'),
73
+ locals: {}
74
+ };
75
+ const resolve = vi.fn().mockResolvedValue(new Response('dashboard content'));
76
+ const response = await hook({ event: event, resolve });
77
+ expect(event.locals.user).toEqual(mockUser);
78
+ expect(event.locals.session).toEqual(mockSession);
79
+ expect(resolve).toHaveBeenCalled();
80
+ expect(await response.text()).toBe('dashboard content');
81
+ });
82
+ });
@@ -1,4 +1,5 @@
1
1
  import { json } from '@sveltejs/kit';
2
+ import { validateEvent } from '@polar-sh/sdk/webhooks';
2
3
  import { eq } from 'drizzle-orm';
3
4
  import { getDb, schemaPg, schemaSqlite } from '../db';
4
5
  /**
@@ -19,20 +20,35 @@ export function createPolarWebhookHandler(options = {}) {
19
20
  return new Response('Method Not Allowed', { status: 405 });
20
21
  }
21
22
  let rawBody;
22
- let payload;
23
23
  try {
24
24
  rawBody = await event.request.text();
25
- payload = JSON.parse(rawBody);
26
25
  }
27
26
  catch {
28
- return new Response('Invalid JSON payload', { status: 400 });
27
+ return new Response('Failed to read request body', { status: 400 });
29
28
  }
30
- // Verify signature if secret provided
29
+ let payload;
30
+ // Cryptographically verify signature if secret provided
31
31
  if (secret) {
32
- const signature = event.request.headers.get('webhook-signature') ||
33
- event.request.headers.get('polar-signature');
34
- if (!signature) {
35
- return new Response('Missing webhook signature', { status: 401 });
32
+ try {
33
+ const headersObj = {};
34
+ event.request.headers.forEach((val, key) => {
35
+ headersObj[key.toLowerCase()] = val;
36
+ });
37
+ if (headersObj['polar-signature'] && !headersObj['webhook-signature']) {
38
+ headersObj['webhook-signature'] = headersObj['polar-signature'];
39
+ }
40
+ payload = validateEvent(rawBody, headersObj, secret);
41
+ }
42
+ catch {
43
+ return new Response('Invalid or missing webhook signature', { status: 401 });
44
+ }
45
+ }
46
+ else {
47
+ try {
48
+ payload = JSON.parse(rawBody);
49
+ }
50
+ catch {
51
+ return new Response('Invalid JSON payload', { status: 400 });
36
52
  }
37
53
  }
38
54
  const eventType = payload.type;
@@ -112,7 +128,7 @@ export function createPolarWebhookHandler(options = {}) {
112
128
  }
113
129
  catch (err) {
114
130
  console.error('[Polar Webhook Error]', err);
115
- return new Response(`Webhook handling error: ${err.message}`, { status: 500 });
131
+ return new Response('Internal Server Error', { status: 500 });
116
132
  }
117
133
  };
118
134
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createPolarWebhookHandler } from './webhook';
3
+ describe('createPolarWebhookHandler', () => {
4
+ it('rejects non-POST requests with 405 Method Not Allowed', async () => {
5
+ const handler = createPolarWebhookHandler();
6
+ const response = await handler({
7
+ request: new Request('https://example.com/api/webhooks/polar', {
8
+ method: 'GET'
9
+ })
10
+ });
11
+ expect(response.status).toBe(405);
12
+ expect(await response.text()).toBe('Method Not Allowed');
13
+ });
14
+ it('rejects invalid JSON payloads with 400 when unauthenticated', async () => {
15
+ const handler = createPolarWebhookHandler();
16
+ const response = await handler({
17
+ request: new Request('https://example.com/api/webhooks/polar', {
18
+ method: 'POST',
19
+ body: 'not-json-content'
20
+ })
21
+ });
22
+ expect(response.status).toBe(400);
23
+ expect(await response.text()).toBe('Invalid JSON payload');
24
+ });
25
+ it('rejects requests with missing or forged signature when secret is configured', async () => {
26
+ const handler = createPolarWebhookHandler({
27
+ webhookSecret: 'whsec_test_secret_key_12345'
28
+ });
29
+ // 1. Missing signature
30
+ const resMissing = await handler({
31
+ request: new Request('https://example.com/api/webhooks/polar', {
32
+ method: 'POST',
33
+ body: JSON.stringify({ type: 'subscription.created', data: {} })
34
+ })
35
+ });
36
+ expect(resMissing.status).toBe(401);
37
+ expect(await resMissing.text()).toBe('Invalid or missing webhook signature');
38
+ // 2. Forged/invalid signature
39
+ const resForged = await handler({
40
+ request: new Request('https://example.com/api/webhooks/polar', {
41
+ method: 'POST',
42
+ headers: {
43
+ 'webhook-id': 'msg_123',
44
+ 'webhook-timestamp': `${Math.floor(Date.now() / 1000)}`,
45
+ 'webhook-signature': 'v1,forged_invalid_signature'
46
+ },
47
+ body: JSON.stringify({ type: 'subscription.created', data: {} })
48
+ })
49
+ });
50
+ expect(resForged.status).toBe(401);
51
+ expect(await resForged.text()).toBe('Invalid or missing webhook signature');
52
+ });
53
+ it('invokes onOrderCreated callback and responds with received: true', async () => {
54
+ const onOrderCreated = vi.fn();
55
+ const handler = createPolarWebhookHandler({
56
+ onOrderCreated
57
+ });
58
+ const payload = {
59
+ type: 'order.created',
60
+ data: { id: 'order_123', amount: 4900 }
61
+ };
62
+ const response = await handler({
63
+ request: new Request('https://example.com/api/webhooks/polar', {
64
+ method: 'POST',
65
+ body: JSON.stringify(payload)
66
+ })
67
+ });
68
+ expect(response.status).toBe(200);
69
+ const json = await response.json();
70
+ expect(json).toEqual({ received: true });
71
+ expect(onOrderCreated).toHaveBeenCalledWith(payload);
72
+ });
73
+ it('sanitizes internal errors and returns 500 without leaking exception messages', async () => {
74
+ const handler = createPolarWebhookHandler({
75
+ onSubscriptionCreated: () => {
76
+ throw new Error('Sensitive DB connection string exposed: postgres://user:pass@host/db');
77
+ }
78
+ });
79
+ const response = await handler({
80
+ request: new Request('https://example.com/api/webhooks/polar', {
81
+ method: 'POST',
82
+ body: JSON.stringify({ type: 'subscription.created', data: {} })
83
+ })
84
+ });
85
+ expect(response.status).toBe(500);
86
+ const text = await response.text();
87
+ expect(text).toBe('Internal Server Error');
88
+ expect(text).not.toContain('postgres://');
89
+ });
90
+ });
@@ -56,7 +56,8 @@ export function defineSiteConfig(config) {
56
56
  ...config.project
57
57
  };
58
58
  return {
59
- defaultLocale: 'en',
59
+ ...config,
60
+ defaultLocale: config.defaultLocale || 'en',
60
61
  email: primaryEmail,
61
62
  theme: {
62
63
  defaultMode: 'system',
@@ -93,8 +94,7 @@ export function defineSiteConfig(config) {
93
94
  priority: 0.8,
94
95
  exclude: ['/api/*'],
95
96
  ...config.sitemap
96
- },
97
- ...config
97
+ }
98
98
  };
99
99
  }
100
100
  export const DEFAULT_SITE_CONFIG = defineSiteConfig({
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { defineSiteConfig, computeProjectBadge } from './config';
3
+ describe('computeProjectBadge', () => {
4
+ it('returns fallback badge when no project is provided', () => {
5
+ expect(computeProjectBadge(undefined)).toBe('Web Application');
6
+ });
7
+ it('respects explicit custom badge', () => {
8
+ expect(computeProjectBadge({ badge: 'Custom Pro SaaS' })).toBe('Custom Pro SaaS');
9
+ });
10
+ it('computes open-source badge with license', () => {
11
+ expect(computeProjectBadge({ type: 'open-source', license: 'MIT' })).toBe('MIT Open Source');
12
+ expect(computeProjectBadge({ type: 'open-source' })).toBe('Open Source');
13
+ });
14
+ it('computes commercial-saas and freemium badges', () => {
15
+ expect(computeProjectBadge({ type: 'commercial-saas' })).toBe('Commercial SaaS');
16
+ expect(computeProjectBadge({ type: 'freemium' })).toBe('Freemium SaaS');
17
+ });
18
+ });
19
+ describe('defineSiteConfig', () => {
20
+ it('computes primary email and fallback notice', () => {
21
+ const config = defineSiteConfig({
22
+ name: 'Test App',
23
+ title: 'Test App Title',
24
+ description: 'Test Description',
25
+ url: 'https://testapp.com',
26
+ legal: {
27
+ paymentProcessor: 'polar'
28
+ },
29
+ project: {
30
+ type: 'open-source',
31
+ license: 'MIT'
32
+ }
33
+ });
34
+ expect(config.name).toBe('Test App');
35
+ expect(config.email).toBe('contact@testapp.com');
36
+ expect(config.legal?.morNotice).toBe('Payments securely processed by Polar.sh (Merchant of Record)');
37
+ expect(config.project?.badge).toBe('MIT Open Source');
38
+ });
39
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateOgSvg } from './og';
3
+ describe('generateOgSvg', () => {
4
+ it('generates a valid 1200x630 SVG element', () => {
5
+ const svg = generateOgSvg({
6
+ title: 'Welcome to Yaxa',
7
+ description: 'A Next-Gen UI library for Svelte 5',
8
+ siteName: 'Yaxa',
9
+ badge: 'v1.5.1'
10
+ });
11
+ expect(svg).toContain('<svg width="1200" height="630" viewBox="0 0 1200 630"');
12
+ expect(svg).toContain('Welcome to Yaxa');
13
+ expect(svg).toContain('A Next-Gen UI library for Svelte 5');
14
+ expect(svg).toContain('v1.5.1');
15
+ expect(svg).toContain('</svg>');
16
+ });
17
+ it('escapes dangerous XML characters in titles and descriptions', () => {
18
+ const svg = generateOgSvg({
19
+ title: 'Tom & Jerry <Script> "Quotes" \'Single\'',
20
+ description: 'Symbols: < > & " \'',
21
+ siteName: 'Yaxa & Co'
22
+ });
23
+ expect(svg).not.toContain('<Script>');
24
+ expect(svg).toContain('&lt;Script&gt;');
25
+ expect(svg).toContain('&amp;');
26
+ expect(svg).toContain('&quot;');
27
+ expect(svg).toContain('&apos;');
28
+ });
29
+ it('handles light mode and dark mode color palettes', () => {
30
+ const darkSvg = generateOgSvg({
31
+ title: 'Dark Mode',
32
+ theme: 'dark'
33
+ });
34
+ const lightSvg = generateOgSvg({
35
+ title: 'Light Mode',
36
+ theme: 'light'
37
+ });
38
+ expect(darkSvg).toContain('#09090b');
39
+ expect(lightSvg).toContain('#fafafa');
40
+ });
41
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createRobotsHandler } from './robots';
3
+ import { defineSiteConfig } from './config';
4
+ const config = defineSiteConfig({
5
+ name: 'Yaxa',
6
+ title: 'Yaxa UI',
7
+ description: 'UI toolkit',
8
+ url: 'https://yaxa.dev'
9
+ });
10
+ describe('createRobotsHandler', () => {
11
+ it('disallows indexing in non-production environments', async () => {
12
+ const handler = createRobotsHandler({
13
+ config,
14
+ isProduction: false
15
+ });
16
+ const response = (await handler({
17
+ url: new URL('http://localhost:5173/robots.txt')
18
+ }));
19
+ const body = await response.text();
20
+ expect(response.headers.get('Content-Type')).toContain('text/plain');
21
+ expect(body).toContain('User-agent: *');
22
+ expect(body).toContain('Disallow: /');
23
+ expect(body).toContain('# Non-production environment');
24
+ });
25
+ it('allows indexing and provides sitemap in production', async () => {
26
+ const handler = createRobotsHandler({
27
+ config,
28
+ isProduction: true
29
+ });
30
+ const response = (await handler({
31
+ url: new URL('https://yaxa.dev/robots.txt')
32
+ }));
33
+ const body = await response.text();
34
+ expect(body).toContain('User-agent: *');
35
+ expect(body).toContain('Allow: /');
36
+ expect(body).toContain('Disallow: /admin/');
37
+ expect(body).toContain('Sitemap: https://yaxa.dev/sitemap.xml');
38
+ });
39
+ it('blocks AI web crawlers when aiCrawlers: disallow', async () => {
40
+ const handler = createRobotsHandler({
41
+ config,
42
+ isProduction: true,
43
+ aiCrawlers: 'disallow'
44
+ });
45
+ const response = (await handler({
46
+ url: new URL('https://yaxa.dev/robots.txt')
47
+ }));
48
+ const body = await response.text();
49
+ expect(body).toContain('User-agent: GPTBot');
50
+ expect(body).toContain('User-agent: ClaudeBot');
51
+ expect(body).toContain('User-agent: PerplexityBot');
52
+ });
53
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateWebSiteSchema, generatePersonSchema, generateOrganizationSchema, generateSoftwareApplicationSchema, generateSoftwareSourceCodeSchema, generateArticleSchema, generateBreadcrumbSchema } from './schema';
3
+ import { defineSiteConfig } from './config';
4
+ const testConfig = defineSiteConfig({
5
+ name: 'Yaxa App',
6
+ title: 'Yaxa App Title',
7
+ description: 'Test application description',
8
+ url: 'https://yaxa.test',
9
+ author: {
10
+ name: 'John Doe',
11
+ url: 'https://johndoe.me',
12
+ twitter: 'johndoe',
13
+ github: 'johndoe'
14
+ },
15
+ company: {
16
+ legalName: 'Yaxa Technologies Inc',
17
+ contactEmail: 'support@yaxa.test',
18
+ country: 'US'
19
+ },
20
+ project: {
21
+ license: 'MIT',
22
+ licenseUrl: 'https://yaxa.test/license',
23
+ type: 'open-source',
24
+ pricingModel: 'free',
25
+ repositoryUrl: 'https://github.com/yaxa/app',
26
+ isAccessibleForFree: true
27
+ }
28
+ });
29
+ describe('SEO Schema Generators', () => {
30
+ it('generates valid WebSite schema', () => {
31
+ const schema = generateWebSiteSchema(testConfig);
32
+ expect(schema['@context']).toBe('https://schema.org');
33
+ expect(schema['@type']).toBe('WebSite');
34
+ expect(schema.name).toBe('Yaxa App');
35
+ expect(schema.url).toBe('https://yaxa.test');
36
+ expect(schema.license).toBe('https://yaxa.test/license');
37
+ expect(schema.isAccessibleForFree).toBe(true);
38
+ expect(schema.author?.name).toBe('John Doe');
39
+ });
40
+ it('generates valid Person schema', () => {
41
+ const schema = generatePersonSchema(testConfig);
42
+ expect(schema['@type']).toBe('Person');
43
+ expect(schema.name).toBe('John Doe');
44
+ expect(schema.sameAs).toContain('https://twitter.com/johndoe');
45
+ expect(schema.sameAs).toContain('https://github.com/johndoe');
46
+ });
47
+ it('generates valid Organization schema', () => {
48
+ const schema = generateOrganizationSchema(testConfig);
49
+ expect(schema['@type']).toBe('Organization');
50
+ expect(schema.name).toBe('Yaxa App');
51
+ expect(schema.legalName).toBe('Yaxa Technologies Inc');
52
+ expect(schema.email).toBe('support@yaxa.test');
53
+ });
54
+ it('generates valid SoftwareApplication schema', () => {
55
+ const schema = generateSoftwareApplicationSchema(testConfig);
56
+ expect(schema['@type']).toBe('SoftwareApplication');
57
+ expect(schema.applicationCategory).toBe('DeveloperApplication');
58
+ expect(schema.offers?.price).toBe('0');
59
+ });
60
+ it('generates valid SoftwareSourceCode schema', () => {
61
+ const schema = generateSoftwareSourceCodeSchema(testConfig);
62
+ expect(schema['@type']).toBe('SoftwareSourceCode');
63
+ expect(schema.codeRepository).toBe('https://github.com/yaxa/app');
64
+ expect(schema.programmingLanguage).toBe('Svelte');
65
+ });
66
+ it('generates valid Article schema', () => {
67
+ const schema = generateArticleSchema(testConfig, {
68
+ title: 'Building with Svelte 5',
69
+ description: 'Guide to runes in Svelte 5',
70
+ url: 'https://yaxa.test/blog/svelte-5',
71
+ datePublished: '2026-01-01T00:00:00Z',
72
+ authorName: 'John Doe'
73
+ });
74
+ expect(schema['@type']).toBe('Article');
75
+ expect(schema.headline).toBe('Building with Svelte 5');
76
+ expect(schema.author?.name).toBe('John Doe');
77
+ expect(schema.publisher?.name).toBe('Yaxa App');
78
+ });
79
+ it('generates valid BreadcrumbList schema', () => {
80
+ const schema = generateBreadcrumbSchema(testConfig, [
81
+ { name: 'Home', url: '/' },
82
+ { name: 'Docs', url: '/docs' },
83
+ { name: 'Components', url: '/docs/components' }
84
+ ]);
85
+ expect(schema['@type']).toBe('BreadcrumbList');
86
+ expect(schema.itemListElement).toHaveLength(3);
87
+ expect(schema.itemListElement[0]).toEqual({
88
+ '@type': 'ListItem',
89
+ position: 1,
90
+ name: 'Home',
91
+ item: 'https://yaxa.test/'
92
+ });
93
+ expect(schema.itemListElement[2].position).toBe(3);
94
+ });
95
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { definePageSeo } from './seo-helpers';
3
+ describe('definePageSeo', () => {
4
+ it('returns the exact SEO configuration object passed to it', () => {
5
+ const seo = definePageSeo({
6
+ title: 'Test Page',
7
+ description: 'A test page description',
8
+ badge: 'Documentation'
9
+ });
10
+ expect(seo).toEqual({
11
+ title: 'Test Page',
12
+ description: 'A test page description',
13
+ badge: 'Documentation'
14
+ });
15
+ });
16
+ it('supports structured OpenGraph configuration', () => {
17
+ const seo = definePageSeo({
18
+ title: 'Analytics',
19
+ ogImage: {
20
+ title: 'Custom OG Title',
21
+ description: 'Custom OG Description',
22
+ badge: 'SaaS',
23
+ theme: 'dark'
24
+ },
25
+ twitterCard: 'summary_large_image'
26
+ });
27
+ expect(seo.ogImage).toEqual({
28
+ title: 'Custom OG Title',
29
+ description: 'Custom OG Description',
30
+ badge: 'SaaS',
31
+ theme: 'dark'
32
+ });
33
+ expect(seo.twitterCard).toBe('summary_large_image');
34
+ });
35
+ it('supports robots directives and schema markup', () => {
36
+ const schema = {
37
+ '@context': 'https://schema.org',
38
+ '@type': 'WebSite',
39
+ name: 'Yaxa'
40
+ };
41
+ const seo = definePageSeo({
42
+ title: 'Secret Page',
43
+ noindex: true,
44
+ nofollow: true,
45
+ schema
46
+ });
47
+ expect(seo.noindex).toBe(true);
48
+ expect(seo.nofollow).toBe(true);
49
+ expect(seo.schema).toBe(schema);
50
+ });
51
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ACCENT_PALETTES, NEUTRAL_PALETTES, FONT_PRESETS, RADIUS_PRESETS, theme } from './theme.svelte';
3
+ describe('Theme Palettes & Engine', () => {
4
+ const requiredShades = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
5
+ it('includes all 7 standard accent palettes with complete shades and ring tokens', () => {
6
+ const expectedAccents = [
7
+ 'svelte',
8
+ 'amber',
9
+ 'emerald',
10
+ 'sky',
11
+ 'violet',
12
+ 'rose',
13
+ 'indigo'
14
+ ];
15
+ for (const accent of expectedAccents) {
16
+ const palette = ACCENT_PALETTES[accent];
17
+ expect(palette).toBeDefined();
18
+ expect(palette.id).toBe(accent);
19
+ expect(palette.color).toMatch(/^#[0-9a-fA-F]{6}$/);
20
+ expect(palette.shades.ring).toBeDefined();
21
+ for (const shade of requiredShades) {
22
+ expect(palette.shades[shade]).toBeDefined();
23
+ }
24
+ }
25
+ });
26
+ it('includes all 4 standard neutral palettes', () => {
27
+ const expectedNeutrals = ['zinc', 'slate', 'stone', 'neutral'];
28
+ for (const neutral of expectedNeutrals) {
29
+ const palette = NEUTRAL_PALETTES[neutral];
30
+ expect(palette).toBeDefined();
31
+ expect(palette.name).toBeDefined();
32
+ for (const shade of requiredShades) {
33
+ expect(palette.shades[shade]).toBeDefined();
34
+ }
35
+ }
36
+ });
37
+ it('provides font presets and radius presets', () => {
38
+ expect(FONT_PRESETS.sans.value).toBeDefined();
39
+ expect(FONT_PRESETS.serif.value).toBeDefined();
40
+ expect(FONT_PRESETS.mono.value).toBeDefined();
41
+ expect(RADIUS_PRESETS.sharp.value).toBe('0px');
42
+ expect(RADIUS_PRESETS.rounded.value).toBe('0.75rem');
43
+ expect(RADIUS_PRESETS.pill.value).toBe('1.25rem');
44
+ });
45
+ it('updates theme state reactively via theme methods', () => {
46
+ theme.setAccent('emerald');
47
+ expect(theme.accent).toBe('emerald');
48
+ theme.setNeutral('stone');
49
+ expect(theme.neutral).toBe('stone');
50
+ theme.setMode('dark');
51
+ expect(theme.mode).toBe('dark');
52
+ });
53
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { cn, tv } from './cn';
3
+ describe('cn utility', () => {
4
+ it('merges class names and handles conditional classes', () => {
5
+ const isEnabled = true;
6
+ const isDisabled = false;
7
+ const result = cn('px-4 py-2', isEnabled && 'bg-red-500', isDisabled && 'text-white', null, undefined);
8
+ expect(result).toBe('px-4 py-2 bg-red-500');
9
+ });
10
+ it('resolves conflicting Tailwind CSS classes correctly', () => {
11
+ const result = cn('px-4 px-8 text-sm text-lg');
12
+ expect(result).toBe('px-8 text-lg');
13
+ });
14
+ });
15
+ describe('tv utility', () => {
16
+ it('generates variant classes accurately', () => {
17
+ const button = tv({
18
+ base: 'rounded font-bold',
19
+ variants: {
20
+ color: {
21
+ primary: 'bg-blue-500 text-white',
22
+ secondary: 'bg-gray-500 text-black'
23
+ },
24
+ size: {
25
+ sm: 'text-sm p-1',
26
+ md: 'text-base p-2'
27
+ }
28
+ },
29
+ defaultVariants: {
30
+ color: 'primary',
31
+ size: 'md'
32
+ }
33
+ });
34
+ expect(button()).toContain('rounded font-bold bg-blue-500 text-white text-base p-2');
35
+ expect(button({ color: 'secondary', size: 'sm' })).toContain('rounded font-bold bg-gray-500 text-black text-sm p-1');
36
+ });
37
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yaxa-svelte",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "The Intuitive Svelte UI Library — Nuxt UI v4 equivalent for SvelteKit 2.7+ & Svelte 5 with automated SEO, dynamic OpenGraph cards, sitemaps, robots.txt, and Tailwind CSS v4.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://yaxa.vercel.app",
@@ -54,8 +54,9 @@
54
54
  "default": "./dist/server/index.js"
55
55
  },
56
56
  "./auth": {
57
- "types": "./dist/server/auth/index.d.ts",
58
- "default": "./dist/server/auth/index.js"
57
+ "types": "./dist/auth/index.d.ts",
58
+ "svelte": "./dist/auth/index.js",
59
+ "default": "./dist/auth/index.js"
59
60
  },
60
61
  "./db": {
61
62
  "types": "./dist/server/db/index.d.ts",
@@ -114,17 +115,16 @@
114
115
  "typescript": "^6.0.3",
115
116
  "typescript-eslint": "^8.60.1",
116
117
  "unplugin-icons": "^23.0.1",
117
- "vite": "^8.0.16"
118
+ "vite": "^8.0.16",
119
+ "vitest": "^5.0.0",
120
+ "zod": "^4.5.4"
118
121
  },
119
122
  "dependencies": {
120
123
  "bits-ui": "^2.19.0",
121
- "formsnap": "^2.0.1",
122
124
  "mode-watcher": "^1.1.0",
123
125
  "runed": "^0.37.1",
124
126
  "svelte-sonner": "^1.2.1",
125
- "sveltekit-superforms": "^2.30.2",
126
- "tailwind-variants": "^3.3.1",
127
- "zod": "^4.5.4"
127
+ "tailwind-variants": "^3.3.1"
128
128
  },
129
129
  "peerDependencies": {
130
130
  "@aws-sdk/client-s3": ">=3.0.0",
@@ -137,7 +137,8 @@
137
137
  "better-auth": ">=1.7.0",
138
138
  "drizzle-orm": ">=0.45.0",
139
139
  "resend": ">=6.27.0",
140
- "svelte": "^5.0.0"
140
+ "svelte": "^5.0.0",
141
+ "zod": ">=4.0.0"
141
142
  },
142
143
  "peerDependenciesMeta": {
143
144
  "@aws-sdk/client-s3": {
@@ -166,6 +167,9 @@
166
167
  },
167
168
  "resend": {
168
169
  "optional": true
170
+ },
171
+ "zod": {
172
+ "optional": true
169
173
  }
170
174
  },
171
175
  "scripts": {
@@ -175,7 +179,9 @@
175
179
  "preview": "vite preview",
176
180
  "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
177
181
  "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
182
+ "test": "vitest run",
183
+ "test:watch": "vitest",
178
184
  "lint": "prettier --check . && eslint .",
179
- "format": "prettier --write ."
185
+ "format": "eslint --fix . && prettier --write ."
180
186
  }
181
187
  }