@ozsarman/clarityjs 0.6.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.
@@ -0,0 +1,439 @@
1
+ /**
2
+ * Clarity.js — Server Actions
3
+ *
4
+ * Type-safe client→server RPC over fetch.
5
+ * Similar to Next.js "use server" / React Server Actions, but framework-agnostic:
6
+ * any Node.js / edge runtime can host the action endpoint.
7
+ *
8
+ * ─── Server side (Node / Bun / Deno / edge) ──────────────────────────────────
9
+ *
10
+ * // actions/user.js — mark with "use server" or defineServerAction()
11
+ * import { defineServerAction } from '@ozsarman/clarityjs/server-actions';
12
+ *
13
+ * export const createUser = defineServerAction('user/create', async ({ name, email }) => {
14
+ * const user = await db.users.create({ name, email });
15
+ * return { user };
16
+ * });
17
+ *
18
+ * export const deleteUser = defineServerAction('user/delete', async ({ id }) => {
19
+ * await db.users.delete(id);
20
+ * return { ok: true };
21
+ * });
22
+ *
23
+ * ─── Server router integration ────────────────────────────────────────────────
24
+ *
25
+ * import { createActionRouter } from '@ozsarman/clarityjs/server-actions';
26
+ * import * as userActions from './actions/user.js';
27
+ *
28
+ * const router = createActionRouter({ ...userActions });
29
+ *
30
+ * // Express:
31
+ * app.post('/_clarity/actions/:name', router.express());
32
+ *
33
+ * // Fetch handler (edge / Bun / Deno):
34
+ * export default { fetch: router.fetch() };
35
+ *
36
+ * ─── Client side (.clarity component) ───────────────────────────────────────
37
+ *
38
+ * import { createServerClient, useServerAction } from '@ozsarman/clarityjs/server-actions';
39
+ *
40
+ * // Create once at app root (reads CLARITY_SERVER_URL env or window.__CLARITY_SERVER__)
41
+ * const server = createServerClient();
42
+ *
43
+ * // In a component:
44
+ * const { execute, loading, error, data } = useServerAction(server, 'user/create');
45
+ *
46
+ * // Call it:
47
+ * await execute({ name: 'Özdemir', email: 'oz@example.com' });
48
+ * // data.get() → { user: { id: 1, name: 'Özdemir', ... } }
49
+ *
50
+ * ─── Optimistic updates ───────────────────────────────────────────────────────
51
+ *
52
+ * const { execute } = useServerAction(server, 'todo/add', {
53
+ * optimistic: (args, currentData) => [...currentData, { text: args.text, pending: true }],
54
+ * });
55
+ *
56
+ * Author: Claude (Anthropic) + Özdemir Sarman
57
+ */
58
+
59
+ import { signal } from './runtime.js';
60
+
61
+ // ─── Server-side: action registry ────────────────────────────────────────────
62
+
63
+ const _actionRegistry = new Map(); // name → handler fn
64
+
65
+ /**
66
+ * Define a server action — registers it in the global registry with a unique name.
67
+ *
68
+ * Returns the original handler so it can also be called locally (server-to-server).
69
+ *
70
+ * @template I, O
71
+ * @param {string} name — unique action identifier (e.g. 'user/create')
72
+ * @param {(input: I) => Promise<O>} handler — async server function
73
+ * @returns {(input: I) => Promise<O>} — the handler itself (callable directly on server)
74
+ */
75
+ export function defineServerAction(name, handler) {
76
+ if (typeof name !== 'string' || !name.trim()) {
77
+ throw new TypeError('[Server Actions] name must be a non-empty string');
78
+ }
79
+ if (typeof handler !== 'function') {
80
+ throw new TypeError(`[Server Actions] handler for "${name}" must be a function`);
81
+ }
82
+
83
+ _actionRegistry.set(name, handler);
84
+
85
+ // Attach metadata so createActionRouter() can discover it
86
+ const fn = async (input) => handler(input);
87
+ fn.__serverAction__ = name;
88
+ fn.__handler__ = handler;
89
+ return fn;
90
+ }
91
+
92
+ /**
93
+ * List all registered server actions.
94
+ * @returns {string[]}
95
+ */
96
+ export function listServerActions() {
97
+ return [..._actionRegistry.keys()];
98
+ }
99
+
100
+ // ─── Server-side: action router ───────────────────────────────────────────────
101
+
102
+ /**
103
+ * Create an HTTP router that dispatches POST requests to registered actions.
104
+ *
105
+ * Endpoint convention: POST /_clarity/actions/:name
106
+ * Request body: JSON { input: {...} }
107
+ * Response body: JSON { data: {...} } or { error: 'message' }
108
+ *
109
+ * @param {...object} actionModules — spread of { [fnName]: serverActionFn } exports
110
+ * @returns {{ express(): Function, fetch(): Function, handle(name, input): Promise }}
111
+ */
112
+ export function createActionRouter(...actionModules) {
113
+ // Register all passed actions
114
+ for (const mod of actionModules) {
115
+ for (const [, fn] of Object.entries(mod ?? {})) {
116
+ if (fn?.__serverAction__) {
117
+ _actionRegistry.set(fn.__serverAction__, fn.__handler__);
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Dispatch an action by name with given input.
124
+ * @param {string} name
125
+ * @param {*} input
126
+ */
127
+ async function handle(name, input) {
128
+ const handler = _actionRegistry.get(name);
129
+ if (!handler) {
130
+ const err = new Error(`Unknown server action: "${name}"`);
131
+ err.status = 404;
132
+ throw err;
133
+ }
134
+ return handler(input);
135
+ }
136
+
137
+ /**
138
+ * Express/Fastify-compatible middleware.
139
+ * Mount at: app.post('/_clarity/actions/:name', router.express())
140
+ */
141
+ function express() {
142
+ return async function clarityActionMiddleware(req, res) {
143
+ const name = req.params?.name ?? req.url?.split('/').pop();
144
+ let input;
145
+ try {
146
+ input = req.body?.input ?? req.body ?? {};
147
+ } catch {
148
+ input = {};
149
+ }
150
+
151
+ try {
152
+ const data = await handle(name, input);
153
+ res.json({ data });
154
+ } catch (err) {
155
+ const status = err.status ?? 500;
156
+ res.status(status).json({ error: err.message ?? 'Internal server error' });
157
+ }
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Fetch-based handler — works in Bun, Deno, Cloudflare Workers, Vercel Edge.
163
+ * Mount as: export default { fetch: router.fetch() }
164
+ *
165
+ * Matches: POST /_clarity/actions/:name
166
+ */
167
+ function fetch() {
168
+ return async function clarityActionFetch(request) {
169
+ const url = new URL(request.url);
170
+
171
+ if (request.method !== 'POST') {
172
+ return new Response('Method Not Allowed', { status: 405 });
173
+ }
174
+
175
+ const prefix = '/_clarity/actions/';
176
+ if (!url.pathname.startsWith(prefix)) {
177
+ return new Response('Not Found', { status: 404 });
178
+ }
179
+
180
+ const name = url.pathname.slice(prefix.length);
181
+ let input = {};
182
+ try {
183
+ const body = await request.json();
184
+ input = body?.input ?? body ?? {};
185
+ } catch { /* empty body */ }
186
+
187
+ try {
188
+ const data = await handle(name, input);
189
+ return Response.json({ data });
190
+ } catch (err) {
191
+ const status = err.status ?? 500;
192
+ return Response.json({ error: err.message ?? 'Internal server error' }, { status });
193
+ }
194
+ };
195
+ }
196
+
197
+ return { handle, express, fetch };
198
+ }
199
+
200
+ // ─── Client-side: server client ───────────────────────────────────────────────
201
+
202
+ /**
203
+ * Create a typed client for calling server actions over HTTP.
204
+ *
205
+ * Reads the base URL from (in priority order):
206
+ * 1. opts.baseUrl
207
+ * 2. globalThis.__CLARITY_SERVER__
208
+ * 3. import.meta.env.VITE_CLARITY_SERVER_URL
209
+ * 4. '' (same origin — default for most setups)
210
+ *
211
+ * @param {object} [opts]
212
+ * @param {string} [opts.baseUrl] — e.g. 'https://api.mysite.com'
213
+ * @param {string} [opts.prefix] — action endpoint prefix (default: '/_clarity/actions')
214
+ * @param {object} [opts.headers] — extra headers (e.g. auth token)
215
+ * @param {Function}[opts.onError] — global error hook (err) => void
216
+ * @returns {ServerClient}
217
+ */
218
+ export function createServerClient({
219
+ baseUrl = '',
220
+ prefix = '/_clarity/actions',
221
+ headers = {},
222
+ onError = null,
223
+ } = {}) {
224
+ // Auto-detect base URL
225
+ const _base = baseUrl ||
226
+ (typeof globalThis !== 'undefined' ? globalThis.__CLARITY_SERVER__ : '') ||
227
+ (typeof import.meta !== 'undefined' ? (import.meta.env?.VITE_CLARITY_SERVER_URL ?? '') : '') ||
228
+ '';
229
+
230
+ /**
231
+ * Call a server action by name.
232
+ *
233
+ * @param {string} name — action name (e.g. 'user/create')
234
+ * @param {*} input — JSON-serializable input
235
+ * @returns {Promise<*>} — resolved action output
236
+ */
237
+ async function call(name, input = {}) {
238
+ const url = `${_base}${prefix}/${encodeURIComponent(name)}`;
239
+ const res = await globalThis.fetch(url, {
240
+ method: 'POST',
241
+ headers: { 'Content-Type': 'application/json', ...headers },
242
+ body: JSON.stringify({ input }),
243
+ });
244
+
245
+ const json = await res.json();
246
+
247
+ if (!res.ok || json.error) {
248
+ const err = new Error(json.error ?? `Server action "${name}" failed (${res.status})`);
249
+ err.status = res.status;
250
+ if (typeof onError === 'function') onError(err);
251
+ throw err;
252
+ }
253
+
254
+ return json.data;
255
+ }
256
+
257
+ return { call, baseUrl: _base, prefix };
258
+ }
259
+
260
+ // ─── Client-side: useServerAction hook ───────────────────────────────────────
261
+
262
+ /**
263
+ * Reactive hook for calling a server action.
264
+ *
265
+ * Returns reactive signals for loading, error, and data states.
266
+ * Supports optimistic updates via the `optimistic` option.
267
+ *
268
+ * @template I, O
269
+ * @param {ServerClient} client — from createServerClient()
270
+ * @param {string} name — server action name
271
+ * @param {object} [opts]
272
+ * @param {O} [opts.initialData] — initial value for data signal
273
+ * @param {Function} [opts.optimistic] — (input, currentData) => O — optimistic updater
274
+ * @param {Function} [opts.onSuccess] — (data: O) => void
275
+ * @param {Function} [opts.onError] — (err: Error) => void
276
+ *
277
+ * @returns {{
278
+ * execute: (input?: I) => Promise<O>,
279
+ * loading: Signal<boolean>,
280
+ * error: Signal<Error|null>,
281
+ * data: Signal<O|null>,
282
+ * reset: () => void,
283
+ * }}
284
+ */
285
+ export function useServerAction(client, name, {
286
+ initialData = null,
287
+ optimistic = null,
288
+ onSuccess = null,
289
+ onError = null,
290
+ } = {}) {
291
+ const loading = signal(false);
292
+ const error = signal(null);
293
+ const data = signal(initialData);
294
+
295
+ // Snapshot of data before optimistic update (for rollback)
296
+ let _prevData = initialData;
297
+
298
+ async function execute(input = {}) {
299
+ loading.set(true);
300
+ error.set(null);
301
+
302
+ // Optimistic update
303
+ if (typeof optimistic === 'function') {
304
+ _prevData = data.get();
305
+ data.set(optimistic(input, _prevData));
306
+ }
307
+
308
+ try {
309
+ const result = await client.call(name, input);
310
+ data.set(result);
311
+ if (typeof onSuccess === 'function') onSuccess(result);
312
+ return result;
313
+ } catch (err) {
314
+ // Rollback optimistic update
315
+ if (typeof optimistic === 'function') {
316
+ data.set(_prevData);
317
+ }
318
+ error.set(err);
319
+ if (typeof onError === 'function') onError(err);
320
+ throw err;
321
+ } finally {
322
+ loading.set(false);
323
+ }
324
+ }
325
+
326
+ function reset() {
327
+ loading.set(false);
328
+ error.set(null);
329
+ data.set(initialData);
330
+ _prevData = initialData;
331
+ }
332
+
333
+ return { execute, loading, error, data, reset };
334
+ }
335
+
336
+ // ─── "use server" directive support ──────────────────────────────────────────
337
+
338
+ /**
339
+ * Auto-register all exported server actions from a module that uses
340
+ * the "use server" convention.
341
+ *
342
+ * In a Clarity + Vite pipeline the compiler can insert this call automatically
343
+ * for any module whose first line is `'use server'`.
344
+ *
345
+ * @param {object} mod — ES module namespace object
346
+ */
347
+ export function registerServerModule(mod) {
348
+ for (const [, fn] of Object.entries(mod ?? {})) {
349
+ if (typeof fn === 'function' && fn.__serverAction__) {
350
+ _actionRegistry.set(fn.__serverAction__, fn.__handler__);
351
+ }
352
+ }
353
+ }
354
+
355
+ // ─── Middleware helpers ───────────────────────────────────────────────────────
356
+
357
+ /**
358
+ * Compose multiple server action middleware functions.
359
+ * Each middleware receives (name, input, next) and must call next().
360
+ *
361
+ * @param {...Function} middlewares
362
+ * @returns {Function} composed handler: (name, input) => Promise<*>
363
+ *
364
+ * @example
365
+ * const handler = composeActionMiddleware(
366
+ * authMiddleware,
367
+ * logMiddleware,
368
+ * );
369
+ * app.post('/_clarity/actions/:name', async (req, res) => {
370
+ * const data = await handler(req.params.name, req.body.input);
371
+ * res.json({ data });
372
+ * });
373
+ */
374
+ export function composeActionMiddleware(...middlewares) {
375
+ return async function dispatch(name, input) {
376
+ let idx = -1;
377
+
378
+ async function next(i) {
379
+ if (i <= idx) throw new Error('[Server Actions] next() called multiple times');
380
+ idx = i;
381
+
382
+ if (i < middlewares.length) {
383
+ return middlewares[i](name, input, () => next(i + 1));
384
+ }
385
+
386
+ // Core dispatch
387
+ const handler = _actionRegistry.get(name);
388
+ if (!handler) {
389
+ const err = new Error(`Unknown server action: "${name}"`);
390
+ err.status = 404;
391
+ throw err;
392
+ }
393
+ return handler(input);
394
+ }
395
+
396
+ return next(0);
397
+ };
398
+ }
399
+
400
+ /**
401
+ * Built-in logging middleware — logs action name, input size, duration, and result.
402
+ *
403
+ * @param {string} name
404
+ * @param {*} input
405
+ * @param {Function} next
406
+ */
407
+ export async function logActionMiddleware(name, input, next) {
408
+ const t0 = Date.now();
409
+ try {
410
+ const result = await next();
411
+ const ms = Date.now() - t0;
412
+ console.log(`[Server Action] ${name} — ${ms}ms ✓`);
413
+ return result;
414
+ } catch (err) {
415
+ const ms = Date.now() - t0;
416
+ console.error(`[Server Action] ${name} — ${ms}ms ✗ ${err.message}`);
417
+ throw err;
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Built-in auth middleware — validates a Bearer token in headers.
423
+ * Inject into composeActionMiddleware() before your action logic.
424
+ *
425
+ * @param {Function} getToken — (name, input) => string | null — extract token from context
426
+ * @param {Function} verify — (token) => boolean | Promise<boolean> — validate token
427
+ */
428
+ export function createAuthMiddleware(getToken, verify) {
429
+ return async function authMiddleware(name, input, next) {
430
+ const token = typeof getToken === 'function' ? getToken(name, input) : null;
431
+ const valid = token ? await verify(token) : false;
432
+ if (!valid) {
433
+ const err = new Error('Unauthorized');
434
+ err.status = 401;
435
+ throw err;
436
+ }
437
+ return next();
438
+ };
439
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Clarity.js — Component-level SSR data fetching (`useServerData`)
3
+ *
4
+ * The companion to the file router's `loader()` export: a hook any component
5
+ * can call to fetch data on the server, have it serialized into the HTML, and
6
+ * hydrate on the client WITHOUT re-fetching. On client-side navigation (no
7
+ * server pass) it falls back to fetching in the browser, like SWR/createQuery.
8
+ *
9
+ * import { useServerData } from '@ozsarman/clarityjs/server-data';
10
+ *
11
+ * component Posts() {
12
+ * const posts = useServerData('posts', () => fetch('/api/posts').then(r => r.json()));
13
+ * // posts.data() · posts.loading() · posts.error() · posts.refetch()
14
+ * }
15
+ *
16
+ * Render side (Express / edge):
17
+ *
18
+ * import { renderWithServerData } from '@ozsarman/clarityjs/server-data';
19
+ * const { html, state } = await renderWithServerData(Posts, props, ctx);
20
+ * // embed state into window.__CLARITY_DATA__ (renderToDocumentWithData does this)
21
+ *
22
+ * Author: Claude (Anthropic) + Özdemir Sarman
23
+ */
24
+
25
+ import { signal } from './runtime.js';
26
+ import { renderToString } from './ssr.js';
27
+
28
+ // ─── SSR collection context (null in the browser) ─────────────────────────────
29
+ // During a server render, `_ctx` holds:
30
+ // data — resolved key → value map (populated on the 2nd pass)
31
+ // pending — [{ key, fetcher }] collected on the 1st pass
32
+ // keys — every key requested (for diagnostics)
33
+ let _ctx = null;
34
+
35
+ /** @internal — begin an SSR data collection scope. */
36
+ export function _beginServerData(data = {}) {
37
+ _ctx = { data, pending: [], keys: new Set() };
38
+ return _ctx;
39
+ }
40
+
41
+ /** @internal — end the current scope and return it. */
42
+ export function _endServerData() {
43
+ const ctx = _ctx;
44
+ _ctx = null;
45
+ return ctx;
46
+ }
47
+
48
+ /** @internal — are we currently collecting on the server? */
49
+ export function _isCollectingServerData() {
50
+ return _ctx !== null;
51
+ }
52
+
53
+ // ─── Hydration read (client) ──────────────────────────────────────────────────
54
+ const SERVER_DATA_KEY = '__serverData';
55
+
56
+ function _readHydrated(key) {
57
+ if (typeof window === 'undefined') return undefined;
58
+ const all = window.__CLARITY_DATA__;
59
+ const bag = all && all[SERVER_DATA_KEY];
60
+ return bag && Object.prototype.hasOwnProperty.call(bag, key) ? bag[key] : undefined;
61
+ }
62
+
63
+ // ─── Result shape ─────────────────────────────────────────────────────────────
64
+ function _result(dataSig, loadingSig, errorSig, fetcher) {
65
+ const refetch = () => {
66
+ if (typeof fetcher !== 'function') return Promise.resolve(dataSig.get());
67
+ loadingSig.set(true);
68
+ errorSig.set(null);
69
+ return Promise.resolve()
70
+ .then(() => fetcher())
71
+ .then(d => { dataSig.set(d); loadingSig.set(false); return d; })
72
+ .catch(e => { errorSig.set(e); loadingSig.set(false); throw e; });
73
+ };
74
+
75
+ return {
76
+ // Signal-style accessors (call to read, reactive inside effects/render)
77
+ data: Object.assign(() => dataSig.get(), dataSig),
78
+ loading: Object.assign(() => loadingSig.get(), loadingSig),
79
+ error: Object.assign(() => errorSig.get(), errorSig),
80
+ refetch,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Fetch data on the server, serialize it, and hydrate it on the client.
86
+ *
87
+ * @param {string} key Stable key used to serialize + look up the data
88
+ * @param {Function} fetcher () => value | Promise<value> (receives the server ctx on the server)
89
+ * @param {object} [options]
90
+ * @param {*} [options.initialData] Seed value before the first fetch resolves
91
+ * @returns {{ data, loading, error, refetch }}
92
+ */
93
+ export function useServerData(key, fetcher, options = {}) {
94
+ const initial = options.initialData;
95
+
96
+ // ── Server path — we are inside renderWithServerData ──────────────────────
97
+ if (_ctx) {
98
+ _ctx.keys.add(key);
99
+ if (Object.prototype.hasOwnProperty.call(_ctx.data, key)) {
100
+ // 2nd pass: data resolved → seed synchronously.
101
+ return _result(signal(_ctx.data[key]), signal(false), signal(null), fetcher);
102
+ }
103
+ // 1st pass: register the fetcher to be awaited, render with the seed value.
104
+ _ctx.pending.push({ key, fetcher });
105
+ return _result(signal(initial), signal(true), signal(null), fetcher);
106
+ }
107
+
108
+ // ── Client path ───────────────────────────────────────────────────────────
109
+ const hydrated = _readHydrated(key);
110
+ if (hydrated !== undefined) {
111
+ // Server already fetched this — adopt it, no client fetch.
112
+ return _result(signal(hydrated), signal(false), signal(null), fetcher);
113
+ }
114
+
115
+ // No server data (client navigation / pure SPA) → fetch in the browser.
116
+ const dataSig = signal(initial);
117
+ const loadingSig = signal(true);
118
+ const errorSig = signal(null);
119
+ Promise.resolve()
120
+ .then(() => (typeof fetcher === 'function' ? fetcher() : initial))
121
+ .then(d => { dataSig.set(d); loadingSig.set(false); })
122
+ .catch(e => { errorSig.set(e); loadingSig.set(false); });
123
+
124
+ return _result(dataSig, loadingSig, errorSig, fetcher);
125
+ }
126
+
127
+ // ─── Server render orchestrator ───────────────────────────────────────────────
128
+
129
+ /**
130
+ * Render a component to HTML, resolving every `useServerData()` call first.
131
+ *
132
+ * Two-pass strategy: render once to collect the requested keys + fetchers,
133
+ * await them, then render again with the resolved data. If the component uses
134
+ * no server data, the second pass is skipped. The resolved data is merged into
135
+ * `state.__serverData` so it can be embedded for client hydration.
136
+ *
137
+ * @param {Function} componentFn
138
+ * @param {object} [props={}]
139
+ * @param {object} [ctx={}] Server context forwarded to each fetcher
140
+ * @returns {Promise<{ html, state, aiContracts, serverData }>}
141
+ */
142
+ export async function renderWithServerData(componentFn, props = {}, ctx = {}) {
143
+ // Pass 1 — collect.
144
+ _beginServerData({});
145
+ let result, collected;
146
+ try {
147
+ result = renderToString(componentFn, props);
148
+ } finally {
149
+ collected = _endServerData();
150
+ }
151
+
152
+ if (!collected || collected.pending.length === 0) {
153
+ return { ...result, serverData: {} };
154
+ }
155
+
156
+ // Resolve all pending fetchers (deduped by key — last writer wins).
157
+ const data = {};
158
+ await Promise.all(
159
+ collected.pending.map(async ({ key, fetcher }) => {
160
+ data[key] = typeof fetcher === 'function' ? await fetcher(ctx) : undefined;
161
+ })
162
+ );
163
+
164
+ // Pass 2 — render with resolved data.
165
+ _beginServerData(data);
166
+ try {
167
+ result = renderToString(componentFn, props);
168
+ } finally {
169
+ _endServerData();
170
+ }
171
+
172
+ // Merge server data into the serialized state for hydration.
173
+ const state = { ...result.state, [SERVER_DATA_KEY]: data };
174
+ return { ...result, state, serverData: data };
175
+ }
176
+
177
+ /**
178
+ * Convenience: render a full HTML document with server data embedded into
179
+ * window.__CLARITY_DATA__ so client `useServerData()` calls hydrate without
180
+ * re-fetching.
181
+ *
182
+ * @param {Function} componentFn
183
+ * @param {object} [options]
184
+ * @param {object} [options.props]
185
+ * @param {object} [options.ctx]
186
+ * @param {string} [options.title]
187
+ * @param {string} [options.lang]
188
+ * @param {string} [options.clientScript]
189
+ * @param {string} [options.styles]
190
+ * @returns {Promise<string>}
191
+ */
192
+ export async function renderToDocumentWithData(componentFn, options = {}) {
193
+ const {
194
+ props = {}, ctx = {}, title = '', lang = 'en',
195
+ clientScript = null, styles = null,
196
+ } = options;
197
+
198
+ const { html, state } = await renderWithServerData(componentFn, props, ctx);
199
+
200
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => (
201
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
202
+ ));
203
+ const stateScript = Object.keys(state).length > 0
204
+ ? `<script>window.__CLARITY_DATA__=${JSON.stringify(state)};</script>`
205
+ : '';
206
+ const clientTag = clientScript ? `<script type="module" src="${esc(clientScript)}"></script>` : '';
207
+ const styleTag = styles ? `<style>${styles}</style>` : '';
208
+
209
+ return [
210
+ `<!DOCTYPE html>`,
211
+ `<html lang="${esc(lang)}">`,
212
+ `<head>`,
213
+ `<meta charset="utf-8">`,
214
+ `<meta name="viewport" content="width=device-width,initial-scale=1">`,
215
+ title ? `<title>${esc(title)}</title>` : '',
216
+ styleTag,
217
+ `</head>`,
218
+ `<body>`,
219
+ `<div id="app">${html}</div>`,
220
+ stateScript,
221
+ clientTag,
222
+ `</body>`,
223
+ `</html>`,
224
+ ].filter(Boolean).join('\n');
225
+ }