effect-start 0.16.0 → 0.17.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.
@@ -120,8 +120,13 @@ export function renderToString(
120
120
  result += ` ${esc(key)}`
121
121
  } else {
122
122
  const resolvedKey = key === "className" ? "class" : key
123
+ const value = props[key]
123
124
 
124
- result += ` ${esc(resolvedKey)}="${esc(props[key])}"`
125
+ if (key.startsWith("data-") && typeof value === "object") {
126
+ result += ` ${esc(resolvedKey)}="${esc(JSON.stringify(value))}"`
127
+ } else {
128
+ result += ` ${esc(resolvedKey)}="${esc(value)}"`
129
+ }
125
130
  }
126
131
  }
127
132
  }
@@ -139,7 +144,10 @@ export function renderToString(
139
144
  result += html
140
145
  } else {
141
146
  const children = props.children
142
- if (Array.isArray(children)) {
147
+
148
+ if (type === "script" && typeof children === "function") {
149
+ result += `(${children.toString()})(window)`
150
+ } else if (Array.isArray(children)) {
143
151
  for (let i = children.length - 1; i >= 0; i--) {
144
152
  stack.push(children[i])
145
153
  }
@@ -12,6 +12,8 @@ export type Props = {
12
12
  | Primitive
13
13
  | HyperNode
14
14
  | Iterable<Primitive | HyperNode>
15
+ | Record<string, unknown>
16
+ | ((window: Window) => void)
15
17
  }
16
18
 
17
19
  export type HyperComponent = (
@@ -0,0 +1,197 @@
1
+ /** @jsxImportSource effect-start */
2
+ import * as test from "bun:test"
3
+ import * as Effect from "effect/Effect"
4
+ import * as Entity from "../Entity.ts"
5
+ import * as Http from "../Http.ts"
6
+ import * as Route from "../Route.ts"
7
+ import * as RouteHttp from "../RouteHttp.ts"
8
+ import * as HyperRoute from "./HyperRoute.ts"
9
+
10
+ test.describe("HyperRoute.html", () => {
11
+ test.it("renders JSX to HTML string", async () => {
12
+ const handler = RouteHttp.toWebHandler(
13
+ Route.get(
14
+ HyperRoute.html(
15
+ <div>
16
+ Hello World
17
+ </div>,
18
+ ),
19
+ ),
20
+ )
21
+
22
+ const response = await Http.fetch(handler, { path: "/" })
23
+
24
+ test.expect(response.status).toBe(200)
25
+ test.expect(response.headers.get("Content-Type")).toBe("text/html; charset=utf-8")
26
+ test.expect(await response.text()).toBe("<div>Hello World</div>")
27
+ })
28
+
29
+ test.it("renders nested JSX elements", async () => {
30
+ const handler = RouteHttp.toWebHandler(
31
+ Route.get(
32
+ HyperRoute.html(
33
+ <div class="container">
34
+ <h1>
35
+ Title
36
+ </h1>
37
+ <p>
38
+ Paragraph
39
+ </p>
40
+ </div>,
41
+ ),
42
+ ),
43
+ )
44
+
45
+ const response = await Http.fetch(handler, { path: "/" })
46
+
47
+ test.expect(await response.text()).toBe(
48
+ "<div class=\"container\"><h1>Title</h1><p>Paragraph</p></div>",
49
+ )
50
+ })
51
+
52
+ test.it("renders JSX from Effect", async () => {
53
+ const handler = RouteHttp.toWebHandler(
54
+ Route.get(
55
+ HyperRoute.html(
56
+ Effect.succeed(
57
+ <span>
58
+ From Effect
59
+ </span>,
60
+ ),
61
+ ),
62
+ ),
63
+ )
64
+
65
+ const response = await Http.fetch(handler, { path: "/" })
66
+
67
+ test.expect(await response.text()).toBe("<span>From Effect</span>")
68
+ })
69
+
70
+ test.it("renders JSX from generator function", async () => {
71
+ const handler = RouteHttp.toWebHandler(
72
+ Route.get(
73
+ HyperRoute.html(
74
+ Effect.gen(function*() {
75
+ const name = yield* Effect.succeed("World")
76
+ return (
77
+ <div>
78
+ Hello {name}
79
+ </div>
80
+ )
81
+ }),
82
+ ),
83
+ ),
84
+ )
85
+
86
+ const response = await Http.fetch(handler, { path: "/" })
87
+
88
+ test.expect(await response.text()).toBe("<div>Hello World</div>")
89
+ })
90
+
91
+ test.it("renders JSX from handler function", async () => {
92
+ const handler = RouteHttp.toWebHandler(
93
+ Route.get(
94
+ HyperRoute.html((context) =>
95
+ Effect.succeed(
96
+ <div>
97
+ Request received
98
+ </div>,
99
+ )
100
+ ),
101
+ ),
102
+ )
103
+
104
+ const response = await Http.fetch(handler, { path: "/" })
105
+
106
+ test.expect(await response.text()).toBe("<div>Request received</div>")
107
+ })
108
+
109
+ test.it("renders JSX with dynamic content", async () => {
110
+ const items = ["Apple", "Banana", "Cherry"]
111
+
112
+ const handler = RouteHttp.toWebHandler(
113
+ Route.get(
114
+ HyperRoute.html(
115
+ <ul>
116
+ {items.map((item) => (
117
+ <li>
118
+ {item}
119
+ </li>
120
+ ))}
121
+ </ul>,
122
+ ),
123
+ ),
124
+ )
125
+
126
+ const response = await Http.fetch(handler, { path: "/" })
127
+
128
+ test.expect(await response.text()).toBe(
129
+ "<ul><li>Apple</li><li>Banana</li><li>Cherry</li></ul>",
130
+ )
131
+ })
132
+
133
+ test.it("handles Entity with JSX body", async () => {
134
+ const handler = RouteHttp.toWebHandler(
135
+ Route.get(
136
+ HyperRoute.html(
137
+ Entity.make(
138
+ <div>
139
+ With Entity
140
+ </div>,
141
+ { status: 201 },
142
+ ),
143
+ ),
144
+ ),
145
+ )
146
+
147
+ const response = await Http.fetch(handler, { path: "/" })
148
+
149
+ test.expect(response.status).toBe(201)
150
+ test.expect(await response.text()).toBe("<div>With Entity</div>")
151
+ })
152
+
153
+ test.it("renders data-* attributes with object values as JSON", async () => {
154
+ const handler = RouteHttp.toWebHandler(
155
+ Route.get(
156
+ HyperRoute.html(
157
+ <div
158
+ data-signals={{
159
+ draft: "",
160
+ pendingDraft: "",
161
+ username: "User123",
162
+ }}
163
+ >
164
+ Content
165
+ </div>,
166
+ ),
167
+ ),
168
+ )
169
+
170
+ const response = await Http.fetch(handler, { path: "/" })
171
+
172
+ test.expect(await response.text()).toBe(
173
+ "<div data-signals=\"{&quot;draft&quot;:&quot;&quot;,&quot;pendingDraft&quot;:&quot;&quot;,&quot;username&quot;:&quot;User123&quot;}\">Content</div>",
174
+ )
175
+ })
176
+
177
+ test.it("renders script with function child as IIFE", async () => {
178
+ const handler = RouteHttp.toWebHandler(
179
+ Route.get(
180
+ HyperRoute.html(
181
+ <script>
182
+ {(window) => {
183
+ console.log("Hello from", window.document.title)
184
+ }}
185
+ </script>,
186
+ ),
187
+ ),
188
+ )
189
+
190
+ const response = await Http.fetch(handler, { path: "/" })
191
+ const text = await response.text()
192
+
193
+ test.expect(text).toContain("<script>(")
194
+ test.expect(text).toContain(")(window)</script>")
195
+ test.expect(text).toContain("window.document.title")
196
+ })
197
+ })
@@ -0,0 +1,61 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Entity from "../Entity.ts"
3
+ import * as Route from "../Route.ts"
4
+ import type * as RouteBody from "../RouteBody.ts"
5
+ import * as HyperHtml from "./HyperHtml.ts"
6
+ import type { JSX } from "./jsx.d.ts"
7
+
8
+ function renderValue(
9
+ value: JSX.Children | Entity.Entity<JSX.Children>,
10
+ ): string | Entity.Entity<string> {
11
+ if (Entity.isEntity(value)) {
12
+ return Entity.make(HyperHtml.renderToString(value.body), {
13
+ status: value.status,
14
+ url: value.url,
15
+ headers: value.headers,
16
+ })
17
+ }
18
+ return HyperHtml.renderToString(value)
19
+ }
20
+
21
+ function normalizeToEffect<B, A, E, R>(
22
+ handler: RouteBody.HandlerInput<B, A, E, R>,
23
+ context: B,
24
+ next: (context?: Partial<B> & Record<string, unknown>) => Entity.Entity<A>,
25
+ ): Effect.Effect<A | Entity.Entity<A>, E, R> {
26
+ if (Effect.isEffect(handler)) {
27
+ return handler
28
+ }
29
+ if (typeof handler === "function") {
30
+ const result = (handler as Function)(context, next)
31
+ if (Effect.isEffect(result)) {
32
+ return result as Effect.Effect<A | Entity.Entity<A>, E, R>
33
+ }
34
+ return Effect.gen(function*() {
35
+ return yield* result
36
+ }) as Effect.Effect<A | Entity.Entity<A>, E, R>
37
+ }
38
+ return Effect.succeed(handler as A | Entity.Entity<A>)
39
+ }
40
+
41
+ export function html<
42
+ D extends Route.RouteDescriptor.Any,
43
+ B extends {},
44
+ I extends Route.Route.Tuple,
45
+ E = never,
46
+ R = never,
47
+ >(
48
+ handler: RouteBody.HandlerInput<
49
+ NoInfer<D & B & Route.ExtractBindings<I> & { format: "html" }>,
50
+ JSX.Children,
51
+ E,
52
+ R
53
+ >,
54
+ ) {
55
+ return Route.html<D, B, I, string, E, R>((context, next) =>
56
+ Effect.map(
57
+ normalizeToEffect(handler, context, next as never),
58
+ renderValue,
59
+ )
60
+ )
61
+ }
@@ -0,0 +1,4 @@
1
+ export * as Hyper from "./Hyper.ts"
2
+ export * as HyperHtml from "./HyperHtml.ts"
3
+ export * as HyperNode from "./HyperNode.ts"
4
+ export * as HyperRoute from "./HyperRoute.ts"
@@ -1818,6 +1818,21 @@ export namespace JSX {
1818
1818
  event?: string | undefined
1819
1819
  /** @deprecated */
1820
1820
  language?: string | undefined
1821
+
1822
+ children?: Children
1823
+ }
1824
+
1825
+ // Separate interface for script elements with function children.
1826
+ // This enables TypeScript to infer the `window` parameter type.
1827
+ //
1828
+ // Using a union in a single interface (`children?: Function | Children`)
1829
+ // doesn't work because TS can't infer callback parameter types from unions.
1830
+ // By splitting into two interfaces, TS can discriminate based on children
1831
+ interface ScriptHTMLAttributesWithHandler<T>
1832
+ extends Omit<ScriptHTMLAttributes<T>, "children" | "type">
1833
+ {
1834
+ children: (window: Window) => void
1835
+ type?: never
1821
1836
  }
1822
1837
  interface SelectHTMLAttributes<T> extends HTMLAttributes<T> {
1823
1838
  autocomplete?: HTMLAutocomplete | undefined
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * as Bundle from "./bundler/Bundle.ts"
2
+ export * as Development from "./Development.ts"
2
3
  export * as Entity from "./Entity.ts"
3
4
  export * as FileRouter from "./FileRouter.ts"
4
5
  export * as Route from "./Route.ts"
@@ -1,3 +1,6 @@
1
+ /*
2
+ * Adapted from @effect/platform
3
+ */
1
4
  import { effectify } from "@effect/platform/Effectify"
2
5
  import * as Error from "@effect/platform/Error"
3
6
  import type {
@@ -715,6 +718,11 @@ const makeFileSystem = Effect.map(
715
718
 
716
719
  export const layer = Layer.effect(FileSystem.FileSystem, makeFileSystem)
717
720
 
721
+ export {
722
+ Error,
723
+ FileSystem,
724
+ }
725
+
718
726
  export function handleErrnoException(
719
727
  module: SystemError["module"],
720
728
  method: string,
@@ -1,242 +0,0 @@
1
- import * as FileSystem from "@effect/platform/FileSystem"
2
- import * as test from "bun:test"
3
- import { MemoryFileSystem } from "effect-memfs"
4
- import * as Chunk from "effect/Chunk"
5
- import * as Effect from "effect/Effect"
6
- import * as Fiber from "effect/Fiber"
7
- import * as Function from "effect/Function"
8
- import * as Stream from "effect/Stream"
9
- import * as FileSystemExtra from "./FileSystemExtra.ts"
10
-
11
- test.describe(`${FileSystemExtra.watchSource.name}`, () => {
12
- test.it("emits events for file creation", () =>
13
- Effect
14
- .gen(function*() {
15
- const fs = yield* FileSystem.FileSystem
16
- const watchDir = "/watch-test"
17
-
18
- const fiber = yield* Function.pipe(
19
- FileSystemExtra.watchSource({ path: watchDir }),
20
- Stream.take(1),
21
- Stream.runCollect,
22
- Effect.fork,
23
- )
24
-
25
- yield* Effect.sleep(1)
26
-
27
- yield* fs.writeFileString(`${watchDir}/test.ts`, "const x = 1")
28
-
29
- const events = yield* Fiber.join(fiber)
30
-
31
- test
32
- .expect(Chunk.size(events))
33
- .toBeGreaterThan(0)
34
- const first = Chunk.unsafeGet(events, 0)
35
- test
36
- .expect(first.path)
37
- .toContain("test.ts")
38
- test
39
- .expect(["rename", "change"])
40
- .toContain(first.eventType)
41
- test
42
- .expect(first.filename)
43
- .toBe("test.ts")
44
- })
45
- .pipe(
46
- Effect.scoped,
47
- Effect.provide(
48
- MemoryFileSystem.layerWith({ "/watch-test/.gitkeep": "" }),
49
- ),
50
- Effect.runPromise,
51
- ))
52
-
53
- test.it(
54
- "emits change event for file modification",
55
- () =>
56
- Effect
57
- .gen(function*() {
58
- const fs = yield* FileSystem.FileSystem
59
- const watchDir = "/watch-mod"
60
- const filePath = `${watchDir}/file.ts`
61
-
62
- const fiber = yield* Function.pipe(
63
- FileSystemExtra.watchSource({ path: watchDir }),
64
- Stream.take(1),
65
- Stream.runCollect,
66
- Effect.fork,
67
- )
68
-
69
- yield* Effect.sleep(1)
70
- yield* fs.writeFileString(filePath, "modified")
71
-
72
- const events = yield* Fiber.join(fiber)
73
-
74
- test
75
- .expect(Chunk.size(events))
76
- .toBeGreaterThan(0)
77
- test
78
- .expect(Chunk.unsafeGet(events, 0).eventType)
79
- .toBe("change")
80
- })
81
- .pipe(
82
- Effect.scoped,
83
- Effect.provide(
84
- MemoryFileSystem.layerWith({ "/watch-mod/file.ts": "initial" }),
85
- ),
86
- Effect.runPromise,
87
- ),
88
- )
89
-
90
- test.it("applies custom filter", () =>
91
- Effect
92
- .gen(function*() {
93
- const fs = yield* FileSystem.FileSystem
94
- const watchDir = "/watch-filter"
95
-
96
- const fiber = yield* Function.pipe(
97
- FileSystemExtra.watchSource({
98
- path: watchDir,
99
- filter: FileSystemExtra.filterSourceFiles,
100
- }),
101
- Stream.take(1),
102
- Stream.runCollect,
103
- Effect.fork,
104
- )
105
-
106
- yield* Effect.sleep(1)
107
- yield* fs.writeFileString(`${watchDir}/ignored.txt`, "ignored")
108
- yield* Effect.sleep(1)
109
- yield* fs.writeFileString(`${watchDir}/included.ts`, "included")
110
-
111
- const events = yield* Fiber.join(fiber)
112
-
113
- test
114
- .expect(Chunk.size(events))
115
- .toBe(1)
116
- test
117
- .expect(Chunk.unsafeGet(events, 0).path)
118
- .toContain("included.ts")
119
- })
120
- .pipe(
121
- Effect.scoped,
122
- Effect.provide(
123
- MemoryFileSystem.layerWith({ "/watch-filter/.gitkeep": "" }),
124
- ),
125
- Effect.runPromise,
126
- ))
127
- })
128
-
129
- test.describe(`${FileSystemExtra.filterSourceFiles.name}`, () => {
130
- test.it("matches source file extensions", () => {
131
- test
132
- .expect(
133
- FileSystemExtra.filterSourceFiles({
134
- eventType: "change",
135
- filename: "x",
136
- path: "/a/b.ts",
137
- }),
138
- )
139
- .toBe(true)
140
- test
141
- .expect(
142
- FileSystemExtra.filterSourceFiles({
143
- eventType: "change",
144
- filename: "x",
145
- path: "/a/b.tsx",
146
- }),
147
- )
148
- .toBe(true)
149
- test
150
- .expect(
151
- FileSystemExtra.filterSourceFiles({
152
- eventType: "change",
153
- filename: "x",
154
- path: "/a/b.js",
155
- }),
156
- )
157
- .toBe(true)
158
- test
159
- .expect(
160
- FileSystemExtra.filterSourceFiles({
161
- eventType: "change",
162
- filename: "x",
163
- path: "/a/b.jsx",
164
- }),
165
- )
166
- .toBe(true)
167
- test
168
- .expect(
169
- FileSystemExtra.filterSourceFiles({
170
- eventType: "change",
171
- filename: "x",
172
- path: "/a/b.json",
173
- }),
174
- )
175
- .toBe(true)
176
- test
177
- .expect(
178
- FileSystemExtra.filterSourceFiles({
179
- eventType: "change",
180
- filename: "x",
181
- path: "/a/b.css",
182
- }),
183
- )
184
- .toBe(true)
185
- test
186
- .expect(
187
- FileSystemExtra.filterSourceFiles({
188
- eventType: "change",
189
- filename: "x",
190
- path: "/a/b.html",
191
- }),
192
- )
193
- .toBe(true)
194
- })
195
-
196
- test.it("rejects non-source files", () => {
197
- test
198
- .expect(
199
- FileSystemExtra.filterSourceFiles({
200
- eventType: "change",
201
- filename: "x",
202
- path: "/a/b.txt",
203
- }),
204
- )
205
- .toBe(false)
206
- test
207
- .expect(
208
- FileSystemExtra.filterSourceFiles({
209
- eventType: "change",
210
- filename: "x",
211
- path: "/a/b.md",
212
- }),
213
- )
214
- .toBe(false)
215
- })
216
- })
217
-
218
- test.describe(`${FileSystemExtra.filterDirectory.name}`, () => {
219
- test.it("matches directories", () => {
220
- test
221
- .expect(
222
- FileSystemExtra.filterDirectory({
223
- eventType: "change",
224
- filename: "x",
225
- path: "/a/b/",
226
- }),
227
- )
228
- .toBe(true)
229
- })
230
-
231
- test.it("rejects files", () => {
232
- test
233
- .expect(
234
- FileSystemExtra.filterDirectory({
235
- eventType: "change",
236
- filename: "x",
237
- path: "/a/b",
238
- }),
239
- )
240
- .toBe(false)
241
- })
242
- })
@@ -1,66 +0,0 @@
1
- import * as Error from "@effect/platform/Error"
2
- import * as FileSystem from "@effect/platform/FileSystem"
3
- import * as Effect from "effect/Effect"
4
- import * as Function from "effect/Function"
5
- import * as Stream from "effect/Stream"
6
- import * as NPath from "node:path"
7
-
8
- const SOURCE_FILENAME = /\.(tsx?|jsx?|html?|css|json)$/
9
-
10
- export type WatchEvent = {
11
- eventType: "rename" | "change"
12
- filename: string
13
- path: string
14
- }
15
-
16
- export const filterSourceFiles = (event: WatchEvent): boolean => {
17
- return SOURCE_FILENAME.test(event.path)
18
- }
19
-
20
- export const filterDirectory = (event: WatchEvent): boolean => {
21
- return event.path.endsWith("/")
22
- }
23
-
24
- export const watchSource = (
25
- opts?: {
26
- path?: string
27
- recursive?: boolean
28
- filter?: (event: WatchEvent) => boolean
29
- },
30
- ): Stream.Stream<WatchEvent, Error.PlatformError, FileSystem.FileSystem> => {
31
- const baseDir = opts?.path ?? process.cwd()
32
- const customFilter = opts?.filter
33
-
34
- return Function.pipe(
35
- Stream.unwrap(
36
- Effect.map(
37
- FileSystem.FileSystem,
38
- fs => fs.watch(baseDir, { recursive: opts?.recursive ?? true }),
39
- ),
40
- ),
41
- Stream.mapEffect(e =>
42
- Effect.gen(function*() {
43
- const fs = yield* FileSystem.FileSystem
44
- const relativePath = NPath.relative(baseDir, e.path)
45
- const eventType: "change" | "rename" = e._tag === "Update"
46
- ? "change"
47
- : "rename"
48
- const info = yield* Effect.either(fs.stat(e.path))
49
- const isDir = info._tag === "Right" && info.right.type === "Directory"
50
- return {
51
- eventType,
52
- filename: relativePath,
53
- path: isDir ? `${e.path}/` : e.path,
54
- }
55
- })
56
- ),
57
- customFilter ? Stream.filter(customFilter) : Function.identity,
58
- Stream.rechunk(1),
59
- Stream.throttle({
60
- units: 1,
61
- cost: () => 1,
62
- duration: "400 millis",
63
- strategy: "enforce",
64
- }),
65
- )
66
- }