elm-ssr 0.6.2 → 0.90.1

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.
@@ -0,0 +1,478 @@
1
+ module ElmSsr.Request.Decode exposing
2
+ ( Decoder, Error
3
+ , required, optional, optionalWithDefault
4
+ , string, int, float, bool
5
+ , validate, custom
6
+ , succeed, fail, map, map2, map3, map4, map5, map6, map7, map8, andThen
7
+ , decodeForm, decodeQuery, decodeParams, decodeRaw
8
+ , email, nonEmpty, minInt, maxInt, minFloat, maxFloat, minLength, maxLength
9
+ )
10
+
11
+ import ElmSsr.Route as Route exposing (Request)
12
+
13
+
14
+ {-| Represents a single validation error on a field. -}
15
+ type alias Error =
16
+ { field : String
17
+ , message : String
18
+ }
19
+
20
+
21
+ {-| A Request/Form decoder that compiles a validation result. -}
22
+ type Decoder a
23
+ = Decoder (List ( String, String ) -> Result (List Error) a)
24
+
25
+
26
+ {-| A field decoder for a single, individual field's string representation. -}
27
+ type FieldDecoder a
28
+ = FieldDecoder (Maybe String -> Result String a)
29
+
30
+
31
+ -- RUNNING DECODERS
32
+
33
+ {-| Decode from the request's raw form data (POST body). -}
34
+ decodeForm : Decoder a -> Request -> Result (List Error) a
35
+ decodeForm (Decoder dec) request =
36
+ dec request.formData
37
+
38
+
39
+ {-| Decode from the request's query string parameters (GET parameters). -}
40
+ decodeQuery : Decoder a -> Request -> Result (List Error) a
41
+ decodeQuery (Decoder dec) request =
42
+ dec request.query
43
+
44
+
45
+ {-| Decode from the dynamic route params. -}
46
+ decodeParams : Decoder a -> Request -> Result (List Error) a
47
+ decodeParams (Decoder dec) request =
48
+ dec request.params
49
+
50
+
51
+ {-| Decode directly from a raw key-value list. -}
52
+ decodeRaw : Decoder a -> List ( String, String ) -> Result (List Error) a
53
+ decodeRaw (Decoder dec) pairs =
54
+ dec pairs
55
+
56
+
57
+ -- BASE DECODERS
58
+
59
+ {-| Decodes a required string field. -}
60
+ string : FieldDecoder String
61
+ string =
62
+ FieldDecoder (\rawValue ->
63
+ case rawValue of
64
+ Just val ->
65
+ Ok val
66
+
67
+ Nothing ->
68
+ Err "Field is required"
69
+ )
70
+
71
+
72
+ {-| Decodes an integer. -}
73
+ int : FieldDecoder Int
74
+ int =
75
+ FieldDecoder (\rawValue ->
76
+ case rawValue of
77
+ Just val ->
78
+ case String.toInt val of
79
+ Just num ->
80
+ Ok num
81
+
82
+ Nothing ->
83
+ Err "Must be a valid integer"
84
+
85
+ Nothing ->
86
+ Err "Field is required"
87
+ )
88
+
89
+
90
+ {-| Decodes a float. -}
91
+ float : FieldDecoder Float
92
+ float =
93
+ FieldDecoder (\rawValue ->
94
+ case rawValue of
95
+ Just val ->
96
+ case String.toFloat val of
97
+ Just num ->
98
+ Ok num
99
+
100
+ Nothing ->
101
+ Err "Must be a valid number"
102
+
103
+ Nothing ->
104
+ Err "Field is required"
105
+ )
106
+
107
+
108
+ {-| Decodes a boolean checkbox. Returns `True` for `"true"`, `"on"`, or presence.
109
+ Returns `False` otherwise or if omitted.
110
+ -}
111
+ bool : FieldDecoder Bool
112
+ bool =
113
+ FieldDecoder (\rawValue ->
114
+ case rawValue of
115
+ Just "true" ->
116
+ Ok True
117
+
118
+ Just "false" ->
119
+ Ok False
120
+
121
+ Just "on" ->
122
+ Ok True
123
+
124
+ Just "" ->
125
+ Ok False
126
+
127
+ Just _ ->
128
+ Ok False
129
+
130
+ Nothing ->
131
+ Ok False
132
+ )
133
+
134
+
135
+ -- PIPELINE HELPERS
136
+
137
+ {-| Decodes a required field. If missing or invalid, accumulates the error. -}
138
+ required : String -> FieldDecoder b -> Decoder (b -> a) -> Decoder a
139
+ required name (FieldDecoder fieldDec) (Decoder decFn) =
140
+ Decoder (\input ->
141
+ let
142
+ rawValue =
143
+ lookup name input
144
+
145
+ resFn =
146
+ decFn input
147
+
148
+ resVal =
149
+ case fieldDec rawValue of
150
+ Ok val ->
151
+ Ok val
152
+
153
+ Err msg ->
154
+ Err [ { field = name, message = msg } ]
155
+ in
156
+ combine resFn resVal
157
+ )
158
+
159
+
160
+ {-| Decodes an optional field, defaulting to `Nothing` if missing or empty. -}
161
+ optional : String -> FieldDecoder b -> Decoder (Maybe b -> a) -> Decoder a
162
+ optional name (FieldDecoder fieldDec) (Decoder decFn) =
163
+ Decoder (\input ->
164
+ let
165
+ rawValue =
166
+ lookup name input
167
+
168
+ resFn =
169
+ decFn input
170
+
171
+ resVal =
172
+ case rawValue of
173
+ Nothing ->
174
+ Ok Nothing
175
+
176
+ Just "" ->
177
+ Ok Nothing
178
+
179
+ Just val ->
180
+ case fieldDec (Just val) of
181
+ Ok decoded ->
182
+ Ok (Just decoded)
183
+
184
+ Err msg ->
185
+ Err [ { field = name, message = msg } ]
186
+ in
187
+ combine resFn resVal
188
+ )
189
+
190
+
191
+ {-| Decodes an optional field, defaulting to the provided value if missing or empty. -}
192
+ optionalWithDefault : String -> b -> FieldDecoder b -> Decoder (b -> a) -> Decoder a
193
+ optionalWithDefault name defaultVal (FieldDecoder fieldDec) (Decoder decFn) =
194
+ Decoder (\input ->
195
+ let
196
+ rawValue =
197
+ lookup name input
198
+
199
+ resFn =
200
+ decFn input
201
+
202
+ resVal =
203
+ case rawValue of
204
+ Nothing ->
205
+ Ok defaultVal
206
+
207
+ Just "" ->
208
+ Ok defaultVal
209
+
210
+ Just val ->
211
+ case fieldDec (Just val) of
212
+ Ok decoded ->
213
+ Ok decoded
214
+
215
+ Err msg ->
216
+ Err [ { field = name, message = msg } ]
217
+ in
218
+ combine resFn resVal
219
+ )
220
+
221
+
222
+ -- APPLICATIVE COMBINATORS
223
+
224
+ {-| A decoder that always succeeds with the given value. -}
225
+ succeed : a -> Decoder a
226
+ succeed val =
227
+ Decoder (\_ -> Ok val)
228
+
229
+
230
+ {-| A decoder that always fails with a single general validation error. -}
231
+ fail : String -> String -> Decoder a
232
+ fail fieldName msg =
233
+ Decoder (\_ -> Err [ { field = fieldName, message = msg } ])
234
+
235
+
236
+ {-| Transform a decoded value. -}
237
+ map : (a -> b) -> Decoder a -> Decoder b
238
+ map f (Decoder dec) =
239
+ Decoder (\input -> Result.map f (dec input))
240
+
241
+
242
+ {-| Apply a function inside a decoder to a value inside a decoder. -}
243
+ apply : Decoder a -> Decoder (a -> b) -> Decoder b
244
+ apply (Decoder decVal) (Decoder decFn) =
245
+ Decoder (\input ->
246
+ combine (decFn input) (decVal input)
247
+ )
248
+
249
+
250
+ {-| Map over 2 decoders, accumulating errors. -}
251
+ map2 : (a -> b -> c) -> Decoder a -> Decoder b -> Decoder c
252
+ map2 f decA decB =
253
+ succeed f
254
+ |> apply decA
255
+ |> apply decB
256
+
257
+
258
+ {-| Map over 3 decoders, accumulating errors. -}
259
+ map3 : (a -> b -> c -> d) -> Decoder a -> Decoder b -> Decoder c -> Decoder d
260
+ map3 f decA decB decC =
261
+ succeed f
262
+ |> apply decA
263
+ |> apply decB
264
+ |> apply decC
265
+
266
+
267
+ {-| Map over 4 decoders, accumulating errors. -}
268
+ map4 : (a -> b -> c -> d -> e) -> Decoder a -> Decoder b -> Decoder c -> Decoder d -> Decoder e
269
+ map4 f decA decB decC decD =
270
+ succeed f
271
+ |> apply decA
272
+ |> apply decB
273
+ |> apply decC
274
+ |> apply decD
275
+
276
+
277
+ {-| Map over 5 decoders, accumulating errors. -}
278
+ map5 : (a -> b -> c -> d -> e -> f) -> Decoder a -> Decoder b -> Decoder c -> Decoder d -> Decoder e -> Decoder f
279
+ map5 f decA decB decC decD decE =
280
+ succeed f
281
+ |> apply decA
282
+ |> apply decB
283
+ |> apply decC
284
+ |> apply decD
285
+ |> apply decE
286
+
287
+
288
+ {-| Map over 6 decoders, accumulating errors. -}
289
+ map6 : (a -> b -> c -> d -> e -> f -> g) -> Decoder a -> Decoder b -> Decoder c -> Decoder d -> Decoder e -> Decoder f -> Decoder g
290
+ map6 f decA decB decC decD decE decF =
291
+ succeed f
292
+ |> apply decA
293
+ |> apply decB
294
+ |> apply decC
295
+ |> apply decD
296
+ |> apply decE
297
+ |> apply decF
298
+
299
+
300
+ {-| Map over 7 decoders, accumulating errors. -}
301
+ map7 : (a -> b -> c -> d -> e -> f -> g -> h) -> Decoder a -> Decoder b -> Decoder c -> Decoder d -> Decoder e -> Decoder f -> Decoder g -> Decoder h
302
+ map7 f decA decB decC decD decE decF decG =
303
+ succeed f
304
+ |> apply decA
305
+ |> apply decB
306
+ |> apply decC
307
+ |> apply decD
308
+ |> apply decE
309
+ |> apply decF
310
+ |> apply decG
311
+
312
+
313
+ {-| Map over 8 decoders, accumulating errors. -}
314
+ map8 : (a -> b -> c -> d -> e -> f -> g -> h -> i) -> Decoder a -> Decoder b -> Decoder c -> Decoder d -> Decoder e -> Decoder f -> Decoder g -> Decoder h -> Decoder i
315
+ map8 f decA decB decC decD decE decF decG decH =
316
+ succeed f
317
+ |> apply decA
318
+ |> apply decB
319
+ |> apply decC
320
+ |> apply decD
321
+ |> apply decE
322
+ |> apply decF
323
+ |> apply decG
324
+ |> apply decH
325
+
326
+
327
+ {-| Chain two decoders, short-circuiting on failure. -}
328
+ andThen : (a -> Decoder b) -> Decoder a -> Decoder b
329
+ andThen f (Decoder decA) =
330
+ Decoder (\input ->
331
+ case decA input of
332
+ Ok a ->
333
+ let
334
+ (Decoder decB) =
335
+ f a
336
+ in
337
+ decB input
338
+
339
+ Err errs ->
340
+ Err errs
341
+ )
342
+
343
+
344
+ -- VALIDATION & CUSTOM TRANSFORMS
345
+
346
+ {-| Run a custom validation function on a field. -}
347
+ validate : (a -> Result String a) -> FieldDecoder a -> FieldDecoder a
348
+ validate validator (FieldDecoder fieldDec) =
349
+ FieldDecoder (\rawValue ->
350
+ fieldDec rawValue
351
+ |> Result.andThen validator
352
+ )
353
+
354
+
355
+ {-| Map a value to a new type, allowing custom validation errors. -}
356
+ custom : (a -> Result String b) -> FieldDecoder a -> FieldDecoder b
357
+ custom f (FieldDecoder fieldDec) =
358
+ FieldDecoder (\rawValue ->
359
+ fieldDec rawValue
360
+ |> Result.andThen f
361
+ )
362
+
363
+
364
+ -- COMMON VALIDATORS
365
+
366
+ {-| Validates a string is a standard email address. -}
367
+ email : String -> Result String String
368
+ email val =
369
+ let
370
+ parts =
371
+ String.split "@" val
372
+ in
373
+ case parts of
374
+ [ _, domain ] ->
375
+ if String.contains "." domain then
376
+ Ok val
377
+
378
+ else
379
+ Err "Invalid email address"
380
+
381
+ _ ->
382
+ Err "Invalid email address"
383
+
384
+
385
+ {-| Validates a string is not empty or only whitespace. -}
386
+ nonEmpty : String -> Result String String
387
+ nonEmpty val =
388
+ if String.isEmpty (String.trim val) then
389
+ Err "Cannot be empty"
390
+
391
+ else
392
+ Ok val
393
+
394
+
395
+ {-| Validates a minimum integer value. -}
396
+ minInt : Int -> Int -> Result String Int
397
+ minInt min val =
398
+ if val >= min then
399
+ Ok val
400
+
401
+ else
402
+ Err ("Must be at least " ++ String.fromInt min)
403
+
404
+
405
+ {-| Validates a maximum integer value. -}
406
+ maxInt : Int -> Int -> Result String Int
407
+ maxInt max val =
408
+ if val <= max then
409
+ Ok val
410
+
411
+ else
412
+ Err ("Must be at most " ++ String.fromInt max)
413
+
414
+
415
+ {-| Validates a minimum float value. -}
416
+ minFloat : Float -> Float -> Result String Float
417
+ minFloat min val =
418
+ if val >= min then
419
+ Ok val
420
+
421
+ else
422
+ Err ("Must be at least " ++ String.fromFloat min)
423
+
424
+
425
+ {-| Validates a maximum float value. -}
426
+ maxFloat : Float -> Float -> Result String Float
427
+ maxFloat max val =
428
+ if val <= max then
429
+ Ok val
430
+
431
+ else
432
+ Err ("Must be at most " ++ String.fromFloat max)
433
+
434
+
435
+ {-| Validates a minimum string length. -}
436
+ minLength : Int -> String -> Result String String
437
+ minLength len val =
438
+ if String.length val >= len then
439
+ Ok val
440
+
441
+ else
442
+ Err ("Must be at least " ++ String.fromInt len ++ " characters")
443
+
444
+
445
+ {-| Validates a maximum string length. -}
446
+ maxLength : Int -> String -> Result String String
447
+ maxLength len val =
448
+ if String.length val <= len then
449
+ Ok val
450
+
451
+ else
452
+ Err ("Must be at most " ++ String.fromInt len ++ " characters")
453
+
454
+
455
+ -- INTERNAL HELPERS
456
+
457
+ lookup : String -> List ( String, String ) -> Maybe String
458
+ lookup key pairs =
459
+ pairs
460
+ |> List.filter (\( name, _ ) -> name == key)
461
+ |> List.head
462
+ |> Maybe.map Tuple.second
463
+
464
+
465
+ combine : Result (List Error) (a -> b) -> Result (List Error) a -> Result (List Error) b
466
+ combine resFn resVal =
467
+ case ( resFn, resVal ) of
468
+ ( Ok fn, Ok val ) ->
469
+ Ok (fn val)
470
+
471
+ ( Err errsFn, Err errsVal ) ->
472
+ Err (errsFn ++ errsVal)
473
+
474
+ ( Err errs, Ok _ ) ->
475
+ Err errs
476
+
477
+ ( Ok _, Err errs ) ->
478
+ Err errs
@@ -122,6 +122,9 @@ advance config loader =
122
122
  Loader.Errored status message ->
123
123
  ( Aborted status message, renderError config status message )
124
124
 
125
+ Loader.Moved url ->
126
+ ( Rendered, config.ports.rendered (Action.encodeStep [] (always Json.null) (Action.Moved url)) )
127
+
125
128
  Loader.Await effect continue ->
126
129
  ( LoadingPage continue, config.ports.effectRequest (Loader.encodeEffect effect) )
127
130
 
package/lib/build.mjs CHANGED
@@ -1,6 +1,10 @@
1
- import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
1
+ import { mkdir, readdir, readFile, rm, writeFile, stat } from "node:fs/promises";
2
2
  import { dirname, relative, resolve } from "node:path";
3
3
  import { gzipSync } from "node:zlib";
4
+ import { exec } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+
7
+ const execAsync = promisify(exec);
4
8
 
5
9
  // This lib is part of elm-ssr. It handles generating Main.elm and
6
10
  // compiling the route apps and island bundles.
@@ -199,10 +203,71 @@ main =
199
203
  await rm(rawOutputPath, { force: true });
200
204
  };
201
205
 
206
+ const findLocalTailwind = async (startDir) => {
207
+ let current = resolve(startDir);
208
+ while (true) {
209
+ const candidate = resolve(current, "node_modules", ".bin", "tailwindcss");
210
+ try {
211
+ await stat(candidate);
212
+ return candidate;
213
+ } catch {
214
+ // ignore
215
+ }
216
+ const parent = dirname(current);
217
+ if (parent === current) break;
218
+ current = parent;
219
+ }
220
+ return null;
221
+ };
222
+
223
+ const compileStylesheet = async ({ inputPath, outputPath, appConfig, appRoot }) => {
224
+ try {
225
+ await stat(inputPath);
226
+ } catch {
227
+ return;
228
+ }
229
+
230
+ let css = "";
231
+ const isTailwindEnabled = appConfig.tailwind === true;
232
+
233
+ if (isTailwindEnabled) {
234
+ try {
235
+ console.log(`[elm-ssr] Compiling Tailwind CSS for app "${appConfig.name}"...`);
236
+ const contentGlob = "src/**/*.elm,src/**/*.ts,src/**/*.js";
237
+
238
+ let tailwindBin = await findLocalTailwind(rootPath);
239
+ if (!tailwindBin) {
240
+ tailwindBin = await findLocalTailwind(cliRoot);
241
+ }
242
+ const binCommand = tailwindBin ? tailwindBin : "npx --yes tailwindcss";
243
+
244
+ const command = `${binCommand} -i ${inputPath} --content "${contentGlob}" --minify`;
245
+ const { stdout } = await execAsync(command, { cwd: appRoot });
246
+ css = stdout.toString().trim();
247
+ } catch (err) {
248
+ console.warn(`[elm-ssr] Tailwind CSS compilation failed. Falling back to plain CSS read. Error:`, err.message);
249
+ const raw = await readFile(inputPath, "utf8");
250
+ css = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\s+/g, " ").trim();
251
+ }
252
+ } else {
253
+ const raw = await readFile(inputPath, "utf8");
254
+ css = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\s+/g, " ").trim();
255
+ }
256
+
257
+ const tsContent = `export const stylesheet = \`${css.replace(/`/g, "\\`").replace(/\$/g, "\\$")}\`;\n`;
258
+ await writeFile(outputPath, tsContent, "utf8");
259
+ };
260
+
202
261
  await mkdir(elmHome, { recursive: true });
203
262
 
204
263
  for (const appConfig of config.apps) {
205
264
  const exampleRoot = resolve(rootPath, appConfig.root);
265
+ await compileStylesheet({
266
+ inputPath: resolve(exampleRoot, "src", "app.css"),
267
+ outputPath: resolve(exampleRoot, "styles.ts"),
268
+ appConfig,
269
+ appRoot: exampleRoot
270
+ });
206
271
  const namespace = appConfig.module;
207
272
  const namespacePath = resolve(exampleRoot, "src", moduleToPath(namespace));
208
273
  const routes = await collectRoutes(namespace, resolve(namespacePath, "Routes"));