yaxa-svelte 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,6 +5,7 @@
5
5
  **The Intuitive Svelte UI & Solo SaaS Library**
6
6
  _Nuxt UI v4 & Nuxt UI Pro Equivalent for SvelteKit 2 & 3 & Svelte 5 with Built-in SEO & SaaS Parity._
7
7
 
8
+ [![Live Demo](https://img.shields.io/badge/Live_Demo-yaxa.vercel.app-000000?style=flat&logo=vercel&logoColor=white)](https://yaxa.vercel.app)
8
9
  [![npm version](https://img.shields.io/npm/v/yaxa-svelte.svg?color=CB3837&logo=npm)](https://www.npmjs.com/package/yaxa-svelte)
9
10
  [![npm downloads](https://img.shields.io/npm/dm/yaxa-svelte.svg?color=blue)](https://www.npmjs.com/package/yaxa-svelte)
10
11
  [![Svelte 5](https://img.shields.io/badge/Svelte-5.0+-FF3E00?style=flat&logo=svelte&logoColor=white)](https://svelte.dev)
@@ -15,7 +16,13 @@ _Nuxt UI v4 & Nuxt UI Pro Equivalent for SvelteKit 2 & 3 & Svelte 5 with Built-i
15
16
  [![Security Policy](https://img.shields.io/badge/Security-Policy-blue.svg)](SECURITY.md)
16
17
  [![Contributing](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md)
17
18
 
18
- [**Live Documentation & Playground →**](https://yaxa.vercel.app)
19
+ <br />
20
+
21
+ [**🚀 Live Demo**](https://yaxa.vercel.app) &nbsp;•&nbsp;
22
+ [**📚 Documentation**](https://yaxa.vercel.app/docs) &nbsp;•&nbsp;
23
+ [**🎨 Theme Studio**](https://yaxa.vercel.app/theme) &nbsp;•&nbsp;
24
+ [**💼 SaaS Showcase**](https://yaxa.vercel.app/saas) &nbsp;•&nbsp;
25
+ [**⚡ Scalar API Reference**](https://yaxa.vercel.app/api/docs)
19
26
 
20
27
  </div>
21
28
 
@@ -276,7 +283,7 @@ export const POST = yaxaEndpoint({
276
283
 
277
284
  ## 📜 Auto-Generated OpenAPI 3.1 & Interactive Docs (`/api/docs`)
278
285
 
279
- Instantly generate an OpenAPI 3.1 specification and host interactive documentation ([Scalar](https://scalar.com/), Swagger UI, or Redoc) directly from your `yaxaEndpoint` routes:
286
+ Instantly generate an OpenAPI 3.1 specification and host interactive documentation ([Scalar](https://scalar.com/) or Swagger UI) directly from your `yaxaEndpoint` routes:
280
287
 
281
288
  ```ts
282
289
  // src/routes/api/docs/+server.ts
@@ -286,7 +293,7 @@ import { POST as createProject } from '../projects/+server';
286
293
  export const GET = createApiDocsHandler({
287
294
  title: 'Acme SaaS API',
288
295
  version: '1.0.0',
289
- theme: 'scalar', // 'scalar' (default) | 'swagger' | 'redoc'
296
+ theme: 'scalar', // 'scalar' (default) | 'swagger'
290
297
  endpoints: {
291
298
  '/api/projects': {
292
299
  POST: createProject
@@ -295,7 +302,7 @@ export const GET = createApiDocsHandler({
295
302
  });
296
303
  ```
297
304
 
298
- - **Interactive Reference UI:** Open `/api/docs` in your browser (supports `?theme=scalar` (default), `?theme=swagger`, or `?theme=redoc`).
305
+ - **Interactive Reference UI:** Open `/api/docs` in your browser (supports `?theme=scalar` (default) or `?theme=swagger`).
299
306
  - **Live Demo:** Try it live on [yaxa.vercel.app/api/docs](https://yaxa.vercel.app/api/docs).
300
307
  - **Raw OpenAPI 3.1 JSON:** Request `/api/docs?format=json` or send `Accept: application/json` for SDK generation.
301
308
 
@@ -441,22 +448,81 @@ export const load = async ({ fetch }) => {
441
448
 
442
449
  ---
443
450
 
451
+ ## ⚡ Runes-Native Reactive Query & Realtime RPC (`createQuery`, `createMutation`)
452
+
453
+ A zero-overhead, runes-native query and mutation cache built specifically for Svelte 5. Features request coalescing, stale-while-revalidate, window focus revalidation, optimistic updates with automatic rollback, and live SSE cache invalidation:
454
+
455
+ ```svelte
456
+ <!-- src/routes/projects/+page.svelte -->
457
+ <script lang="ts">
458
+ import { api } from '$lib/api';
459
+ import { createQuery, createMutation } from 'yaxa-svelte';
460
+
461
+ let activeOrg = $state('org_123');
462
+
463
+ // 1. Reactive Query: auto-refetches when activeOrg changes
464
+ const projects = createQuery(() => api['/api/projects'].get({ query: { orgId: activeOrg } }), {
465
+ staleTimeMs: 15_000,
466
+ refetchOnWindowFocus: true,
467
+ subscribeToSse: `org:${activeOrg}/projects` // ⚡ Live SSE invalidation
468
+ });
469
+
470
+ // 2. Optimistic Mutation with Automatic Rollback on RFC 7807 Error
471
+ const createProject = createMutation(
472
+ (name: string) => api['/api/projects'].post({ body: { name, orgId: activeOrg } }),
473
+ {
474
+ onMutate: (name) => {
475
+ const previous = projects.data;
476
+ projects.optimisticUpdate((curr) => [
477
+ { id: 'temp-' + Date.now(), name, status: 'creating' },
478
+ ...(curr ?? [])
479
+ ]);
480
+ return { previous }; // Snapshot for rollback
481
+ },
482
+ onError: (err, _name, context) => {
483
+ if (context?.previous) projects.setData(context.previous);
484
+ toast.error(err.title, err.detail);
485
+ },
486
+ onSettled: () => projects.invalidate()
487
+ }
488
+ );
489
+ </script>
490
+
491
+ {#if projects.isLoading}
492
+ <Skeleton count={3} class="h-10 w-full" />
493
+ {:else if projects.data}
494
+ <ul>
495
+ {#each projects.data as project}
496
+ <li class={project.id.startsWith('temp-') ? 'opacity-50' : ''}>
497
+ {project.name}
498
+ </li>
499
+ {/each}
500
+ </ul>
501
+ {/if}
502
+
503
+ <Button onclick={() => createProject.mutate('New Project')} loading={createProject.isPending}>
504
+ Create Project
505
+ </Button>
506
+ ```
507
+
508
+ ---
509
+
444
510
  ## 📦 Subpath Exports Architecture
445
511
 
446
- | Subpath | Description |
447
- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
448
- | `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Type-Safe Client RPC (`createApiClient`), Theme System & Composables (`useGate`, `useUpload`, `useSSE`, `useToast`, `useClipboard`, etc.) |
449
- | `yaxa-svelte/admin` | Drizzle ORM Schema Introspection, `<AdminDashboard />`, `createYaxaAdminHook`, `createDrizzleAdmin`, `<DevSandbox />`, `<RecordDrawer />` |
450
- | `yaxa-svelte/server` | `yaxaEndpoint` Pipeline, Declarative Guards, OpenAPI 3.1 & Scalar Docs, RFC 7807, Real-Time SSE (`yaxaSSE`, `broadcast`), Background Jobs (`enqueueJob`, `createJobRunner`), `eventBus`, Server Hooks, DB, Storage & Email |
451
- | `yaxa-svelte/auth` | Better-Auth client & server integration (`useAuth`, `<AuthCard />`, `<PasskeyUI />`, `<TwoFactorModal />`, `createYaxaAuth`) |
452
- | `yaxa-svelte/db` | Multi-dialect Drizzle ORM clients & schemas for Neon (PostgreSQL) and Turso (SQLite/LibSQL) |
453
- | `yaxa-svelte/polar` | Polar.sh billing, customer portal sessions, and webhook synchronization |
454
- | `yaxa-svelte/email` | Resend transactional email templates with automatic development console fallback |
455
- | `yaxa-svelte/storage` | AWS S3 and Cloudflare R2 presigned upload URL generators |
456
- | `yaxa-svelte/testing` | Test mocks & utilities (`createMockSiteConfig`, `createMockUser`, `createTestWrapperProps`) |
457
- | `yaxa-svelte/vite` | Turnkey Vite plugin (`yaxa()`) |
458
- | `yaxa-svelte/yaxa.css` | Standalone turnkey Tailwind CSS v4 stylesheet with base styling, `@theme`, and scrollbars |
459
- | `yaxa-svelte/theme.css` | Pure Tailwind CSS v4 design tokens, `:root` variables, and harmonic radius scale without `@import 'tailwindcss'` |
512
+ | Subpath | Description |
513
+ | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
514
+ | `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Type-Safe Client RPC (`createApiClient`), Runes Query Engine (`createQuery`, `createMutation`, `QueryClient`), Theme System & Composables (`useGate`, `useUpload`, `useSSE`, `useToast`, `useClipboard`, etc.) |
515
+ | `yaxa-svelte/admin` | Drizzle ORM Schema Introspection, `<AdminDashboard />`, `createYaxaAdminHook`, `createDrizzleAdmin`, `<DevSandbox />`, `<RecordDrawer />` |
516
+ | `yaxa-svelte/server` | `yaxaEndpoint` Pipeline, Declarative Guards, OpenAPI 3.1 & Scalar Docs, RFC 7807, Real-Time SSE (`yaxaSSE`, `broadcast`), Background Jobs (`enqueueJob`, `createJobRunner`), `eventBus`, Server Hooks, DB, Storage & Email |
517
+ | `yaxa-svelte/auth` | Better-Auth client & server integration (`useAuth`, `<AuthCard />`, `<PasskeyUI />`, `<TwoFactorModal />`, `createYaxaAuth`) |
518
+ | `yaxa-svelte/db` | Multi-dialect Drizzle ORM clients & schemas for Neon (PostgreSQL) and Turso (SQLite/LibSQL) |
519
+ | `yaxa-svelte/polar` | Polar.sh billing, customer portal sessions, and webhook synchronization |
520
+ | `yaxa-svelte/email` | Resend transactional email templates with automatic development console fallback |
521
+ | `yaxa-svelte/storage` | AWS S3 and Cloudflare R2 presigned upload URL generators |
522
+ | `yaxa-svelte/testing` | Test mocks & utilities (`createMockSiteConfig`, `createMockUser`, `createTestWrapperProps`) |
523
+ | `yaxa-svelte/vite` | Turnkey Vite plugin (`yaxa()`) |
524
+ | `yaxa-svelte/yaxa.css` | Standalone turnkey Tailwind CSS v4 stylesheet with base styling, `@theme`, and scrollbars |
525
+ | `yaxa-svelte/theme.css` | Pure Tailwind CSS v4 design tokens, `:root` variables, and harmonic radius scale without `@import 'tailwindcss'` |
460
526
 
461
527
  ---
462
528
 
@@ -1,2 +1,5 @@
1
1
  export * from './types';
2
2
  export * from './client';
3
+ export * from './query-cache.svelte';
4
+ export * from './query.svelte';
5
+ export * from './mutation.svelte';
@@ -1,2 +1,5 @@
1
1
  export * from './types';
2
2
  export * from './client';
3
+ export * from './query-cache.svelte';
4
+ export * from './query.svelte';
5
+ export * from './mutation.svelte';
@@ -0,0 +1,37 @@
1
+ import type { ClientResponse, ProblemDetails } from './types';
2
+ export type MutationFn<TData = any, TVariables = any> = (variables: TVariables) => Promise<ClientResponse<TData> | TData>;
3
+ export interface CreateMutationOptions<TData = any, TVariables = any, TError = ProblemDetails | Error, TContext = unknown> {
4
+ /**
5
+ * Callback fired immediately before the mutation runs.
6
+ * Ideal for optimistic updates; returned context will be passed to onError and onSettled.
7
+ */
8
+ onMutate?: (variables: TVariables) => Promise<TContext> | TContext;
9
+ /**
10
+ * Callback fired when mutation succeeds.
11
+ */
12
+ onSuccess?: (data: TData, variables: TVariables, context: TContext | undefined) => Promise<void> | void;
13
+ /**
14
+ * Callback fired when mutation fails.
15
+ */
16
+ onError?: (error: TError, variables: TVariables, context: TContext | undefined) => Promise<void> | void;
17
+ /**
18
+ * Callback fired when mutation completes (either success or error).
19
+ */
20
+ onSettled?: (data: TData | null, error: TError | null, variables: TVariables, context: TContext | undefined) => Promise<void> | void;
21
+ }
22
+ export interface CreateMutationResult<TData = any, TVariables = any, TError = ProblemDetails | Error> {
23
+ readonly data: TData | null;
24
+ readonly error: TError | null;
25
+ readonly status: 'idle' | 'pending' | 'success' | 'error';
26
+ readonly isPending: boolean;
27
+ readonly isSuccess: boolean;
28
+ readonly isError: boolean;
29
+ readonly isIdle: boolean;
30
+ mutate: (variables: TVariables) => Promise<TData>;
31
+ reset: () => void;
32
+ }
33
+ /**
34
+ * Idiomatic Svelte 5 Runes Mutation Composable.
35
+ * Features optimistic rollbacks, lifecycle callbacks, and RFC 7807 problem details unwrapping.
36
+ */
37
+ export declare function createMutation<TData = any, TVariables = void, TError = ProblemDetails | Error, TContext = unknown>(mutationFn: MutationFn<TData, TVariables>, options?: CreateMutationOptions<TData, TVariables, TError, TContext>): CreateMutationResult<TData, TVariables, TError>;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Checks if a response is a Yaxa ClientResponse.
3
+ */
4
+ function isClientResponse(res) {
5
+ return (typeof res === 'object' &&
6
+ res !== null &&
7
+ 'ok' in res &&
8
+ 'status' in res &&
9
+ ('data' in res || 'error' in res));
10
+ }
11
+ /**
12
+ * Idiomatic Svelte 5 Runes Mutation Composable.
13
+ * Features optimistic rollbacks, lifecycle callbacks, and RFC 7807 problem details unwrapping.
14
+ */
15
+ export function createMutation(mutationFn, options = {}) {
16
+ let data = $state(null);
17
+ let error = $state(null);
18
+ let status = $state('idle');
19
+ const isPending = $derived(status === 'pending');
20
+ const isSuccess = $derived(status === 'success');
21
+ const isError = $derived(status === 'error');
22
+ const isIdle = $derived(status === 'idle');
23
+ async function mutate(variables) {
24
+ status = 'pending';
25
+ error = null;
26
+ let context;
27
+ try {
28
+ if (options.onMutate) {
29
+ context = await options.onMutate(variables);
30
+ }
31
+ const result = await mutationFn(variables);
32
+ let finalData;
33
+ if (isClientResponse(result)) {
34
+ if (!result.ok) {
35
+ throw result.error;
36
+ }
37
+ finalData = result.data;
38
+ }
39
+ else {
40
+ finalData = result;
41
+ }
42
+ data = finalData;
43
+ status = 'success';
44
+ await options.onSuccess?.(finalData, variables, context);
45
+ await options.onSettled?.(finalData, null, variables, context);
46
+ return finalData;
47
+ }
48
+ catch (err) {
49
+ const errPayload = err;
50
+ error = errPayload;
51
+ status = 'error';
52
+ await options.onError?.(errPayload, variables, context);
53
+ await options.onSettled?.(null, errPayload, variables, context);
54
+ throw err;
55
+ }
56
+ }
57
+ function reset() {
58
+ data = null;
59
+ error = null;
60
+ status = 'idle';
61
+ }
62
+ return {
63
+ get data() {
64
+ return data;
65
+ },
66
+ get error() {
67
+ return error;
68
+ },
69
+ get status() {
70
+ return status;
71
+ },
72
+ get isPending() {
73
+ return isPending;
74
+ },
75
+ get isSuccess() {
76
+ return isSuccess;
77
+ },
78
+ get isError() {
79
+ return isError;
80
+ },
81
+ get isIdle() {
82
+ return isIdle;
83
+ },
84
+ mutate,
85
+ reset
86
+ };
87
+ }
@@ -0,0 +1,51 @@
1
+ import { SvelteSet } from 'svelte/reactivity';
2
+ export type QueryKey = string | readonly unknown[];
3
+ /**
4
+ * Deterministically serializes a QueryKey into a cache string.
5
+ */
6
+ export declare function hashQueryKey(key: QueryKey): string;
7
+ export type QueryStatus = 'idle' | 'loading' | 'success' | 'error';
8
+ export interface QueryCacheEntry<TData = any, TError = any> {
9
+ readonly key: string;
10
+ data: TData | null;
11
+ error: TError | null;
12
+ status: QueryStatus;
13
+ isFetching: boolean;
14
+ updatedAt: number;
15
+ inFlightPromise: Promise<TData | null> | null;
16
+ subscribers: SvelteSet<() => void>;
17
+ invalidate: () => Promise<void>;
18
+ }
19
+ export interface InvalidateOptions {
20
+ key?: QueryKey;
21
+ exact?: boolean;
22
+ }
23
+ /**
24
+ * Runes-native Query Client managing cache entries, request coalescing,
25
+ * and real-time invalidation.
26
+ */
27
+ export declare class QueryClient {
28
+ private entries;
29
+ /**
30
+ * Retrieves or initializes a cache entry for a given key string.
31
+ */
32
+ getOrCreateEntry<TData = any, TError = any>(keyHash: string, initialData?: TData): QueryCacheEntry<TData, TError>;
33
+ /**
34
+ * Retrieves current data for a query key from the cache.
35
+ */
36
+ getQueryData<TData = any>(key: QueryKey): TData | null;
37
+ /**
38
+ * Manually sets or optimistically updates data for a query key.
39
+ */
40
+ setQueryData<TData = any>(key: QueryKey, updater: TData | ((previous: TData | null) => TData)): TData;
41
+ /**
42
+ * Invalidates queries matching a key or filter, triggering active subscribers to refetch.
43
+ */
44
+ invalidateQueries(filter?: InvalidateOptions | QueryKey): Promise<void>;
45
+ /**
46
+ * Clears the cache completely.
47
+ */
48
+ clear(): void;
49
+ }
50
+ export declare function getQueryClient(): QueryClient;
51
+ export declare function setQueryClient(client: QueryClient): void;
@@ -0,0 +1,172 @@
1
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
2
+ /**
3
+ * Deterministically serializes a QueryKey into a cache string.
4
+ */
5
+ export function hashQueryKey(key) {
6
+ if (typeof key === 'string')
7
+ return key;
8
+ try {
9
+ return JSON.stringify(key, (_, val) => {
10
+ if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
11
+ // Sort object keys deterministically
12
+ return Object.keys(val)
13
+ .sort()
14
+ .reduce((acc, k) => {
15
+ acc[k] = val[k];
16
+ return acc;
17
+ }, {});
18
+ }
19
+ return val;
20
+ });
21
+ }
22
+ catch {
23
+ return String(key);
24
+ }
25
+ }
26
+ /**
27
+ * Runes-native Query Client managing cache entries, request coalescing,
28
+ * and real-time invalidation.
29
+ */
30
+ export class QueryClient {
31
+ entries = new SvelteMap();
32
+ /**
33
+ * Retrieves or initializes a cache entry for a given key string.
34
+ */
35
+ getOrCreateEntry(keyHash, initialData) {
36
+ const entry = this.entries.get(keyHash);
37
+ if (!entry) {
38
+ let data = $state(initialData !== undefined ? initialData : null);
39
+ let error = $state(null);
40
+ let status = $state(initialData !== undefined ? 'success' : 'idle');
41
+ let isFetching = $state(false);
42
+ let updatedAt = $state(initialData !== undefined ? Date.now() : 0);
43
+ const subscribers = new SvelteSet();
44
+ const newEntry = {
45
+ key: keyHash,
46
+ get data() {
47
+ return data;
48
+ },
49
+ set data(val) {
50
+ data = val;
51
+ },
52
+ get error() {
53
+ return error;
54
+ },
55
+ set error(val) {
56
+ error = val;
57
+ },
58
+ get status() {
59
+ return status;
60
+ },
61
+ set status(val) {
62
+ status = val;
63
+ },
64
+ get isFetching() {
65
+ return isFetching;
66
+ },
67
+ set isFetching(val) {
68
+ isFetching = val;
69
+ },
70
+ get updatedAt() {
71
+ return updatedAt;
72
+ },
73
+ set updatedAt(val) {
74
+ updatedAt = val;
75
+ },
76
+ inFlightPromise: null,
77
+ subscribers,
78
+ invalidate: async () => {
79
+ for (const notify of Array.from(subscribers)) {
80
+ try {
81
+ notify();
82
+ }
83
+ catch {
84
+ // Ignored
85
+ }
86
+ }
87
+ }
88
+ };
89
+ this.entries.set(keyHash, newEntry);
90
+ return newEntry;
91
+ }
92
+ // If entry exists but has no data and initialData was provided, populate it
93
+ if (entry.data === null && initialData !== undefined && entry.status === 'idle') {
94
+ entry.data = initialData;
95
+ entry.status = 'success';
96
+ entry.updatedAt = Date.now();
97
+ }
98
+ return entry;
99
+ }
100
+ /**
101
+ * Retrieves current data for a query key from the cache.
102
+ */
103
+ getQueryData(key) {
104
+ const hash = hashQueryKey(key);
105
+ const entry = this.entries.get(hash);
106
+ return entry ? entry.data : null;
107
+ }
108
+ /**
109
+ * Manually sets or optimistically updates data for a query key.
110
+ */
111
+ setQueryData(key, updater) {
112
+ const hash = hashQueryKey(key);
113
+ const entry = this.getOrCreateEntry(hash);
114
+ const nextData = typeof updater === 'function'
115
+ ? updater(entry.data)
116
+ : updater;
117
+ entry.data = nextData;
118
+ entry.error = null;
119
+ entry.status = 'success';
120
+ entry.updatedAt = Date.now();
121
+ return nextData;
122
+ }
123
+ /**
124
+ * Invalidates queries matching a key or filter, triggering active subscribers to refetch.
125
+ */
126
+ async invalidateQueries(filter) {
127
+ if (!filter) {
128
+ for (const entry of this.entries.values()) {
129
+ await entry.invalidate();
130
+ }
131
+ return;
132
+ }
133
+ const isKey = typeof filter === 'string' || Array.isArray(filter);
134
+ const key = isKey ? filter : filter.key;
135
+ const exact = isKey ? false : Boolean(filter.exact);
136
+ if (!key) {
137
+ for (const entry of this.entries.values()) {
138
+ await entry.invalidate();
139
+ }
140
+ return;
141
+ }
142
+ const targetHash = hashQueryKey(key);
143
+ for (const [hash, entry] of this.entries.entries()) {
144
+ if (exact) {
145
+ if (hash === targetHash) {
146
+ await entry.invalidate();
147
+ }
148
+ }
149
+ else {
150
+ if (hash.startsWith(targetHash) || hash === targetHash) {
151
+ await entry.invalidate();
152
+ }
153
+ }
154
+ }
155
+ }
156
+ /**
157
+ * Clears the cache completely.
158
+ */
159
+ clear() {
160
+ this.entries.clear();
161
+ }
162
+ }
163
+ /**
164
+ * Global default query client singleton.
165
+ */
166
+ let globalClient = new QueryClient();
167
+ export function getQueryClient() {
168
+ return globalClient;
169
+ }
170
+ export function setQueryClient(client) {
171
+ globalClient = client;
172
+ }
@@ -0,0 +1,71 @@
1
+ import { type QueryClient, type QueryKey, type QueryStatus } from './query-cache.svelte';
2
+ import type { ClientResponse, ProblemDetails } from './types';
3
+ import type { SSEResult } from '../composables/useSSE.svelte';
4
+ export type QueryFn<TData = any> = () => Promise<ClientResponse<TData> | TData>;
5
+ export interface CreateQueryOptions<TData = any, TError = ProblemDetails | Error> {
6
+ /**
7
+ * Explicit cache key or reactive key getter. If omitted, an auto-derived key is used.
8
+ */
9
+ key?: QueryKey | (() => QueryKey);
10
+ /**
11
+ * Whether the query should execute automatically (defaults to true).
12
+ * Can be a boolean or a reactive boolean getter: `() => !!userId`.
13
+ */
14
+ enabled?: boolean | (() => boolean);
15
+ /**
16
+ * Initial data (useful for SSR hydration to avoid loading flashes).
17
+ */
18
+ initialData?: TData;
19
+ /**
20
+ * Time in milliseconds before data is considered stale (defaults to 0).
21
+ */
22
+ staleTimeMs?: number;
23
+ /**
24
+ * Whether to refetch data when the browser window regains focus (defaults to true).
25
+ */
26
+ refetchOnWindowFocus?: boolean;
27
+ /**
28
+ * Whether to refetch data when network reconnects (defaults to true).
29
+ */
30
+ refetchOnReconnect?: boolean;
31
+ /**
32
+ * Custom query client instance (defaults to global client).
33
+ */
34
+ client?: QueryClient;
35
+ /**
36
+ * Server-Sent Events stream or channel for live real-time cache invalidation.
37
+ */
38
+ subscribeToSse?: string | {
39
+ sse?: SSEResult;
40
+ event?: string;
41
+ };
42
+ /**
43
+ * Callback on successful fetch.
44
+ */
45
+ onSuccess?: (data: TData) => void;
46
+ /**
47
+ * Callback on error.
48
+ */
49
+ onError?: (error: TError) => void;
50
+ }
51
+ export interface CreateQueryResult<TData = any, TError = ProblemDetails | Error> {
52
+ readonly data: TData | null;
53
+ readonly error: TError | null;
54
+ readonly status: QueryStatus;
55
+ readonly isLoading: boolean;
56
+ readonly isFetching: boolean;
57
+ readonly isSuccess: boolean;
58
+ readonly isError: boolean;
59
+ readonly updatedAt: number;
60
+ refetch: () => Promise<TData | null>;
61
+ invalidate: () => Promise<void>;
62
+ setData: (updater: TData | ((prev: TData | null) => TData)) => void;
63
+ optimisticUpdate: (updater: (prev: TData | null) => TData) => void;
64
+ destroy: () => void;
65
+ }
66
+ /**
67
+ * Idiomatic Svelte 5 Runes Query Composable.
68
+ * Features automatic dependency tracking, request coalescing, stale-while-revalidate,
69
+ * window focus revalidation, and SSE live invalidation.
70
+ */
71
+ export declare function createQuery<TData = any, TError = ProblemDetails | Error>(keyOrFn: QueryKey | (() => QueryKey) | QueryFn<TData>, fnOrOptions?: QueryFn<TData> | CreateQueryOptions<TData, TError>, maybeOptions?: CreateQueryOptions<TData, TError>): CreateQueryResult<TData, TError>;
@@ -0,0 +1,213 @@
1
+ import { hashQueryKey, getQueryClient } from './query-cache.svelte';
2
+ /**
3
+ * Checks if a response is a Yaxa ClientResponse.
4
+ */
5
+ function isClientResponse(res) {
6
+ return (typeof res === 'object' &&
7
+ res !== null &&
8
+ 'ok' in res &&
9
+ 'status' in res &&
10
+ ('data' in res || 'error' in res));
11
+ }
12
+ /**
13
+ * Normalizes query key evaluation.
14
+ */
15
+ function resolveKey(keyOrFn, fallback) {
16
+ if (typeof keyOrFn === 'function') {
17
+ return keyOrFn();
18
+ }
19
+ if (keyOrFn !== undefined) {
20
+ return keyOrFn;
21
+ }
22
+ return fallback || 'default-query-key';
23
+ }
24
+ /**
25
+ * Idiomatic Svelte 5 Runes Query Composable.
26
+ * Features automatic dependency tracking, request coalescing, stale-while-revalidate,
27
+ * window focus revalidation, and SSE live invalidation.
28
+ */
29
+ export function createQuery(keyOrFn, fnOrOptions, maybeOptions) {
30
+ let keyConfig;
31
+ let queryFn;
32
+ let options;
33
+ if (typeof keyOrFn === 'function' && fnOrOptions && typeof fnOrOptions === 'function') {
34
+ // createQuery(keyFn, queryFn, options)
35
+ keyConfig = keyOrFn;
36
+ queryFn = fnOrOptions;
37
+ options = maybeOptions || {};
38
+ }
39
+ else if (typeof keyOrFn === 'function' && (!fnOrOptions || typeof fnOrOptions === 'object')) {
40
+ // createQuery(queryFn, options)
41
+ queryFn = keyOrFn;
42
+ options = fnOrOptions || {};
43
+ keyConfig = options.key;
44
+ }
45
+ else {
46
+ // createQuery(key, queryFn, options)
47
+ keyConfig = keyOrFn;
48
+ queryFn = fnOrOptions;
49
+ options = maybeOptions || {};
50
+ }
51
+ const client = options.client || getQueryClient();
52
+ const staleTimeMs = options.staleTimeMs ?? 0;
53
+ const refetchOnWindowFocus = options.refetchOnWindowFocus ?? true;
54
+ const refetchOnReconnect = options.refetchOnReconnect ?? true;
55
+ const fallbackKey = queryFn.toString();
56
+ const initialKey = resolveKey(keyConfig, fallbackKey);
57
+ const initialHash = hashQueryKey(initialKey);
58
+ // Pre-seed entry immediately on creation with initialData if provided
59
+ client.getOrCreateEntry(initialHash, options.initialData);
60
+ function getCurrentKey() {
61
+ return resolveKey(keyConfig, fallbackKey);
62
+ }
63
+ function getCurrentHash() {
64
+ return hashQueryKey(getCurrentKey());
65
+ }
66
+ function getIsEnabled() {
67
+ return typeof options.enabled === 'function'
68
+ ? options.enabled()
69
+ : options.enabled !== undefined
70
+ ? options.enabled
71
+ : true;
72
+ }
73
+ function getEntry() {
74
+ return client.getOrCreateEntry(getCurrentHash(), options.initialData);
75
+ }
76
+ async function executeFetch(force = false) {
77
+ if (!getIsEnabled())
78
+ return null;
79
+ const targetEntry = getEntry();
80
+ // 1. Check Stale Time if not forced
81
+ if (!force && targetEntry.updatedAt > 0) {
82
+ const age = Date.now() - targetEntry.updatedAt;
83
+ if (age < staleTimeMs && targetEntry.data !== null) {
84
+ return targetEntry.data;
85
+ }
86
+ }
87
+ // 2. Request Coalescing: Reuse in-flight Promise if already running
88
+ if (targetEntry.inFlightPromise) {
89
+ return targetEntry.inFlightPromise;
90
+ }
91
+ targetEntry.isFetching = true;
92
+ if (targetEntry.status === 'idle') {
93
+ targetEntry.status = 'loading';
94
+ }
95
+ const promise = (async () => {
96
+ try {
97
+ const result = await queryFn();
98
+ let finalData;
99
+ if (isClientResponse(result)) {
100
+ if (!result.ok) {
101
+ throw result.error;
102
+ }
103
+ finalData = result.data;
104
+ }
105
+ else {
106
+ finalData = result;
107
+ }
108
+ targetEntry.data = finalData;
109
+ targetEntry.error = null;
110
+ targetEntry.status = 'success';
111
+ targetEntry.updatedAt = Date.now();
112
+ options.onSuccess?.(finalData);
113
+ return finalData;
114
+ }
115
+ catch (err) {
116
+ targetEntry.error = err;
117
+ targetEntry.status = 'error';
118
+ options.onError?.(err);
119
+ return null;
120
+ }
121
+ finally {
122
+ targetEntry.isFetching = false;
123
+ targetEntry.inFlightPromise = null;
124
+ }
125
+ })();
126
+ targetEntry.inFlightPromise = promise;
127
+ return promise;
128
+ }
129
+ // Subscriber notification callback
130
+ const subscriber = () => {
131
+ executeFetch(true);
132
+ };
133
+ const initialEntry = getEntry();
134
+ initialEntry.subscribers.add(subscriber);
135
+ // Initial execution if enabled and not already hydrated/fresh
136
+ if (getIsEnabled() && initialEntry.status === 'idle') {
137
+ executeFetch(false);
138
+ }
139
+ // Window listeners
140
+ let cleanupWindowListeners;
141
+ if (typeof window !== 'undefined') {
142
+ const onFocus = () => {
143
+ if (refetchOnWindowFocus && getIsEnabled()) {
144
+ executeFetch(false);
145
+ }
146
+ };
147
+ const onOnline = () => {
148
+ if (refetchOnReconnect && getIsEnabled()) {
149
+ executeFetch(true);
150
+ }
151
+ };
152
+ window.addEventListener('focus', onFocus);
153
+ window.addEventListener('online', onOnline);
154
+ cleanupWindowListeners = () => {
155
+ window.removeEventListener('focus', onFocus);
156
+ window.removeEventListener('online', onOnline);
157
+ };
158
+ }
159
+ // SSE Live-Invalidation Bridge
160
+ let cleanupSse;
161
+ if (options.subscribeToSse && typeof options.subscribeToSse === 'object') {
162
+ const sseOption = options.subscribeToSse;
163
+ if (sseOption.sse && sseOption.event) {
164
+ cleanupSse = sseOption.sse.on(sseOption.event, () => {
165
+ executeFetch(true);
166
+ });
167
+ }
168
+ }
169
+ function destroy() {
170
+ const currentEntry = getEntry();
171
+ currentEntry.subscribers.delete(subscriber);
172
+ cleanupWindowListeners?.();
173
+ cleanupSse?.();
174
+ }
175
+ return {
176
+ get data() {
177
+ return getEntry().data;
178
+ },
179
+ get error() {
180
+ return getEntry().error;
181
+ },
182
+ get status() {
183
+ return getEntry().status;
184
+ },
185
+ get isLoading() {
186
+ const e = getEntry();
187
+ return e.status === 'loading' && e.data === null;
188
+ },
189
+ get isFetching() {
190
+ return getEntry().isFetching;
191
+ },
192
+ get isSuccess() {
193
+ return getEntry().status === 'success';
194
+ },
195
+ get isError() {
196
+ return getEntry().status === 'error';
197
+ },
198
+ get updatedAt() {
199
+ return getEntry().updatedAt;
200
+ },
201
+ refetch: () => executeFetch(true),
202
+ invalidate: async () => {
203
+ await getEntry().invalidate();
204
+ },
205
+ setData: (updater) => {
206
+ client.setQueryData(getCurrentKey(), updater);
207
+ },
208
+ optimisticUpdate: (updater) => {
209
+ client.setQueryData(getCurrentKey(), updater);
210
+ },
211
+ destroy
212
+ };
213
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,242 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { hashQueryKey, QueryClient, createQuery, createMutation, setQueryClient } from './index';
3
+ describe('Runes Query Engine - hashQueryKey', () => {
4
+ it('returns string keys as-is', () => {
5
+ expect(hashQueryKey('projects')).toBe('projects');
6
+ expect(hashQueryKey('/api/items')).toBe('/api/items');
7
+ });
8
+ it('deterministically hashes arrays and nested objects regardless of key order', () => {
9
+ const keyA = ['projects', { orgId: '123', status: 'active' }];
10
+ const keyB = ['projects', { status: 'active', orgId: '123' }];
11
+ expect(hashQueryKey(keyA)).toBe(hashQueryKey(keyB));
12
+ });
13
+ });
14
+ describe('Runes Query Engine - QueryClient', () => {
15
+ let client;
16
+ beforeEach(() => {
17
+ client = new QueryClient();
18
+ setQueryClient(client);
19
+ });
20
+ it('creates, retrieves, and updates query cache entries', () => {
21
+ expect(client.getQueryData('users')).toBeNull();
22
+ client.setQueryData('users', [{ id: '1', name: 'Alice' }]);
23
+ expect(client.getQueryData('users')).toEqual([{ id: '1', name: 'Alice' }]);
24
+ // Functional updater
25
+ client.setQueryData('users', (prev) => [
26
+ ...(prev ?? []),
27
+ { id: '2', name: 'Bob' }
28
+ ]);
29
+ expect(client.getQueryData('users')).toHaveLength(2);
30
+ });
31
+ it('invalidates matching queries with exact and prefix filters', async () => {
32
+ const entry1 = client.getOrCreateEntry('projects-list');
33
+ const entry2 = client.getOrCreateEntry('projects-detail-1');
34
+ const entry3 = client.getOrCreateEntry('users');
35
+ const listener1 = vi.fn();
36
+ const listener2 = vi.fn();
37
+ const listener3 = vi.fn();
38
+ entry1.subscribers.add(listener1);
39
+ entry2.subscribers.add(listener2);
40
+ entry3.subscribers.add(listener3);
41
+ // Invalidate all projects
42
+ await client.invalidateQueries('projects');
43
+ expect(listener1).toHaveBeenCalledTimes(1);
44
+ expect(listener2).toHaveBeenCalledTimes(1);
45
+ expect(listener3).not.toHaveBeenCalled();
46
+ // Exact invalidation
47
+ await client.invalidateQueries({ key: 'projects-list', exact: true });
48
+ expect(listener1).toHaveBeenCalledTimes(2);
49
+ expect(listener2).toHaveBeenCalledTimes(1);
50
+ });
51
+ });
52
+ describe('Runes Query Engine - createQuery', () => {
53
+ let client;
54
+ beforeEach(() => {
55
+ client = new QueryClient();
56
+ setQueryClient(client);
57
+ });
58
+ it('fetches data and unwraps ClientResponse successfully', async () => {
59
+ const fetchMock = vi.fn().mockResolvedValue({
60
+ ok: true,
61
+ status: 200,
62
+ data: [{ id: 'proj_1', name: 'Alpha' }],
63
+ error: null,
64
+ response: new Response()
65
+ });
66
+ const query = createQuery('test-projects', fetchMock, { client });
67
+ expect(query.isLoading).toBe(true);
68
+ expect(query.data).toBeNull();
69
+ const result = await query.refetch();
70
+ expect(result).toEqual([{ id: 'proj_1', name: 'Alpha' }]);
71
+ expect(query.data).toEqual([{ id: 'proj_1', name: 'Alpha' }]);
72
+ expect(query.isSuccess).toBe(true);
73
+ expect(query.isLoading).toBe(false);
74
+ expect(query.isError).toBe(false);
75
+ });
76
+ it('handles ClientResponse errors and RFC 7807 problem details', async () => {
77
+ const errorResponse = {
78
+ ok: false,
79
+ status: 404,
80
+ data: null,
81
+ error: {
82
+ type: 'https://yaxa.dev/errors/not-found',
83
+ title: 'Project Not Found',
84
+ status: 404,
85
+ detail: 'No project with ID 999 exists'
86
+ },
87
+ response: new Response(null, { status: 404 })
88
+ };
89
+ const fetchMock = vi.fn().mockResolvedValue(errorResponse);
90
+ const onError = vi.fn();
91
+ const query = createQuery('not-found-query', fetchMock, { client, onError });
92
+ await query.refetch();
93
+ expect(query.data).toBeNull();
94
+ expect(query.isError).toBe(true);
95
+ expect(query.error).toEqual(errorResponse.error);
96
+ expect(onError).toHaveBeenCalledWith(errorResponse.error);
97
+ });
98
+ it('coalesces multiple in-flight concurrent requests to the same key', async () => {
99
+ let resolveCall = () => { };
100
+ const fetchMock = vi.fn().mockImplementation(() => new Promise((res) => {
101
+ resolveCall = res;
102
+ }));
103
+ const query1 = createQuery('shared-key', fetchMock, { client });
104
+ const query2 = createQuery('shared-key', fetchMock, { client });
105
+ // Trigger both simultaneously
106
+ const p1 = query1.refetch();
107
+ const p2 = query2.refetch();
108
+ expect(fetchMock).toHaveBeenCalledTimes(1);
109
+ resolveCall({
110
+ ok: true,
111
+ status: 200,
112
+ data: { result: 'shared' },
113
+ error: null,
114
+ response: new Response()
115
+ });
116
+ const [res1, res2] = await Promise.all([p1, p2]);
117
+ expect(res1).toEqual({ result: 'shared' });
118
+ expect(res2).toEqual({ result: 'shared' });
119
+ });
120
+ it('respects staleTimeMs and avoids refetching when cache is fresh', async () => {
121
+ const fetchMock = vi.fn().mockResolvedValue('data-1');
122
+ const query = createQuery('stale-key', fetchMock, {
123
+ client,
124
+ staleTimeMs: 10_000
125
+ });
126
+ await query.refetch();
127
+ expect(fetchMock).toHaveBeenCalledTimes(1);
128
+ // Non-forced fetch should return cached data because staleTimeMs hasn't elapsed
129
+ client.getOrCreateEntry('stale-key').updatedAt = Date.now();
130
+ const entry = client.getOrCreateEntry('stale-key');
131
+ expect(entry.data).toBe('data-1');
132
+ });
133
+ it('hydrates with initialData with zero loading state', () => {
134
+ const query = createQuery('ssr-key', async () => 'fetched', {
135
+ client,
136
+ initialData: 'preloaded'
137
+ });
138
+ expect(query.data).toBe('preloaded');
139
+ expect(query.isLoading).toBe(false);
140
+ expect(query.isSuccess).toBe(true);
141
+ });
142
+ it('supports optimisticUpdate and setData', () => {
143
+ const query = createQuery('list-key', async () => ['item1'], {
144
+ client,
145
+ initialData: ['item1']
146
+ });
147
+ query.optimisticUpdate((prev) => [...(prev ?? []), 'item2']);
148
+ expect(query.data).toEqual(['item1', 'item2']);
149
+ query.setData(['item-override']);
150
+ expect(query.data).toEqual(['item-override']);
151
+ });
152
+ it('respects enabled: false and skips automatic fetching', async () => {
153
+ const fetchMock = vi.fn().mockResolvedValue('data');
154
+ const query = createQuery('disabled-key', fetchMock, {
155
+ client,
156
+ enabled: false
157
+ });
158
+ const res = await query.refetch();
159
+ expect(res).toBeNull();
160
+ expect(fetchMock).not.toHaveBeenCalled();
161
+ });
162
+ it('supports live invalidation via subscribeToSse', async () => {
163
+ const mockSse = {
164
+ on: vi.fn((_event, handler) => {
165
+ // Save handler
166
+ mockSse.trigger = handler;
167
+ return () => { };
168
+ })
169
+ };
170
+ const fetchMock = vi.fn().mockResolvedValue('sse-data');
171
+ createQuery('sse-query', fetchMock, {
172
+ client,
173
+ subscribeToSse: {
174
+ sse: mockSse,
175
+ event: 'project.updated'
176
+ }
177
+ });
178
+ expect(mockSse.on).toBeDefined();
179
+ });
180
+ });
181
+ describe('Runes Query Engine - createMutation', () => {
182
+ it('executes successful mutation and runs lifecycle hooks', async () => {
183
+ const onMutate = vi.fn().mockReturnValue({ tempId: 'temp_123' });
184
+ const onSuccess = vi.fn();
185
+ const onSettled = vi.fn();
186
+ const mutationFn = vi.fn().mockResolvedValue({
187
+ ok: true,
188
+ status: 201,
189
+ data: { id: 'real_123', name: 'Created' },
190
+ error: null,
191
+ response: new Response(null, { status: 201 })
192
+ });
193
+ const mutation = createMutation(mutationFn, {
194
+ onMutate,
195
+ onSuccess,
196
+ onSettled
197
+ });
198
+ expect(mutation.isIdle).toBe(true);
199
+ const promise = mutation.mutate({ name: 'Created' });
200
+ expect(mutation.isPending).toBe(true);
201
+ const result = await promise;
202
+ expect(result).toEqual({ id: 'real_123', name: 'Created' });
203
+ expect(mutation.data).toEqual({ id: 'real_123', name: 'Created' });
204
+ expect(mutation.isSuccess).toBe(true);
205
+ expect(mutation.isPending).toBe(false);
206
+ expect(onMutate).toHaveBeenCalledWith({ name: 'Created' });
207
+ expect(onSuccess).toHaveBeenCalledWith({ id: 'real_123', name: 'Created' }, { name: 'Created' }, { tempId: 'temp_123' });
208
+ expect(onSettled).toHaveBeenCalledWith({ id: 'real_123', name: 'Created' }, null, { name: 'Created' }, { tempId: 'temp_123' });
209
+ });
210
+ it('handles mutation errors, passes context for rollback, and re-throws', async () => {
211
+ const rollbackContext = { previousList: ['item1'] };
212
+ const onMutate = vi.fn().mockReturnValue(rollbackContext);
213
+ const onError = vi.fn();
214
+ const onSettled = vi.fn();
215
+ const errorResponse = {
216
+ ok: false,
217
+ status: 400,
218
+ data: null,
219
+ error: {
220
+ type: 'https://yaxa.dev/errors/validation',
221
+ title: 'Bad Request',
222
+ status: 400,
223
+ detail: 'Name must be at least 3 characters'
224
+ },
225
+ response: new Response(null, { status: 400 })
226
+ };
227
+ const mutation = createMutation(async () => errorResponse, {
228
+ onMutate,
229
+ onError,
230
+ onSettled
231
+ });
232
+ await expect(mutation.mutate(undefined)).rejects.toEqual(errorResponse.error);
233
+ expect(mutation.isError).toBe(true);
234
+ expect(mutation.error).toEqual(errorResponse.error);
235
+ expect(onError).toHaveBeenCalledWith(errorResponse.error, undefined, rollbackContext);
236
+ expect(onSettled).toHaveBeenCalledWith(null, errorResponse.error, undefined, rollbackContext);
237
+ mutation.reset();
238
+ expect(mutation.isIdle).toBe(true);
239
+ expect(mutation.error).toBeNull();
240
+ expect(mutation.data).toBeNull();
241
+ });
242
+ });
package/dist/index.d.ts CHANGED
@@ -207,7 +207,7 @@ export { default as FeatureGrid, featureGridVariants } from './components/blocks
207
207
  export type { FeatureGridProps, FeatureItem } from './components/blocks/FeatureGrid.svelte';
208
208
  export { createMockSiteConfig, createMockUser, createTestWrapperProps } from './testing';
209
209
  export type { YaxaTestOptions } from './testing';
210
- export { createApiClient, interpolatePath, serializeQueryParams } from './client';
211
- export type { ApiClient, ApiClientConfig, ClientRequestOptions, ClientResponse, InferEndpoint, RouteMethods, ProblemDetails as ClientProblemDetails } from './client';
210
+ export { createApiClient, interpolatePath, serializeQueryParams, createQuery, createMutation, QueryClient, getQueryClient, setQueryClient, hashQueryKey } from './client';
211
+ export type { ApiClient, ApiClientConfig, ClientRequestOptions, ClientResponse, InferEndpoint, RouteMethods, ProblemDetails as ClientProblemDetails, QueryKey, QueryStatus, QueryCacheEntry, CreateQueryOptions, CreateQueryResult, CreateMutationOptions, CreateMutationResult, QueryFn, MutationFn } from './client';
212
212
  export { cn, tv } from './utils/cn';
213
213
  export type { VariantProps } from './utils/cn';
package/dist/index.js CHANGED
@@ -138,7 +138,7 @@ export { default as FAQ, faqVariants } from './components/blocks/FAQ.svelte';
138
138
  export { default as FeatureGrid, featureGridVariants } from './components/blocks/FeatureGrid.svelte';
139
139
  // Testing Utilities
140
140
  export { createMockSiteConfig, createMockUser, createTestWrapperProps } from './testing';
141
- // Type-Safe Client RPC
142
- export { createApiClient, interpolatePath, serializeQueryParams } from './client';
141
+ // Type-Safe Client RPC & Runes Query Engine
142
+ export { createApiClient, interpolatePath, serializeQueryParams, createQuery, createMutation, QueryClient, getQueryClient, setQueryClient, hashQueryKey } from './client';
143
143
  // Utilities
144
144
  export { cn, tv } from './utils/cn';
@@ -2,6 +2,6 @@ import type { RequestHandler } from '@sveltejs/kit';
2
2
  import type { ApiDocsOptions } from './types';
3
3
  /**
4
4
  * Creates a turnkey SvelteKit RequestHandler that generates an interactive OpenAPI 3.1 documentation
5
- * page (Scalar, Swagger UI, or Redoc) or returns raw OpenAPI 3.1 JSON when ?format=json is requested.
5
+ * page (Scalar or Swagger UI) or returns raw OpenAPI 3.1 JSON when ?format=json is requested.
6
6
  */
7
7
  export declare function createApiDocsHandler(options?: ApiDocsOptions): RequestHandler;
@@ -1,11 +1,35 @@
1
1
  import { generateOpenApiSpec } from './generator';
2
- function renderScalarHtml(specJson, title) {
2
+ function renderHeadMeta({ title, description, canonicalUrl, ogImage }) {
3
+ const descTag = description
4
+ ? `<meta name="description" content="${description.replace(/"/g, '&quot;')}" />`
5
+ : '';
6
+ const canonicalTag = canonicalUrl ? `<link rel="canonical" href="${canonicalUrl}" />` : '';
7
+ const ogImageTag = ogImage ? `<meta property="og:image" content="${ogImage}" />` : '';
8
+ const ogDescTag = description
9
+ ? `<meta property="og:description" content="${description.replace(/"/g, '&quot;')}" />`
10
+ : '';
11
+ const ogUrlTag = canonicalUrl ? `<meta property="og:url" content="${canonicalUrl}" />` : '';
12
+ return ` <title>${title}</title>
13
+ ${descTag}
14
+ <meta name="robots" content="index, follow" />
15
+ ${canonicalTag}
16
+ <meta property="og:type" content="website" />
17
+ <meta property="og:title" content="${title.replace(/"/g, '&quot;')}" />
18
+ ${ogDescTag}
19
+ ${ogUrlTag}
20
+ ${ogImageTag}
21
+ <meta name="twitter:card" content="summary_large_image" />
22
+ <meta name="twitter:title" content="${title.replace(/"/g, '&quot;')}" />
23
+ ${ogDescTag}
24
+ ${ogImageTag}`.trim();
25
+ }
26
+ function renderScalarHtml(specJson, meta) {
3
27
  return `<!doctype html>
4
28
  <html lang="en">
5
29
  <head>
6
30
  <meta charset="utf-8" />
7
31
  <meta name="viewport" content="width=device-width, initial-scale=1" />
8
- <title>${title}</title>
32
+ ${renderHeadMeta(meta)}
9
33
  <style>
10
34
  body { margin: 0; background-color: #0f172a; }
11
35
  </style>
@@ -18,13 +42,13 @@ function renderScalarHtml(specJson, title) {
18
42
  </body>
19
43
  </html>`;
20
44
  }
21
- function renderSwaggerHtml(specJson, title) {
45
+ function renderSwaggerHtml(specJson, meta) {
22
46
  return `<!DOCTYPE html>
23
47
  <html lang="en">
24
48
  <head>
25
49
  <meta charset="utf-8" />
26
50
  <meta name="viewport" content="width=device-width, initial-scale=1" />
27
- <title>${title}</title>
51
+ ${renderHeadMeta(meta)}
28
52
  <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
29
53
  <style>
30
54
  body { margin: 0; background: #fafafa; }
@@ -48,34 +72,9 @@ function renderSwaggerHtml(specJson, title) {
48
72
  </body>
49
73
  </html>`;
50
74
  }
51
- function renderRedocHtml(specJson, title) {
52
- return `<!DOCTYPE html>
53
- <html lang="en">
54
- <head>
55
- <meta charset="utf-8" />
56
- <meta name="viewport" content="width=device-width, initial-scale=1" />
57
- <title>${title}</title>
58
- <style>
59
- body { margin: 0; padding: 0; }
60
- </style>
61
- </head>
62
- <body>
63
- <div id="redoc-container"></div>
64
- <script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
65
- <script>
66
- Redoc.init(${specJson}, {
67
- scrollYOffset: 50,
68
- theme: {
69
- colors: { primary: { main: '#3b82f6' } }
70
- }
71
- }, document.getElementById('redoc-container'));
72
- </script>
73
- </body>
74
- </html>`;
75
- }
76
75
  /**
77
76
  * Creates a turnkey SvelteKit RequestHandler that generates an interactive OpenAPI 3.1 documentation
78
- * page (Scalar, Swagger UI, or Redoc) or returns raw OpenAPI 3.1 JSON when ?format=json is requested.
77
+ * page (Scalar or Swagger UI) or returns raw OpenAPI 3.1 JSON when ?format=json is requested.
79
78
  */
80
79
  export function createApiDocsHandler(options = {}) {
81
80
  const defaultTheme = options.theme || 'scalar';
@@ -83,7 +82,7 @@ export function createApiDocsHandler(options = {}) {
83
82
  const { url, request } = event;
84
83
  const format = url.searchParams.get('format');
85
84
  const themeQuery = url.searchParams.get('theme');
86
- const activeTheme = themeQuery && ['scalar', 'swagger', 'redoc'].includes(themeQuery) ? themeQuery : defaultTheme;
85
+ const activeTheme = themeQuery && ['scalar', 'swagger'].includes(themeQuery) ? themeQuery : defaultTheme;
87
86
  const acceptHeader = request.headers.get('accept') || '';
88
87
  const spec = generateOpenApiSpec(options);
89
88
  // If caller requests JSON via query param, accept header, or path ending in .json
@@ -99,16 +98,17 @@ export function createApiDocsHandler(options = {}) {
99
98
  }
100
99
  // Render Interactive HTML Docs
101
100
  const specJson = JSON.stringify(spec);
102
- let html;
103
- if (activeTheme === 'swagger') {
104
- html = renderSwaggerHtml(specJson, spec.info.title);
105
- }
106
- else if (activeTheme === 'redoc') {
107
- html = renderRedocHtml(specJson, spec.info.title);
108
- }
109
- else {
110
- html = renderScalarHtml(specJson, spec.info.title);
111
- }
101
+ const canonicalUrl = `${url.origin}${url.pathname}`;
102
+ const ogImage = `${url.origin}/api/og?title=${encodeURIComponent(spec.info.title)}&badge=API+Docs`;
103
+ const meta = {
104
+ title: spec.info.title,
105
+ description: spec.info.description,
106
+ canonicalUrl,
107
+ ogImage
108
+ };
109
+ const html = activeTheme === 'swagger'
110
+ ? renderSwaggerHtml(specJson, meta)
111
+ : renderScalarHtml(specJson, meta);
112
112
  return new Response(html, {
113
113
  headers: {
114
114
  'content-type': 'text/html; charset=utf-8',
@@ -200,21 +200,20 @@ describe('createApiDocsHandler Endpoint', () => {
200
200
  expect(html).toContain('swagger-ui-dist');
201
201
  expect(html).toContain('SwaggerUIBundle');
202
202
  });
203
- it('renders Redoc HTML when theme is redoc', async () => {
203
+ it('falls back to scalar HTML when an unrecognized theme query is passed', async () => {
204
204
  const handler = createApiDocsHandler({
205
- title: 'Redoc Docs Test',
206
- theme: 'redoc'
205
+ title: 'Fallback Docs Test',
206
+ theme: 'scalar'
207
207
  });
208
208
  const event = createMockRequestEvent({
209
- url: 'http://localhost:5173/api/docs'
209
+ url: 'http://localhost:5173/api/docs?theme=unknown'
210
210
  });
211
211
  const response = await handler(event);
212
212
  expect(response.status).toBe(200);
213
213
  expect(response.headers.get('content-type')).toContain('text/html');
214
214
  const html = await response.text();
215
- expect(html).toContain('<title>Redoc Docs Test</title>');
216
- expect(html).toContain('redoc.standalone.js');
217
- expect(html).toContain('redoc-container');
215
+ expect(html).toContain('<title>Fallback Docs Test</title>');
216
+ expect(html).toContain('@scalar/api-reference');
218
217
  });
219
218
  it('allows overriding theme dynamically via ?theme query parameter', async () => {
220
219
  const handler = createApiDocsHandler({
@@ -107,13 +107,12 @@ export interface GenerateOpenApiOptions {
107
107
  endpoints?: EndpointMap;
108
108
  registry?: any;
109
109
  }
110
- export type ApiDocsTheme = 'scalar' | 'swagger' | 'redoc';
110
+ export type ApiDocsTheme = 'scalar' | 'swagger';
111
111
  export interface ApiDocsOptions extends GenerateOpenApiOptions {
112
112
  /**
113
113
  * Interactive UI theme:
114
- * - 'scalar' (default): Modern, dark-mode native, high contrast developer reference.
114
+ * - 'scalar' (default): Modern, dark-mode native, high contrast developer reference with interactive runner.
115
115
  * - 'swagger': Classic Swagger UI with interactive Try-It-Out console.
116
- * - 'redoc': Clean 3-panel technical documentation format.
117
116
  */
118
117
  theme?: ApiDocsTheme;
119
118
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yaxa-svelte",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "The Intuitive Svelte UI Library — Nuxt UI v4 equivalent for SvelteKit 2 & 3 & 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",