blokd 0.1.0-beta.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.
Files changed (48) hide show
  1. package/README.md +28 -0
  2. package/dist/LICENSE +21 -0
  3. package/dist/client.d.ts +9 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/client.js +48 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/core.d.ts +25 -0
  8. package/dist/core.d.ts.map +1 -0
  9. package/dist/core.js +206 -0
  10. package/dist/core.js.map +1 -0
  11. package/dist/dom.d.ts +16 -0
  12. package/dist/dom.d.ts.map +1 -0
  13. package/dist/dom.js +140 -0
  14. package/dist/dom.js.map +1 -0
  15. package/dist/hono.d.ts +49 -0
  16. package/dist/hono.d.ts.map +1 -0
  17. package/dist/hono.js +310 -0
  18. package/dist/hono.js.map +1 -0
  19. package/dist/index.d.ts +7 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +4 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/jsx-runtime.d.ts +45 -0
  24. package/dist/jsx-runtime.d.ts.map +1 -0
  25. package/dist/jsx-runtime.js +264 -0
  26. package/dist/jsx-runtime.js.map +1 -0
  27. package/dist/resume.d.ts +46 -0
  28. package/dist/resume.d.ts.map +1 -0
  29. package/dist/resume.js +231 -0
  30. package/dist/resume.js.map +1 -0
  31. package/dist/server.d.ts +39 -0
  32. package/dist/server.d.ts.map +1 -0
  33. package/dist/server.js +232 -0
  34. package/dist/server.js.map +1 -0
  35. package/dist/vite.d.ts +20 -0
  36. package/dist/vite.d.ts.map +1 -0
  37. package/dist/vite.js +421 -0
  38. package/dist/vite.js.map +1 -0
  39. package/package.json +92 -0
  40. package/src/client.ts +50 -0
  41. package/src/core.ts +234 -0
  42. package/src/dom.ts +142 -0
  43. package/src/hono.ts +368 -0
  44. package/src/index.ts +7 -0
  45. package/src/jsx-runtime.ts +306 -0
  46. package/src/resume.ts +274 -0
  47. package/src/server.ts +241 -0
  48. package/src/vite.ts +413 -0
package/src/resume.ts ADDED
@@ -0,0 +1,274 @@
1
+ import { jsx, type Renderable } from './jsx-runtime.js';
2
+
3
+ export type JsonPrimitive = string | number | boolean | null;
4
+ export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
5
+
6
+ export type ResumableRef = `${string}#${string}` | string;
7
+
8
+ export type ResumeContext<TState = JsonValue> = {
9
+ event: Event;
10
+ element: Element;
11
+ island: Element | null;
12
+ state: TState | null;
13
+ setState(next: TState | ((previous: TState | null) => TState)): void;
14
+ ref: string;
15
+ };
16
+
17
+ export type ResumableEventHandler<TState = JsonValue> = (
18
+ event: Event,
19
+ ctx: ResumeContext<TState>
20
+ ) => unknown | Promise<unknown>;
21
+
22
+ export type ResumableHandler<TState = JsonValue> = {
23
+ readonly __blokdResumable: true;
24
+ readonly ref: string;
25
+ readonly handler?: ResumableEventHandler<TState>;
26
+ };
27
+
28
+ export type IslandProps<TState extends JsonValue = JsonValue> = {
29
+ name: string;
30
+ state?: TState;
31
+ id?: string;
32
+ as?: string;
33
+ children?: Renderable;
34
+ };
35
+
36
+ export type StartResumabilityOptions = {
37
+ root?: Document | Element;
38
+ events?: readonly string[];
39
+ /**
40
+ * Production safety valve. Return false to reject a data-blokd-on* ref before dynamic import.
41
+ */
42
+ allowRef?: (ref: string) => boolean;
43
+ onError?: (error: unknown, ctx: Omit<ResumeContext, 'state' | 'setState'>) => void;
44
+ };
45
+
46
+ const registry = new Map<string, ResumableEventHandler>();
47
+ const inflight = new Map<string, Promise<ResumableEventHandler>>();
48
+ const DEFAULT_EVENTS = ['click', 'input', 'change', 'submit'] as const;
49
+ type RootTarget = Document | Element;
50
+ type ListenerRecord = { listener: EventListener; count: number };
51
+ type RootResumabilityState = {
52
+ options: StartResumabilityOptions;
53
+ listeners: Map<string, ListenerRecord>;
54
+ };
55
+ const rootStates = new WeakMap<RootTarget, RootResumabilityState>();
56
+
57
+ export function resumable<TState = JsonValue>(
58
+ ref: ResumableRef,
59
+ handler?: ResumableEventHandler<TState>
60
+ ): ResumableHandler<TState> {
61
+ const normalized = String(ref);
62
+ assertValidRef(normalized);
63
+ if (handler) {
64
+ registry.set(normalized, handler as unknown as ResumableEventHandler);
65
+ return { __blokdResumable: true, ref: normalized, handler };
66
+ }
67
+ return { __blokdResumable: true, ref: normalized };
68
+ }
69
+
70
+ export function isResumableHandler(value: unknown): value is ResumableHandler {
71
+ return !!value && typeof value === 'object' && (value as ResumableHandler).__blokdResumable === true;
72
+ }
73
+
74
+ export function registerResumable<TState = JsonValue>(ref: ResumableRef, handler: ResumableEventHandler<TState>): void {
75
+ const normalized = String(ref);
76
+ assertValidRef(normalized);
77
+ registry.set(normalized, handler as unknown as ResumableEventHandler);
78
+ }
79
+
80
+ export function unregisterResumable(ref: ResumableRef): void {
81
+ registry.delete(String(ref));
82
+ inflight.delete(String(ref));
83
+ }
84
+
85
+ export function Island<TState extends JsonValue = JsonValue>(props: IslandProps<TState>): Renderable {
86
+ const tag = props.as ?? 'div';
87
+ validateIslandTag(tag);
88
+ const attrs: Record<string, unknown> = {
89
+ 'data-blokd-island': props.name,
90
+ children: props.children
91
+ };
92
+ if (props.id) attrs.id = props.id;
93
+ if (props.state !== undefined) attrs['data-blokd-state'] = serializeState(props.state);
94
+ return jsx(tag, attrs);
95
+ }
96
+
97
+ export function eventAttributeName(eventName: string): string {
98
+ if (!/^[a-z][a-z0-9_-]*$/i.test(eventName)) throw new Error(`Invalid event name: ${eventName}`);
99
+ return `data-blokd-on${eventName.toLowerCase()}`;
100
+ }
101
+
102
+ export function serializeState(value: JsonValue): string {
103
+ const serialized = JSON.stringify(value);
104
+ if (serialized === undefined) return 'null';
105
+ return serialized
106
+ .replaceAll('<', '\\u003c')
107
+ .replaceAll('>', '\\u003e')
108
+ .replaceAll('&', '\\u0026')
109
+ .replaceAll('\u2028', '\\u2028')
110
+ .replaceAll('\u2029', '\\u2029');
111
+ }
112
+
113
+ export function parseState<TState = JsonValue>(value: string | null): TState | null {
114
+ if (!value) return null;
115
+ try {
116
+ return JSON.parse(value) as TState;
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ export function startResumability(options: StartResumabilityOptions = {}): () => void {
123
+ if (typeof document === 'undefined') return () => undefined;
124
+ const root = options.root ?? document;
125
+ const events = Array.from(new Set(options.events ?? DEFAULT_EVENTS));
126
+ let state = rootStates.get(root);
127
+ if (!state) {
128
+ state = { options, listeners: new Map() };
129
+ rootStates.set(root, state);
130
+ }
131
+ state.options = options;
132
+
133
+ for (const eventName of events) {
134
+ eventAttributeName(eventName);
135
+ let record = state.listeners.get(eventName);
136
+ if (!record) {
137
+ const listener: EventListener = event => {
138
+ const latest = rootStates.get(root);
139
+ if (latest) void dispatchResumableEvent(event, latest.options);
140
+ };
141
+ root.addEventListener(eventName, listener, true);
142
+ record = { listener, count: 0 };
143
+ state.listeners.set(eventName, record);
144
+ }
145
+ record.count++;
146
+ }
147
+
148
+ let disposed = false;
149
+ return () => {
150
+ if (disposed) return;
151
+ disposed = true;
152
+ const current = rootStates.get(root);
153
+ if (!current) return;
154
+ for (const eventName of events) {
155
+ const record = current.listeners.get(eventName);
156
+ if (!record) continue;
157
+ record.count--;
158
+ if (record.count <= 0) {
159
+ root.removeEventListener(eventName, record.listener, true);
160
+ current.listeners.delete(eventName);
161
+ }
162
+ }
163
+ if (current.listeners.size === 0) rootStates.delete(root);
164
+ };
165
+ }
166
+
167
+ async function dispatchResumableEvent(event: Event, options: StartResumabilityOptions): Promise<void> {
168
+ const match = findResumableElement(event);
169
+ if (!match) return;
170
+
171
+ const { element, ref } = match;
172
+ const island = element.closest('[data-blokd-island]');
173
+ const baseCtx = { event, element, island, ref };
174
+
175
+ try {
176
+ if (options.allowRef && !options.allowRef(ref)) throw new Error(`Blokd resumable ref rejected by allowRef(): ${ref}`);
177
+ if (!isSafeDynamicRef(ref)) throw new Error(`Unsafe Blokd resumable ref: ${ref}`);
178
+ const handler = await loadHandler(ref);
179
+ const ctx = createResumeContext(baseCtx);
180
+ await handler(event, ctx);
181
+ } catch (error) {
182
+ if (options.onError) options.onError(error, baseCtx);
183
+ else setTimeout(() => { throw error; }, 0);
184
+ }
185
+ }
186
+
187
+ function findResumableElement(event: Event): { element: Element; ref: string } | null {
188
+ const attr = eventAttributeName(event.type);
189
+ const path = typeof event.composedPath === 'function' ? event.composedPath() : [];
190
+ for (const item of path) {
191
+ if (!(item instanceof Element)) continue;
192
+ const ref = item.getAttribute(attr);
193
+ if (ref) return { element: item, ref };
194
+ }
195
+
196
+ const target = event.target;
197
+ if (!(target instanceof Element)) return null;
198
+ const element = target.closest(`[${attr}]`);
199
+ const ref = element?.getAttribute(attr);
200
+ return element && ref ? { element, ref } : null;
201
+ }
202
+
203
+ function createResumeContext(base: Omit<ResumeContext, 'state' | 'setState'>): ResumeContext {
204
+ const getStateElement = () => base.island ?? base.element;
205
+ const read = () => parseState(getStateElement().getAttribute('data-blokd-state'));
206
+ return {
207
+ ...base,
208
+ get state() {
209
+ return read();
210
+ },
211
+ setState(next) {
212
+ const previous = read();
213
+ const value = typeof next === 'function' ? (next as (previous: JsonValue | null) => JsonValue)(previous) : next;
214
+ getStateElement().setAttribute('data-blokd-state', serializeState(value));
215
+ }
216
+ } as ResumeContext;
217
+ }
218
+
219
+ async function loadHandler(ref: string): Promise<ResumableEventHandler> {
220
+ const registered = registry.get(ref);
221
+ if (registered) return registered;
222
+ const active = inflight.get(ref);
223
+ if (active) return active;
224
+
225
+ const promise = importHandler(ref);
226
+ inflight.set(ref, promise);
227
+ try {
228
+ const handler = await promise;
229
+ registry.set(ref, handler);
230
+ return handler;
231
+ } finally {
232
+ inflight.delete(ref);
233
+ }
234
+ }
235
+
236
+ async function importHandler(ref: string): Promise<ResumableEventHandler> {
237
+ const { moduleId, exportName } = parseRef(ref);
238
+ const mod = await import(/* @vite-ignore */ moduleId) as Record<string, unknown>;
239
+ const handler = mod[exportName];
240
+ if (typeof handler !== 'function') {
241
+ throw new Error(`Blokd resumable handler ${exportName} was not found in ${moduleId}.`);
242
+ }
243
+ return handler as ResumableEventHandler;
244
+ }
245
+
246
+ function parseRef(ref: string): { moduleId: string; exportName: string } {
247
+ const index = ref.lastIndexOf('#');
248
+ if (index <= 0 || index === ref.length - 1) {
249
+ throw new Error(`Invalid Blokd resumable ref: ${ref}. Expected "module#export".`);
250
+ }
251
+ return { moduleId: ref.slice(0, index), exportName: ref.slice(index + 1) };
252
+ }
253
+
254
+ function assertValidRef(ref: string): void {
255
+ parseRef(ref);
256
+ if (!isSafeDynamicRef(ref)) throw new Error(`Unsafe Blokd resumable ref: ${ref}`);
257
+ }
258
+
259
+ function isSafeDynamicRef(ref: string): boolean {
260
+ const { moduleId } = parseRef(ref);
261
+ const lowered = moduleId.trim().toLowerCase();
262
+ if (lowered.startsWith('javascript:') || lowered.startsWith('data:') || lowered.startsWith('blob:')) return false;
263
+ if (/^https?:\/\//.test(lowered)) {
264
+ if (typeof location === 'undefined') return false;
265
+ try { return new URL(moduleId, location.href).origin === location.origin; }
266
+ catch { return false; }
267
+ }
268
+ return true;
269
+ }
270
+
271
+ function validateIslandTag(tag: string): void {
272
+ if (!/^[a-z][a-z0-9.-]*$/i.test(tag)) throw new Error(`Invalid Island tag: ${tag}`);
273
+ if (tag.toLowerCase() === 'script') throw new Error('Island cannot render as <script>.');
274
+ }
package/src/server.ts ADDED
@@ -0,0 +1,241 @@
1
+ import { runWithEffectsDisabled } from './core.js';
2
+ import { isLazy, type Renderable, type VElement, type VFragment, type Dynamic } from './jsx-runtime.js';
3
+ import { eventAttributeName, isResumableHandler } from './resume.js';
4
+
5
+ export type MetaDescriptor = {
6
+ title?: string;
7
+ description?: string;
8
+ htmlAttrs?: Record<string, string | number | bigint | boolean | null | undefined>;
9
+ bodyAttrs?: Record<string, string | number | bigint | boolean | null | undefined>;
10
+ meta?: Array<Record<string, string | number | boolean>>;
11
+ links?: Array<Record<string, string | number | boolean>>;
12
+ scripts?: Array<Record<string, string | number | boolean>>;
13
+ };
14
+
15
+ export type ResponseLike = Response | Renderable | string | number | bigint | boolean | null | undefined;
16
+
17
+ export class HttpError extends Error {
18
+ readonly response: Response;
19
+ constructor(response: Response) {
20
+ super(`${response.status} ${response.statusText}`.trim());
21
+ this.name = 'HttpError';
22
+ this.response = response;
23
+ }
24
+ }
25
+
26
+ export class Redirect extends HttpError {
27
+ constructor(location: string, status = 302) {
28
+ validateRedirect(location, status);
29
+ super(new Response(null, { status, headers: { location } }));
30
+ this.name = 'Redirect';
31
+ }
32
+ }
33
+
34
+ export function redirect(location: string, status = 302): never {
35
+ throw new Redirect(location, status);
36
+ }
37
+
38
+ export function notFound(message = 'Not Found'): never {
39
+ throw new HttpError(new Response(message, { status: 404, statusText: 'Not Found' }));
40
+ }
41
+
42
+ export function httpError(status: number, message?: string, init?: ResponseInit): never {
43
+ if (!Number.isInteger(status) || status < 400 || status > 599) throw new Error(`httpError() requires a 4xx or 5xx status. Received ${status}.`);
44
+ throw new HttpError(new Response(message ?? defaultStatusText(status), { ...init, status }));
45
+ }
46
+
47
+ export function isHttpError(error: unknown): error is HttpError {
48
+ return error instanceof HttpError;
49
+ }
50
+
51
+ export function json(data: unknown, init: ResponseInit = {}): Response {
52
+ const headers = new Headers(init.headers);
53
+ if (!headers.has('content-type')) headers.set('content-type', 'application/json; charset=utf-8');
54
+ return new Response(JSON.stringify(data) ?? 'null', { ...init, headers });
55
+ }
56
+
57
+ export function html(body: string, init: ResponseInit = {}): Response {
58
+ const headers = new Headers(init.headers);
59
+ if (!headers.has('content-type')) headers.set('content-type', 'text/html; charset=utf-8');
60
+ return new Response(body, { ...init, headers });
61
+ }
62
+
63
+ export function escapeHtml(value: unknown): string {
64
+ return String(value)
65
+ .replaceAll('&', '&amp;')
66
+ .replaceAll('<', '&lt;')
67
+ .replaceAll('>', '&gt;')
68
+ .replaceAll('"', '&quot;')
69
+ .replaceAll("'", '&#39;');
70
+ }
71
+
72
+ export function safeJsonScript(data: unknown): string {
73
+ return (JSON.stringify(data) ?? 'null')
74
+ .replaceAll('<', '\\u003c')
75
+ .replaceAll('>', '\\u003e')
76
+ .replaceAll('&', '\\u0026')
77
+ .replaceAll('\u2028', '\\u2028')
78
+ .replaceAll('\u2029', '\\u2029');
79
+ }
80
+
81
+ export function renderToString(input: Renderable | (() => Renderable)): string {
82
+ return runWithEffectsDisabled(() => renderValue(typeof input === 'function' ? (input as () => Renderable)() : input));
83
+ }
84
+
85
+ function renderValue(value: Renderable): string {
86
+ if (value === null || value === undefined || value === false || value === true) return '';
87
+ if (Array.isArray(value)) return value.map(renderValue).join('');
88
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint') return escapeHtml(value);
89
+ if (isLazy(value)) return renderValue(value.fn() as Renderable);
90
+ if (typeof Node !== 'undefined' && value instanceof Node) return escapeHtml(value.textContent ?? '');
91
+ if (typeof value === 'object' && value && 'kind' in value) {
92
+ const kind = (value as { kind: string }).kind;
93
+ if (kind === 'element') return renderElement(value as VElement);
94
+ if (kind === 'fragment') return renderFragment(value as VFragment);
95
+ if (kind === 'dynamic') return renderValue((value as Dynamic).fn() as Renderable);
96
+ }
97
+ return escapeHtml(String(value));
98
+ }
99
+
100
+ const voidElements = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr']);
101
+ const rawTextElements = new Set(['script', 'style']);
102
+ const blockedAttrs = new Set(['innerHTML', 'outerHTML']);
103
+
104
+ function renderElement(element: VElement): string {
105
+ const attrs = renderAttrs(element.props);
106
+ if (voidElements.has(element.tag)) return `<${element.tag}${attrs}>`;
107
+ const children = rawTextElements.has(element.tag)
108
+ ? element.children.map(child => child == null ? '' : String(resolveOnce(child))).join('')
109
+ : element.children.map(renderValue).join('');
110
+ return `<${element.tag}${attrs}>${children}</${element.tag}>`;
111
+ }
112
+
113
+ function renderFragment(fragment: VFragment): string {
114
+ return fragment.children.map(renderValue).join('');
115
+ }
116
+
117
+ function resolveOnce(value: Renderable): unknown {
118
+ if (isLazy(value)) return value.fn();
119
+ return value;
120
+ }
121
+
122
+ function renderAttrs(props: Record<string, unknown>): string {
123
+ let out = '';
124
+ const explicitClass = props.class ?? props.className;
125
+ const classList = props.classList;
126
+ const classListValue = classList && typeof classList === 'object'
127
+ ? Object.entries(classList as Record<string, unknown>)
128
+ .filter(([, v]) => Boolean(isLazy(v) ? v.fn() : v))
129
+ .map(([k]) => k)
130
+ .join(' ')
131
+ : '';
132
+
133
+ for (const [rawName, rawValue] of Object.entries(props)) {
134
+ if (rawName === 'children' || rawName === 'ref' || rawName === 'classList') continue;
135
+ if (blockedAttrs.has(rawName)) throw new Error(`Blokd blocks ${rawName} during SSR. Use text children or an explicit sanitization boundary.`);
136
+ const eventMatch = /^on([A-Z].*|[a-z].*)$/.exec(rawName);
137
+ if (eventMatch) {
138
+ const value = isLazy(rawValue) ? rawValue.fn() : rawValue;
139
+ if (isResumableHandler(value)) out += ` ${eventAttributeName(eventMatch[1]!.toLowerCase())}="${escapeHtml(value.ref)}"`;
140
+ continue;
141
+ }
142
+ const name = rawName === 'className' ? 'class' : rawName;
143
+ let value = isLazy(rawValue) ? rawValue.fn() : rawValue;
144
+ if (name === 'class' && classListValue) value = [value, classListValue].filter(Boolean).join(' ');
145
+ if (value === false || value === null || value === undefined) continue;
146
+ if (value === true) {
147
+ out += ` ${escapeHtml(name)}`;
148
+ continue;
149
+ }
150
+ if (name === 'style' && value && typeof value === 'object') {
151
+ const style = Object.entries(value as Record<string, unknown>)
152
+ .filter(([, v]) => v !== null && v !== undefined && v !== false)
153
+ .map(([k, v]) => `${k}:${String(isLazy(v) ? v.fn() : v)}`)
154
+ .join(';');
155
+ if (style) out += ` style="${escapeHtml(style)}"`;
156
+ continue;
157
+ }
158
+ out += ` ${escapeHtml(name)}="${escapeHtml(value)}"`;
159
+ }
160
+
161
+ if (classListValue && explicitClass === undefined) out += ` class="${escapeHtml(classListValue)}"`;
162
+ return out;
163
+ }
164
+
165
+ export function renderDocument(options: {
166
+ body: Renderable | (() => Renderable);
167
+ data?: unknown;
168
+ meta?: MetaDescriptor;
169
+ status?: number;
170
+ headers?: HeadersInit;
171
+ entryClient?: string;
172
+ }): Response {
173
+ const meta = options.meta ?? {};
174
+ const htmlAttrs = attrsToString(meta.htmlAttrs ?? { lang: 'en' });
175
+ const bodyAttrs = attrsToString(meta.bodyAttrs ?? {});
176
+ const head = renderHead(meta);
177
+ const body = renderToString(options.body);
178
+ const data = options.data === undefined ? '' : `<script id="__BLOKD_DATA__" type="application/json">${safeJsonScript(options.data)}</script>`;
179
+ const client = options.entryClient ? `<script type="module" src="${escapeHtml(options.entryClient)}"></script>` : '';
180
+ const init: ResponseInit = { status: options.status ?? 200 };
181
+ if (options.headers !== undefined) init.headers = options.headers;
182
+ return html(`<!doctype html><html${htmlAttrs}><head>${head}</head><body${bodyAttrs}>${body}${data}${client}</body></html>`, init);
183
+ }
184
+
185
+ export function renderHead(meta: MetaDescriptor): string {
186
+ const pieces: string[] = ['<meta charset="utf-8">', '<meta name="viewport" content="width=device-width, initial-scale=1">'];
187
+ if (meta.title) pieces.push(`<title>${escapeHtml(meta.title)}</title>`);
188
+ if (meta.description) pieces.push(`<meta name="description" content="${escapeHtml(meta.description)}">`);
189
+ for (const item of meta.meta ?? []) pieces.push(`<meta${attrsToString(item)}>`);
190
+ for (const item of meta.links ?? []) pieces.push(`<link${attrsToString(item)}>`);
191
+ for (const item of meta.scripts ?? []) pieces.push(`<script${attrsToString(item)}></script>`);
192
+ return pieces.join('');
193
+ }
194
+
195
+ export function mergeMeta(...metas: Array<MetaDescriptor | null | undefined>): MetaDescriptor {
196
+ const out: MetaDescriptor = { htmlAttrs: {}, bodyAttrs: {}, meta: [], links: [], scripts: [] };
197
+ for (const meta of metas) {
198
+ if (!meta) continue;
199
+ if (meta.title !== undefined) out.title = meta.title;
200
+ if (meta.description !== undefined) out.description = meta.description;
201
+ out.htmlAttrs = { ...(out.htmlAttrs ?? {}), ...(meta.htmlAttrs ?? {}) };
202
+ out.bodyAttrs = { ...(out.bodyAttrs ?? {}), ...(meta.bodyAttrs ?? {}) };
203
+ out.meta = [...(out.meta ?? []), ...(meta.meta ?? [])];
204
+ out.links = [...(out.links ?? []), ...(meta.links ?? [])];
205
+ out.scripts = [...(out.scripts ?? []), ...(meta.scripts ?? [])];
206
+ }
207
+ return out;
208
+ }
209
+
210
+ export function attrsToString(attrs: Record<string, string | number | bigint | boolean | null | undefined>): string {
211
+ let out = '';
212
+ for (const [name, value] of Object.entries(attrs)) {
213
+ if (value === false || value === null || value === undefined) continue;
214
+ if (value === true) out += ` ${escapeHtml(name)}`;
215
+ else out += ` ${escapeHtml(name)}="${escapeHtml(value)}"`;
216
+ }
217
+ return out;
218
+ }
219
+
220
+ function validateRedirect(location: string, status: number): void {
221
+ if (!/^(301|302|303|307|308)$/.test(String(status))) throw new Error(`redirect() status must be one of 301, 302, 303, 307, or 308. Received ${status}.`);
222
+ if (/\r|\n/.test(location)) throw new Error('redirect() location must not contain CR or LF characters.');
223
+ }
224
+
225
+ function defaultStatusText(status: number): string {
226
+ const map: Record<number, string> = {
227
+ 400: 'Bad Request',
228
+ 401: 'Unauthorized',
229
+ 403: 'Forbidden',
230
+ 404: 'Not Found',
231
+ 405: 'Method Not Allowed',
232
+ 409: 'Conflict',
233
+ 422: 'Unprocessable Content',
234
+ 429: 'Too Many Requests',
235
+ 500: 'Internal Server Error',
236
+ 502: 'Bad Gateway',
237
+ 503: 'Service Unavailable',
238
+ 504: 'Gateway Timeout'
239
+ };
240
+ return map[status] ?? 'Error';
241
+ }