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.
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/package.json +69 -0
- package/src/auth/index.ts +147 -0
- package/src/build/client.ts +121 -0
- package/src/build/css-modules.ts +69 -0
- package/src/build/devalue-parse.ts +2 -0
- package/src/build/rpc-transform.ts +62 -0
- package/src/build/server-strip.ts +87 -0
- package/src/build/ssg.ts +100 -0
- package/src/cli/bun-plugin.ts +37 -0
- package/src/cli/cmd-build.ts +182 -0
- package/src/cli/cmd-check.ts +225 -0
- package/src/cli/cmd-create.ts +313 -0
- package/src/cli/cmd-dev.ts +13 -0
- package/src/cli/cmd-generate.ts +147 -0
- package/src/cli/cmd-migrate.ts +45 -0
- package/src/cli/cmd-routes.ts +29 -0
- package/src/cli/cmd-start.ts +21 -0
- package/src/cli/cmd-typegen.ts +83 -0
- package/src/cli/framework-md.ts +196 -0
- package/src/cli/index.ts +84 -0
- package/src/db/index.ts +2 -0
- package/src/db/migrate.ts +89 -0
- package/src/db/sqlite.ts +40 -0
- package/src/deploy/dockerfile.ts +38 -0
- package/src/dev/error-overlay.ts +54 -0
- package/src/dev/hmr.ts +31 -0
- package/src/dev/partial-handler.ts +109 -0
- package/src/dev/request-handler.ts +158 -0
- package/src/dev/watcher.ts +48 -0
- package/src/dev.ts +273 -0
- package/src/env/index.ts +74 -0
- package/src/errors/catalog.ts +48 -0
- package/src/errors/formatter.ts +63 -0
- package/src/errors/index.ts +2 -0
- package/src/i18n/index.ts +72 -0
- package/src/index.ts +27 -0
- package/src/jsx-runtime-client.ts +13 -0
- package/src/jsx-runtime.ts +20 -0
- package/src/jsx-types-html.ts +242 -0
- package/src/log/index.ts +44 -0
- package/src/prod.ts +310 -0
- package/src/reactive/computed.ts +7 -0
- package/src/reactive/effect.ts +7 -0
- package/src/reactive/index.ts +7 -0
- package/src/reactive/live.ts +97 -0
- package/src/reactive/optimistic.ts +83 -0
- package/src/reactive/resource.ts +138 -0
- package/src/reactive/signal.ts +20 -0
- package/src/reactive/store.ts +36 -0
- package/src/router/index.ts +2 -0
- package/src/router/matcher.ts +53 -0
- package/src/router/scanner.ts +206 -0
- package/src/runtime/client.ts +28 -0
- package/src/runtime/error-boundary.ts +35 -0
- package/src/runtime/event-replay.ts +50 -0
- package/src/runtime/form.ts +49 -0
- package/src/runtime/head.ts +113 -0
- package/src/runtime/html-escape.ts +30 -0
- package/src/runtime/hydration.ts +95 -0
- package/src/runtime/image.ts +48 -0
- package/src/runtime/index.ts +12 -0
- package/src/runtime/island-hydrator.ts +84 -0
- package/src/runtime/island.ts +88 -0
- package/src/runtime/jsx-runtime.ts +167 -0
- package/src/runtime/link.ts +45 -0
- package/src/runtime/router.ts +224 -0
- package/src/runtime/server.ts +102 -0
- package/src/runtime/stream.ts +182 -0
- package/src/runtime/suspense.ts +37 -0
- package/src/runtime/typed-routes.ts +26 -0
- package/src/runtime/validated-form.ts +106 -0
- package/src/security/cors.ts +80 -0
- package/src/security/csrf.ts +85 -0
- package/src/security/headers.ts +50 -0
- package/src/security/index.ts +4 -0
- package/src/security/rate-limit.ts +80 -0
- package/src/server/action.ts +48 -0
- package/src/server/cache.ts +102 -0
- package/src/server/compress.ts +60 -0
- package/src/server/etag.ts +23 -0
- package/src/server/guard.ts +69 -0
- package/src/server/index.ts +19 -0
- package/src/server/middleware.ts +143 -0
- package/src/server/mime.ts +48 -0
- package/src/server/pipe.ts +46 -0
- package/src/server/rpc-hash.ts +17 -0
- package/src/server/rpc.ts +125 -0
- package/src/server/sse.ts +96 -0
- package/src/server/ws.ts +56 -0
- package/src/testing/index.ts +74 -0
- package/src/types/index.ts +4 -0
- package/src/types/safe-html.ts +32 -0
- package/src/types/safe-sql.ts +28 -0
- package/src/types/safe-url.ts +40 -0
- package/src/types/user-input.ts +12 -0
- package/src/unsafe/index.ts +18 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Hydration context -- cursor-based DOM reuse
|
|
2
|
+
// During hydration, jsx() reuses server-rendered DOM nodes
|
|
3
|
+
// instead of creating new ones, then attaches reactive bindings
|
|
4
|
+
|
|
5
|
+
interface HydrationCursor {
|
|
6
|
+
parent: Node
|
|
7
|
+
index: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const stack: HydrationCursor[] = []
|
|
11
|
+
let active = false
|
|
12
|
+
let mismatches = 0
|
|
13
|
+
|
|
14
|
+
export function isHydrating(): boolean {
|
|
15
|
+
return active
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getHydrationMismatches(): number {
|
|
19
|
+
return mismatches
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function enterHydration(root: Element): void {
|
|
23
|
+
active = true
|
|
24
|
+
mismatches = 0
|
|
25
|
+
stack.length = 0
|
|
26
|
+
stack.push({ parent: root, index: 0 })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function exitHydration(): void {
|
|
30
|
+
active = false
|
|
31
|
+
stack.length = 0
|
|
32
|
+
if (mismatches > 0 && typeof console !== "undefined") {
|
|
33
|
+
console.warn(`[gorsee] Hydration completed with ${mismatches} mismatch(es). Server and client HTML may differ.`)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function claimElement(expectedTag?: string): Element | null {
|
|
38
|
+
const cursor = stack[stack.length - 1]
|
|
39
|
+
if (!cursor) return null
|
|
40
|
+
|
|
41
|
+
// Skip whitespace-only text nodes and comments (server may differ from client)
|
|
42
|
+
while (cursor.index < cursor.parent.childNodes.length) {
|
|
43
|
+
const node = cursor.parent.childNodes[cursor.index]!
|
|
44
|
+
if (node.nodeType === 1) {
|
|
45
|
+
// Element node — check tag match if provided
|
|
46
|
+
if (expectedTag && (node as Element).tagName.toLowerCase() !== expectedTag.toLowerCase()) {
|
|
47
|
+
mismatches++
|
|
48
|
+
// Skip mismatched element and continue looking
|
|
49
|
+
cursor.index++
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
cursor.index++
|
|
53
|
+
return node as Element
|
|
54
|
+
}
|
|
55
|
+
if (node.nodeType === 3 && node.textContent?.trim() === "") {
|
|
56
|
+
cursor.index++
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
if (node.nodeType === 8) {
|
|
60
|
+
// Comment node -- skip
|
|
61
|
+
cursor.index++
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
break
|
|
65
|
+
}
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function claimText(): Text | null {
|
|
70
|
+
const cursor = stack[stack.length - 1]
|
|
71
|
+
if (!cursor) return null
|
|
72
|
+
|
|
73
|
+
while (cursor.index < cursor.parent.childNodes.length) {
|
|
74
|
+
const node = cursor.parent.childNodes[cursor.index]!
|
|
75
|
+
if (node.nodeType === 3) {
|
|
76
|
+
cursor.index++
|
|
77
|
+
return node as Text
|
|
78
|
+
}
|
|
79
|
+
// Skip comments
|
|
80
|
+
if (node.nodeType === 8) {
|
|
81
|
+
cursor.index++
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
break
|
|
85
|
+
}
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function pushCursor(parent: Node): void {
|
|
90
|
+
stack.push({ parent, index: 0 })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function popCursor(): void {
|
|
94
|
+
stack.pop()
|
|
95
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// <Image> component -- optimized image rendering
|
|
2
|
+
// Server: renders <img> with lazy loading, width/height, srcset
|
|
3
|
+
// Prevents layout shift with explicit dimensions
|
|
4
|
+
|
|
5
|
+
export interface ImageProps {
|
|
6
|
+
src: string
|
|
7
|
+
alt: string
|
|
8
|
+
width?: number
|
|
9
|
+
height?: number
|
|
10
|
+
loading?: "lazy" | "eager"
|
|
11
|
+
priority?: boolean
|
|
12
|
+
sizes?: string
|
|
13
|
+
class?: string
|
|
14
|
+
className?: string
|
|
15
|
+
[key: string]: unknown
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Image(props: ImageProps): unknown {
|
|
19
|
+
const {
|
|
20
|
+
src,
|
|
21
|
+
alt,
|
|
22
|
+
width,
|
|
23
|
+
height,
|
|
24
|
+
loading: loadingProp,
|
|
25
|
+
priority,
|
|
26
|
+
sizes,
|
|
27
|
+
...rest
|
|
28
|
+
} = props
|
|
29
|
+
|
|
30
|
+
const loading = priority ? "eager" : (loadingProp ?? "lazy")
|
|
31
|
+
const fetchpriority = priority ? "high" : undefined
|
|
32
|
+
const decoding = priority ? "sync" : "async"
|
|
33
|
+
|
|
34
|
+
const imgProps: Record<string, unknown> = {
|
|
35
|
+
src,
|
|
36
|
+
alt,
|
|
37
|
+
loading,
|
|
38
|
+
decoding,
|
|
39
|
+
...rest,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (width !== undefined) imgProps.width = String(width)
|
|
43
|
+
if (height !== undefined) imgProps.height = String(height)
|
|
44
|
+
if (sizes) imgProps.sizes = sizes
|
|
45
|
+
if (fetchpriority) imgProps.fetchpriority = fetchpriority
|
|
46
|
+
|
|
47
|
+
return { type: "img", props: imgProps }
|
|
48
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { Suspense } from "./suspense.ts"
|
|
2
|
+
export { render, hydrate } from "./client.ts"
|
|
3
|
+
export { enterHydration, exitHydration, isHydrating } from "./hydration.ts"
|
|
4
|
+
export { renderToString, ssrJsx, ssrJsxs } from "./server.ts"
|
|
5
|
+
export { renderToStream, StreamSuspense, streamJsx, streamJsxs } from "./stream.ts"
|
|
6
|
+
export { EVENT_REPLAY_SCRIPT, replayEvents, stopEventCapture } from "./event-replay.ts"
|
|
7
|
+
export { Link } from "./link.ts"
|
|
8
|
+
export { Head } from "./head.ts"
|
|
9
|
+
export { navigate, onNavigate, beforeNavigate, getCurrentPath, prefetch, initRouter, setLoadingElement } from "./router.ts"
|
|
10
|
+
export { useFormAction } from "./form.ts"
|
|
11
|
+
export { island, isIsland } from "./island.ts"
|
|
12
|
+
export { hydrateIslands, registerIsland } from "./island-hydrator.ts"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Client-side island hydration
|
|
2
|
+
// Scans DOM for data-island elements, hydrates each independently
|
|
3
|
+
// Static content around islands stays untouched (zero JS)
|
|
4
|
+
|
|
5
|
+
import { enterHydration, exitHydration } from "./hydration.ts"
|
|
6
|
+
import { replayEvents } from "./event-replay.ts"
|
|
7
|
+
|
|
8
|
+
type IslandLoader = () => Promise<{ default: (props: Record<string, unknown>) => unknown }>
|
|
9
|
+
|
|
10
|
+
const islandRegistry = new Map<string, IslandLoader>()
|
|
11
|
+
|
|
12
|
+
/** Register an island component loader by name. */
|
|
13
|
+
export function registerIsland(name: string, loader: IslandLoader): void {
|
|
14
|
+
islandRegistry.set(name, loader)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Parse the escaped JSON props from a data-props attribute. */
|
|
18
|
+
function parseIslandProps(raw: string): Record<string, unknown> {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(raw) as Record<string, unknown>
|
|
21
|
+
} catch {
|
|
22
|
+
console.warn(`[gorsee] Failed to parse island props: ${raw}`)
|
|
23
|
+
return {}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Hydrate a single island element. */
|
|
28
|
+
async function hydrateOne(el: Element): Promise<void> {
|
|
29
|
+
const name = el.getAttribute("data-island")
|
|
30
|
+
if (!name) return
|
|
31
|
+
|
|
32
|
+
const loader = islandRegistry.get(name)
|
|
33
|
+
if (!loader) {
|
|
34
|
+
console.warn(`[gorsee] Island "${name}" not found in registry`)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const mod = await loader()
|
|
39
|
+
const component = mod.default
|
|
40
|
+
const rawProps = el.getAttribute("data-props") ?? "{}"
|
|
41
|
+
const props = parseIslandProps(rawProps)
|
|
42
|
+
|
|
43
|
+
enterHydration(el as HTMLElement)
|
|
44
|
+
component(props)
|
|
45
|
+
exitHydration()
|
|
46
|
+
|
|
47
|
+
replayEvents(el as HTMLElement)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Hydrate all island components on the page.
|
|
52
|
+
* Finds all elements with data-island attribute and hydrates each independently.
|
|
53
|
+
* Lazy islands use IntersectionObserver to defer until visible.
|
|
54
|
+
*/
|
|
55
|
+
export function hydrateIslands(): void {
|
|
56
|
+
const islands = document.querySelectorAll("[data-island]")
|
|
57
|
+
|
|
58
|
+
for (let i = 0; i < islands.length; i++) {
|
|
59
|
+
const el = islands[i]!
|
|
60
|
+
const isLazy = el.hasAttribute("data-island-lazy")
|
|
61
|
+
|
|
62
|
+
if (isLazy && typeof IntersectionObserver !== "undefined") {
|
|
63
|
+
observeLazy(el)
|
|
64
|
+
} else {
|
|
65
|
+
void hydrateOne(el)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Observe a lazy island and hydrate when it enters the viewport. */
|
|
71
|
+
function observeLazy(el: Element): void {
|
|
72
|
+
const observer = new IntersectionObserver(
|
|
73
|
+
(entries) => {
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
if (entry.isIntersecting) {
|
|
76
|
+
observer.unobserve(el)
|
|
77
|
+
void hydrateOne(el)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
{ rootMargin: "200px" },
|
|
82
|
+
)
|
|
83
|
+
observer.observe(el)
|
|
84
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Island component wrapper -- marks components for client-side hydration
|
|
2
|
+
// Usage: export default island(MyComponent) in route files
|
|
3
|
+
// Server: renders component with data-island attribute + serialized props
|
|
4
|
+
// Client: returns wrapped element for hydration
|
|
5
|
+
|
|
6
|
+
import type { VNode } from "./server.ts"
|
|
7
|
+
|
|
8
|
+
export interface IslandOptions {
|
|
9
|
+
lazy?: boolean // load island JS lazily (IntersectionObserver)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Symbol to mark island wrappers for identification
|
|
13
|
+
export const ISLAND_MARKER = Symbol("gorsee-island")
|
|
14
|
+
|
|
15
|
+
interface IslandWrapper<P extends Record<string, unknown>> {
|
|
16
|
+
(props: P): unknown
|
|
17
|
+
[ISLAND_MARKER]: true
|
|
18
|
+
componentName: string
|
|
19
|
+
originalComponent: (props: P) => unknown
|
|
20
|
+
options: IslandOptions
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Escape a JSON string for safe embedding in HTML data attributes.
|
|
25
|
+
* Prevents breaking out of attribute values or injecting HTML.
|
|
26
|
+
*/
|
|
27
|
+
function escapePropsForAttr(json: string): string {
|
|
28
|
+
return json
|
|
29
|
+
.replace(/&/g, "&")
|
|
30
|
+
.replace(/"/g, """)
|
|
31
|
+
.replace(/</g, "<")
|
|
32
|
+
.replace(/>/g, ">")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Wrap a component as an island -- only this component gets hydrated on the client.
|
|
37
|
+
* Static surrounding content stays as server-rendered HTML with zero JS.
|
|
38
|
+
*/
|
|
39
|
+
export function island<P extends Record<string, unknown>>(
|
|
40
|
+
component: (props: P) => unknown,
|
|
41
|
+
options: IslandOptions = {},
|
|
42
|
+
): IslandWrapper<P> {
|
|
43
|
+
const name = component.name || "Anonymous"
|
|
44
|
+
|
|
45
|
+
const wrapper = function islandWrapper(props: P): unknown {
|
|
46
|
+
const propsWithoutChildren = extractSerializableProps(props)
|
|
47
|
+
const serialized = escapePropsForAttr(JSON.stringify(propsWithoutChildren))
|
|
48
|
+
|
|
49
|
+
const attrs: Record<string, unknown> = {
|
|
50
|
+
"data-island": name,
|
|
51
|
+
"data-props": serialized,
|
|
52
|
+
children: component(props),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (options.lazy) {
|
|
56
|
+
attrs["data-island-lazy"] = "true"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// On server: ssrJsx produces VNode; on client: jsx produces DOM node
|
|
60
|
+
// We return a plain object that both renderers understand
|
|
61
|
+
return { type: "div", props: attrs } as VNode
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
wrapper[ISLAND_MARKER] = true as const
|
|
65
|
+
wrapper.componentName = name
|
|
66
|
+
wrapper.originalComponent = component
|
|
67
|
+
wrapper.options = options
|
|
68
|
+
|
|
69
|
+
return wrapper
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Extract only serializable props (skip children, functions, symbols). */
|
|
73
|
+
function extractSerializableProps(
|
|
74
|
+
props: Record<string, unknown>,
|
|
75
|
+
): Record<string, unknown> {
|
|
76
|
+
const result: Record<string, unknown> = {}
|
|
77
|
+
for (const key in props) {
|
|
78
|
+
if (key === "children") continue
|
|
79
|
+
const val = props[key]
|
|
80
|
+
if (typeof val === "function" || typeof val === "symbol") continue
|
|
81
|
+
result[key] = val
|
|
82
|
+
}
|
|
83
|
+
return result
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isIsland(fn: unknown): fn is IslandWrapper<Record<string, unknown>> {
|
|
87
|
+
return typeof fn === "function" && ISLAND_MARKER in fn
|
|
88
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Gorsee.js JSX Runtime
|
|
2
|
+
// Compiles JSX to direct DOM operations with reactive bindings
|
|
3
|
+
// No Virtual DOM -- signals update DOM nodes directly
|
|
4
|
+
// Supports hydration mode: reuses server-rendered DOM nodes
|
|
5
|
+
|
|
6
|
+
import { createEffect } from "../reactive/effect.ts"
|
|
7
|
+
import { isHydrating, claimElement, claimText, pushCursor, popCursor } from "./hydration.ts"
|
|
8
|
+
import { isSignal } from "./html-escape.ts"
|
|
9
|
+
|
|
10
|
+
export type GorseeNode = Node | string | number | boolean | null | undefined | GorseeNode[]
|
|
11
|
+
export type Component = (props: Record<string, unknown>) => GorseeNode
|
|
12
|
+
|
|
13
|
+
export const Fragment = Symbol("Fragment")
|
|
14
|
+
|
|
15
|
+
export interface JSXElement {
|
|
16
|
+
type: string | Component | typeof Fragment
|
|
17
|
+
props: Record<string, unknown>
|
|
18
|
+
children: unknown[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function createTextNode(value: unknown): Text {
|
|
22
|
+
return document.createTextNode(String(value ?? ""))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function bindProperty(el: HTMLElement, key: string, value: unknown): void {
|
|
26
|
+
if (key === "children" || key === "ref") return
|
|
27
|
+
|
|
28
|
+
if (key.startsWith("on:")) {
|
|
29
|
+
el.addEventListener(key.slice(3), value as EventListener)
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (key === "className" || key === "class") {
|
|
34
|
+
if (isSignal(value)) {
|
|
35
|
+
createEffect(() => { el.className = String(value()) })
|
|
36
|
+
} else if (!isHydrating()) {
|
|
37
|
+
el.className = String(value ?? "")
|
|
38
|
+
}
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (key === "style" && typeof value === "object" && value !== null) {
|
|
43
|
+
const styles = value as Record<string, unknown>
|
|
44
|
+
for (const [prop, val] of Object.entries(styles)) {
|
|
45
|
+
if (isSignal(val)) {
|
|
46
|
+
const p = prop
|
|
47
|
+
createEffect(() => { el.style.setProperty(p, String(val())) })
|
|
48
|
+
} else if (!isHydrating()) {
|
|
49
|
+
el.style.setProperty(prop, String(val))
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (isSignal(value)) {
|
|
56
|
+
createEffect(() => { el.setAttribute(key, String(value())) })
|
|
57
|
+
} else if (!isHydrating()) {
|
|
58
|
+
if (typeof value === "boolean") {
|
|
59
|
+
if (value) el.setAttribute(key, "")
|
|
60
|
+
else el.removeAttribute(key)
|
|
61
|
+
} else if (value != null) {
|
|
62
|
+
el.setAttribute(key, String(value))
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hydrateChild(parent: Node, child: unknown): void {
|
|
68
|
+
if (child == null || typeof child === "boolean") return
|
|
69
|
+
|
|
70
|
+
if (Array.isArray(child)) {
|
|
71
|
+
for (const c of child) hydrateChild(parent, c)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (typeof child === "function" && isSignal(child)) {
|
|
76
|
+
const textNode = claimText() ?? createTextNode(child())
|
|
77
|
+
createEffect(() => { textNode.textContent = String(child() ?? "") })
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (typeof child === "object" && child instanceof Node) return
|
|
82
|
+
|
|
83
|
+
// Static text -- just advance cursor
|
|
84
|
+
claimText()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function insertChild(parent: Node, child: unknown): void {
|
|
88
|
+
if (child == null || typeof child === "boolean") return
|
|
89
|
+
|
|
90
|
+
if (Array.isArray(child)) {
|
|
91
|
+
for (const c of child) insertChild(parent, c)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (child instanceof Node) {
|
|
96
|
+
parent.appendChild(child)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (isSignal(child)) {
|
|
101
|
+
const textNode = createTextNode(child())
|
|
102
|
+
parent.appendChild(textNode)
|
|
103
|
+
createEffect(() => { textNode.textContent = String(child() ?? "") })
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
parent.appendChild(createTextNode(child))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function jsx(
|
|
111
|
+
type: string | Component | typeof Fragment,
|
|
112
|
+
props: Record<string, unknown> | null
|
|
113
|
+
): Node | DocumentFragment {
|
|
114
|
+
const allProps = props ?? {}
|
|
115
|
+
const children = allProps.children
|
|
116
|
+
const hydrating = isHydrating()
|
|
117
|
+
|
|
118
|
+
// Fragment
|
|
119
|
+
if (type === Fragment) {
|
|
120
|
+
if (hydrating) {
|
|
121
|
+
if (children != null) hydrateChild(document.createDocumentFragment(), children)
|
|
122
|
+
return document.createDocumentFragment()
|
|
123
|
+
}
|
|
124
|
+
const frag = document.createDocumentFragment()
|
|
125
|
+
if (children != null) insertChild(frag, children)
|
|
126
|
+
return frag
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Component
|
|
130
|
+
if (typeof type === "function") {
|
|
131
|
+
const result = type(allProps)
|
|
132
|
+
if (result instanceof Node) return result
|
|
133
|
+
if (hydrating) return document.createDocumentFragment()
|
|
134
|
+
const wrapper = document.createDocumentFragment()
|
|
135
|
+
insertChild(wrapper, result)
|
|
136
|
+
return wrapper
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// HTML element
|
|
140
|
+
if (hydrating) {
|
|
141
|
+
const el = claimElement() as HTMLElement
|
|
142
|
+
if (el) {
|
|
143
|
+
// Attach event listeners and reactive bindings to existing element
|
|
144
|
+
for (const [key, value] of Object.entries(allProps)) {
|
|
145
|
+
if (key !== "children") bindProperty(el, key, value)
|
|
146
|
+
}
|
|
147
|
+
// Hydrate children
|
|
148
|
+
if (children != null) {
|
|
149
|
+
pushCursor(el)
|
|
150
|
+
hydrateChild(el, children)
|
|
151
|
+
popCursor()
|
|
152
|
+
}
|
|
153
|
+
return el
|
|
154
|
+
}
|
|
155
|
+
// Fallback: element missing from server HTML, create it
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const el = document.createElement(type)
|
|
159
|
+
for (const [key, value] of Object.entries(allProps)) {
|
|
160
|
+
if (key !== "children") bindProperty(el, key, value)
|
|
161
|
+
}
|
|
162
|
+
if (children != null) insertChild(el, children)
|
|
163
|
+
return el
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const jsxs = jsx
|
|
167
|
+
export const jsxDEV = jsx
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// <Link> component -- SPA navigation link with prefetch
|
|
2
|
+
// Server: renders <a> tag
|
|
3
|
+
// Client: renders <a> tag with router integration
|
|
4
|
+
|
|
5
|
+
import { navigate, prefetch } from "./router.ts"
|
|
6
|
+
|
|
7
|
+
export interface LinkProps {
|
|
8
|
+
href: string
|
|
9
|
+
children?: unknown
|
|
10
|
+
class?: string
|
|
11
|
+
className?: string
|
|
12
|
+
prefetch?: boolean | "hover" | "viewport"
|
|
13
|
+
replace?: boolean
|
|
14
|
+
target?: string
|
|
15
|
+
[key: string]: unknown
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Client-side Link component
|
|
19
|
+
export function Link(props: LinkProps): unknown {
|
|
20
|
+
const { href, prefetch: prefetchMode, replace, children, ...rest } = props
|
|
21
|
+
|
|
22
|
+
// Eagerly prefetch if requested
|
|
23
|
+
if (prefetchMode === true && typeof window !== "undefined") {
|
|
24
|
+
prefetch(href)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
type: "a",
|
|
29
|
+
props: {
|
|
30
|
+
href,
|
|
31
|
+
"on:click": (e: MouseEvent) => {
|
|
32
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return
|
|
33
|
+
const target = (e.currentTarget as HTMLAnchorElement)?.target
|
|
34
|
+
if (target && target !== "_self") return
|
|
35
|
+
e.preventDefault()
|
|
36
|
+
if (replace) {
|
|
37
|
+
history.replaceState({ gorsee: true }, "", href)
|
|
38
|
+
}
|
|
39
|
+
navigate(href, !replace)
|
|
40
|
+
},
|
|
41
|
+
...rest,
|
|
42
|
+
children,
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
}
|