elm-pages 3.0.26 → 3.0.28

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.
@@ -145,7 +145,7 @@ declarationVisitor node context =
145
145
  (\recordSetter ->
146
146
  case Node.value recordSetter of
147
147
  ( keyNode, valueNode ) ->
148
- if Node.value keyNode == "data" || Node.value keyNode == "action" then
148
+ if Node.value keyNode == "data" || Node.value keyNode == "action" || Node.value keyNode == "pages" then
149
149
  if isAlreadyApplied context.lookupTable (Node.value valueNode) then
150
150
  Nothing
151
151
 
@@ -203,7 +203,7 @@ expressionVisitor node context =
203
203
  (\recordSetter ->
204
204
  case Node.value recordSetter of
205
205
  ( keyNode, valueNode ) ->
206
- if Node.value keyNode == "data" || Node.value keyNode == "action" then
206
+ if Node.value keyNode == "data" || Node.value keyNode == "action" || Node.value keyNode == "pages" then
207
207
  if isAlreadyApplied context.lookupTable (Node.value valueNode) then
208
208
  Nothing
209
209
 
@@ -234,16 +234,28 @@ expressionVisitor node context =
234
234
  ++ " = "
235
235
  ++ (case pageBuilderName of
236
236
  "preRender" ->
237
- "\\_ -> "
238
- ++ referenceFunction context.importContext ( [ "BackendTask" ], "fail" )
239
- ++ " "
240
- ++ exceptionFromString
237
+ if key == "pages" then
238
+ referenceFunction context.importContext ( [ "BackendTask" ], "fail" )
239
+ ++ " "
240
+ ++ exceptionFromString
241
+
242
+ else
243
+ "\\_ -> "
244
+ ++ referenceFunction context.importContext ( [ "BackendTask" ], "fail" )
245
+ ++ " "
246
+ ++ exceptionFromString
241
247
 
242
248
  "preRenderWithFallback" ->
243
- "\\_ -> "
244
- ++ referenceFunction context.importContext ( [ "BackendTask" ], "fail" )
245
- ++ " "
246
- ++ exceptionFromString
249
+ if key == "pages" then
250
+ referenceFunction context.importContext ( [ "BackendTask" ], "fail" )
251
+ ++ " "
252
+ ++ exceptionFromString
253
+
254
+ else
255
+ "\\_ -> "
256
+ ++ referenceFunction context.importContext ( [ "BackendTask" ], "fail" )
257
+ ++ " "
258
+ ++ exceptionFromString
247
259
 
248
260
  "serverRender" ->
249
261
  "\\_ _ -> "
@@ -311,7 +311,7 @@ route =
311
311
  RouteBuilder.preRender
312
312
  { data = data
313
313
  , head = head
314
- , pages = pages
314
+ , pages = BackendTask.fail (FatalError.fromString "")
315
315
  }
316
316
  |> RouteBuilder.buildNoState { view = view }
317
317
 
@@ -366,7 +366,7 @@ route =
366
366
  RouteBuilder.preRender
367
367
  { data = \\_ -> BackendTask.fail (FatalError.fromString "")
368
368
  , head = head
369
- , pages = pages
369
+ , pages = BackendTask.fail (FatalError.fromString "")
370
370
  }
371
371
  |> RouteBuilder.buildNoState { view = view }
372
372
 
@@ -374,6 +374,109 @@ route =
374
374
  data : BackendTask Data
375
375
  data =
376
376
  BackendTask.succeed ()
377
+ """
378
+ ]
379
+ , test "replaces pages record setter in preRender" <|
380
+ \() ->
381
+ """module Route.Blog.Slug_ exposing (Data, Model, Msg, route)
382
+
383
+ import Server.Request as Request
384
+
385
+ import BackendTask exposing (BackendTask)
386
+ import FatalError
387
+ import RouteBuilder exposing (Page, App)
388
+ import Pages.PageUrl exposing (PageUrl)
389
+ import Pages.Url
390
+ import UrlPath
391
+ import Route exposing (Route)
392
+ import Shared
393
+ import View exposing (View)
394
+
395
+
396
+ type alias Model =
397
+ {}
398
+
399
+
400
+ type alias Msg =
401
+ ()
402
+
403
+
404
+ type alias RouteParams =
405
+ { slug : String }
406
+
407
+
408
+ type alias Data =
409
+ ()
410
+
411
+
412
+ route : StatelessRoute RouteParams Data ActionData
413
+ route =
414
+ RouteBuilder.preRender
415
+ { data = \\_ -> BackendTask.fail (FatalError.fromString "")
416
+ , head = head
417
+ , pages = pages
418
+ }
419
+ |> RouteBuilder.buildNoState { view = view }
420
+
421
+
422
+ pages : BackendTask (List RouteParams)
423
+ pages =
424
+ BackendTask.succeed [ { slug = "hello" } ]
425
+ """
426
+ |> Review.Test.run rule
427
+ |> Review.Test.expectErrors
428
+ [ Review.Test.error
429
+ { message = "Codemod"
430
+ , details =
431
+ [ "" ]
432
+ , under =
433
+ """pages = pages
434
+ }"""
435
+ }
436
+ |> Review.Test.whenFixed
437
+ """module Route.Blog.Slug_ exposing (Data, Model, Msg, route)
438
+
439
+ import Server.Request as Request
440
+
441
+ import BackendTask exposing (BackendTask)
442
+ import FatalError
443
+ import RouteBuilder exposing (Page, App)
444
+ import Pages.PageUrl exposing (PageUrl)
445
+ import Pages.Url
446
+ import UrlPath
447
+ import Route exposing (Route)
448
+ import Shared
449
+ import View exposing (View)
450
+
451
+
452
+ type alias Model =
453
+ {}
454
+
455
+
456
+ type alias Msg =
457
+ ()
458
+
459
+
460
+ type alias RouteParams =
461
+ { slug : String }
462
+
463
+
464
+ type alias Data =
465
+ ()
466
+
467
+
468
+ route : StatelessRoute RouteParams Data ActionData
469
+ route =
470
+ RouteBuilder.preRender
471
+ { data = \\_ -> BackendTask.fail (FatalError.fromString "")
472
+ , head = head
473
+ , pages = BackendTask.fail (FatalError.fromString "")}
474
+ |> RouteBuilder.buildNoState { view = view }
475
+
476
+
477
+ pages : BackendTask (List RouteParams)
478
+ pages =
479
+ BackendTask.succeed [ { slug = "hello" } ]
377
480
  """
378
481
  ]
379
482
  , test "replaces data record setter with RouteBuilder.serverRendered" <|
@@ -12,7 +12,7 @@
12
12
  "elm/json": "1.1.3",
13
13
  "elm/regex": "1.0.0",
14
14
  "jfmengels/elm-review": "2.15.1",
15
- "mdgriffith/elm-codegen": "5.2.0",
15
+ "mdgriffith/elm-codegen": "6.0.1",
16
16
  "stil4m/elm-syntax": "7.3.8",
17
17
  "the-sett/elm-syntax-dsl": "6.0.3"
18
18
  },
@@ -1,3 +1,3 @@
1
- export const compatibilityKey = 22;
1
+ export const compatibilityKey = 23;
2
2
 
3
- export const packageVersion = "3.0.26";
3
+ export const packageVersion = "3.0.28";
@@ -536,6 +536,8 @@ async function runInternalJob(
536
536
  return [requestHash, await runWhich(requestToPerform)];
537
537
  case "elm-pages-internal://question":
538
538
  return [requestHash, await runQuestion(requestToPerform)];
539
+ case "elm-pages-internal://readKey":
540
+ return [requestHash, await runReadKey(requestToPerform)];
539
541
  case "elm-pages-internal://shell":
540
542
  return [requestHash, await runShell(requestToPerform)];
541
543
  case "elm-pages-internal://stream":
@@ -638,6 +640,10 @@ async function runQuestion(req) {
638
640
  return jsonResponse(req, await question(req.body.args[0]));
639
641
  }
640
642
 
643
+ async function runReadKey(req) {
644
+ return jsonResponse(req, await readKey());
645
+ }
646
+
641
647
  function runStream(req, portsFile) {
642
648
  return new Promise(async (resolve) => {
643
649
  const context = getContext(req);
@@ -667,19 +673,21 @@ function runStream(req, portsFile) {
667
673
  index += 1;
668
674
  }
669
675
  if (kind === "json") {
670
- resolve(
671
- jsonResponse(req, {
672
- body: await consumers.json(lastStream),
673
- metadata: await tryCallingFunction(metadataResponse),
674
- })
675
- );
676
+ try {
677
+ const body = await consumers.json(lastStream);
678
+ const metadata = await tryCallingFunction(metadataResponse);
679
+ resolve(jsonResponse(req, { body, metadata }));
680
+ } catch (error) {
681
+ resolve(jsonResponse(req, { error: error.toString() }));
682
+ }
676
683
  } else if (kind === "text") {
677
- resolve(
678
- jsonResponse(req, {
679
- body: await consumers.text(lastStream),
680
- metadata: await tryCallingFunction(metadataResponse),
681
- })
682
- );
684
+ try {
685
+ const body = await consumers.text(lastStream);
686
+ const metadata = await tryCallingFunction(metadataResponse);
687
+ resolve(jsonResponse(req, { body, metadata }));
688
+ } catch (error) {
689
+ resolve(jsonResponse(req, { error: error.toString() }));
690
+ }
683
691
  } else if (kind === "none") {
684
692
  if (!lastStream) {
685
693
  // ensure all error handling gets a chance to fire before resolving successfully
@@ -687,22 +695,21 @@ function runStream(req, portsFile) {
687
695
  resolve(jsonResponse(req, { body: null }));
688
696
  } else {
689
697
  let resolvedMeta = await tryCallingFunction(metadataResponse);
690
- lastStream.once("finish", async () => {
698
+ // Writable streams emit "finish", Readable streams emit "end"
699
+ // Duplex streams emit both - use a flag to prevent double-resolve
700
+ let resolved = false;
701
+ const onComplete = () => {
702
+ if (resolved) return;
703
+ resolved = true;
691
704
  resolve(
692
705
  jsonResponse(req, {
693
706
  body: null,
694
707
  metadata: resolvedMeta,
695
708
  })
696
709
  );
697
- });
698
- lastStream.once("end", async () => {
699
- resolve(
700
- jsonResponse(req, {
701
- body: null,
702
- metadata: resolvedMeta,
703
- })
704
- );
705
- });
710
+ };
711
+ lastStream.once("finish", onComplete);
712
+ lastStream.once("end", onComplete);
706
713
  }
707
714
  } else if (kind === "command") {
708
715
  // already handled in parts.forEach
@@ -745,19 +752,36 @@ function runStream(req, portsFile) {
745
752
  env,
746
753
  });
747
754
  if (validateStream.isDuplexStream(newLocal.stream)) {
755
+ newLocal.stream.once("error", (error) => {
756
+ newLocal.stream.destroy();
757
+ resolve({ error: `Custom duplex stream '${part.portName}' error: ${error.message}` });
758
+ });
748
759
  pipeIfPossible(lastStream, newLocal.stream);
760
+ if (!lastStream) {
761
+ endStreamIfNoInput(newLocal.stream);
762
+ }
749
763
  return newLocal;
750
764
  } else {
751
765
  throw `Expected '${part.portName}' to be a duplex stream!`;
752
766
  }
753
767
  } else if (part.name === "customRead") {
768
+ const newLocal = await portsFile[part.portName](part.input, {
769
+ cwd,
770
+ quiet,
771
+ env,
772
+ });
773
+ // customRead can return either a stream directly or { stream, metadata }
774
+ const stream = newLocal.stream || newLocal;
775
+ if (!validateStream.isReadableStream(stream)) {
776
+ throw `Expected '${part.portName}' to return a readable stream!`;
777
+ }
778
+ stream.once("error", (error) => {
779
+ stream.destroy();
780
+ resolve({ error: `Custom read stream '${part.portName}' error: ${error.message}` });
781
+ });
754
782
  return {
755
- metadata: null,
756
- stream: await portsFile[part.portName](part.input, {
757
- cwd,
758
- quiet,
759
- env,
760
- }),
783
+ metadata: newLocal.metadata || null,
784
+ stream: stream,
761
785
  };
762
786
  } else if (part.name === "customWrite") {
763
787
  const newLocal = await portsFile[part.portName](part.input, {
@@ -766,25 +790,42 @@ function runStream(req, portsFile) {
766
790
  env,
767
791
  });
768
792
  if (!validateStream.isWritableStream(newLocal.stream)) {
769
- console.error("Expected a writable stream!");
770
- resolve({ error: "Expected a writable stream!" });
771
- } else {
772
- pipeIfPossible(lastStream, newLocal.stream);
793
+ throw `Expected '${part.portName}' to return a writable stream!`;
794
+ }
795
+ newLocal.stream.once("error", (error) => {
796
+ newLocal.stream.destroy();
797
+ resolve({ error: `Custom write stream '${part.portName}' error: ${error.message}` });
798
+ });
799
+ pipeIfPossible(lastStream, newLocal.stream);
800
+ if (!lastStream) {
801
+ endStreamIfNoInput(newLocal.stream);
773
802
  }
774
803
  return newLocal;
775
804
  } else if (part.name === "gzip") {
776
805
  const gzip = zlib.createGzip();
806
+ gzip.once("error", (error) => {
807
+ gzip.destroy();
808
+ resolve({ error: `gzip error: ${error.message}` });
809
+ });
777
810
  if (!lastStream) {
778
- gzip.end();
811
+ endStreamIfNoInput(gzip);
779
812
  }
780
813
  return {
781
814
  metadata: null,
782
815
  stream: pipeIfPossible(lastStream, gzip),
783
816
  };
784
817
  } else if (part.name === "unzip") {
818
+ const unzip = zlib.createUnzip();
819
+ unzip.once("error", (error) => {
820
+ unzip.destroy();
821
+ resolve({ error: `unzip error: ${error.message}` });
822
+ });
823
+ if (!lastStream) {
824
+ endStreamIfNoInput(unzip);
825
+ }
785
826
  return {
786
827
  metadata: null,
787
- stream: pipeIfPossible(lastStream, zlib.createUnzip()),
828
+ stream: pipeIfPossible(lastStream, unzip),
788
829
  };
789
830
  } else if (part.name === "fileWrite") {
790
831
  const destinationPath = path.resolve(part.path);
@@ -801,9 +842,13 @@ function runStream(req, portsFile) {
801
842
  newLocal.removeAllListeners();
802
843
  resolve({ error: error.toString() });
803
844
  });
845
+ pipeIfPossible(lastStream, newLocal);
846
+ if (!lastStream) {
847
+ endStreamIfNoInput(newLocal);
848
+ }
804
849
  return {
805
850
  metadata: null,
806
- stream: pipeIfPossible(lastStream, newLocal),
851
+ stream: newLocal,
807
852
  };
808
853
  } else if (part.name === "httpWrite") {
809
854
  const makeFetchHappen = makeFetchHappenOriginal.defaults({
@@ -865,6 +910,9 @@ function runStream(req, portsFile) {
865
910
  });
866
911
 
867
912
  pipeIfPossible(lastStream, newProcess.stdin);
913
+ if (!lastStream) {
914
+ endStreamIfNoInput(newProcess.stdin);
915
+ }
868
916
  let newStream;
869
917
  if (output === "MergeWithStdout") {
870
918
  newStream = mergeStreams([newProcess.stdout, newProcess.stderr]);
@@ -874,28 +922,40 @@ function runStream(req, portsFile) {
874
922
  newStream = newProcess.stdout;
875
923
  }
876
924
 
925
+ // For the last process, we need to track metadata resolution
926
+ // so we can resolve it even if the process errors
927
+ let resolveMeta = null;
928
+ const metadataPromise = isLastProcess
929
+ ? new Promise((resolve) => {
930
+ resolveMeta = resolve;
931
+ })
932
+ : null;
933
+
877
934
  newProcess.once("error", (error) => {
878
935
  newStream && newStream.end();
879
936
  newProcess.removeAllListeners();
937
+ // Resolve metadata Promise to prevent hanging awaits
938
+ if (resolveMeta) {
939
+ resolveMeta({ exitCode: null, error: error.toString() });
940
+ }
880
941
  resolve({ error: error.toString() });
881
942
  });
943
+
882
944
  if (isLastProcess) {
945
+ newProcess.once("exit", (code) => {
946
+ if (code !== 0 && !allowNon0Status) {
947
+ newStream && newStream.end();
948
+ resolve({
949
+ error: `Command ${command} exited with code ${code}`,
950
+ });
951
+ }
952
+ resolveMeta({
953
+ exitCode: code,
954
+ });
955
+ });
883
956
  return {
884
957
  stream: newStream,
885
- metadata: new Promise((resoveMeta) => {
886
- newProcess.once("exit", (code) => {
887
- if (code !== 0 && !allowNon0Status) {
888
- newStream && newStream.end();
889
- resolve({
890
- error: `Command ${command} exited with code ${code}`,
891
- });
892
- }
893
-
894
- resoveMeta({
895
- exitCode: code,
896
- });
897
- });
898
- }),
958
+ metadata: metadataPromise,
899
959
  };
900
960
  } else {
901
961
  return { metadata: null, stream: newStream };
@@ -930,6 +990,45 @@ function pipeIfPossible(input, destination) {
930
990
  }
931
991
  }
932
992
 
993
+ /**
994
+ * Safely signals EOF to a writable stream when no input will be piped to it.
995
+ *
996
+ * This is necessary because when a writable stream (like a child process's stdin)
997
+ * is created but nothing is piped to it, the receiving end has no way to know
998
+ * that no data is coming. It will wait indefinitely for the pipe to close.
999
+ *
1000
+ * GUI applications like ksdiff/meld are particularly affected - they wait for
1001
+ * stdin to close before proceeding, causing hangs if we don't explicitly end it.
1002
+ *
1003
+ * @param {import('stream').Writable | null | undefined} stream - The writable stream to end
1004
+ */
1005
+ function endStreamIfNoInput(stream) {
1006
+ if (!stream) {
1007
+ return;
1008
+ }
1009
+
1010
+ // Check if stream is still in a state where .end() is valid
1011
+ // - writable: false if the stream has been destroyed or ended
1012
+ // - writableEnded: true if .end() has already been called
1013
+ // - destroyed: true if .destroy() has been called
1014
+ if (!stream.writable || stream.writableEnded || stream.destroyed) {
1015
+ return;
1016
+ }
1017
+
1018
+ // Add a one-time error handler to prevent unhandled error crashes
1019
+ // This can happen if the child process exits before we call .end()
1020
+ stream.once("error", (err) => {
1021
+ // EPIPE: "broken pipe" - the other end closed before we finished
1022
+ // This is expected if the child process exits quickly
1023
+ if (err.code !== "EPIPE") {
1024
+ // Log unexpected errors but don't crash - this is cleanup code
1025
+ console.error("Stream end error:", err.message);
1026
+ }
1027
+ });
1028
+
1029
+ stream.end();
1030
+ }
1031
+
933
1032
  function stdout() {
934
1033
  return new Writable({
935
1034
  write(chunk, encoding, callback) {
@@ -1077,7 +1176,7 @@ export function pipeShells(
1077
1176
  if (previousProcess === null) {
1078
1177
  currentProcess = spawnCallback(command, args, {
1079
1178
  stdio: ["inherit", "pipe", "inherit"],
1080
- timeout: timeout ? undefined : timeout,
1179
+ timeout: timeout,
1081
1180
  cwd: cwd,
1082
1181
  env: env,
1083
1182
  });
@@ -1087,14 +1186,14 @@ export function pipeShells(
1087
1186
  stdio: quiet
1088
1187
  ? ["pipe", "ignore", "ignore"]
1089
1188
  : ["pipe", "inherit", "inherit"],
1090
- timeout: timeout ? undefined : timeout,
1189
+ timeout: timeout,
1091
1190
  cwd: cwd,
1092
1191
  env: env,
1093
1192
  });
1094
1193
  } else {
1095
1194
  currentProcess = spawnCallback(command, args, {
1096
1195
  stdio: ["pipe", "pipe", "pipe"],
1097
- timeout: timeout ? undefined : timeout,
1196
+ timeout: timeout,
1098
1197
  cwd: cwd,
1099
1198
  env: env,
1100
1199
  });
@@ -1153,6 +1252,48 @@ export async function question({ prompt }) {
1153
1252
  });
1154
1253
  }
1155
1254
 
1255
+ /**
1256
+ * Read a single keypress from stdin without requiring Enter.
1257
+ * Uses raw mode to capture individual keypresses.
1258
+ * Falls back to line-buffered input when not in a TTY (e.g., piped input).
1259
+ */
1260
+ export async function readKey() {
1261
+ const stdin = process.stdin;
1262
+
1263
+ if (!stdin.isTTY) {
1264
+ // Fall back to reading a line when not in a TTY (piped input, CI, etc.)
1265
+ // Takes the first character of the input line
1266
+ const rl = readline.createInterface({ input: stdin });
1267
+ return new Promise((resolve) => {
1268
+ rl.once("line", (line) => {
1269
+ rl.close();
1270
+ resolve(line.charAt(0) || "\n");
1271
+ });
1272
+ });
1273
+ }
1274
+
1275
+ // TTY mode - single keypress without Enter
1276
+ return new Promise((resolve) => {
1277
+ const wasRaw = stdin.isRaw;
1278
+
1279
+ stdin.setRawMode(true);
1280
+ stdin.resume();
1281
+ stdin.setEncoding("utf8");
1282
+
1283
+ stdin.once("data", (key) => {
1284
+ stdin.setRawMode(wasRaw);
1285
+ stdin.pause();
1286
+
1287
+ // Handle Ctrl+C to exit gracefully
1288
+ if (key === "\u0003") {
1289
+ process.exit();
1290
+ }
1291
+
1292
+ resolve(key);
1293
+ });
1294
+ });
1295
+ }
1296
+
1156
1297
  async function runWriteFileJob(req) {
1157
1298
  const { cwd } = getContext(req);
1158
1299
  const data = req.body.args[0];
@@ -1223,7 +1364,7 @@ async function runGlobNew(req, patternsToWatch) {
1223
1364
  }
1224
1365
  return {
1225
1366
  fullPath: fullPath.path,
1226
- captures: mm.capture(pattern, fullPath.path),
1367
+ captures: mm.capture(pattern, fullPath.path) || [],
1227
1368
  fileStats: {
1228
1369
  size: stats.size,
1229
1370
  atime: Math.round(stats.atime.getTime()),
@@ -31,7 +31,7 @@
31
31
  "elm-community/result-extra": "2.4.0",
32
32
  "jluckyiv/elm-utc-date-strings": "1.0.0",
33
33
  "justinmimbs/date": "4.1.0",
34
- "mdgriffith/elm-codegen": "5.2.0",
34
+ "mdgriffith/elm-codegen": "6.0.1",
35
35
  "miniBill/elm-codec": "2.2.0",
36
36
  "noahzgordon/elm-color-extra": "1.0.2",
37
37
  "robinheghan/fnv1a": "1.0.0",
@@ -13,7 +13,7 @@
13
13
  "elm/core": "1.0.5",
14
14
  "elm/html": "1.0.0",
15
15
  "elm/json": "1.1.3",
16
- "mdgriffith/elm-codegen": "5.2.0"
16
+ "mdgriffith/elm-codegen": "6.0.1"
17
17
  },
18
18
  "indirect": {
19
19
  "Chadtech/elm-bool-extra": "2.4.2",