blokd 0.1.0-beta.0 → 0.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hono.ts DELETED
@@ -1,368 +0,0 @@
1
- import { Hono, type Context } from 'hono';
2
- import {
3
- html,
4
- isHttpError,
5
- json,
6
- mergeMeta,
7
- renderDocument,
8
- renderToString,
9
- type MetaDescriptor,
10
- type ResponseLike
11
- } from './server.js';
12
- import type { Component, Renderable } from './jsx-runtime.js';
13
-
14
- export type LoaderArgs<C extends Context = Context> = {
15
- request: Request;
16
- params: Record<string, string>;
17
- ctx: C;
18
- };
19
-
20
- export type ActionArgs<C extends Context = Context> = LoaderArgs<C>;
21
-
22
- export type RouteModule<C extends Context = Context> = {
23
- default?: Component<any>;
24
- loader?: (args: LoaderArgs<C>) => Promise<unknown> | unknown;
25
- action?: (args: ActionArgs<C>) => Promise<ResponseLike | Record<string, unknown>> | ResponseLike | Record<string, unknown>;
26
- meta?: (args: LoaderArgs<C> & { data: Record<string, unknown>; error?: unknown }) => MetaDescriptor | Promise<MetaDescriptor>;
27
- headers?: (args: LoaderArgs<C> & { data: Record<string, unknown>; error?: unknown }) => HeadersInit | Promise<HeadersInit>;
28
- };
29
-
30
- export type RouteEntry<C extends Context = Context> = {
31
- id: string;
32
- path: string;
33
- module: () => Promise<RouteModule<C>>;
34
- layouts?: Array<() => Promise<RouteModule<C>>>;
35
- error?: () => Promise<RouteModule<C>>;
36
- notFound?: () => Promise<RouteModule<C>>;
37
- hasClient?: boolean;
38
- };
39
-
40
- export type CreatePagesOptions<C extends Context = Context> = {
41
- routes: RouteEntry<C>[];
42
- /**
43
- * Client entry emitted into documents only when the matched route manifest has hasClient !== false.
44
- * Static routes generated by the Vite plugin omit this script automatically.
45
- */
46
- entryClient?: string;
47
- dataQueryParam?: string;
48
- onError?: (error: unknown, ctx: C) => Response | Promise<Response>;
49
- };
50
-
51
- type Match<C extends Context = Context> = {
52
- entry: RouteEntry<C>;
53
- params: Record<string, string>;
54
- };
55
-
56
- type LoadedRoute<C extends Context = Context> = {
57
- match: Match<C>;
58
- modules: RouteModule<C>[];
59
- leaf: RouteModule<C>;
60
- data: Record<string, unknown>;
61
- meta: MetaDescriptor;
62
- headers: Headers;
63
- };
64
-
65
- const actionMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
66
-
67
- export function createPages<C extends Context = Context>(options: CreatePagesOptions<C>): Hono {
68
- validateRoutes(options.routes);
69
- const app = new Hono();
70
-
71
- app.all('*', async c => {
72
- const ctx = c as C;
73
- const request = c.req.raw;
74
- const url = new URL(request.url);
75
- const match = matchRoute(options.routes, url.pathname);
76
-
77
- try {
78
- if (!match) {
79
- const response = await renderNotFound(options.routes, ctx, request);
80
- return request.method === 'HEAD' ? withoutBody(response) : response;
81
- }
82
-
83
- if (actionMethods.has(request.method)) return await handleAction(match, ctx, options);
84
- if (request.method !== 'GET' && request.method !== 'HEAD') {
85
- return new Response('Method Not Allowed', { status: 405, headers: { allow: 'GET, HEAD, POST, PUT, PATCH, DELETE' } });
86
- }
87
-
88
- const loaded = await loadRoute(match, ctx);
89
- if (isDataRequest(request, options.dataQueryParam ?? '__blokd')) {
90
- const response = json({ data: loaded.data, meta: loaded.meta }, { headers: loaded.headers });
91
- return request.method === 'HEAD' ? withoutBody(response) : response;
92
- }
93
-
94
- const response = renderDocumentForLoadedRoute(loaded, ctx, options);
95
- return request.method === 'HEAD' ? withoutBody(response) : response;
96
- } catch (error) {
97
- if (isHttpError(error)) {
98
- if (error.response.status < 400) return request.method === 'HEAD' ? withoutBody(error.response) : error.response;
99
- if (match && request.method !== 'HEAD' && !isDataRequest(request, options.dataQueryParam ?? '__blokd')) {
100
- return renderHttpBoundary(match, ctx, options, error);
101
- }
102
- return request.method === 'HEAD' ? withoutBody(error.response) : error.response;
103
- }
104
- if (options.onError) return options.onError(error, ctx);
105
- if (match && request.method !== 'HEAD' && !isDataRequest(request, options.dataQueryParam ?? '__blokd')) {
106
- return renderErrorBoundary(match, ctx, options, error, 500);
107
- }
108
- const message = typeof process !== 'undefined' && process.env?.NODE_ENV === 'production'
109
- ? 'Internal Server Error'
110
- : error instanceof Error ? error.stack ?? error.message : String(error);
111
- const response = html(`<pre>${escapeText(message)}</pre>`, { status: 500 });
112
- return request.method === 'HEAD' ? withoutBody(response) : response;
113
- }
114
- });
115
-
116
- return app;
117
- }
118
-
119
- function withoutBody(response: Response): Response {
120
- return new Response(null, { status: response.status, statusText: response.statusText, headers: response.headers });
121
- }
122
-
123
- function isDataRequest(request: Request, dataQueryParam: string): boolean {
124
- const url = new URL(request.url);
125
- if (url.searchParams.has(dataQueryParam)) return true;
126
- const accept = request.headers.get('accept') ?? '';
127
- return accept.includes('application/json') && !accept.includes('text/html');
128
- }
129
-
130
- async function handleAction<C extends Context>(match: Match<C>, ctx: C, options: CreatePagesOptions<C>): Promise<Response> {
131
- try {
132
- const leaf = await match.entry.module();
133
- if (!leaf.action) return new Response('Method Not Allowed', { status: 405, headers: { allow: 'GET, HEAD' } });
134
- const result = await leaf.action({ request: ctx.req.raw, params: match.params, ctx });
135
- return normalizeActionResult(result);
136
- } catch (error) {
137
- if (isHttpError(error)) return error.response;
138
- if (options.onError) return options.onError(error, ctx);
139
- throw error;
140
- }
141
- }
142
-
143
- function normalizeActionResult(result: ResponseLike | Record<string, unknown>): Response {
144
- if (result instanceof Response) return result;
145
- if (typeof result === 'string') return new Response(result, { headers: { 'content-type': 'text/plain; charset=utf-8' } });
146
- if (typeof result === 'number' || typeof result === 'bigint' || typeof result === 'boolean') return json(result);
147
- if (result === null || result === undefined) return new Response(null, { status: 204 });
148
- if (Array.isArray(result) || isRenderableObject(result)) return html(renderToString(result as Renderable));
149
- return json(result);
150
- }
151
-
152
- async function loadRoute<C extends Context>(match: Match<C>, ctx: C): Promise<LoadedRoute<C>> {
153
- const layoutModules = await Promise.all((match.entry.layouts ?? []).map(load => load()));
154
- const leaf = await match.entry.module();
155
- const modules = [...layoutModules, leaf];
156
- const data: Record<string, unknown> = {};
157
- const headers = new Headers();
158
- const metas: MetaDescriptor[] = [];
159
-
160
- for (const mod of modules) {
161
- if (mod.loader) {
162
- const value = await mod.loader({ request: ctx.req.raw, params: match.params, ctx });
163
- if (isPlainRecord(value)) Object.assign(data, value);
164
- else data[mod.default?.name || 'value'] = value;
165
- }
166
- }
167
-
168
- for (const mod of modules) {
169
- if (mod.meta) metas.push(await mod.meta({ request: ctx.req.raw, params: match.params, ctx, data }));
170
- if (mod.headers) appendHeaders(headers, new Headers(await mod.headers({ request: ctx.req.raw, params: match.params, ctx, data })));
171
- }
172
-
173
- return { match, modules, leaf, data, meta: mergeMeta(...metas), headers };
174
- }
175
-
176
- function renderDocumentForLoadedRoute<C extends Context>(loaded: LoadedRoute<C>, ctx: C, options: CreatePagesOptions<C>): Response {
177
- const body = renderLoadedRoute(loaded, ctx);
178
- const documentOptions: Parameters<typeof renderDocument>[0] = {
179
- body,
180
- data: { route: loaded.match.entry.id, params: loaded.match.params, data: loaded.data },
181
- meta: loaded.meta,
182
- headers: loaded.headers
183
- };
184
- if (options.entryClient !== undefined && loaded.match.entry.hasClient !== false) documentOptions.entryClient = options.entryClient;
185
- return renderDocument(documentOptions);
186
- }
187
-
188
- function renderLoadedRoute<C extends Context>(loaded: LoadedRoute<C>, ctx: C): Renderable {
189
- if (!loaded.leaf.default) return null;
190
- let child: Renderable = loaded.leaf.default({ data: loaded.data, params: loaded.match.params, ctx });
191
- for (let i = loaded.modules.length - 2; i >= 0; i--) {
192
- const layout = loaded.modules[i];
193
- if (layout?.default) child = layout.default({ children: child, data: loaded.data, params: loaded.match.params, ctx });
194
- }
195
- return child;
196
- }
197
-
198
- async function renderNotFound<C extends Context>(routes: RouteEntry<C>[], ctx: C, request: Request): Promise<Response> {
199
- const rootRoute = routes.find(item => normalizePath(item.path) === '/' && (item.notFound || item.error));
200
- const route = rootRoute ?? routes.find(item => item.notFound || item.error);
201
- if (!route) return new Response('Not Found', { status: 404 });
202
- const match: Match<C> = { entry: route, params: {} };
203
- return renderBoundary(match, ctx, request, new Error('Not Found'), 404, route.notFound ?? route.error);
204
- }
205
-
206
- function renderHttpBoundary<C extends Context>(match: Match<C>, ctx: C, options: CreatePagesOptions<C>, error: { response: Response }): Promise<Response> | Response {
207
- const boundary = error.response.status === 404 ? match.entry.notFound ?? match.entry.error : match.entry.error;
208
- if (!boundary) return error.response;
209
- return renderBoundary(match, ctx, ctx.req.raw, error, error.response.status, boundary, options.entryClient);
210
- }
211
-
212
- function renderErrorBoundary<C extends Context>(match: Match<C>, ctx: C, options: CreatePagesOptions<C>, error: unknown, status: number): Promise<Response> | Response {
213
- if (!match.entry.error) {
214
- const message = typeof process !== 'undefined' && process.env?.NODE_ENV === 'production'
215
- ? 'Internal Server Error'
216
- : error instanceof Error ? error.stack ?? error.message : String(error);
217
- return html(`<pre>${escapeText(message)}</pre>`, { status });
218
- }
219
- return renderBoundary(match, ctx, ctx.req.raw, error, status, match.entry.error, options.entryClient);
220
- }
221
-
222
- async function renderBoundary<C extends Context>(
223
- match: Match<C>,
224
- ctx: C,
225
- request: Request,
226
- error: unknown,
227
- status: number,
228
- loadBoundary?: () => Promise<RouteModule<C>>,
229
- entryClient?: string
230
- ): Promise<Response> {
231
- if (!loadBoundary) return new Response(status === 404 ? 'Not Found' : 'Internal Server Error', { status });
232
- const layoutModules = await Promise.all((match.entry.layouts ?? []).map(load => load()));
233
- const boundary = await loadBoundary();
234
- const data: Record<string, unknown> = {};
235
- const metas: MetaDescriptor[] = [];
236
- const headers = new Headers();
237
- const modules = [...layoutModules, boundary];
238
- for (const mod of modules) {
239
- if (mod.meta) metas.push(await mod.meta({ request, params: match.params, ctx, data, error }));
240
- if (mod.headers) appendHeaders(headers, new Headers(await mod.headers({ request, params: match.params, ctx, data, error })));
241
- }
242
- const body = renderBoundaryRoute(modules, { error, status, data, params: match.params, ctx });
243
- const documentOptions: Parameters<typeof renderDocument>[0] = {
244
- body,
245
- data: { route: match.entry.id, params: match.params, data, error: boundaryErrorPayload(error, status) },
246
- meta: mergeMeta(...metas),
247
- headers,
248
- status
249
- };
250
- if (entryClient !== undefined && match.entry.hasClient !== false) documentOptions.entryClient = entryClient;
251
- return renderDocument(documentOptions);
252
- }
253
-
254
- function renderBoundaryRoute<C extends Context>(modules: RouteModule<C>[], props: Record<string, unknown>): Renderable {
255
- const leaf = modules.at(-1);
256
- let child: Renderable = leaf?.default ? leaf.default(props) : null;
257
- for (let i = modules.length - 2; i >= 0; i--) {
258
- const layout = modules[i];
259
- if (layout?.default) child = layout.default({ ...props, children: child });
260
- }
261
- return child;
262
- }
263
-
264
- function boundaryErrorPayload(error: unknown, status: number): Record<string, unknown> {
265
- if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'production') return { status };
266
- if (isHttpError(error)) return { status: error.response.status, statusText: error.response.statusText };
267
- if (error instanceof Error) return { status, name: error.name, message: error.message, stack: error.stack };
268
- return { status, message: String(error) };
269
- }
270
-
271
- export function matchRoute<C extends Context>(routes: RouteEntry<C>[], pathname: string): Match<C> | null {
272
- const normalized = normalizePath(pathname);
273
- const ranked = [...routes].sort(compareRoutes);
274
- for (const entry of ranked) {
275
- const params = matchPath(entry.path, normalized);
276
- if (params) return { entry, params };
277
- }
278
- return null;
279
- }
280
-
281
- function compareRoutes<C extends Context>(a: RouteEntry<C>, b: RouteEntry<C>): number {
282
- const score = scoreRoute(b.path) - scoreRoute(a.path);
283
- if (score !== 0) return score;
284
- return b.path.length - a.path.length;
285
- }
286
-
287
- function normalizePath(pathname: string): string {
288
- if (!pathname.startsWith('/')) pathname = '/' + pathname;
289
- pathname = pathname.replace(/\/+/g, '/');
290
- if (pathname.length > 1 && pathname.endsWith('/')) pathname = pathname.slice(0, -1);
291
- return pathname;
292
- }
293
-
294
- function scoreRoute(path: string): number {
295
- return normalizePath(path).split('/').filter(Boolean).reduce((score, segment) => {
296
- if (segment.startsWith(':') && segment.endsWith('*')) return score + 1;
297
- if (segment.startsWith(':')) return score + 3;
298
- return score + 10;
299
- }, 0);
300
- }
301
-
302
- function matchPath(pattern: string, pathname: string): Record<string, string> | null {
303
- const patternSegments = normalizePath(pattern).split('/').filter(Boolean);
304
- const pathSegments = normalizePath(pathname).split('/').filter(Boolean);
305
- const params: Record<string, string> = {};
306
-
307
- for (let i = 0, j = 0; i < patternSegments.length; i++, j++) {
308
- const patternSegment = patternSegments[i]!;
309
- if (patternSegment.startsWith(':') && patternSegment.endsWith('*')) {
310
- const key = patternSegment.slice(1, -1);
311
- const decoded = safeDecode(pathSegments.slice(j).join('/'));
312
- if (decoded === null) return null;
313
- params[key] = decoded;
314
- return params;
315
- }
316
- const pathSegment = pathSegments[j];
317
- if (pathSegment === undefined) return null;
318
- if (patternSegment.startsWith(':')) {
319
- const decoded = safeDecode(pathSegment);
320
- if (decoded === null) return null;
321
- params[patternSegment.slice(1)] = decoded;
322
- continue;
323
- }
324
- if (patternSegment !== pathSegment) return null;
325
- }
326
-
327
- return patternSegments.length === pathSegments.length ? params : null;
328
- }
329
-
330
- function safeDecode(value: string): string | null {
331
- try {
332
- return decodeURIComponent(value);
333
- } catch {
334
- return null;
335
- }
336
- }
337
-
338
- function validateRoutes<C extends Context>(routes: RouteEntry<C>[]): void {
339
- const seen = new Map<string, string>();
340
- for (const route of routes) {
341
- const normalized = normalizePath(route.path);
342
- const previous = seen.get(normalized);
343
- if (previous) throw new Error(`Duplicate Blokd route path ${normalized}: ${previous} and ${route.id}`);
344
- seen.set(normalized, route.id);
345
- }
346
- }
347
-
348
- function appendHeaders(target: Headers, source: Headers): void {
349
- source.forEach((value, key) => {
350
- if (key.toLowerCase() === 'set-cookie') target.append(key, value);
351
- else target.set(key, value);
352
- });
353
- }
354
-
355
- function isPlainRecord(value: unknown): value is Record<string, unknown> {
356
- if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
357
- if (isRenderableObject(value)) return false;
358
- const proto = Object.getPrototypeOf(value);
359
- return proto === Object.prototype || proto === null;
360
- }
361
-
362
- function isRenderableObject(value: unknown): boolean {
363
- return !!value && typeof value === 'object' && 'kind' in value;
364
- }
365
-
366
- function escapeText(value: string): string {
367
- return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
368
- }
package/src/index.ts DELETED
@@ -1,7 +0,0 @@
1
- export { signal, memo, effect, cleanup, batch, untrack, root, getOwner, runWithOwner, dispose } from './core.js';
2
- export { Show, For, render, hydrate, dynamic } from './dom.js';
3
- export type { Accessor, Setter } from './core.js';
4
- export type { Component, Renderable, Props } from './jsx-runtime.js';
5
-
6
- export { Island, resumable, startResumability, registerResumable, unregisterResumable, isResumableHandler, parseState, serializeState } from './resume.js';
7
- export type { JsonValue, ResumeContext, ResumableEventHandler, ResumableHandler } from './resume.js';
@@ -1,306 +0,0 @@
1
- import { cleanup, effect, getOwner, root } from './core.js';
2
- import { eventAttributeName, isResumableHandler } from './resume.js';
3
-
4
- export type Component<P = Record<string, unknown>> = (props: P) => Renderable;
5
- export type Lazy<T = unknown> = { readonly __blokdLazy: true; readonly fn: () => T };
6
- export type Props = Record<string, unknown> & { children?: unknown };
7
-
8
- export type VElement = {
9
- kind: 'element';
10
- tag: string;
11
- props: Props;
12
- children: Renderable[];
13
- };
14
-
15
- export type VFragment = {
16
- kind: 'fragment';
17
- children: Renderable[];
18
- };
19
-
20
- export type Dynamic = {
21
- kind: 'dynamic';
22
- fn: () => unknown;
23
- };
24
-
25
- export type Renderable =
26
- | Node
27
- | VElement
28
- | VFragment
29
- | Dynamic
30
- | string
31
- | number
32
- | bigint
33
- | boolean
34
- | null
35
- | undefined
36
- | Renderable[];
37
-
38
- declare global {
39
- namespace JSX {
40
- type Element = Renderable;
41
- interface ElementChildrenAttribute { children: {}; }
42
- interface IntrinsicElements {
43
- [elemName: string]: Record<string, unknown>;
44
- }
45
- }
46
- }
47
-
48
- export const Fragment = Symbol.for('blokd.fragment');
49
-
50
- export function lazy<T>(fn: () => T): Lazy<T> {
51
- return { __blokdLazy: true, fn };
52
- }
53
-
54
- export function isLazy(value: unknown): value is Lazy {
55
- return !!value && typeof value === 'object' && (value as Lazy).__blokdLazy === true;
56
- }
57
-
58
- function isBrowser(): boolean {
59
- return typeof document !== 'undefined' && typeof Node !== 'undefined';
60
- }
61
-
62
- function isEventName(name: string): boolean {
63
- return /^on[A-Z]/.test(name) || /^on[a-z]/.test(name);
64
- }
65
-
66
- function normalizeEventName(name: string): string {
67
- return name.slice(2).toLowerCase();
68
- }
69
-
70
- function isRenderableObject(value: unknown): value is VElement | VFragment | Dynamic {
71
- return !!value && typeof value === 'object' && 'kind' in value;
72
- }
73
-
74
- function normalizeChildren(children: unknown): Renderable[] {
75
- if (children === undefined || children === null || children === false || children === true) return [];
76
- if (Array.isArray(children)) return children.flatMap(normalizeChildren) as Renderable[];
77
- return [children as Renderable];
78
- }
79
-
80
- function createComponentProps(raw: Props): Props {
81
- const out: Props = {};
82
- for (const [key, value] of Object.entries(raw)) {
83
- if (isLazy(value)) {
84
- Object.defineProperty(out, key, {
85
- enumerable: true,
86
- configurable: true,
87
- get: () => value.fn()
88
- });
89
- } else {
90
- out[key] = value;
91
- }
92
- }
93
- return out;
94
- }
95
-
96
- export function jsx(type: string | Component | typeof Fragment, props: Props | null): Renderable {
97
- const finalProps = props ?? {};
98
- if (type === Fragment) return createFragment(finalProps.children);
99
- if (typeof type === 'function') return type(createComponentProps(finalProps));
100
- if (isBrowser()) return createDomElement(type, finalProps);
101
- return createVNode(type, finalProps);
102
- }
103
-
104
- export const jsxs = jsx;
105
- export const jsxDEV = jsx;
106
-
107
- function createFragment(children: unknown): Renderable {
108
- const normalized = normalizeChildren(children);
109
- if (isBrowser()) {
110
- const fragment = document.createDocumentFragment();
111
- appendChildren(fragment, normalized);
112
- return fragment;
113
- }
114
- return { kind: 'fragment', children: normalized } satisfies VFragment;
115
- }
116
-
117
- function createVNode(tag: string, props: Props): VElement {
118
- const children = normalizeChildren(props.children);
119
- const elementProps: Props = { ...props };
120
- delete elementProps.children;
121
- return { kind: 'element', tag, props: elementProps, children };
122
- }
123
-
124
- function createDomElement(tag: string, props: Props): Element {
125
- const element = tag === 'svg'
126
- ? document.createElementNS('http://www.w3.org/2000/svg', tag)
127
- : document.createElement(tag);
128
-
129
- for (const [name, value] of Object.entries(props)) {
130
- if (name === 'children') continue;
131
- applyProp(element, name, value);
132
- }
133
- appendChildren(element, normalizeChildren(props.children));
134
- return element;
135
- }
136
-
137
- function applyProp(element: Element, name: string, value: unknown): void {
138
- if (name === 'ref') {
139
- if (typeof value === 'function') (value as (node: Element) => void)(element);
140
- return;
141
- }
142
- if (name === 'className') name = 'class';
143
- if (name === 'classList' && value && typeof value === 'object') {
144
- bindClassList(element, value as Record<string, unknown>);
145
- return;
146
- }
147
- if (name === 'style' && value && typeof value === 'object' && !isLazy(value)) {
148
- bindStyle(element as HTMLElement, value as Record<string, unknown>);
149
- return;
150
- }
151
- if (isEventName(name)) {
152
- const eventName = normalizeEventName(name);
153
- if (isResumableHandler(value)) {
154
- element.setAttribute(eventAttributeName(eventName), value.ref);
155
- return;
156
- }
157
- if (typeof value !== 'function') return;
158
- element.addEventListener(eventName, value as EventListener);
159
- if (getOwner()) cleanup(() => element.removeEventListener(eventName, value as EventListener));
160
- return;
161
- }
162
- if (isLazy(value)) {
163
- effect(() => setDomProp(element, name, value.fn()));
164
- return;
165
- }
166
- setDomProp(element, name, value);
167
- }
168
-
169
- function bindClassList(element: Element, classList: Record<string, unknown>): void {
170
- for (const [className, active] of Object.entries(classList)) {
171
- if (isLazy(active)) effect(() => element.classList.toggle(className, Boolean(active.fn())));
172
- else element.classList.toggle(className, Boolean(active));
173
- }
174
- }
175
-
176
- function bindStyle(element: HTMLElement, styles: Record<string, unknown>): void {
177
- for (const [name, value] of Object.entries(styles)) {
178
- const set = (next: unknown) => {
179
- if (next === null || next === undefined || next === false) element.style.removeProperty(name);
180
- else element.style.setProperty(name, String(next));
181
- };
182
- if (isLazy(value)) effect(() => set(value.fn()));
183
- else set(value);
184
- }
185
- }
186
-
187
- function setDomProp(element: Element, name: string, value: unknown): void {
188
- if (value === false || value === null || value === undefined) {
189
- element.removeAttribute(name);
190
- if (name in element) {
191
- try { (element as unknown as Record<string, unknown>)[name] = value === false ? false : ''; } catch {}
192
- }
193
- return;
194
- }
195
-
196
- if (value === true) {
197
- element.setAttribute(name, '');
198
- if (name in element) {
199
- try { (element as unknown as Record<string, unknown>)[name] = true; } catch {}
200
- }
201
- return;
202
- }
203
-
204
- if (name === 'innerHTML' || name === 'outerHTML') {
205
- throw new Error(`Blokd blocks direct ${name} assignment. Use text children or an explicit sanitization boundary.`);
206
- }
207
-
208
- if (name in element && !name.includes('-')) {
209
- try {
210
- (element as unknown as Record<string, unknown>)[name] = value;
211
- return;
212
- } catch {}
213
- }
214
- element.setAttribute(name, String(value));
215
- }
216
-
217
- function appendChildren(parent: Node, children: Renderable[]): void {
218
- for (const child of children) appendChild(parent, child);
219
- }
220
-
221
- function appendChild(parent: Node, child: Renderable): void {
222
- if (child === null || child === undefined || child === false || child === true) return;
223
- if (Array.isArray(child)) {
224
- for (const nested of child) appendChild(parent, nested);
225
- return;
226
- }
227
- if (isLazy(child)) {
228
- appendDynamic(parent, child.fn);
229
- return;
230
- }
231
- if (isRenderableObject(child) && child.kind === 'dynamic') {
232
- appendDynamic(parent, child.fn);
233
- return;
234
- }
235
- if (typeof Node !== 'undefined' && child instanceof Node) {
236
- parent.appendChild(child);
237
- return;
238
- }
239
- parent.appendChild(document.createTextNode(String(child)));
240
- }
241
-
242
- function appendDynamic(parent: Node, fn: () => unknown): void {
243
- const start = document.createComment('bd');
244
- const end = document.createComment('/bd');
245
- parent.appendChild(start);
246
- parent.appendChild(end);
247
- let disposers: Array<() => void> = [];
248
-
249
- const clear = () => {
250
- for (const dispose of disposers.splice(0)) dispose();
251
- let node = start.nextSibling;
252
- while (node && node !== end) {
253
- const next = node.nextSibling;
254
- node.parentNode?.removeChild(node);
255
- node = next;
256
- }
257
- };
258
-
259
- effect(() => {
260
- clear();
261
- const value = fn();
262
- const nodes = materialize(value as Renderable, disposers);
263
- for (const node of nodes) parent.insertBefore(node, end);
264
- });
265
-
266
- cleanup(clear);
267
- }
268
-
269
- function materialize(value: Renderable, disposers: Array<() => void>): Node[] {
270
- if (value === null || value === undefined || value === false || value === true) return [];
271
- if (Array.isArray(value)) return value.flatMap(item => materialize(item, disposers));
272
- if (isLazy(value)) return materialize(value.fn() as Renderable, disposers);
273
- if (isRenderableObject(value) && value.kind === 'dynamic') {
274
- const fragment = document.createDocumentFragment();
275
- root(dispose => {
276
- disposers.push(dispose);
277
- appendDynamic(fragment, value.fn);
278
- });
279
- return Array.from(fragment.childNodes);
280
- }
281
- if (typeof Node !== 'undefined' && value instanceof Node) return [value];
282
- return [document.createTextNode(String(value))];
283
- }
284
-
285
- export function dynamic(fn: () => unknown): Dynamic | Node {
286
- if (!isBrowser()) return { kind: 'dynamic', fn };
287
- const fragment = document.createDocumentFragment();
288
- appendDynamic(fragment, fn);
289
- return fragment;
290
- }
291
-
292
- export function render(fn: () => Renderable, rootElement: Element | DocumentFragment): () => void {
293
- rootElement.textContent = '';
294
- return root(dispose => {
295
- appendChild(rootElement, fn());
296
- return dispose;
297
- });
298
- }
299
-
300
- export function hydrate(fn: () => Renderable, rootElement: Element | DocumentFragment): () => void {
301
- return render(fn, rootElement);
302
- }
303
-
304
- export function from(value: unknown): Renderable {
305
- return value as Renderable;
306
- }