what-server 0.2.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/index.d.ts ADDED
@@ -0,0 +1,237 @@
1
+ // What Framework Server - TypeScript Definitions
2
+
3
+ import { VNode, VNodeChild, Signal } from '../core';
4
+
5
+ // --- SSR ---
6
+
7
+ /** Render VNode tree to HTML string */
8
+ export function renderToString(vnode: VNode): string;
9
+
10
+ /** Render VNode tree as async iterator for streaming */
11
+ export function renderToStream(vnode: VNode): AsyncGenerator<string>;
12
+
13
+ // --- Page Configuration ---
14
+
15
+ export interface PageConfig {
16
+ /** Rendering mode */
17
+ mode?: 'static' | 'server' | 'client' | 'hybrid';
18
+ /** Page title */
19
+ title?: string;
20
+ /** Meta tags */
21
+ meta?: Record<string, string>;
22
+ /** Page component */
23
+ component: (data?: any) => VNode;
24
+ /** Islands to hydrate */
25
+ islands?: string[];
26
+ /** Scripts to load */
27
+ scripts?: string[];
28
+ /** Stylesheets to load */
29
+ styles?: string[];
30
+ }
31
+
32
+ export function definePage(config: Partial<PageConfig>): PageConfig;
33
+
34
+ /** Generate static HTML for a page */
35
+ export function generateStaticPage(page: PageConfig, data?: any): string;
36
+
37
+ /** Mark component as server-only (no client JS) */
38
+ export function server<P>(component: (props: P) => VNode): (props: P) => VNode;
39
+
40
+ // --- Islands ---
41
+
42
+ export interface IslandOptions {
43
+ /** Hydration mode */
44
+ mode?: 'static' | 'idle' | 'visible' | 'load' | 'media' | 'action';
45
+ /** Media query for 'media' mode */
46
+ media?: string;
47
+ /** Priority (higher = hydrate first) */
48
+ priority?: number;
49
+ /** Shared stores this island uses */
50
+ stores?: string[];
51
+ }
52
+
53
+ /** Register an island component */
54
+ export function island(
55
+ name: string,
56
+ loader: () => Promise<any>,
57
+ options?: IslandOptions
58
+ ): void;
59
+
60
+ /** Island component wrapper for SSR */
61
+ export function Island(props: {
62
+ name: string;
63
+ props?: Record<string, any>;
64
+ mode?: IslandOptions['mode'];
65
+ priority?: number;
66
+ stores?: string[];
67
+ children?: VNodeChild;
68
+ }): VNode;
69
+
70
+ /** Hydrate all islands on the page */
71
+ export function hydrateIslands(): void;
72
+
73
+ /** Auto-discover and register islands */
74
+ export function autoIslands(registry: Record<string, {
75
+ loader: () => Promise<any>;
76
+ mode?: IslandOptions['mode'];
77
+ media?: string;
78
+ priority?: number;
79
+ stores?: string[];
80
+ } | (() => Promise<any>)>): void;
81
+
82
+ /** Boost hydration priority for an island */
83
+ export function boostIslandPriority(name: string, newPriority?: number): void;
84
+
85
+ // --- Shared Island State ---
86
+
87
+ export interface IslandStore<T extends Record<string, any>> {
88
+ _signals: Record<keyof T, Signal<any>>;
89
+ _subscribe: (key: keyof T, fn: (value: any) => void) => () => void;
90
+ _batch: (fn: () => void) => void;
91
+ _getSnapshot: () => T;
92
+ _hydrate: (data: Partial<T>) => void;
93
+ }
94
+
95
+ /** Create a shared store for islands */
96
+ export function createIslandStore<T extends Record<string, any>>(
97
+ name: string,
98
+ initialState: T
99
+ ): T & IslandStore<T>;
100
+
101
+ /** Get or create a shared store */
102
+ export function useIslandStore<T extends Record<string, any>>(
103
+ name: string,
104
+ fallbackInitial?: T
105
+ ): T & IslandStore<T>;
106
+
107
+ /** Serialize all shared stores for SSR */
108
+ export function serializeIslandStores(): string;
109
+
110
+ /** Hydrate shared stores from SSR data */
111
+ export function hydrateIslandStores(serialized: string | Record<string, any>): void;
112
+
113
+ // --- Progressive Enhancement ---
114
+
115
+ /** Enhance elements matching selector */
116
+ export function enhance(selector: string, handler: (el: Element) => void): void;
117
+
118
+ /** Enhance forms for AJAX submission */
119
+ export function enhanceForms(selector?: string): void;
120
+
121
+ // --- Debugging ---
122
+
123
+ export interface IslandStatus {
124
+ registered: string[];
125
+ hydrated: number;
126
+ pending: number;
127
+ queue: { name: string; priority: number }[];
128
+ stores: string[];
129
+ }
130
+
131
+ export function getIslandStatus(): IslandStatus;
132
+
133
+ // --- Server Actions ---
134
+
135
+ export interface ActionOptions {
136
+ id?: string;
137
+ onError?: (error: Error) => void;
138
+ onSuccess?: (result: any) => void;
139
+ revalidate?: string[];
140
+ }
141
+
142
+ /** Define a server action */
143
+ export function action<T extends any[], R>(
144
+ fn: (...args: T) => Promise<R>,
145
+ options?: ActionOptions
146
+ ): (...args: T) => Promise<R>;
147
+
148
+ /** Create a form action handler */
149
+ export function formAction<R>(
150
+ actionFn: (data: Record<string, any>) => Promise<R>,
151
+ options?: {
152
+ onSuccess?: (result: R, form?: HTMLFormElement) => void;
153
+ onError?: (error: Error, form?: HTMLFormElement) => void;
154
+ resetOnSuccess?: boolean;
155
+ }
156
+ ): (formDataOrEvent: FormData | Event) => Promise<R>;
157
+
158
+ // --- useAction Hook ---
159
+
160
+ export interface UseActionResult<T extends any[], R> {
161
+ trigger: (...args: T) => Promise<R>;
162
+ isPending: () => boolean;
163
+ error: () => Error | null;
164
+ data: () => R | null;
165
+ reset: () => void;
166
+ }
167
+
168
+ export function useAction<T extends any[], R>(
169
+ actionFn: (...args: T) => Promise<R>
170
+ ): UseActionResult<T, R>;
171
+
172
+ // --- useFormAction Hook ---
173
+
174
+ export interface UseFormActionResult<R> extends UseActionResult<[FormData], R> {
175
+ handleSubmit: (e: Event) => Promise<R>;
176
+ formRef: { current: HTMLFormElement | null };
177
+ }
178
+
179
+ export function useFormAction<R>(
180
+ actionFn: (data: Record<string, any>) => Promise<R>,
181
+ options?: { resetOnSuccess?: boolean }
182
+ ): UseFormActionResult<R>;
183
+
184
+ // --- Optimistic Updates ---
185
+
186
+ export interface UseOptimisticResult<T, A> {
187
+ value: () => T;
188
+ isPending: () => boolean;
189
+ addOptimistic: (action: A) => void;
190
+ resolve: (action: A) => void;
191
+ rollback: (action: A, realValue: T) => void;
192
+ set: (value: T) => void;
193
+ }
194
+
195
+ export function useOptimistic<T, A>(
196
+ initialValue: T,
197
+ reducer: (currentValue: T, action: A) => T
198
+ ): UseOptimisticResult<T, A>;
199
+
200
+ // --- Mutations ---
201
+
202
+ export interface UseMutationResult<T extends any[], R> {
203
+ mutate: (...args: T) => Promise<R>;
204
+ isPending: () => boolean;
205
+ error: () => Error | null;
206
+ data: () => R | null;
207
+ reset: () => void;
208
+ }
209
+
210
+ export function useMutation<T extends any[], R>(
211
+ mutationFn: (...args: T) => Promise<R>,
212
+ options?: {
213
+ onSuccess?: (result: R, ...args: T) => void;
214
+ onError?: (error: Error, ...args: T) => void;
215
+ onSettled?: (data: R | null, error: Error | null, ...args: T) => void;
216
+ }
217
+ ): UseMutationResult<T, R>;
218
+
219
+ // --- Revalidation ---
220
+
221
+ export function onRevalidate(path: string, callback: () => void): () => void;
222
+ export function invalidatePath(path: string): void;
223
+
224
+ // --- Server Handler ---
225
+
226
+ export interface ActionResponse {
227
+ status: number;
228
+ body: any;
229
+ }
230
+
231
+ export function handleActionRequest(
232
+ req: any,
233
+ actionId: string,
234
+ args: any[]
235
+ ): Promise<ActionResponse>;
236
+
237
+ export function getRegisteredActions(): string[];
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "what-server",
3
+ "version": "0.2.0",
4
+ "description": "What Framework - SSR, islands architecture, static generation",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "types": "index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "import": "./src/index.js"
12
+ },
13
+ "./islands": {
14
+ "import": "./src/islands.js"
15
+ },
16
+ "./actions": {
17
+ "import": "./src/actions.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "src",
22
+ "index.d.ts"
23
+ ],
24
+ "sideEffects": false,
25
+ "keywords": [
26
+ "ssr",
27
+ "islands",
28
+ "static-generation",
29
+ "server-rendering",
30
+ "what-framework"
31
+ ],
32
+ "author": "",
33
+ "license": "MIT",
34
+ "peerDependencies": {
35
+ "what-core": "^0.1.0"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/aspect/what-fw"
40
+ }
41
+ }
package/src/actions.js ADDED
@@ -0,0 +1,305 @@
1
+ // What Framework - Server Actions
2
+ // Call server-side functions from client code seamlessly.
3
+ // Similar to Next.js Server Actions / SolidStart server functions.
4
+ //
5
+ // Usage:
6
+ // // Define on server
7
+ // const saveUser = action(async (formData) => {
8
+ // 'use server';
9
+ // const user = await db.users.create(formData);
10
+ // return { success: true, id: user.id };
11
+ // });
12
+ //
13
+ // // Call from client
14
+ // const result = await saveUser({ name: 'John' });
15
+
16
+ import { signal, batch } from 'what-core';
17
+
18
+ // Registry of server actions
19
+ const actionRegistry = new Map();
20
+ let actionIdCounter = 0;
21
+
22
+ // --- Define a server action ---
23
+
24
+ export function action(fn, options = {}) {
25
+ const id = options.id || `action_${++actionIdCounter}`;
26
+ const { onError, onSuccess, revalidate } = options;
27
+
28
+ // Server-side: register the action
29
+ if (typeof window === 'undefined') {
30
+ actionRegistry.set(id, { fn, options });
31
+ }
32
+
33
+ // Create the callable wrapper
34
+ async function callAction(...args) {
35
+ // Server-side: call directly
36
+ if (typeof window === 'undefined') {
37
+ return fn(...args);
38
+ }
39
+
40
+ // Client-side: call via fetch
41
+ try {
42
+ const response = await fetch('/__what_action', {
43
+ method: 'POST',
44
+ headers: {
45
+ 'Content-Type': 'application/json',
46
+ 'X-What-Action': id,
47
+ },
48
+ body: JSON.stringify({ args }),
49
+ });
50
+
51
+ if (!response.ok) {
52
+ const error = await response.json().catch(() => ({ message: 'Action failed' }));
53
+ throw new Error(error.message || 'Action failed');
54
+ }
55
+
56
+ const result = await response.json();
57
+
58
+ if (onSuccess) onSuccess(result);
59
+ if (revalidate) {
60
+ // Trigger revalidation of specified paths
61
+ for (const path of revalidate) {
62
+ invalidatePath(path);
63
+ }
64
+ }
65
+
66
+ return result;
67
+ } catch (error) {
68
+ if (onError) onError(error);
69
+ throw error;
70
+ }
71
+ }
72
+
73
+ callAction._actionId = id;
74
+ callAction._isAction = true;
75
+
76
+ return callAction;
77
+ }
78
+
79
+ // --- Form action helper ---
80
+ // For forms that submit to server actions.
81
+
82
+ export function formAction(actionFn, options = {}) {
83
+ const { onSuccess, onError, resetOnSuccess = true } = options;
84
+
85
+ return async (formDataOrEvent) => {
86
+ let formData;
87
+ let form;
88
+
89
+ if (formDataOrEvent instanceof Event) {
90
+ formDataOrEvent.preventDefault();
91
+ form = formDataOrEvent.target;
92
+ formData = new FormData(form);
93
+ } else {
94
+ formData = formDataOrEvent;
95
+ }
96
+
97
+ // Convert FormData to plain object
98
+ const data = {};
99
+ for (const [key, value] of formData.entries()) {
100
+ if (data[key]) {
101
+ // Handle multiple values (e.g., checkboxes)
102
+ if (Array.isArray(data[key])) {
103
+ data[key].push(value);
104
+ } else {
105
+ data[key] = [data[key], value];
106
+ }
107
+ } else {
108
+ data[key] = value;
109
+ }
110
+ }
111
+
112
+ try {
113
+ const result = await actionFn(data);
114
+ if (onSuccess) onSuccess(result, form);
115
+ if (resetOnSuccess && form) form.reset();
116
+ return result;
117
+ } catch (error) {
118
+ if (onError) onError(error, form);
119
+ throw error;
120
+ }
121
+ };
122
+ }
123
+
124
+ // --- useAction hook ---
125
+ // Returns action state and trigger function.
126
+
127
+ export function useAction(actionFn) {
128
+ const isPending = signal(false);
129
+ const error = signal(null);
130
+ const data = signal(null);
131
+
132
+ async function trigger(...args) {
133
+ isPending.set(true);
134
+ error.set(null);
135
+
136
+ try {
137
+ const result = await actionFn(...args);
138
+ data.set(result);
139
+ return result;
140
+ } catch (e) {
141
+ error.set(e);
142
+ throw e;
143
+ } finally {
144
+ isPending.set(false);
145
+ }
146
+ }
147
+
148
+ return {
149
+ trigger,
150
+ isPending: () => isPending(),
151
+ error: () => error(),
152
+ data: () => data(),
153
+ reset: () => {
154
+ error.set(null);
155
+ data.set(null);
156
+ },
157
+ };
158
+ }
159
+
160
+ // --- useFormAction hook ---
161
+ // Combines useAction with form handling.
162
+
163
+ export function useFormAction(actionFn, options = {}) {
164
+ const { resetOnSuccess = true } = options;
165
+ const formRef = { current: null };
166
+ const actionState = useAction(formAction(actionFn, { resetOnSuccess }));
167
+
168
+ function handleSubmit(e) {
169
+ e.preventDefault();
170
+ const formData = new FormData(e.target);
171
+ formRef.current = e.target;
172
+ return actionState.trigger(formData);
173
+ }
174
+
175
+ return {
176
+ ...actionState,
177
+ handleSubmit,
178
+ formRef,
179
+ };
180
+ }
181
+
182
+ // --- Optimistic updates ---
183
+
184
+ export function useOptimistic(initialValue, reducer) {
185
+ const value = signal(initialValue);
186
+ const pending = signal([]);
187
+
188
+ function addOptimistic(action) {
189
+ const optimisticValue = reducer(value.peek(), action);
190
+ batch(() => {
191
+ pending.set([...pending.peek(), action]);
192
+ value.set(optimisticValue);
193
+ });
194
+ }
195
+
196
+ function resolve(action) {
197
+ pending.set(pending.peek().filter(a => a !== action));
198
+ }
199
+
200
+ function rollback(action, realValue) {
201
+ batch(() => {
202
+ pending.set(pending.peek().filter(a => a !== action));
203
+ value.set(realValue);
204
+ });
205
+ }
206
+
207
+ return {
208
+ value: () => value(),
209
+ isPending: () => pending().length > 0,
210
+ addOptimistic,
211
+ resolve,
212
+ rollback,
213
+ set: (v) => value.set(v),
214
+ };
215
+ }
216
+
217
+ // --- Path revalidation ---
218
+
219
+ const revalidationCallbacks = new Map();
220
+
221
+ export function onRevalidate(path, callback) {
222
+ if (!revalidationCallbacks.has(path)) {
223
+ revalidationCallbacks.set(path, new Set());
224
+ }
225
+ revalidationCallbacks.get(path).add(callback);
226
+
227
+ return () => {
228
+ revalidationCallbacks.get(path)?.delete(callback);
229
+ };
230
+ }
231
+
232
+ export function invalidatePath(path) {
233
+ const callbacks = revalidationCallbacks.get(path);
234
+ if (callbacks) {
235
+ for (const cb of callbacks) {
236
+ try { cb(); } catch (e) { console.error('[what] Revalidation error:', e); }
237
+ }
238
+ }
239
+ }
240
+
241
+ // --- Server-side action handler ---
242
+ // Add this to your server middleware.
243
+
244
+ export function handleActionRequest(req, actionId, args) {
245
+ const action = actionRegistry.get(actionId);
246
+ if (!action) {
247
+ return { status: 404, body: { message: `Action "${actionId}" not found` } };
248
+ }
249
+
250
+ return action.fn(...args)
251
+ .then(result => ({ status: 200, body: result }))
252
+ .catch(error => ({
253
+ status: 500,
254
+ body: { message: error.message || 'Action failed' },
255
+ }));
256
+ }
257
+
258
+ // --- Get all registered actions (for SSR/build) ---
259
+
260
+ export function getRegisteredActions() {
261
+ return [...actionRegistry.keys()];
262
+ }
263
+
264
+ // --- Mutation helper ---
265
+ // Like useSWR mutation but simpler.
266
+
267
+ export function useMutation(mutationFn, options = {}) {
268
+ const { onSuccess, onError, onSettled } = options;
269
+
270
+ const state = {
271
+ isPending: signal(false),
272
+ error: signal(null),
273
+ data: signal(null),
274
+ };
275
+
276
+ async function mutate(...args) {
277
+ state.isPending.set(true);
278
+ state.error.set(null);
279
+
280
+ try {
281
+ const result = await mutationFn(...args);
282
+ state.data.set(result);
283
+ if (onSuccess) onSuccess(result, ...args);
284
+ return result;
285
+ } catch (error) {
286
+ state.error.set(error);
287
+ if (onError) onError(error, ...args);
288
+ throw error;
289
+ } finally {
290
+ state.isPending.set(false);
291
+ if (onSettled) onSettled(state.data.peek(), state.error.peek(), ...args);
292
+ }
293
+ }
294
+
295
+ return {
296
+ mutate,
297
+ isPending: () => state.isPending(),
298
+ error: () => state.error(),
299
+ data: () => state.data(),
300
+ reset: () => {
301
+ state.error.set(null);
302
+ state.data.set(null);
303
+ },
304
+ };
305
+ }
package/src/index.js ADDED
@@ -0,0 +1,217 @@
1
+ // What Framework - Server
2
+ // SSR, static site generation, server components.
3
+ // Zero-JS pages by default. Islands opt-in to client JS.
4
+
5
+ import { h } from 'what-core';
6
+
7
+ // --- Render to String ---
8
+ // Renders a VNode tree to an HTML string. Used for SSR and static gen.
9
+
10
+ export function renderToString(vnode) {
11
+ if (vnode == null || vnode === false || vnode === true) return '';
12
+
13
+ // Text
14
+ if (typeof vnode === 'string' || typeof vnode === 'number') {
15
+ return escapeHtml(String(vnode));
16
+ }
17
+
18
+ // Array
19
+ if (Array.isArray(vnode)) {
20
+ return vnode.map(renderToString).join('');
21
+ }
22
+
23
+ // Component
24
+ if (typeof vnode.tag === 'function') {
25
+ const result = vnode.tag({ ...vnode.props, children: vnode.children });
26
+ return renderToString(result);
27
+ }
28
+
29
+ // Element
30
+ const { tag, props, children } = vnode;
31
+ const attrs = renderAttrs(props || {});
32
+ const open = `<${tag}${attrs}>`;
33
+
34
+ // Void elements
35
+ if (VOID_ELEMENTS.has(tag)) return open;
36
+
37
+ const inner = children.map(renderToString).join('');
38
+ return `${open}${inner}</${tag}>`;
39
+ }
40
+
41
+ // --- Stream Render ---
42
+ // Returns an async iterator for streaming SSR.
43
+
44
+ export async function* renderToStream(vnode) {
45
+ if (vnode == null || vnode === false || vnode === true) return;
46
+
47
+ if (typeof vnode === 'string' || typeof vnode === 'number') {
48
+ yield escapeHtml(String(vnode));
49
+ return;
50
+ }
51
+
52
+ if (Array.isArray(vnode)) {
53
+ for (const child of vnode) {
54
+ yield* renderToStream(child);
55
+ }
56
+ return;
57
+ }
58
+
59
+ if (typeof vnode.tag === 'function') {
60
+ const result = vnode.tag({ ...vnode.props, children: vnode.children });
61
+ // Support async components
62
+ const resolved = result instanceof Promise ? await result : result;
63
+ yield* renderToStream(resolved);
64
+ return;
65
+ }
66
+
67
+ const { tag, props, children } = vnode;
68
+ const attrs = renderAttrs(props || {});
69
+ yield `<${tag}${attrs}>`;
70
+
71
+ if (!VOID_ELEMENTS.has(tag)) {
72
+ for (const child of children) {
73
+ yield* renderToStream(child);
74
+ }
75
+ yield `</${tag}>`;
76
+ }
77
+ }
78
+
79
+ // --- Static Site Generation ---
80
+
81
+ export function definePage(config) {
82
+ return {
83
+ // 'static' = pre-render at build time (default)
84
+ // 'server' = render on each request
85
+ // 'client' = render in browser (SPA)
86
+ // 'hybrid' = static shell + islands
87
+ mode: 'static',
88
+ ...config,
89
+ };
90
+ }
91
+
92
+ // Generate static HTML for a page
93
+ export function generateStaticPage(page, data = {}) {
94
+ const vnode = page.component(data);
95
+ const html = renderToString(vnode);
96
+ const islands = page.islands || [];
97
+
98
+ return wrapDocument({
99
+ title: page.title || '',
100
+ meta: page.meta || {},
101
+ body: html,
102
+ islands,
103
+ scripts: page.mode === 'static' ? [] : page.scripts || [],
104
+ styles: page.styles || [],
105
+ mode: page.mode,
106
+ });
107
+ }
108
+
109
+ function wrapDocument({ title, meta, body, islands, scripts, styles, mode }) {
110
+ const metaTags = Object.entries(meta)
111
+ .map(([name, content]) => `<meta name="${name}" content="${escapeHtml(content)}">`)
112
+ .join('\n ');
113
+
114
+ const styleTags = styles
115
+ .map(href => `<link rel="stylesheet" href="${escapeHtml(href)}">`)
116
+ .join('\n ');
117
+
118
+ const islandScript = islands.length > 0 ? `
119
+ <script type="module">
120
+ import { hydrateIslands } from '/@what/islands.js';
121
+ hydrateIslands();
122
+ </script>` : '';
123
+
124
+ const scriptTags = scripts
125
+ .map(src => `<script type="module" src="${escapeHtml(src)}"></script>`)
126
+ .join('\n ');
127
+
128
+ const clientScript = mode === 'client' ? `
129
+ <script type="module" src="/@what/client.js"></script>` : '';
130
+
131
+ return `<!DOCTYPE html>
132
+ <html lang="en">
133
+ <head>
134
+ <meta charset="UTF-8">
135
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
136
+ ${metaTags}
137
+ <title>${escapeHtml(title)}</title>
138
+ ${styleTags}
139
+ </head>
140
+ <body>
141
+ <div id="app">${body}</div>
142
+ ${islandScript}
143
+ ${scriptTags}
144
+ ${clientScript}
145
+ </body>
146
+ </html>`;
147
+ }
148
+
149
+ // --- Server Component ---
150
+ // Renders on the server, sends HTML to client. No JS shipped.
151
+
152
+ export function server(Component) {
153
+ Component._server = true;
154
+ return Component;
155
+ }
156
+
157
+ // --- Helpers ---
158
+
159
+ function renderAttrs(props) {
160
+ let out = '';
161
+ for (const [key, val] of Object.entries(props)) {
162
+ if (key === 'key' || key === 'ref' || key === 'children' || key === 'dangerouslySetInnerHTML') continue;
163
+ if (key.startsWith('on') && key.length > 2) continue; // Skip event handlers in SSR
164
+ if (val === false || val == null) continue;
165
+
166
+ if (key === 'className' || key === 'class') {
167
+ out += ` class="${escapeHtml(String(val))}"`;
168
+ } else if (key === 'style' && typeof val === 'object') {
169
+ const css = Object.entries(val)
170
+ .map(([p, v]) => `${camelToKebab(p)}:${v}`)
171
+ .join(';');
172
+ out += ` style="${escapeHtml(css)}"`;
173
+ } else if (val === true) {
174
+ out += ` ${key}`;
175
+ } else {
176
+ out += ` ${key}="${escapeHtml(String(val))}"`;
177
+ }
178
+ }
179
+
180
+ // Handle dangerouslySetInnerHTML separately
181
+ if (props.dangerouslySetInnerHTML) {
182
+ // This is handled in the content rendering, not attrs
183
+ }
184
+
185
+ return out;
186
+ }
187
+
188
+ function escapeHtml(str) {
189
+ return str
190
+ .replace(/&/g, '&amp;')
191
+ .replace(/</g, '&lt;')
192
+ .replace(/>/g, '&gt;')
193
+ .replace(/"/g, '&quot;');
194
+ }
195
+
196
+ function camelToKebab(str) {
197
+ return str.replace(/([A-Z])/g, '-$1').toLowerCase();
198
+ }
199
+
200
+ const VOID_ELEMENTS = new Set([
201
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
202
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
203
+ ]);
204
+
205
+ // Re-export server actions
206
+ export {
207
+ action,
208
+ formAction,
209
+ useAction,
210
+ useFormAction,
211
+ useOptimistic,
212
+ useMutation,
213
+ onRevalidate,
214
+ invalidatePath,
215
+ handleActionRequest,
216
+ getRegisteredActions,
217
+ } from './actions.js';
package/src/islands.js ADDED
@@ -0,0 +1,399 @@
1
+ // What Framework - Islands Architecture
2
+ // Each interactive piece of the page is an "island" — a self-contained
3
+ // component that hydrates independently. The rest is static HTML.
4
+ //
5
+ // Features:
6
+ // - Multiple hydration modes (load, idle, visible, action, media, static)
7
+ // - Shared state across islands
8
+ // - Priority-based hydration queue
9
+ // - Progressive enhancement
10
+ //
11
+ // Modes:
12
+ // 'static' - No JS shipped. Pure HTML. (nav, footer, etc.)
13
+ // 'idle' - Hydrate when browser is idle (requestIdleCallback)
14
+ // 'visible' - Hydrate when scrolled into view (IntersectionObserver)
15
+ // 'load' - Hydrate immediately on page load
16
+ // 'media' - Hydrate when media query matches (e.g., mobile-only)
17
+ // 'action' - Hydrate on first user interaction (click, focus, hover)
18
+
19
+ import { mount, signal, batch } from 'what-core';
20
+
21
+ const islandRegistry = new Map();
22
+ const hydratedIslands = new Set();
23
+ const hydrationQueue = [];
24
+ let isProcessingQueue = false;
25
+
26
+ // --- Shared Island State ---
27
+ // Global reactive store that persists across islands and page navigations
28
+
29
+ const sharedStores = new Map();
30
+
31
+ export function createIslandStore(name, initialState) {
32
+ if (sharedStores.has(name)) {
33
+ return sharedStores.get(name);
34
+ }
35
+
36
+ const store = {};
37
+ const signals = {};
38
+
39
+ // Create signals for each key in initial state
40
+ for (const [key, value] of Object.entries(initialState)) {
41
+ signals[key] = signal(value);
42
+ Object.defineProperty(store, key, {
43
+ get: () => signals[key](),
44
+ set: (val) => signals[key].set(val),
45
+ enumerable: true,
46
+ });
47
+ }
48
+
49
+ // Methods to interact with store
50
+ store._signals = signals;
51
+ store._subscribe = (key, fn) => {
52
+ if (signals[key]) {
53
+ return signals[key].subscribe(fn);
54
+ }
55
+ };
56
+ store._batch = (fn) => batch(fn);
57
+ store._getSnapshot = () => {
58
+ const snapshot = {};
59
+ for (const [key, sig] of Object.entries(signals)) {
60
+ snapshot[key] = sig.peek();
61
+ }
62
+ return snapshot;
63
+ };
64
+ store._hydrate = (data) => {
65
+ batch(() => {
66
+ for (const [key, value] of Object.entries(data)) {
67
+ if (signals[key]) {
68
+ signals[key].set(value);
69
+ }
70
+ }
71
+ });
72
+ };
73
+
74
+ sharedStores.set(name, store);
75
+ return store;
76
+ }
77
+
78
+ // Get or create a shared store
79
+ export function useIslandStore(name, fallbackInitial = {}) {
80
+ if (sharedStores.has(name)) {
81
+ return sharedStores.get(name);
82
+ }
83
+ return createIslandStore(name, fallbackInitial);
84
+ }
85
+
86
+ // Serialize all shared stores for SSR
87
+ export function serializeIslandStores() {
88
+ const data = {};
89
+ for (const [name, store] of sharedStores) {
90
+ data[name] = store._getSnapshot();
91
+ }
92
+ return JSON.stringify(data);
93
+ }
94
+
95
+ // Hydrate shared stores from SSR data
96
+ export function hydrateIslandStores(serialized) {
97
+ try {
98
+ const data = typeof serialized === 'string' ? JSON.parse(serialized) : serialized;
99
+ for (const [name, storeData] of Object.entries(data)) {
100
+ const store = useIslandStore(name, storeData);
101
+ store._hydrate(storeData);
102
+ }
103
+ } catch (e) {
104
+ console.warn('[what] Failed to hydrate island stores:', e);
105
+ }
106
+ }
107
+
108
+ // --- Register an island component ---
109
+
110
+ export function island(name, loader, opts = {}) {
111
+ islandRegistry.set(name, {
112
+ loader, // () => import('./MyComponent.js')
113
+ mode: opts.mode || 'idle',
114
+ media: opts.media || null,
115
+ priority: opts.priority || 0, // Higher = hydrate first
116
+ stores: opts.stores || [], // Shared stores this island uses
117
+ });
118
+ }
119
+
120
+ // --- Island wrapper for SSR ---
121
+ // Renders the static HTML with a marker the client can find.
122
+
123
+ export function Island({ name, props = {}, children, mode, priority, stores }) {
124
+ const entry = islandRegistry.get(name);
125
+ const resolvedMode = mode || entry?.mode || 'idle';
126
+ const resolvedPriority = priority ?? entry?.priority ?? 0;
127
+ const resolvedStores = stores || entry?.stores || [];
128
+
129
+ // Server: render as a div with data attributes for hydration
130
+ return {
131
+ tag: 'div',
132
+ props: {
133
+ 'data-island': name,
134
+ 'data-island-mode': resolvedMode,
135
+ 'data-island-props': JSON.stringify(props),
136
+ 'data-island-priority': resolvedPriority,
137
+ 'data-island-stores': JSON.stringify(resolvedStores),
138
+ },
139
+ children: children || [],
140
+ key: null,
141
+ _vnode: true,
142
+ };
143
+ }
144
+
145
+ // --- Priority Hydration Queue ---
146
+
147
+ function enqueueHydration(task) {
148
+ // Insert in priority order (higher priority first)
149
+ let inserted = false;
150
+ for (let i = 0; i < hydrationQueue.length; i++) {
151
+ if (task.priority > hydrationQueue[i].priority) {
152
+ hydrationQueue.splice(i, 0, task);
153
+ inserted = true;
154
+ break;
155
+ }
156
+ }
157
+ if (!inserted) {
158
+ hydrationQueue.push(task);
159
+ }
160
+
161
+ processQueue();
162
+ }
163
+
164
+ function processQueue() {
165
+ if (isProcessingQueue || hydrationQueue.length === 0) return;
166
+ isProcessingQueue = true;
167
+
168
+ // Process one task at a time to avoid blocking
169
+ const task = hydrationQueue.shift();
170
+
171
+ Promise.resolve(task.hydrate())
172
+ .catch(e => console.error('[what] Island hydration failed:', task.name, e))
173
+ .finally(() => {
174
+ isProcessingQueue = false;
175
+ // Continue processing after a microtask
176
+ queueMicrotask(processQueue);
177
+ });
178
+ }
179
+
180
+ // Boost priority for an island (e.g., on user interaction)
181
+ export function boostIslandPriority(name, newPriority = 100) {
182
+ for (const task of hydrationQueue) {
183
+ if (task.name === name) {
184
+ task.priority = newPriority;
185
+ // Re-sort queue
186
+ hydrationQueue.sort((a, b) => b.priority - a.priority);
187
+ break;
188
+ }
189
+ }
190
+ }
191
+
192
+ // --- Client-side hydration ---
193
+
194
+ export function hydrateIslands() {
195
+ // First, hydrate any shared stores from the page
196
+ const storeScript = document.querySelector('script[data-island-stores]');
197
+ if (storeScript) {
198
+ hydrateIslandStores(storeScript.textContent);
199
+ }
200
+
201
+ const islands = document.querySelectorAll('[data-island]');
202
+
203
+ for (const el of islands) {
204
+ const name = el.dataset.island;
205
+ const mode = el.dataset.islandMode || 'idle';
206
+ const props = JSON.parse(el.dataset.islandProps || '{}');
207
+ const priority = parseInt(el.dataset.islandPriority || '0', 10);
208
+ const stores = JSON.parse(el.dataset.islandStores || '[]');
209
+ const entry = islandRegistry.get(name);
210
+
211
+ if (!entry) {
212
+ console.warn(`[what] Island "${name}" not registered`);
213
+ continue;
214
+ }
215
+
216
+ // Skip if already hydrated
217
+ if (hydratedIslands.has(el)) continue;
218
+
219
+ scheduleHydration(el, entry, props, mode, priority, name, stores);
220
+ }
221
+ }
222
+
223
+ function scheduleHydration(el, entry, props, mode, priority, name, stores) {
224
+ const hydrate = async () => {
225
+ if (hydratedIslands.has(el)) return;
226
+ hydratedIslands.add(el);
227
+
228
+ const mod = await entry.loader();
229
+ const Component = mod.default || mod;
230
+
231
+ // Inject shared stores into props
232
+ const storeProps = {};
233
+ for (const storeName of stores) {
234
+ storeProps[storeName] = useIslandStore(storeName);
235
+ }
236
+
237
+ mount(Component({ ...props, ...storeProps }), el);
238
+
239
+ // Clean up data attributes
240
+ el.removeAttribute('data-island');
241
+ el.removeAttribute('data-island-mode');
242
+ el.removeAttribute('data-island-props');
243
+ el.removeAttribute('data-island-priority');
244
+ el.removeAttribute('data-island-stores');
245
+
246
+ // Dispatch event for analytics/debugging
247
+ el.dispatchEvent(new CustomEvent('island:hydrated', {
248
+ bubbles: true,
249
+ detail: { name, mode },
250
+ }));
251
+ };
252
+
253
+ switch (mode) {
254
+ case 'load':
255
+ // Immediate hydration via queue (respects priority)
256
+ enqueueHydration({ name, priority: priority + 1000, hydrate });
257
+ break;
258
+
259
+ case 'idle':
260
+ if ('requestIdleCallback' in window) {
261
+ requestIdleCallback(() => {
262
+ enqueueHydration({ name, priority, hydrate });
263
+ });
264
+ } else {
265
+ setTimeout(() => {
266
+ enqueueHydration({ name, priority, hydrate });
267
+ }, 200);
268
+ }
269
+ break;
270
+
271
+ case 'visible': {
272
+ const observer = new IntersectionObserver((entries, obs) => {
273
+ for (const entry of entries) {
274
+ if (entry.isIntersecting) {
275
+ obs.disconnect();
276
+ enqueueHydration({ name, priority, hydrate });
277
+ break;
278
+ }
279
+ }
280
+ }, { rootMargin: '200px' });
281
+ observer.observe(el);
282
+ break;
283
+ }
284
+
285
+ case 'media': {
286
+ const mq = window.matchMedia(entry.media || '(max-width: 768px)');
287
+ if (mq.matches) {
288
+ enqueueHydration({ name, priority, hydrate });
289
+ } else {
290
+ mq.addEventListener('change', (e) => {
291
+ if (e.matches) {
292
+ enqueueHydration({ name, priority, hydrate });
293
+ }
294
+ }, { once: true });
295
+ }
296
+ break;
297
+ }
298
+
299
+ case 'action': {
300
+ const events = ['click', 'focus', 'mouseover', 'touchstart'];
301
+ const handler = () => {
302
+ events.forEach(e => el.removeEventListener(e, handler));
303
+ // Boost priority since user interacted
304
+ enqueueHydration({ name, priority: priority + 500, hydrate });
305
+ };
306
+ events.forEach(e => el.addEventListener(e, handler, { once: true, passive: true }));
307
+ break;
308
+ }
309
+
310
+ case 'static':
311
+ // Never hydrate
312
+ break;
313
+
314
+ default:
315
+ enqueueHydration({ name, priority, hydrate });
316
+ }
317
+ }
318
+
319
+ // --- Auto-discover islands from data attributes ---
320
+ // Call this once on the client to set up all islands.
321
+
322
+ export function autoIslands(registry) {
323
+ for (const [name, config] of Object.entries(registry)) {
324
+ island(name, config.loader || config, {
325
+ mode: config.mode || 'idle',
326
+ media: config.media,
327
+ priority: config.priority || 0,
328
+ stores: config.stores || [],
329
+ });
330
+ }
331
+
332
+ if (typeof document !== 'undefined') {
333
+ if (document.readyState === 'loading') {
334
+ document.addEventListener('DOMContentLoaded', hydrateIslands);
335
+ } else {
336
+ hydrateIslands();
337
+ }
338
+ }
339
+ }
340
+
341
+ // --- Progressive Enhancement Helpers ---
342
+
343
+ // Mark an element as progressively enhanced
344
+ export function enhance(selector, handler) {
345
+ if (typeof document === 'undefined') return;
346
+
347
+ const elements = document.querySelectorAll(selector);
348
+ for (const el of elements) {
349
+ if (el.dataset.enhanced) continue;
350
+ el.dataset.enhanced = 'true';
351
+ handler(el);
352
+ }
353
+ }
354
+
355
+ // Form enhancement: submit via fetch instead of page reload
356
+ export function enhanceForms(selector = 'form[data-enhance]') {
357
+ enhance(selector, (form) => {
358
+ form.addEventListener('submit', async (e) => {
359
+ e.preventDefault();
360
+
361
+ const formData = new FormData(form);
362
+ const method = form.method.toUpperCase() || 'POST';
363
+ const action = form.action || location.href;
364
+
365
+ try {
366
+ const response = await fetch(action, {
367
+ method,
368
+ body: method === 'GET' ? undefined : formData,
369
+ headers: {
370
+ 'X-Requested-With': 'XMLHttpRequest',
371
+ },
372
+ });
373
+
374
+ form.dispatchEvent(new CustomEvent('form:response', {
375
+ bubbles: true,
376
+ detail: { response, ok: response.ok },
377
+ }));
378
+ } catch (error) {
379
+ form.dispatchEvent(new CustomEvent('form:error', {
380
+ bubbles: true,
381
+ detail: { error },
382
+ }));
383
+ }
384
+ });
385
+ });
386
+ }
387
+
388
+ // --- Debugging ---
389
+
390
+ export function getIslandStatus() {
391
+ const status = {
392
+ registered: [...islandRegistry.keys()],
393
+ hydrated: hydratedIslands.size,
394
+ pending: hydrationQueue.length,
395
+ queue: hydrationQueue.map(t => ({ name: t.name, priority: t.priority })),
396
+ stores: [...sharedStores.keys()],
397
+ };
398
+ return status;
399
+ }