gorsee 0.1.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 (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/package.json +69 -0
  4. package/src/auth/index.ts +147 -0
  5. package/src/build/client.ts +121 -0
  6. package/src/build/css-modules.ts +69 -0
  7. package/src/build/devalue-parse.ts +2 -0
  8. package/src/build/rpc-transform.ts +62 -0
  9. package/src/build/server-strip.ts +87 -0
  10. package/src/build/ssg.ts +100 -0
  11. package/src/cli/bun-plugin.ts +37 -0
  12. package/src/cli/cmd-build.ts +182 -0
  13. package/src/cli/cmd-check.ts +225 -0
  14. package/src/cli/cmd-create.ts +313 -0
  15. package/src/cli/cmd-dev.ts +13 -0
  16. package/src/cli/cmd-generate.ts +147 -0
  17. package/src/cli/cmd-migrate.ts +45 -0
  18. package/src/cli/cmd-routes.ts +29 -0
  19. package/src/cli/cmd-start.ts +21 -0
  20. package/src/cli/cmd-typegen.ts +83 -0
  21. package/src/cli/framework-md.ts +196 -0
  22. package/src/cli/index.ts +84 -0
  23. package/src/db/index.ts +2 -0
  24. package/src/db/migrate.ts +89 -0
  25. package/src/db/sqlite.ts +40 -0
  26. package/src/deploy/dockerfile.ts +38 -0
  27. package/src/dev/error-overlay.ts +54 -0
  28. package/src/dev/hmr.ts +31 -0
  29. package/src/dev/partial-handler.ts +109 -0
  30. package/src/dev/request-handler.ts +158 -0
  31. package/src/dev/watcher.ts +48 -0
  32. package/src/dev.ts +273 -0
  33. package/src/env/index.ts +74 -0
  34. package/src/errors/catalog.ts +48 -0
  35. package/src/errors/formatter.ts +63 -0
  36. package/src/errors/index.ts +2 -0
  37. package/src/i18n/index.ts +72 -0
  38. package/src/index.ts +27 -0
  39. package/src/jsx-runtime-client.ts +13 -0
  40. package/src/jsx-runtime.ts +20 -0
  41. package/src/jsx-types-html.ts +242 -0
  42. package/src/log/index.ts +44 -0
  43. package/src/prod.ts +310 -0
  44. package/src/reactive/computed.ts +7 -0
  45. package/src/reactive/effect.ts +7 -0
  46. package/src/reactive/index.ts +7 -0
  47. package/src/reactive/live.ts +97 -0
  48. package/src/reactive/optimistic.ts +83 -0
  49. package/src/reactive/resource.ts +138 -0
  50. package/src/reactive/signal.ts +20 -0
  51. package/src/reactive/store.ts +36 -0
  52. package/src/router/index.ts +2 -0
  53. package/src/router/matcher.ts +53 -0
  54. package/src/router/scanner.ts +206 -0
  55. package/src/runtime/client.ts +28 -0
  56. package/src/runtime/error-boundary.ts +35 -0
  57. package/src/runtime/event-replay.ts +50 -0
  58. package/src/runtime/form.ts +49 -0
  59. package/src/runtime/head.ts +113 -0
  60. package/src/runtime/html-escape.ts +30 -0
  61. package/src/runtime/hydration.ts +95 -0
  62. package/src/runtime/image.ts +48 -0
  63. package/src/runtime/index.ts +12 -0
  64. package/src/runtime/island-hydrator.ts +84 -0
  65. package/src/runtime/island.ts +88 -0
  66. package/src/runtime/jsx-runtime.ts +167 -0
  67. package/src/runtime/link.ts +45 -0
  68. package/src/runtime/router.ts +224 -0
  69. package/src/runtime/server.ts +102 -0
  70. package/src/runtime/stream.ts +182 -0
  71. package/src/runtime/suspense.ts +37 -0
  72. package/src/runtime/typed-routes.ts +26 -0
  73. package/src/runtime/validated-form.ts +106 -0
  74. package/src/security/cors.ts +80 -0
  75. package/src/security/csrf.ts +85 -0
  76. package/src/security/headers.ts +50 -0
  77. package/src/security/index.ts +4 -0
  78. package/src/security/rate-limit.ts +80 -0
  79. package/src/server/action.ts +48 -0
  80. package/src/server/cache.ts +102 -0
  81. package/src/server/compress.ts +60 -0
  82. package/src/server/etag.ts +23 -0
  83. package/src/server/guard.ts +69 -0
  84. package/src/server/index.ts +19 -0
  85. package/src/server/middleware.ts +143 -0
  86. package/src/server/mime.ts +48 -0
  87. package/src/server/pipe.ts +46 -0
  88. package/src/server/rpc-hash.ts +17 -0
  89. package/src/server/rpc.ts +125 -0
  90. package/src/server/sse.ts +96 -0
  91. package/src/server/ws.ts +56 -0
  92. package/src/testing/index.ts +74 -0
  93. package/src/types/index.ts +4 -0
  94. package/src/types/safe-html.ts +32 -0
  95. package/src/types/safe-sql.ts +28 -0
  96. package/src/types/safe-url.ts +40 -0
  97. package/src/types/user-input.ts +12 -0
  98. package/src/unsafe/index.ts +18 -0
@@ -0,0 +1,224 @@
1
+ // Client-side SPA router
2
+ // Intercepts link clicks, fetches partial pages from server, swaps content
3
+ // Uses History API for back/forward navigation
4
+
5
+ import { hydrate } from "./client.ts"
6
+
7
+ interface NavigationResult {
8
+ html: string
9
+ data?: unknown
10
+ params?: Record<string, string>
11
+ title?: string
12
+ css?: string[]
13
+ script?: string
14
+ }
15
+
16
+ type NavigateCallback = (url: string) => void
17
+ type BeforeNavigateCallback = (url: string) => boolean | void
18
+
19
+ let currentPath = ""
20
+ const subscribers: NavigateCallback[] = []
21
+ const beforeNavigateHooks: BeforeNavigateCallback[] = []
22
+ let navigating = false
23
+ let loadingElement: HTMLElement | null = null
24
+
25
+ /** Set a loading indicator element to show during navigation */
26
+ export function setLoadingElement(el: HTMLElement): void {
27
+ loadingElement = el
28
+ }
29
+
30
+ function showLoading(): void {
31
+ if (loadingElement) {
32
+ loadingElement.style.display = ""
33
+ loadingElement.removeAttribute("hidden")
34
+ }
35
+ }
36
+
37
+ function hideLoading(): void {
38
+ if (loadingElement) {
39
+ loadingElement.style.display = "none"
40
+ }
41
+ }
42
+
43
+ export function onNavigate(fn: NavigateCallback): () => void {
44
+ subscribers.push(fn)
45
+ return () => {
46
+ const i = subscribers.indexOf(fn)
47
+ if (i >= 0) subscribers.splice(i, 1)
48
+ }
49
+ }
50
+
51
+ export function beforeNavigate(fn: BeforeNavigateCallback): () => void {
52
+ beforeNavigateHooks.push(fn)
53
+ return () => {
54
+ const i = beforeNavigateHooks.indexOf(fn)
55
+ if (i >= 0) beforeNavigateHooks.splice(i, 1)
56
+ }
57
+ }
58
+
59
+ export function getCurrentPath(): string {
60
+ return currentPath
61
+ }
62
+
63
+ async function fetchPage(url: string): Promise<NavigationResult> {
64
+ const res = await fetch(url, {
65
+ headers: { "X-Gorsee-Navigate": "partial" },
66
+ })
67
+ if (!res.ok) throw new Error(`Navigation failed: ${res.status}`)
68
+ return res.json()
69
+ }
70
+
71
+ function updateHead(result: NavigationResult): void {
72
+ if (result.title) document.title = result.title
73
+
74
+ // Remove old route CSS
75
+ document.querySelectorAll("link[data-g-route-css]").forEach((el) => el.remove())
76
+
77
+ // Add new route CSS
78
+ if (result.css) {
79
+ for (const href of result.css) {
80
+ const link = document.createElement("link")
81
+ link.rel = "stylesheet"
82
+ link.href = href
83
+ link.dataset.gRouteCss = ""
84
+ document.head.appendChild(link)
85
+ }
86
+ }
87
+ }
88
+
89
+ function updateDataScript(result: NavigationResult): void {
90
+ // Update __GORSEE_DATA__
91
+ let dataEl = document.getElementById("__GORSEE_DATA__")
92
+ if (result.data !== undefined) {
93
+ if (!dataEl) {
94
+ const script = document.createElement("script")
95
+ script.id = "__GORSEE_DATA__"
96
+ script.type = "application/json"
97
+ document.body.appendChild(script)
98
+ dataEl = script
99
+ }
100
+ dataEl.textContent = JSON.stringify(result.data)
101
+ } else if (dataEl) {
102
+ dataEl.remove()
103
+ }
104
+
105
+ // Update __GORSEE_PARAMS__
106
+ if (result.params) {
107
+ (globalThis as unknown as Record<string, unknown>).__GORSEE_PARAMS__ = result.params
108
+ }
109
+ }
110
+
111
+ export async function navigate(url: string, pushState = true): Promise<void> {
112
+ if (navigating) return
113
+ if (url === currentPath) return
114
+
115
+ // Before-navigate hooks (can cancel)
116
+ for (const hook of beforeNavigateHooks) {
117
+ if (hook(url) === false) return
118
+ }
119
+
120
+ navigating = true
121
+ showLoading()
122
+ try {
123
+ const result = await fetchPage(url)
124
+ const container = document.getElementById("app")
125
+ if (!container) return
126
+
127
+ // Update DOM
128
+ container.innerHTML = result.html
129
+ updateHead(result)
130
+ updateDataScript(result)
131
+
132
+ // Update history
133
+ if (pushState) {
134
+ history.pushState({ gorsee: true }, "", url)
135
+ }
136
+ currentPath = url
137
+
138
+ // Hydrate new content
139
+ if (result.script) {
140
+ const mod = await import(/* @vite-ignore */ result.script)
141
+ if (mod.default && typeof mod.default === "function") {
142
+ const data = result.data ?? {}
143
+ const params = result.params ?? {}
144
+ hydrate(() => mod.default({ data, params }), container)
145
+ }
146
+ }
147
+
148
+ // Scroll to top
149
+ window.scrollTo(0, 0)
150
+
151
+ // Notify subscribers
152
+ for (const fn of subscribers) fn(url)
153
+ } finally {
154
+ hideLoading()
155
+ navigating = false
156
+ }
157
+ }
158
+
159
+ // Prefetch cache
160
+ const prefetchCache = new Set<string>()
161
+
162
+ export function prefetch(url: string): void {
163
+ if (prefetchCache.has(url)) return
164
+ prefetchCache.add(url)
165
+ // Use low-priority fetch
166
+ const link = document.createElement("link")
167
+ link.rel = "prefetch"
168
+ link.href = url
169
+ link.setAttribute("as", "fetch")
170
+ link.setAttribute("crossorigin", "")
171
+ document.head.appendChild(link)
172
+ }
173
+
174
+ function shouldHandleClick(e: MouseEvent, anchor: HTMLAnchorElement): boolean {
175
+ // Don't handle modified clicks (new tab, etc.)
176
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return false
177
+ if (e.button !== 0) return false
178
+
179
+ // Don't handle links with target
180
+ if (anchor.target && anchor.target !== "_self") return false
181
+
182
+ // Only handle same-origin
183
+ if (anchor.origin !== location.origin) return false
184
+
185
+ // Don't handle downloads
186
+ if (anchor.hasAttribute("download")) return false
187
+
188
+ // Don't handle hash-only links
189
+ if (anchor.pathname === location.pathname && anchor.hash) return false
190
+
191
+ return true
192
+ }
193
+
194
+ export function initRouter(): void {
195
+ currentPath = location.pathname
196
+
197
+ // Intercept link clicks
198
+ document.addEventListener("click", (e) => {
199
+ const anchor = (e.target as Element).closest?.("a[href]") as HTMLAnchorElement | null
200
+ if (!anchor) return
201
+ if (anchor.dataset.gNoRouter !== undefined) return
202
+
203
+ if (shouldHandleClick(e, anchor)) {
204
+ e.preventDefault()
205
+ navigate(anchor.pathname + anchor.search)
206
+ }
207
+ })
208
+
209
+ // Handle back/forward
210
+ window.addEventListener("popstate", (e) => {
211
+ if (e.state?.gorsee || currentPath !== location.pathname) {
212
+ navigate(location.pathname + location.search, false)
213
+ }
214
+ })
215
+
216
+ // Prefetch on hover
217
+ document.addEventListener("mouseover", (e) => {
218
+ const anchor = (e.target as Element).closest?.("a[href]") as HTMLAnchorElement | null
219
+ if (!anchor) return
220
+ if (anchor.origin !== location.origin) return
221
+ if (anchor.dataset.gNoRouter !== undefined) return
222
+ prefetch(anchor.pathname + anchor.search)
223
+ })
224
+ }
@@ -0,0 +1,102 @@
1
+ // Server-side rendering -- renders component tree to HTML string
2
+ // No DOM API used -- pure string concatenation
3
+
4
+ import type { Component } from "./jsx-runtime.ts"
5
+ import { escapeHTML, escapeAttr, VOID_ELEMENTS, resolveValue } from "./html-escape.ts"
6
+
7
+ export type VNode = {
8
+ type: string | Component | symbol
9
+ props: Record<string, unknown>
10
+ }
11
+
12
+ function renderChild(child: unknown): string {
13
+ if (child == null || typeof child === "boolean") return ""
14
+
15
+ if (Array.isArray(child)) {
16
+ let s = ""
17
+ for (let i = 0; i < child.length; i++) s += renderChild(child[i])
18
+ return s
19
+ }
20
+
21
+ if (typeof child === "object" && child !== null && "type" in child) {
22
+ return renderVNode(child as VNode)
23
+ }
24
+
25
+ const resolved = resolveValue(child)
26
+ if (resolved == null || typeof resolved === "boolean") return ""
27
+ return escapeHTML(String(resolved))
28
+ }
29
+
30
+ function renderAttrs(props: Record<string, unknown>): string {
31
+ let result = ""
32
+
33
+ for (const key in props) {
34
+ if (key === "children" || key === "ref" || key.startsWith("on:")) continue
35
+
36
+ const value = resolveValue(props[key])
37
+
38
+ if (key === "className" || key === "class") {
39
+ if (value != null) result += ` class="${escapeAttr(String(value))}"`
40
+ continue
41
+ }
42
+
43
+ if (key === "style" && typeof value === "object" && value !== null) {
44
+ let styles = ""
45
+ for (const p in value as Record<string, unknown>) {
46
+ if (styles) styles += "; "
47
+ const sv = resolveValue((value as Record<string, unknown>)[p])
48
+ if (sv != null) styles += `${p}: ${sv}`
49
+ }
50
+ result += ` style="${escapeAttr(styles)}"`
51
+ continue
52
+ }
53
+
54
+ if (typeof value === "boolean") {
55
+ if (value) result += ` ${key}`
56
+ } else if (value != null) {
57
+ result += ` ${key}="${escapeAttr(String(value))}"`
58
+ }
59
+ }
60
+
61
+ return result
62
+ }
63
+
64
+ function renderVNode(vnode: VNode): string {
65
+ const { type, props } = vnode
66
+
67
+ // Fragment
68
+ if (typeof type === "symbol") {
69
+ return renderChild(props.children)
70
+ }
71
+
72
+ // Component
73
+ if (typeof type === "function") {
74
+ const result = type(props)
75
+ return renderChild(result)
76
+ }
77
+
78
+ // HTML element
79
+ const tag = type as string
80
+ const attrs = renderAttrs(props)
81
+
82
+ if (VOID_ELEMENTS.has(tag)) {
83
+ return `<${tag}${attrs} />`
84
+ }
85
+
86
+ const children = renderChild(props.children)
87
+ return `<${tag}${attrs}>${children}</${tag}>`
88
+ }
89
+
90
+ // Server-side jsx function -- creates VNodes instead of DOM nodes
91
+ export function ssrJsx(
92
+ type: string | Component | symbol,
93
+ props: Record<string, unknown> | null
94
+ ): VNode {
95
+ return { type, props: props ?? {} }
96
+ }
97
+
98
+ export const ssrJsxs = ssrJsx
99
+
100
+ export function renderToString(root: unknown): string {
101
+ return renderChild(root)
102
+ }
@@ -0,0 +1,182 @@
1
+ // Out-of-order streaming SSR
2
+ // Sends HTML shell with Suspense fallbacks immediately,
3
+ // then streams resolved chunks as data becomes available
4
+
5
+ import type { Component } from "./jsx-runtime.ts"
6
+ import { escapeHTML, escapeAttr, VOID_ELEMENTS, resolveValue } from "./html-escape.ts"
7
+
8
+ interface SuspenseSlot {
9
+ id: string
10
+ fallback: unknown
11
+ children: unknown
12
+ resolve: () => Promise<unknown>
13
+ }
14
+
15
+ interface StreamContext {
16
+ suspenseSlots: SuspenseSlot[]
17
+ nextId: number
18
+ }
19
+
20
+ interface VNode {
21
+ type: string | Component | symbol
22
+ props: Record<string, unknown>
23
+ __gorsee_suspense?: boolean
24
+ }
25
+
26
+ export function streamJsx(
27
+ type: string | Component | symbol,
28
+ props: Record<string, unknown> | null
29
+ ): VNode {
30
+ return { type, props: props ?? {} }
31
+ }
32
+
33
+ export const streamJsxs = streamJsx
34
+
35
+ // Marker for Suspense components in streaming mode
36
+ export function StreamSuspense(props: {
37
+ fallback: unknown
38
+ children: unknown
39
+ __streamCtx?: StreamContext
40
+ }): VNode {
41
+ const node = streamJsx("gorsee-suspense", {
42
+ fallback: props.fallback,
43
+ children: props.children,
44
+ })
45
+ node.__gorsee_suspense = true
46
+ return node
47
+ }
48
+
49
+ function renderAttrs(props: Record<string, unknown>): string {
50
+ const parts: string[] = []
51
+ for (const [key, rawValue] of Object.entries(props)) {
52
+ if (key === "children" || key === "ref" || key === "fallback" || key.startsWith("on:")) continue
53
+ const value = resolveValue(rawValue)
54
+ if (key === "className" || key === "class") {
55
+ if (value != null) parts.push(` class="${escapeAttr(String(value))}"`)
56
+ } else if (typeof value === "boolean") {
57
+ if (value) parts.push(` ${key}`)
58
+ } else if (value != null) {
59
+ parts.push(` ${key}="${escapeAttr(String(value))}"`)
60
+ }
61
+ }
62
+ return parts.join("")
63
+ }
64
+
65
+ function renderShellNode(node: unknown, ctx: StreamContext): string {
66
+ if (node == null || typeof node === "boolean") return ""
67
+
68
+ if (Array.isArray(node)) {
69
+ return node.map((n) => renderShellNode(n, ctx)).join("")
70
+ }
71
+
72
+ if (typeof node === "object" && node !== null && "type" in node) {
73
+ const vnode = node as VNode
74
+
75
+ // Suspense boundary -- render fallback, register slot for later resolution
76
+ if (vnode.__gorsee_suspense) {
77
+ const id = `s${ctx.nextId++}`
78
+ const fallbackHtml = renderShellNode(vnode.props.fallback, ctx)
79
+
80
+ ctx.suspenseSlots.push({
81
+ id,
82
+ fallback: vnode.props.fallback,
83
+ children: vnode.props.children,
84
+ resolve: async () => {
85
+ // Resolve the async children
86
+ const children = vnode.props.children
87
+ if (typeof children === "function") {
88
+ return await (children as () => Promise<unknown>)()
89
+ }
90
+ return children
91
+ },
92
+ })
93
+
94
+ return `<div data-g-suspense="${id}">${fallbackHtml}</div>`
95
+ }
96
+
97
+ // Fragment
98
+ if (typeof vnode.type === "symbol") {
99
+ return renderShellNode(vnode.props.children, ctx)
100
+ }
101
+
102
+ // Component
103
+ if (typeof vnode.type === "function") {
104
+ const result = vnode.type(vnode.props)
105
+ return renderShellNode(result, ctx)
106
+ }
107
+
108
+ // HTML element
109
+ const tag = vnode.type as string
110
+ const attrs = renderAttrs(vnode.props)
111
+ if (VOID_ELEMENTS.has(tag)) return `<${tag}${attrs} />`
112
+ const children = renderShellNode(vnode.props.children, ctx)
113
+ return `<${tag}${attrs}>${children}</${tag}>`
114
+ }
115
+
116
+ const resolved = resolveValue(node)
117
+ if (resolved == null || typeof resolved === "boolean") return ""
118
+ return escapeHTML(String(resolved))
119
+ }
120
+
121
+ // Generate the inline script that swaps a Suspense fallback with resolved content
122
+ function chunkScript(slotId: string, html: string): string {
123
+ return [
124
+ `<template data-g-chunk="${slotId}">${html}</template>`,
125
+ `<script>`,
126
+ `(function(){`,
127
+ ` var t=document.querySelector('[data-g-chunk="${slotId}"]');`,
128
+ ` var s=document.querySelector('[data-g-suspense="${slotId}"]');`,
129
+ ` if(t&&s){s.innerHTML=t.content.firstChild?'':'';s.replaceChildren(...t.content.childNodes);t.remove();s.removeAttribute('data-g-suspense')}`,
130
+ `})();`,
131
+ `</script>`,
132
+ ].join("")
133
+ }
134
+
135
+ export interface StreamOptions {
136
+ shell?: (body: string) => string
137
+ }
138
+
139
+ const defaultShell = (body: string) => `<!DOCTYPE html>
140
+ <html lang="en">
141
+ <head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Gorsee App</title></head>
142
+ <body><div id="app">${body}</div>`
143
+
144
+ const STREAM_TAIL = `</body></html>`
145
+
146
+ export function renderToStream(
147
+ root: unknown,
148
+ options?: StreamOptions
149
+ ): ReadableStream<Uint8Array> {
150
+ const shell = options?.shell ?? defaultShell
151
+ const encoder = new TextEncoder()
152
+ const ctx: StreamContext = { suspenseSlots: [], nextId: 0 }
153
+
154
+ return new ReadableStream({
155
+ async start(controller) {
156
+ // 1. Render shell synchronously (with fallbacks for Suspense boundaries)
157
+ const shellHtml = renderShellNode(root, ctx)
158
+ controller.enqueue(encoder.encode(shell(shellHtml)))
159
+
160
+ // 2. Resolve each Suspense slot and stream chunks
161
+ const promises = ctx.suspenseSlots.map(async (slot) => {
162
+ try {
163
+ const resolved = await slot.resolve()
164
+ const html = renderShellNode(resolved, ctx)
165
+ const script = chunkScript(slot.id, html)
166
+ controller.enqueue(encoder.encode(script))
167
+ } catch (err) {
168
+ const message = err instanceof Error ? err.message : String(err)
169
+ const errorHtml = `<div class="error">Error: ${escapeHTML(message)}</div>`
170
+ const script = chunkScript(slot.id, errorHtml)
171
+ controller.enqueue(encoder.encode(script))
172
+ }
173
+ })
174
+
175
+ await Promise.all(promises)
176
+
177
+ // 3. Close the stream
178
+ controller.enqueue(encoder.encode(STREAM_TAIL))
179
+ controller.close()
180
+ },
181
+ })
182
+ }
@@ -0,0 +1,37 @@
1
+ // Suspense component -- shows fallback while async resources load
2
+ // Works with createResource() from reactive module
3
+
4
+ import { createSignal } from "../reactive/signal.ts"
5
+
6
+ export interface SuspenseProps {
7
+ fallback: unknown
8
+ children: unknown
9
+ }
10
+
11
+ type AsyncChild = () => Promise<unknown>
12
+
13
+ // Client-side Suspense: renders fallback, then swaps to content when ready
14
+ export function Suspense(props: SuspenseProps): unknown {
15
+ const children = props.children
16
+
17
+ // If children is a function that returns a promise, handle async
18
+ if (typeof children === "function") {
19
+ const asyncFn = children as AsyncChild
20
+ const [resolved, setResolved] = createSignal<unknown>(null)
21
+ const [pending, setPending] = createSignal(true)
22
+
23
+ asyncFn().then((result) => {
24
+ setResolved(result)
25
+ setPending(false)
26
+ }).catch((err) => {
27
+ setResolved(err instanceof Error ? err.message : String(err))
28
+ setPending(false)
29
+ })
30
+
31
+ // Return a reactive choice between fallback and resolved
32
+ return () => pending() ? props.fallback : resolved()
33
+ }
34
+
35
+ // Synchronous children -- just return them
36
+ return children
37
+ }
@@ -0,0 +1,26 @@
1
+ // Runtime helpers for type-safe route links and navigation
2
+
3
+ /**
4
+ * Build a URL from a route pattern and params.
5
+ * Replaces [param] and [...param] placeholders with actual values.
6
+ */
7
+ export function typedLink(
8
+ path: string,
9
+ params: Record<string, string> = {},
10
+ ): string {
11
+ return path
12
+ .replace(/\[\.\.\.(\w+)\]/g, (_, key) => params[key] ?? "")
13
+ .replace(/\[(\w+)\]/g, (_, key) => encodeURIComponent(params[key] ?? ""))
14
+ }
15
+
16
+ /**
17
+ * Navigate to a typed route. Client-side only.
18
+ * Dynamically imports the router to avoid circular dependencies.
19
+ */
20
+ export function typedNavigate(
21
+ path: string,
22
+ params: Record<string, string> = {},
23
+ ): Promise<void> {
24
+ const url = typedLink(path, params)
25
+ return import("./router.ts").then((m) => m.navigate(url))
26
+ }
@@ -0,0 +1,106 @@
1
+ // Type-safe validated forms with branded types
2
+ // Combines client-side validation with server-side action safety
3
+
4
+ export interface FieldRule {
5
+ required?: boolean
6
+ minLength?: number
7
+ maxLength?: number
8
+ pattern?: RegExp
9
+ min?: number
10
+ max?: number
11
+ custom?: (value: string) => string | null // return error message or null
12
+ }
13
+
14
+ export interface FormField {
15
+ name: string
16
+ rules: FieldRule
17
+ label?: string
18
+ }
19
+
20
+ export interface FormSchema {
21
+ fields: FormField[]
22
+ }
23
+
24
+ export interface ValidationError {
25
+ field: string
26
+ message: string
27
+ }
28
+
29
+ export interface ValidationResult<T> {
30
+ valid: boolean
31
+ data: T | null
32
+ errors: ValidationError[]
33
+ }
34
+
35
+ /** Define a form schema for validation */
36
+ export function defineForm(fields: FormField[]): FormSchema {
37
+ return { fields }
38
+ }
39
+
40
+ function validateField(value: string | undefined, field: FormField): string | null {
41
+ const { rules, label } = field
42
+ const name = label ?? field.name
43
+ const v = value ?? ""
44
+
45
+ if (rules.required && !v.trim()) return `${name} is required`
46
+ if (!v && !rules.required) return null
47
+ if (rules.minLength !== undefined && v.length < rules.minLength)
48
+ return `${name} must be at least ${rules.minLength} characters`
49
+ if (rules.maxLength !== undefined && v.length > rules.maxLength)
50
+ return `${name} must be at most ${rules.maxLength} characters`
51
+ if (rules.pattern && !rules.pattern.test(v)) return `${name} format is invalid`
52
+ if (rules.min !== undefined && Number(v) < rules.min) return `${name} must be at least ${rules.min}`
53
+ if (rules.max !== undefined && Number(v) > rules.max) return `${name} must be at most ${rules.max}`
54
+ if (rules.custom) return rules.custom(v)
55
+ return null
56
+ }
57
+
58
+ /** Validate form data against schema */
59
+ export function validateForm<T extends Record<string, string>>(
60
+ data: Record<string, string>,
61
+ schema: FormSchema,
62
+ ): ValidationResult<T> {
63
+ const errors: ValidationError[] = []
64
+
65
+ for (const field of schema.fields) {
66
+ const error = validateField(data[field.name], field)
67
+ if (error) errors.push({ field: field.name, message: error })
68
+ }
69
+
70
+ return {
71
+ valid: errors.length === 0,
72
+ data: errors.length === 0 ? (data as T) : null,
73
+ errors,
74
+ }
75
+ }
76
+
77
+ /** Server action helper: parse request and validate */
78
+ export async function validateAction<T extends Record<string, string>>(
79
+ request: Request,
80
+ schema: FormSchema,
81
+ ): Promise<ValidationResult<T>> {
82
+ const contentType = request.headers.get("content-type") ?? ""
83
+ let data: Record<string, string>
84
+
85
+ if (contentType.includes("application/json")) {
86
+ data = await request.json()
87
+ } else {
88
+ const formData = await request.formData()
89
+ data = {}
90
+ formData.forEach((v, k) => { data[k] = String(v) })
91
+ }
92
+
93
+ return validateForm<T>(data, schema)
94
+ }
95
+
96
+ /** Generate client-side validation attributes for HTML inputs */
97
+ export function fieldAttrs(field: FormField): Record<string, unknown> {
98
+ const attrs: Record<string, unknown> = { name: field.name }
99
+ if (field.rules.required) attrs.required = true
100
+ if (field.rules.minLength !== undefined) attrs.minlength = field.rules.minLength
101
+ if (field.rules.maxLength !== undefined) attrs.maxlength = field.rules.maxLength
102
+ if (field.rules.pattern) attrs.pattern = field.rules.pattern.source
103
+ if (field.rules.min !== undefined) attrs.min = field.rules.min
104
+ if (field.rules.max !== undefined) attrs.max = field.rules.max
105
+ return attrs
106
+ }