From 4bf588ab6cb45d798b6c2304c12e8bb97e5fc027 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Fri, 26 Apr 2024 06:18:46 -0500 Subject: [PATCH 01/43] simplify port initialization --- src/elm.rs | 12 ++---------- src/schema.rs | 14 ++++++++------ 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/elm.rs b/src/elm.rs index 8e3b1bc..5d99465 100644 --- a/src/elm.rs +++ b/src/elm.rs @@ -739,18 +739,10 @@ pub enum PortDirection { } impl Port { - pub fn new_send(name: String, type_: Decl) -> Self { + pub fn new(name: String, direction: PortDirection, type_: Decl) -> Self { Self { name, - direction: PortDirection::Send, - type_, - } - } - - pub fn new_subscribe(name: String, type_: Decl) -> Self { - Self { - name, - direction: PortDirection::Subscribe, + direction, type_, } } diff --git a/src/schema.rs b/src/schema.rs index a133a36..7435d7a 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -184,12 +184,14 @@ impl Schema { ) .wrap_err_with(|| format!("could not convert the `{port}` port to Elm"))?; - ports_module.insert_port(match port_schema.metadata.direction { - PortDirection::ElmToJs => elm::Port::new_send(port.to_owned(), port_type), - PortDirection::JsToElm => { - elm::Port::new_subscribe(port.to_owned(), port_type) - } - }) + ports_module.insert_port(elm::Port::new( + port.to_owned(), + match port_schema.metadata.direction { + PortDirection::ElmToJs => elm::PortDirection::Send, + PortDirection::JsToElm => elm::PortDirection::Subscribe, + }, + port_type, + )) } files.insert( From a8473d972749e51abf31e2cbbd44d5bc421c5ee2 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Fri, 26 Apr 2024 06:21:28 -0500 Subject: [PATCH 02/43] use send/subscribe prefix for type-safe variants --- src/elm.rs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/elm.rs b/src/elm.rs index 5d99465..ad8c205 100644 --- a/src/elm.rs +++ b/src/elm.rs @@ -757,11 +757,23 @@ impl Port { PortDirection::Subscribe => out.push_str("(Value -> msg) -> Sub msg\n\n\n"), } - out.push_str(&self.name); - out.push_str("_ : "); - let type_ref = self.type_.name(); + let type_safe_name = { + let mut out = String::new(); + + match self.direction { + PortDirection::Send => out.push_str("send"), + PortDirection::Subscribe => out.push_str("subscribeTo"), + } + out.push_str(&InflectedString::from(self.name.clone()).to_pascal_case()?); + + out + }; + + out.push_str(&type_safe_name); + out.push_str(" : "); + match self.direction { PortDirection::Send => { out.push_str(&type_ref.to_pascal_case()?); @@ -783,8 +795,8 @@ impl Port { } } - out.push_str(&self.name); - out.push_str("_ "); + out.push_str(&type_safe_name); + out.push(' '); match self.direction { PortDirection::Send => { From e47f2415aefd4307b014e5b99259c396392e8e29 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Fri, 26 Apr 2024 06:26:22 -0500 Subject: [PATCH 03/43] regenerate examples --- README.md | 8 ++++---- examples/jwt_schema/Main/Ports.elm | 8 ++++---- .../cmd/port_roundtrip.out/src/Main/Ports.elm | 8 ++++---- tests/cmd/tinyping.out/src/Main/Ports.elm | 20 +++++++++---------- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 04da896..f69514f 100644 --- a/README.md +++ b/README.md @@ -168,16 +168,16 @@ encodeNewJwt newJwt = port logout : Value -> Cmd msg -logout_ : Logout -> Cmd msg -logout_ value = +sendLogout : Logout -> Cmd msg +sendLogout value = logout (encodeLogout value) port newJwt : Value -> Cmd msg -newJwt_ : NewJwt -> Cmd msg -newJwt_ value = +sendNewJwt : NewJwt -> Cmd msg +sendNewJwt value = newJwt (encodeNewJwt value) ``` diff --git a/examples/jwt_schema/Main/Ports.elm b/examples/jwt_schema/Main/Ports.elm index 95d694c..9a174ae 100644 --- a/examples/jwt_schema/Main/Ports.elm +++ b/examples/jwt_schema/Main/Ports.elm @@ -39,14 +39,14 @@ encodeNewJwt newJwt = port logout : Value -> Cmd msg -logout_ : Logout -> Cmd msg -logout_ value = +sendLogout : Logout -> Cmd msg +sendLogout value = logout (encodeLogout value) port newJwt : Value -> Cmd msg -newJwt_ : NewJwt -> Cmd msg -newJwt_ value = +sendNewJwt : NewJwt -> Cmd msg +sendNewJwt value = newJwt (encodeNewJwt value) diff --git a/tests/cmd/port_roundtrip.out/src/Main/Ports.elm b/tests/cmd/port_roundtrip.out/src/Main/Ports.elm index de46ecc..74e4c3b 100644 --- a/tests/cmd/port_roundtrip.out/src/Main/Ports.elm +++ b/tests/cmd/port_roundtrip.out/src/Main/Ports.elm @@ -47,14 +47,14 @@ encodeJsToElm jsToElm = port elmToJs : Value -> Cmd msg -elmToJs_ : ElmToJs -> Cmd msg -elmToJs_ value = +sendElmToJs : ElmToJs -> Cmd msg +sendElmToJs value = elmToJs (encodeElmToJs value) port jsToElm : (Value -> msg) -> Sub msg -jsToElm_ : (Result Json.Decode.Error JsToElm -> msg) -> Sub msg -jsToElm_ toMsg = +subscribeToJsToElm : (Result Json.Decode.Error JsToElm -> msg) -> Sub msg +subscribeToJsToElm toMsg = jsToElm (/value -> toMsg (Json.Decode.decodeValue value jsToElmDecoder) diff --git a/tests/cmd/tinyping.out/src/Main/Ports.elm b/tests/cmd/tinyping.out/src/Main/Ports.elm index 0e63799..ee97980 100644 --- a/tests/cmd/tinyping.out/src/Main/Ports.elm +++ b/tests/cmd/tinyping.out/src/Main/Ports.elm @@ -414,38 +414,38 @@ encodeSendNotification sendNotification = port changeDocument : Value -> Cmd msg -changeDocument_ : ChangeDocument -> Cmd msg -changeDocument_ value = +sendChangeDocument : ChangeDocument -> Cmd msg +sendChangeDocument value = changeDocument (encodeChangeDocument value) port docFromAutomerge : (Value -> msg) -> Sub msg -docFromAutomerge_ : (Result Json.Decode.Error DocFromAutomerge -> msg) -> Sub msg -docFromAutomerge_ toMsg = +subscribeToDocFromAutomerge : (Result Json.Decode.Error DocFromAutomerge -> msg) -> Sub msg +subscribeToDocFromAutomerge toMsg = docFromAutomerge (/value -> toMsg (Json.Decode.decodeValue value docFromAutomergeDecoder) port notificationPermission : (Value -> msg) -> Sub msg -notificationPermission_ : (Result Json.Decode.Error NotificationPermission -> msg) -> Sub msg -notificationPermission_ toMsg = +subscribeToNotificationPermission : (Result Json.Decode.Error NotificationPermission -> msg) -> Sub msg +subscribeToNotificationPermission toMsg = notificationPermission (/value -> toMsg (Json.Decode.decodeValue value notificationPermissionDecoder) port requestNotificationPermission : Value -> Cmd msg -requestNotificationPermission_ : RequestNotificationPermission -> Cmd msg -requestNotificationPermission_ value = +sendRequestNotificationPermission : RequestNotificationPermission -> Cmd msg +sendRequestNotificationPermission value = requestNotificationPermission (encodeRequestNotificationPermission value) port sendNotification : Value -> Cmd msg -sendNotification_ : SendNotification -> Cmd msg -sendNotification_ value = +sendSendNotification : SendNotification -> Cmd msg +sendSendNotification value = sendNotification (encodeSendNotification value) From 3e54a5857baccfaf1130a0c49d628fa1eb58dd95 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Fri, 26 Apr 2024 06:28:02 -0500 Subject: [PATCH 04/43] rename port newNotification to avoid double "send" --- tests/cmd/tinyping.in/schema.json | 2 +- tests/cmd/tinyping.out/elm.ts | 14 +-- tests/cmd/tinyping.out/src/Main/Ports.elm | 134 +++++++++++----------- 3 files changed, 75 insertions(+), 75 deletions(-) diff --git a/tests/cmd/tinyping.in/schema.json b/tests/cmd/tinyping.in/schema.json index 095e992..61feac6 100644 --- a/tests/cmd/tinyping.in/schema.json +++ b/tests/cmd/tinyping.in/schema.json @@ -31,7 +31,7 @@ "granted" ] }, - "sendNotification": { + "newNotification": { "metadata": { "direction": "ElmToJs" }, diff --git a/tests/cmd/tinyping.out/elm.ts b/tests/cmd/tinyping.out/elm.ts index 0afc2e1..41afb95 100644 --- a/tests/cmd/tinyping.out/elm.ts +++ b/tests/cmd/tinyping.out/elm.ts @@ -36,13 +36,7 @@ declare module Elm { version: "v1"; }) => void; }; - notificationPermission: { - send: (value: "default" | "denied" | "granted") => void; - }; - requestNotificationPermission: { - subscribe: (callback: (value: Record) => void) => void; - }; - sendNotification: { + newNotification: { subscribe: (callback: (value: { options: { badge: string | null; @@ -56,6 +50,12 @@ declare module Elm { title: string; }) => void) => void; }; + notificationPermission: { + send: (value: "default" | "denied" | "granted") => void; + }; + requestNotificationPermission: { + subscribe: (callback: (value: Record) => void) => void; + }; } function init(config: { diff --git a/tests/cmd/tinyping.out/src/Main/Ports.elm b/tests/cmd/tinyping.out/src/Main/Ports.elm index ee97980..2dddd6b 100644 --- a/tests/cmd/tinyping.out/src/Main/Ports.elm +++ b/tests/cmd/tinyping.out/src/Main/Ports.elm @@ -254,57 +254,6 @@ encodeDocFromAutomerge docFromAutomerge = encodeDocV1 docV1 -type NotificationPermission - = Default - | Denied - | Granted - - - -notificationPermissionDecoder : Decoder NotificationPermission -notificationPermissionDecoder = - Json.Decode.andThen - (/tag -> - case tag of - "default" -> - Json.Decode.succeed Default - - "denied" -> - Json.Decode.succeed Denied - - "granted" -> - Json.Decode.succeed Granted - ) - Json.Decode.string - - -encodeNotificationPermission : NotificationPermission -> Json.Encode.Value -encodeNotificationPermission notificationPermission = - case notificationPermission of - Default -> - Json.Encode.string "default" - - Denied -> - Json.Encode.string "denied" - - Granted -> - Json.Encode.string "granted" - - -type alias RequestNotificationPermission = - () - - -requestNotificationPermissionDecoder : Decoder RequestNotificationPermission -requestNotificationPermissionDecoder = - Json.Decode.null () - - -encodeRequestNotificationPermission : RequestNotificationPermission -> Json.Encode.Value -encodeRequestNotificationPermission requestNotificationPermission = - Json.Encode.null - - type alias NotificationOptions = { badge : Maybe String , body : Maybe String @@ -390,27 +339,78 @@ encodeNotificationOptions notificationOptions = ] -type alias SendNotification = +type alias NewNotification = { options : NotificationOptions , title : String } -sendNotificationDecoder : Decoder SendNotification -sendNotificationDecoder = - Json.Decode.succeed SendNotification +newNotificationDecoder : Decoder NewNotification +newNotificationDecoder = + Json.Decode.succeed NewNotification |> Json.Decode.Pipeline.required "options" notificationOptionsDecoder |> Json.Decode.Pipeline.required "title" Json.Decode.string -encodeSendNotification : SendNotification -> Json.Encode.Value -encodeSendNotification sendNotification = +encodeNewNotification : NewNotification -> Json.Encode.Value +encodeNewNotification newNotification = Json.Encode.object - [ ( "options", encodeNotificationOptions sendNotification.options ) - , ( "title", Json.Encode.string sendNotification.title ) + [ ( "options", encodeNotificationOptions newNotification.options ) + , ( "title", Json.Encode.string newNotification.title ) ] +type NotificationPermission + = Default + | Denied + | Granted + + + +notificationPermissionDecoder : Decoder NotificationPermission +notificationPermissionDecoder = + Json.Decode.andThen + (/tag -> + case tag of + "default" -> + Json.Decode.succeed Default + + "denied" -> + Json.Decode.succeed Denied + + "granted" -> + Json.Decode.succeed Granted + ) + Json.Decode.string + + +encodeNotificationPermission : NotificationPermission -> Json.Encode.Value +encodeNotificationPermission notificationPermission = + case notificationPermission of + Default -> + Json.Encode.string "default" + + Denied -> + Json.Encode.string "denied" + + Granted -> + Json.Encode.string "granted" + + +type alias RequestNotificationPermission = + () + + +requestNotificationPermissionDecoder : Decoder RequestNotificationPermission +requestNotificationPermissionDecoder = + Json.Decode.null () + + +encodeRequestNotificationPermission : RequestNotificationPermission -> Json.Encode.Value +encodeRequestNotificationPermission requestNotificationPermission = + Json.Encode.null + + port changeDocument : Value -> Cmd msg @@ -427,6 +427,14 @@ subscribeToDocFromAutomerge toMsg = docFromAutomerge (/value -> toMsg (Json.Decode.decodeValue value docFromAutomergeDecoder) +port newNotification : Value -> Cmd msg + + +sendNewNotification : NewNotification -> Cmd msg +sendNewNotification value = + newNotification (encodeNewNotification value) + + port notificationPermission : (Value -> msg) -> Sub msg @@ -441,11 +449,3 @@ port requestNotificationPermission : Value -> Cmd msg sendRequestNotificationPermission : RequestNotificationPermission -> Cmd msg sendRequestNotificationPermission value = requestNotificationPermission (encodeRequestNotificationPermission value) - - -port sendNotification : Value -> Cmd msg - - -sendSendNotification : SendNotification -> Cmd msg -sendSendNotification value = - sendNotification (encodeSendNotification value) From 52b9183e37138c274649f552c0ed422ed28b91f8 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Fri, 26 Apr 2024 06:29:17 -0500 Subject: [PATCH 05/43] smooth out intro --- README.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f69514f..385a034 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,12 @@ # elm-duet -Elm is great, and TypeScript is great, but the flags and ports between them are hard to use safely. -They're the only part of the a system between those two languages that aren't typed by default. +I like Elm and TypeScript for building apps, but I find it annoying to make the boundary between them type-safe. -You can get around this in various ways, of course, either by maintaining a definitions by hand or generating one side from the other. +I can get around this in various ways, of course, either by maintaining a definitions by hand or generating one side from the other. In general, though, you run into a couple different issues: - It's easy for one side or the other to get out of date and errors to slip through CI and code review into production. -- Definitions in one language may not be translatable to the other (despite the two type systems having pretty good overlap.) +- Definitions in one language may not be translatable to the other (despite the two type systems having a high degree of overlap.) `elm-duet` tries to get around this by creating a single source of truth to generate both TypeScript definitions and Elm types with decoders. We use [JSON Type Definitions](https://jsontypedef.com/) (JTD, [five-minute tutorial](https://jsontypedef.com/docs/jtd-in-5-minutes/)) to say precisely what we want and generate ergonomic types on both sides (plus helpers like encoders to make testing easy!) @@ -65,7 +64,7 @@ declare module Elm { type Flags = { currentJwt: string | null; } - + type Ports = { logout: { subscribe: (callback: (value: Record) => void) => void; @@ -74,7 +73,7 @@ declare module Elm { subscribe: (callback: (value: string) => void) => void; }; } - + function init(config: { flags: Flags; node: HTMLElement; @@ -116,7 +115,7 @@ encodeFlags flags = , case flags.currentJwt of Just value -> Json.Encode.string value - + Nothing -> Json.Encode.null ) From 4cca24e84307c069290e58a7c86d35713b4c3b9a Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Fri, 26 Apr 2024 06:30:23 -0500 Subject: [PATCH 06/43] these aren't just flags --- src/main.rs | 2 +- src/schema.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 90eb385..8c2935a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,7 +29,7 @@ impl Cli { // TODO: better error message in all of this - std::fs::write(&self.typescript_dest, schema.flags_to_ts()?)?; + std::fs::write(&self.typescript_dest, schema.to_ts()?)?; println!("wrote {}", self.typescript_dest.display()); for (name, contents) in schema.to_elm()? { diff --git a/src/schema.rs b/src/schema.rs index 7435d7a..2d50dfd 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -61,7 +61,7 @@ impl Schema { Ok(out) } - pub fn flags_to_ts(&self) -> Result { + pub fn to_ts(&self) -> Result { let mut builder = NamespaceBuilder::root("Elm"); let globals = self.globals()?; From a664f028e14ef1f10249ab0b10145143a44c5f5f Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Fri, 26 Apr 2024 06:31:24 -0500 Subject: [PATCH 07/43] add TODO --- src/schema.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/schema.rs b/src/schema.rs index 2d50dfd..336af40 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -61,6 +61,7 @@ impl Schema { Ok(out) } + // TODO: audit how much work this does and consider moving responsibility into the TS module pub fn to_ts(&self) -> Result { let mut builder = NamespaceBuilder::root("Elm"); From 844d3c7b3732cf5c489dcb7ee6de6751d2bdc56e Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Fri, 26 Apr 2024 06:36:00 -0500 Subject: [PATCH 08/43] start matching on file extension --- src/schema.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/schema.rs b/src/schema.rs index 336af40..0138752 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -2,7 +2,7 @@ use crate::elm; use crate::typescript::NamespaceBuilder; use crate::typescript::TSType; use color_eyre::Result; -use eyre::WrapErr; +use eyre::{bail, WrapErr}; use serde::Deserialize; use std::collections::BTreeMap; use std::path::Path; @@ -43,8 +43,19 @@ pub enum PortDirection { impl Schema { pub fn from_fs(path: &Path) -> Result { let bytes = std::fs::read(path).wrap_err_with(|| format!("could not read {path:?}"))?; - serde_json::from_slice(&bytes) - .wrap_err_with(|| format!("could not read schema from {path:?}")) + + match path.extension().and_then(std::ffi::OsStr::to_str) { + Some("json") => serde_json::from_slice(&bytes) + .wrap_err_with(|| format!("could not read schema from {path:?}")), + Some(_) => bail!( + "I can't deserialize a schema from a {:?} file", + path.extension() + ), + None => bail!( + "I couldn't figure out what kind of file {} is because it doesn't have an extension!", + path.display(), + ), + } } fn globals(&self) -> Result> { From 27ae02d23f9bc989bb38a59b6575adebdefde691 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Fri, 26 Apr 2024 06:37:16 -0500 Subject: [PATCH 09/43] support yaml schema definitions --- Cargo.lock | 20 ++++++++++++++++++++ Cargo.toml | 1 + src/schema.rs | 2 ++ 3 files changed, 23 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 0bfcdaa..3670fce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -286,6 +286,7 @@ dependencies = [ "jtd", "serde", "serde_json", + "serde_yaml", "tracing", "trycmd", ] @@ -681,6 +682,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "shlex" version = "1.3.0" @@ -849,6 +863,12 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "utf8parse" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 761cccc..7e2bb2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ eyre = "0.6.12" jtd = "0.3.1" serde = { version = "1.0.198", features = ["derive"] } serde_json = "1.0.116" +serde_yaml = "0.9.34" tracing = "0.1.40" [profile.dev.package.backtrace] diff --git a/src/schema.rs b/src/schema.rs index 0138752..564a2e0 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -47,6 +47,8 @@ impl Schema { match path.extension().and_then(std::ffi::OsStr::to_str) { Some("json") => serde_json::from_slice(&bytes) .wrap_err_with(|| format!("could not read schema from {path:?}")), + Some("yaml") => serde_yaml::from_slice(&bytes) + .wrap_err_with(|| format!("could not read schema from {path:?}")), Some(_) => bail!( "I can't deserialize a schema from a {:?} file", path.extension() From 5e13d2b8529cb1115f606b64ce6d1b311e6eca53 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Fri, 26 Apr 2024 06:42:36 -0500 Subject: [PATCH 10/43] convert the jwt schema to yaml --- README.md | 50 ++++++++++++++++------------------------ examples/jwt_schema.json | 27 ---------------------- examples/jwt_schema.yaml | 15 ++++++++++++ 3 files changed, 35 insertions(+), 57 deletions(-) delete mode 100644 examples/jwt_schema.json create mode 100644 examples/jwt_schema.yaml diff --git a/README.md b/README.md index 385a034..164ba44 100644 --- a/README.md +++ b/README.md @@ -13,41 +13,31 @@ We use [JSON Type Definitions](https://jsontypedef.com/) (JTD, [five-minute tuto Here's an example for an app that stores a [jwt](https://jwt.io/) in `localStorage` or similar to present to Elm: -```json {source=examples/jwt_schema.json} -{ - "modules": { - "Main": { - "flags": { - "properties": { - "currentJwt": { - "type": "string", - "nullable": true - } - } - }, - "ports": { - "newJwt": { - "metadata": { - "direction": "ElmToJs" - }, - "type": "string" - }, - "logout": { - "metadata": { - "direction": "ElmToJs" - } - } - } - } - } -} +```json {source=examples/jwt_schema.yaml} +modules: + Main: + flags: + properties: + currentJwt: + type: string + nullable: true + ports: + newJwt: + metadata: + direction: ElmToJs + type: string + logout: + metadata: + direction: ElmToJs ``` -You can generate code from this by calling `elm-duet path/to/your/schema.json`: +(We're using YAML in this example so we can use comments, but JSON schemas also work just fine.) + +You can generate code from this by calling `elm-duet path/to/your/schema.(yaml|json)`: ```console -$ elm-duet examples/jwt_schema.json --typescript-dest examples/jwt_schema.ts --elm-dest examples/jwt_schema +$ elm-duet examples/jwt_schema.yaml --typescript-dest examples/jwt_schema.ts --elm-dest examples/jwt_schema wrote examples/jwt_schema.ts wrote examples/jwt_schema/Main/Flags.elm wrote examples/jwt_schema/Main/Ports.elm diff --git a/examples/jwt_schema.json b/examples/jwt_schema.json deleted file mode 100644 index 60cfcce..0000000 --- a/examples/jwt_schema.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "modules": { - "Main": { - "flags": { - "properties": { - "currentJwt": { - "type": "string", - "nullable": true - } - } - }, - "ports": { - "newJwt": { - "metadata": { - "direction": "ElmToJs" - }, - "type": "string" - }, - "logout": { - "metadata": { - "direction": "ElmToJs" - } - } - } - } - } -} diff --git a/examples/jwt_schema.yaml b/examples/jwt_schema.yaml new file mode 100644 index 0000000..635f9d1 --- /dev/null +++ b/examples/jwt_schema.yaml @@ -0,0 +1,15 @@ +modules: + Main: + flags: + properties: + currentJwt: + type: string + nullable: true + ports: + newJwt: + metadata: + direction: ElmToJs + type: string + logout: + metadata: + direction: ElmToJs From 9a4bd5c9e5560f565ea1554a27b53e8f9a7d0d45 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Fri, 26 Apr 2024 06:57:06 -0500 Subject: [PATCH 11/43] add lots of comments explaining the schema --- README.md | 46 +++++++++++++++++++++++++++++++++++----- examples/jwt_schema.yaml | 40 ++++++++++++++++++++++++++++++++-- 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 164ba44..45eb016 100644 --- a/README.md +++ b/README.md @@ -14,18 +14,54 @@ We use [JSON Type Definitions](https://jsontypedef.com/) (JTD, [five-minute tuto Here's an example for an app that stores a [jwt](https://jwt.io/) in `localStorage` or similar to present to Elm: ```json {source=examples/jwt_schema.yaml} +# An example app that uses JWTs to manage authentication. Imagine that the JWTs +# are stored in localStorage so that they can persist across sessions. The +# lifecycle of this app might look like: +# +# 1. On init, the JS side passes the Elm runtime the current value of the JWT +# (or `null`, if unset) +# 2. Elm is responsible for authentication and for telling JS when it gets a +# new JWT (for example, when the user logs in) +# 3. Elm is also responsible for telling JS if someone logs out, in which case +# we should clear the JWT from localStorage. + +# For elm-duet, we start by defining our data types. In this case, we're just +# going to keep things simple and define a "jwt" that will just be an alias to +# a string. +definitions: + jwt: + type: string + +# Now we say how to use it. Each key in this object is a module in your Elm +# application, in which we can define our flags and ports. modules: Main: + # First we'll define flags. As we mentioned above, that's either a JWT or + # null. We'll define it by referring to + # + # If your app doesn't use flags, you can omit this key. flags: properties: currentJwt: - type: string + ref: jwt nullable: true + + # Next we'll do ports. As with flags, if your app doesn't use ports, you + # can omit this key. ports: + # Like flags, ports are specified with a JTD schema. In this case, we + # want a non-nullable version of the same JWT as earlier. + # + # Ports also have a direction. We specify this in the + # `metadata.direction` key, either `ElmToJs` or `JsToElm`. newJwt: metadata: direction: ElmToJs - type: string + ref: jwt + + # Finally, logout is a bit odd: we really don't need a payload. JTD + # handles this by defining an "empty" case, which we specify by omitting + # any type information. We still need the direction metadata, though! logout: metadata: direction: ElmToJs @@ -54,7 +90,7 @@ declare module Elm { type Flags = { currentJwt: string | null; } - + type Ports = { logout: { subscribe: (callback: (value: Record) => void) => void; @@ -63,7 +99,7 @@ declare module Elm { subscribe: (callback: (value: string) => void) => void; }; } - + function init(config: { flags: Flags; node: HTMLElement; @@ -105,7 +141,7 @@ encodeFlags flags = , case flags.currentJwt of Just value -> Json.Encode.string value - + Nothing -> Json.Encode.null ) diff --git a/examples/jwt_schema.yaml b/examples/jwt_schema.yaml index 635f9d1..318c59c 100644 --- a/examples/jwt_schema.yaml +++ b/examples/jwt_schema.yaml @@ -1,15 +1,51 @@ +# An example app that uses JWTs to manage authentication. Imagine that the JWTs +# are stored in localStorage so that they can persist across sessions. The +# lifecycle of this app might look like: +# +# 1. On init, the JS side passes the Elm runtime the current value of the JWT +# (or `null`, if unset) +# 2. Elm is responsible for authentication and for telling JS when it gets a +# new JWT (for example, when the user logs in) +# 3. Elm is also responsible for telling JS if someone logs out, in which case +# we should clear the JWT from localStorage. + +# For elm-duet, we start by defining our data types. In this case, we're just +# going to keep things simple and define a "jwt" that will just be an alias to +# a string. +definitions: + jwt: + type: string + +# Now we say how to use it. Each key in this object is a module in your Elm +# application, in which we can define our flags and ports. modules: Main: + # First we'll define flags. As we mentioned above, that's either a JWT or + # null. We'll define it by referring to + # + # If your app doesn't use flags, you can omit this key. flags: properties: currentJwt: - type: string + ref: jwt nullable: true + + # Next we'll do ports. As with flags, if your app doesn't use ports, you + # can omit this key. ports: + # Like flags, ports are specified with a JTD schema. In this case, we + # want a non-nullable version of the same JWT as earlier. + # + # Ports also have a direction. We specify this in the + # `metadata.direction` key, either `ElmToJs` or `JsToElm`. newJwt: metadata: direction: ElmToJs - type: string + ref: jwt + + # Finally, logout is a bit odd: we really don't need a payload. JTD + # handles this by defining an "empty" case, which we specify by omitting + # any type information. We still need the direction metadata, though! logout: metadata: direction: ElmToJs From 1cb1f6a77cc4dab7ab2d73615d2ae65719096a26 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Fri, 26 Apr 2024 07:08:29 -0500 Subject: [PATCH 12/43] talk through the example more --- README.md | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 45eb016..ebe1205 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,12 @@ In general, though, you run into a couple different issues: `elm-duet` tries to get around this by creating a single source of truth to generate both TypeScript definitions and Elm types with decoders. We use [JSON Type Definitions](https://jsontypedef.com/) (JTD, [five-minute tutorial](https://jsontypedef.com/docs/jtd-in-5-minutes/)) to say precisely what we want and generate ergonomic types on both sides (plus helpers like encoders to make testing easy!) -Here's an example for an app that stores a [jwt](https://jwt.io/) in `localStorage` or similar to present to Elm: +In addition, `elm-duet` produces files that make following good practices around interop easier! +I'll call those out as we get to them. + +## Example 1: JWTs + +Here's an example for an app that stores [JWTs](https://jwt.io/) in `localStorage`: ```json {source=examples/jwt_schema.yaml} # An example app that uses JWTs to manage authentication. Imagine that the JWTs @@ -80,7 +85,7 @@ wrote examples/jwt_schema/Main/Ports.elm ``` -Which results in this schema: +This produces this TypeScript file: ```typescript {source=examples/jwt_schema.ts} // Warning: this file is automatically generated. Don't edit by hand! @@ -110,7 +115,9 @@ declare module Elm { } ``` -And these Elm flags: +This should be flexible enough to use both if you're embedding your Elm app (e.g. with `esbuild`) or referring to it as an external JS file. + +We also get this file containing Elm flags: ```elm {source=examples/jwt_schema/Main/Flags.elm} module Main.Flags exposing (..) @@ -149,7 +156,13 @@ encodeFlags flags = ``` -And these for the ports: +In your `init`, you can accept a `Json.Decode.Value` and call `Decode.decodeValue Main.Flags.flagsDecoder flags` to get complete control over the error experience. +It also lets you custom types in your flags, since you're specifying the decoder. + +Note that `elm-duet` creates both decoders and encoders for all the types it generates. +This is to make your life easier during testing: you can hook up tools like [elm-program-test](https://package.elm-lang.org/packages/avh4/elm-program-test/latest/) without having to redo your data encoding. + +Finally, we have the ports: ```elm {source=examples/jwt_schema/Main/Ports.elm} port module Main.Ports exposing (..) @@ -207,6 +220,9 @@ sendNewJwt value = ``` +You'll notice that in addition to decoders and encoders, `elm-duet` generates type-safe wrappers around the ports. +This is, again, to let you send custom types through the ports in a way we control: if you specify an `enum`, for example, we ensure that both Elm and TypeScript have enough information to take advantage of the best parts of their respective type systems without falling back to plain strings. + Here's the full help to give you an idea of what you can do with the tool: ```console From 72f87d7a5f5bbc1409cca58d5261491245d52ace Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Fri, 26 Apr 2024 07:08:48 -0500 Subject: [PATCH 13/43] add a header for the full help --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index ebe1205..088cda4 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,8 @@ sendNewJwt value = You'll notice that in addition to decoders and encoders, `elm-duet` generates type-safe wrappers around the ports. This is, again, to let you send custom types through the ports in a way we control: if you specify an `enum`, for example, we ensure that both Elm and TypeScript have enough information to take advantage of the best parts of their respective type systems without falling back to plain strings. +## The Full Help + Here's the full help to give you an idea of what you can do with the tool: ```console From 9e7c90a50c5bde794880b9e55cce2df648242861 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Fri, 26 Apr 2024 07:09:34 -0500 Subject: [PATCH 14/43] add LICENSE --- LICENSE | 13 +++++++++++++ README.md | 4 ++++ 2 files changed, 17 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7f2d954 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2024 Brian Hicks + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + diff --git a/README.md b/README.md index 088cda4..f0fbcca 100644 --- a/README.md +++ b/README.md @@ -243,3 +243,7 @@ Options: -V, --version Print version ``` + +## License + +BSD 3-Clause, same as Elm. From eeb07d83adf007d4326a9675c738f2d477b28960 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Fri, 26 Apr 2024 07:13:38 -0500 Subject: [PATCH 15/43] use yaml highlighting --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f0fbcca..a4df1d7 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ I'll call those out as we get to them. Here's an example for an app that stores [JWTs](https://jwt.io/) in `localStorage`: -```json {source=examples/jwt_schema.yaml} +```yaml {source=examples/jwt_schema.yaml} # An example app that uses JWTs to manage authentication. Imagine that the JWTs # are stored in localStorage so that they can persist across sessions. The # lifecycle of this app might look like: From 6a553d4a64af4a1766a17e071e5c0096a42cc3de Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Mon, 29 Apr 2024 06:29:30 -0500 Subject: [PATCH 16/43] add slightly broken all-in-one example --- README.md | 57 ++++++++++++++++++++++++++++++++++++++++ examples/all_in_one.yaml | 33 +++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 examples/all_in_one.yaml diff --git a/README.md b/README.md index a4df1d7..df7ea25 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,63 @@ sendNewJwt value = You'll notice that in addition to decoders and encoders, `elm-duet` generates type-safe wrappers around the ports. This is, again, to let you send custom types through the ports in a way we control: if you specify an `enum`, for example, we ensure that both Elm and TypeScript have enough information to take advantage of the best parts of their respective type systems without falling back to plain strings. +## Example 2: An All-in-One Port + +Some people like to define an all-in-one port for their application to make sure that they only have a single place to hook up new messages. +`elm-duet` and JDT support this with discriminators and mappings: + +```yaml {source=examples/all_in_one.yaml} +# We're going to define a port named `toWorld` that sends all our messages to +# the JS in the same place. You can do the same thing for `fromWorld` for +# subscriptions, but we're leaving that off to keep things succinct. +modules: + Main: + ports: + toWorld: + metadata: + direction: ElmToJs + + # JTD lets us distinguish between different types of an object by using + # the discriminator/mapping case. The first step is to define the field + # that "tags" the message. In this case, we'll literally use `tag`: + discriminator: tag + + # Next, tell JTD all the possible values of `tag` and what types are + # associated with them. We'll use the same option as in the JWT schema + # example, but all in one port. Note that all the options need to be + # non-nullable objects, since we can't set a tag otherwise. + mapping: + newJwt: + properties: + value: + ref: jwt + + logout: + # in cases where we don't want any payload, we specify an empty + # object aside from the tag. + properties: + +definitions: + jwt: + type: string + +``` + +Again, we generate everything in `examples`: + +```console +$ elm-duet examples/all_in_one.yaml --typescript-dest examples/all_in_one.ts --elm-dest examples/all_in_one +? 1 + + 0: could not convert port toWorld + 1: jtd discriminator should have enforced that the value type must be an object + 2: add_key_to_object only works on objects + +Backtrace omitted. Run with RUST_BACKTRACE=1 environment variable to display it. +Run with RUST_BACKTRACE=full to include source snippets. + +``` + ## The Full Help Here's the full help to give you an idea of what you can do with the tool: diff --git a/examples/all_in_one.yaml b/examples/all_in_one.yaml new file mode 100644 index 0000000..e3e594a --- /dev/null +++ b/examples/all_in_one.yaml @@ -0,0 +1,33 @@ +# We're going to define a port named `toWorld` that sends all our messages to +# the JS in the same place. You can do the same thing for `fromWorld` for +# subscriptions, but we're leaving that off to keep things succinct. +modules: + Main: + ports: + toWorld: + metadata: + direction: ElmToJs + + # JTD lets us distinguish between different types of an object by using + # the discriminator/mapping case. The first step is to define the field + # that "tags" the message. In this case, we'll literally use `tag`: + discriminator: tag + + # Next, tell JTD all the possible values of `tag` and what types are + # associated with them. We'll use the same option as in the JWT schema + # example, but all in one port. Note that all the options need to be + # non-nullable objects, since we can't set a tag otherwise. + mapping: + newJwt: + properties: + value: + ref: jwt + + logout: + # in cases where we don't want any payload, we specify an empty + # object aside from the tag. + properties: + +definitions: + jwt: + type: string From 4bb16c27af76ad34dd32d4d69651910c88118d6f Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Mon, 29 Apr 2024 06:32:35 -0500 Subject: [PATCH 17/43] be OK with never objects --- src/typescript.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/typescript.rs b/src/typescript.rs index 7e4e138..aa33ff0 100644 --- a/src/typescript.rs +++ b/src/typescript.rs @@ -143,6 +143,11 @@ impl TSType { let mut value_type = Self::from_schema(value, globals) .wrap_err_with(|| format!("could not convert the {tag} tag"))?; + // This happens if the payload is empty in a mapping field. + if value_type == Self::NeverObject { + value_type = Self::new_object(BTreeMap::new()) + } + value_type .add_key_to_object(&discriminator, Self::StringScalar(tag)) .wrap_err("jtd discriminator should have enforced that the value type must be an object")?; From 2146a2247b5d6284575e19a1ce0ab8ebe0a5f2e7 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Mon, 29 Apr 2024 06:32:46 -0500 Subject: [PATCH 18/43] remove accidentally-committed elm.ts --- elm.ts | 59 ---------------------------------------------------------- 1 file changed, 59 deletions(-) delete mode 100644 elm.ts diff --git a/elm.ts b/elm.ts deleted file mode 100644 index fcd08a9..0000000 --- a/elm.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Warning: this file is automatically generated. Don't edit by hand! - -declare module Elm { - namespace Foo { - namespace Bar { - namespace Main { - type Flags = { - currentTimeMillis: number; - notificationPermission: "default" | "denied" | "granted"; - } - - type Ports = { - changeDocument: { - subscribe: (callback: (value: { - tag: "AddNewPingAt"; - value: number; - } | { - tag: "SetMinutesPerPing"; - value: number; - } | { - index: number; - tag: "SetTagForPing"; - value: string; - }) => void) => void; - }; - docFromAutomerge: { - send: (value: { - }) => void; - }; - notificationPermission: { - send: (value: "default" | "denied" | "granted") => void; - }; - requestNotificationPermission: { - subscribe: (callback: (value: Record) => void) => void; - }; - sendNotification: { - subscribe: (callback: (value: { - options: { - badge: string | null; - body: string | null; - icon: string | null; - lang: string | null; - requireInteraction: bool | null; - silent: bool | null; - tag: string | null; - }; - title: string; - }) => void) => void; - }; - } - - function init(config: { - flags: Flags; - node: HTMLElement; - }): void - } - } - } -} \ No newline at end of file From e67521a4bce566f72524795400685721adec994a Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Mon, 29 Apr 2024 06:35:34 -0500 Subject: [PATCH 19/43] regenerate examples --- README.md | 107 +++++++++++++++++++++++++++-- examples/all_in_one.ts | 25 +++++++ examples/all_in_one/Main/Ports.elm | 65 ++++++++++++++++++ 3 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 examples/all_in_one.ts create mode 100644 examples/all_in_one/Main/Ports.elm diff --git a/README.md b/README.md index df7ea25..6cd6fcf 100644 --- a/README.md +++ b/README.md @@ -269,14 +269,109 @@ Again, we generate everything in `examples`: ```console $ elm-duet examples/all_in_one.yaml --typescript-dest examples/all_in_one.ts --elm-dest examples/all_in_one -? 1 +wrote examples/all_in_one.ts +wrote examples/all_in_one/Main/Ports.elm - 0: could not convert port toWorld - 1: jtd discriminator should have enforced that the value type must be an object - 2: add_key_to_object only works on objects +``` -Backtrace omitted. Run with RUST_BACKTRACE=1 environment variable to display it. -Run with RUST_BACKTRACE=full to include source snippets. +We get this in TypeScript: + +```typescript {source=examples/all_in_one.ts} +// Warning: this file is automatically generated. Don't edit by hand! + +declare module Elm { + namespace Main { + type Flags = Record + + type Ports = { + toWorld: { + subscribe: (callback: (value: { + tag: "logout"; + } | { + tag: "newJwt"; + value: string; + }) => void) => void; + }; + } + + function init(config: { + flags: Flags; + node: HTMLElement; + }): { + ports: Ports; + } + } +} +``` + +And this Elm: + +```elm {source=examples/all_in_one/Main/Ports.elm} +port module Main.Ports exposing (..) + +{-| Warning: this file is automatically generated. Don't edit by hand! +-} + +import Json.Decode +import Json.Decode.Pipeline +import Json.Encode + + +type alias NewJwt = + { value : String + } + + +newJwtDecoder : Decoder NewJwt +newJwtDecoder = + Json.Decode.succeed NewJwt + |> Json.Decode.Pipeline.required "value" Json.Decode.string + + +encodeNewJwt : NewJwt -> Json.Encode.Value +encodeNewJwt newJwt = + Json.Encode.object + [ ( "value", Json.Encode.string newJwt.value ) + , ( "tag", Json.Encode.string "newJwt" ) + ] + + +type ToWorld + = Logout () + | NewJwt NewJwt + + + +toWorldDecoder : Decoder ToWorld +toWorldDecoder = + Json.Decode.andThen + (\tag -> + case tag of + "logout" -> + Json.Decode.map Logout Json.Decode.null () + + "newJwt" -> + Json.Decode.map NewJwt newJwtDecoder + ) + (Json.Decode.field "tag" Json.Decode.string) + + +encodeToWorld : ToWorld -> Json.Encode.Value +encodeToWorld toWorld = + case toWorld of + Logout logout -> + Json.Encode.null + + NewJwt newJwt -> + encodeNewJwt newJwt + + +port toWorld : Value -> Cmd msg + + +sendToWorld : ToWorld -> Cmd msg +sendToWorld value = + toWorld (encodeToWorld value) ``` diff --git a/examples/all_in_one.ts b/examples/all_in_one.ts new file mode 100644 index 0000000..556afd6 --- /dev/null +++ b/examples/all_in_one.ts @@ -0,0 +1,25 @@ +// Warning: this file is automatically generated. Don't edit by hand! + +declare module Elm { + namespace Main { + type Flags = Record + + type Ports = { + toWorld: { + subscribe: (callback: (value: { + tag: "logout"; + } | { + tag: "newJwt"; + value: string; + }) => void) => void; + }; + } + + function init(config: { + flags: Flags; + node: HTMLElement; + }): { + ports: Ports; + } + } +} \ No newline at end of file diff --git a/examples/all_in_one/Main/Ports.elm b/examples/all_in_one/Main/Ports.elm new file mode 100644 index 0000000..2ef62ee --- /dev/null +++ b/examples/all_in_one/Main/Ports.elm @@ -0,0 +1,65 @@ +port module Main.Ports exposing (..) + +{-| Warning: this file is automatically generated. Don't edit by hand! +-} + +import Json.Decode +import Json.Decode.Pipeline +import Json.Encode + + +type alias NewJwt = + { value : String + } + + +newJwtDecoder : Decoder NewJwt +newJwtDecoder = + Json.Decode.succeed NewJwt + |> Json.Decode.Pipeline.required "value" Json.Decode.string + + +encodeNewJwt : NewJwt -> Json.Encode.Value +encodeNewJwt newJwt = + Json.Encode.object + [ ( "value", Json.Encode.string newJwt.value ) + , ( "tag", Json.Encode.string "newJwt" ) + ] + + +type ToWorld + = Logout () + | NewJwt NewJwt + + + +toWorldDecoder : Decoder ToWorld +toWorldDecoder = + Json.Decode.andThen + (\tag -> + case tag of + "logout" -> + Json.Decode.map Logout Json.Decode.null () + + "newJwt" -> + Json.Decode.map NewJwt newJwtDecoder + ) + (Json.Decode.field "tag" Json.Decode.string) + + +encodeToWorld : ToWorld -> Json.Encode.Value +encodeToWorld toWorld = + case toWorld of + Logout logout -> + Json.Encode.null + + NewJwt newJwt -> + encodeNewJwt newJwt + + +port toWorld : Value -> Cmd msg + + +sendToWorld : ToWorld -> Cmd msg +sendToWorld value = + toWorld (encodeToWorld value) From 5c18a5032697b82829d004783367793f681920ab Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Mon, 29 Apr 2024 07:05:32 -0500 Subject: [PATCH 20/43] push the discriminator around so that null types work --- src/elm.rs | 90 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 65 insertions(+), 25 deletions(-) diff --git a/src/elm.rs b/src/elm.rs index ad8c205..61d2c76 100644 --- a/src/elm.rs +++ b/src/elm.rs @@ -267,11 +267,12 @@ impl Type { schema: Schema, name_suggestion: Option, globals: &BTreeMap, + discriminator: Option<(String, String)>, ) -> Result<(Self, Vec)> { let mut is_nullable = false; let mut decls = Vec::new(); - let base = match schema { + let mut base = match schema { Schema::Empty { .. } => Self::Unit, Schema::Ref { definitions, @@ -286,6 +287,7 @@ impl Type { schema.clone(), name_suggestion.or_else(|| Some(ref_.to_string())), globals, + discriminator.clone(), ) .wrap_err_with(|| format!("could not convert the value of ref `{ref_}`"))?; @@ -353,6 +355,7 @@ impl Type { *elements, name_suggestion.map(|n| format!("{n}Elements")), globals, + discriminator.clone(), ) .wrap_err("could not convert elements of a list")?; @@ -375,11 +378,15 @@ impl Type { let mut fields = BTreeMap::new(); for (field_name, field_schema) in properties { - let (field_type, field_decls) = - Self::from_schema(field_schema, Some(field_name.clone()), globals) - .wrap_err_with(|| { - format!("could not convert the type of `{field_name}`") - })?; + let (field_type, field_decls) = Self::from_schema( + field_schema, + Some(field_name.clone()), + globals, + None, // We'll actually use this in the unified handler below! + ) + .wrap_err_with(|| { + format!("could not convert the type of `{field_name}`") + })?; decls.extend(field_decls); fields.insert(field_name.into(), field_type); @@ -404,6 +411,7 @@ impl Type { *values, name_suggestion.map(|n| format!("{n}Values")), globals, + discriminator.clone(), ) .wrap_err("could not convert elements of a list")?; @@ -415,7 +423,7 @@ impl Type { metadata, nullable, mapping, - discriminator, + discriminator: discriminator_field, .. } => match metadata .get("name") @@ -427,28 +435,20 @@ impl Type { let mut cases = BTreeMap::new(); for (tag, tag_schema) in mapping { - let (value_type, mut value_decls) = - Self::from_schema(tag_schema, Some(tag.to_string()), globals) - .wrap_err_with(|| { - format!("could not convert mapping for `{tag}`") - })?; - - // tell the referred decl (if we got one) that it needs to include the - // discriminator tag in its encoder - if let Type::Ref(ref_name) = &value_type { - for decl in &mut value_decls { - if decl.name() == ref_name { - decl.add_discriminator(discriminator.clone(), tag.clone())?; - } - } - } + let (value_type, value_decls) = Self::from_schema( + tag_schema, + Some(tag.to_string()), + globals, + Some((discriminator_field.clone(), tag.to_string())), + ) + .wrap_err_with(|| format!("could not convert mapping for `{tag}`"))?; decls.extend(value_decls); cases.insert(tag.into(), Some(value_type)); } decls.push(Decl::CustomTypeEnum { name: name.into(), - discriminator: Some(discriminator), + discriminator: Some(discriminator_field), constructor_prefix: metadata .get("constructorPrefix") .and_then(|n| n.as_str()) @@ -463,6 +463,42 @@ impl Type { }, }; + if let Some((discriminator_tag, discriminator_value)) = discriminator { + match &base { + Self::Unit => { + // TODO: this doesn't include any constructor prefixs. This could be a problem? + // It might also be fine? We'll have to see. + let name = InflectedString::from(format!("{discriminator_tag}_{discriminator_value}")); + + decls.push(Decl::TypeAlias { + name: name.clone(), + type_: Type::Record(BTreeMap::new()), + discriminator: Some((discriminator_tag, discriminator_value)), + }); + + base = Self::Ref(name); + } + Self::Ref(ref_name) => { + for decl in &mut decls { + if decl.name() == ref_name { + decl.add_discriminator( + discriminator_tag.clone(), + discriminator_value.clone(), + )?; + } + } + } + Self::Int => bail!("I can't add a discriminator to an int"), + Self::Float => bail!("I can't add a discriminator to an float"), + Self::Bool => bail!("I can't add a discriminator to a bool"), + Self::String => bail!("I can't add a discriminator to a string"), + Self::Maybe(_) => bail!("I can't add a discriminator to a maybe"), + Self::DictWithStringKeys(_) => bail!("I can't add a discriminator to a dict"), + Self::List(_) => bail!("I can't add a discriminator to a list"), + Self::Record(_) => bail!("As silly as it seems, I can't add a discriminator to a record type directly. That has to be done at the decl level, and I don't know which decl to reference."), + } + } + Ok(( if is_nullable { Self::Maybe(Box::new(base)) @@ -841,7 +877,7 @@ impl Module { name_suggestion: Option, globals: &BTreeMap, ) -> Result { - let (type_, decls) = Type::from_schema(schema, name_suggestion.clone(), globals)?; + let (type_, decls) = Type::from_schema(schema, name_suggestion.clone(), globals, None)?; self.decls.extend(decls); @@ -927,7 +963,7 @@ mod tests { } fn from_schema(value: Value) -> (Type, Vec) { - Type::from_schema(from_json(value), None, &BTreeMap::new()) + Type::from_schema(from_json(value), None, &BTreeMap::new(), None) .expect("valid schema from JSON value") } @@ -1082,6 +1118,7 @@ mod tests { })), None, &BTreeMap::new(), + None, ) .unwrap_err(); @@ -1128,6 +1165,7 @@ mod tests { })), None, &BTreeMap::new(), + None, ) .unwrap_err(); @@ -1240,6 +1278,7 @@ mod tests { })), None, &BTreeMap::from([("foo".into(), from_json(json!({"type": "string"})))]), + None, ) .unwrap(); @@ -1255,6 +1294,7 @@ mod tests { })), None, &BTreeMap::from([("foo".into(), from_json(json!({"properties": {}})))]), + None, ) .unwrap(); From a96c114bca44b81ffc1af96df482997398174d5f Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Mon, 29 Apr 2024 07:08:38 -0500 Subject: [PATCH 21/43] regenerate examples --- README.md | 22 +++++++++++++++++++--- examples/all_in_one/Main/Ports.elm | 22 +++++++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6cd6fcf..b9f47bf 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,22 @@ import Json.Decode.Pipeline import Json.Encode +type alias TagLogout = + {} + + +tagLogoutDecoder : Decoder TagLogout +tagLogoutDecoder = + Json.Decode.succeed TagLogout + + +encodeTagLogout : TagLogout -> Json.Encode.Value +encodeTagLogout tagLogout = + Json.Encode.object + , ( "tag", Json.Encode.string "logout" ) + ] + + type alias NewJwt = { value : String } @@ -337,7 +353,7 @@ encodeNewJwt newJwt = type ToWorld - = Logout () + = Logout TagLogout | NewJwt NewJwt @@ -348,7 +364,7 @@ toWorldDecoder = (\tag -> case tag of "logout" -> - Json.Decode.map Logout Json.Decode.null () + Json.Decode.map Logout tagLogoutDecoder "newJwt" -> Json.Decode.map NewJwt newJwtDecoder @@ -360,7 +376,7 @@ encodeToWorld : ToWorld -> Json.Encode.Value encodeToWorld toWorld = case toWorld of Logout logout -> - Json.Encode.null + encodeTagLogout logout NewJwt newJwt -> encodeNewJwt newJwt diff --git a/examples/all_in_one/Main/Ports.elm b/examples/all_in_one/Main/Ports.elm index 2ef62ee..392ba75 100644 --- a/examples/all_in_one/Main/Ports.elm +++ b/examples/all_in_one/Main/Ports.elm @@ -8,6 +8,22 @@ import Json.Decode.Pipeline import Json.Encode +type alias TagLogout = + {} + + +tagLogoutDecoder : Decoder TagLogout +tagLogoutDecoder = + Json.Decode.succeed TagLogout + + +encodeTagLogout : TagLogout -> Json.Encode.Value +encodeTagLogout tagLogout = + Json.Encode.object + , ( "tag", Json.Encode.string "logout" ) + ] + + type alias NewJwt = { value : String } @@ -28,7 +44,7 @@ encodeNewJwt newJwt = type ToWorld - = Logout () + = Logout TagLogout | NewJwt NewJwt @@ -39,7 +55,7 @@ toWorldDecoder = (\tag -> case tag of "logout" -> - Json.Decode.map Logout Json.Decode.null () + Json.Decode.map Logout tagLogoutDecoder "newJwt" -> Json.Decode.map NewJwt newJwtDecoder @@ -51,7 +67,7 @@ encodeToWorld : ToWorld -> Json.Encode.Value encodeToWorld toWorld = case toWorld of Logout logout -> - Json.Encode.null + encodeTagLogout logout NewJwt newJwt -> encodeNewJwt newJwt From 3aefa9a113ec66e2401213461a561d732684bc00 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Mon, 29 Apr 2024 07:09:30 -0500 Subject: [PATCH 22/43] grab npm and nodejs for formatting --- flake.nix | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 8317155..639f40b 100644 --- a/flake.nix +++ b/flake.nix @@ -20,9 +20,11 @@ pkgs.rustfmt # formatters - pkgs.elmPackages.elm-format - pkgs.nodePackages.prettier pkgs.typos + + # formatters + pkgs.nodePackages.npm + pkgs.nodejs ]; }; } From a6384751ea43b3c160688cb33115d70ca0128207 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Mon, 29 Apr 2024 07:10:53 -0500 Subject: [PATCH 23/43] add prettier and elm-format with npm --- .gitignore | 5 +- package-lock.json | 114 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 18 ++++++++ 3 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.gitignore b/.gitignore index 867e994..cf3f399 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ +/node_modules /result - - -# Added by cargo - /target diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6c28278 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,114 @@ +{ + "name": "elm-duet", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "elm-duet", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "elm-format": "^0.8.7", + "prettier": "^3.2.5" + } + }, + "node_modules/@avh4/elm-format-darwin-arm64": { + "version": "0.8.7-2", + "resolved": "https://registry.npmjs.org/@avh4/elm-format-darwin-arm64/-/elm-format-darwin-arm64-0.8.7-2.tgz", + "integrity": "sha512-F5JD44mJ3KX960J5GkXMfh1/dtkXuPcQpX2EToHQKjLTZUfnhZ++ytQQt0gAvrJ0bzoOvhNzjNjUHDA1ruTVbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@avh4/elm-format-darwin-x64": { + "version": "0.8.7-2", + "resolved": "https://registry.npmjs.org/@avh4/elm-format-darwin-x64/-/elm-format-darwin-x64-0.8.7-2.tgz", + "integrity": "sha512-4pfF1cl0KyTion+7Mg4XKM3yi4Yc7vP76Kt/DotLVGJOSag4ISGic1og2mt8RZZ7XArybBmHNyYkiUbe/cEiCw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@avh4/elm-format-linux-arm64": { + "version": "0.8.7-2", + "resolved": "https://registry.npmjs.org/@avh4/elm-format-linux-arm64/-/elm-format-linux-arm64-0.8.7-2.tgz", + "integrity": "sha512-WkVmuce2zU6s9dupHhqPc886Vaqpea8dZlxv2fpZ4wSzPUbiiKHoHZzoVndMIMTUL0TZukP3Ps0n/lWO5R5+FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@avh4/elm-format-linux-x64": { + "version": "0.8.7-2", + "resolved": "https://registry.npmjs.org/@avh4/elm-format-linux-x64/-/elm-format-linux-x64-0.8.7-2.tgz", + "integrity": "sha512-kmncfJrTBjVT94JtQvMf4M5Pn2Yl0sZt3wo7AzgFiDnB/CiZ+KjJyXuWM64NeGiv4MQqzPq65tsFXUH1CIJeiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@avh4/elm-format-win32-x64": { + "version": "0.8.7-2", + "resolved": "https://registry.npmjs.org/@avh4/elm-format-win32-x64/-/elm-format-win32-x64-0.8.7-2.tgz", + "integrity": "sha512-sBdMBGq/8mD8Y5C+fIr5vlb3N50yB7S1MfgeAq2QEbvkr/sKrCZI540i43lZDH9gWsfA1w2W8wCe0penFYzsGw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/elm-format": { + "version": "0.8.7", + "resolved": "https://registry.npmjs.org/elm-format/-/elm-format-0.8.7.tgz", + "integrity": "sha512-sVzFXfWnb+6rzXK+q3e3Ccgr6/uS5mFbFk1VSmigC+x2XZ28QycAa7lS8owl009ALPhRQk+pZ95Eq5ANjpEZsQ==", + "dev": true, + "hasInstallScript": true, + "bin": { + "elm-format": "bin/elm-format" + }, + "optionalDependencies": { + "@avh4/elm-format-darwin-arm64": "0.8.7-2", + "@avh4/elm-format-darwin-x64": "0.8.7-2", + "@avh4/elm-format-linux-arm64": "0.8.7-2", + "@avh4/elm-format-linux-x64": "0.8.7-2", + "@avh4/elm-format-win32-x64": "0.8.7-2" + } + }, + "node_modules/prettier": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", + "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..884e27f --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "elm-duet", + "version": "1.0.0", + "main": "index.js", + "directories": { + "example": "examples", + "test": "tests" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "devDependencies": { + "elm-format": "^0.8.7", + "prettier": "^3.2.5" + } +} From da65c096a2b02fb19154f79363489df71fa5b86e Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Mon, 29 Apr 2024 07:14:14 -0500 Subject: [PATCH 24/43] add flags for formatting --- README.md | 18 ++++++++++++++---- src/main.rs | 14 ++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b9f47bf..98b008a 100644 --- a/README.md +++ b/README.md @@ -405,10 +405,20 @@ Arguments: Location of the definition file Options: - --typescript-dest Destination for TypeScript types [default: elm.ts] - --elm-dest Destination for Elm types [default: src/] - -h, --help Print help - -V, --version Print version + --typescript-dest + Destination for TypeScript types [default: elm.ts] + --elm-dest + Destination for Elm types [default: src/] + --no-format + Turn off automatic formatting discovery + --ts-formatter + What formatter should I use for TypeScript? (Assumed to take a `-w` flag to modify files in place.) [default: prettier] + --elm-formatter + What formatter should I use for Elm? (Assumed to take a `--yes` flag to modify files in place without confirmation.) [default: elm-format] + -h, --help + Print help + -V, --version + Print version ``` diff --git a/src/main.rs b/src/main.rs index 8c2935a..4947d33 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,20 @@ struct Cli { /// Destination for Elm types #[clap(long, default_value = "src/")] elm_dest: PathBuf, + + /// Turn off automatic formatting discovery + #[clap(long)] + no_format: bool, + + /// What formatter should I use for TypeScript? (Assumed to take a `-w` flag to modify files in + /// place.) + #[clap(long, default_value = "prettier")] + ts_formatter: String, + + /// What formatter should I use for Elm? (Assumed to take a `--yes` flag to modify files in + /// place without confirmation.) + #[clap(long, default_value = "elm-format")] + elm_formatter: String, } impl Cli { From 9522dc7556adb8640b95327816253fcb4a1f0c08 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Tue, 30 Apr 2024 05:50:29 -0500 Subject: [PATCH 25/43] format TypeScript files with prettier --- src/formatting.rs | 72 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 14 +++++++++ 2 files changed, 86 insertions(+) create mode 100644 src/formatting.rs diff --git a/src/formatting.rs b/src/formatting.rs new file mode 100644 index 0000000..ef1231a --- /dev/null +++ b/src/formatting.rs @@ -0,0 +1,72 @@ +use color_eyre::{Help, SectionExt}; +use eyre::{eyre, Result, WrapErr}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +pub struct Formatter { + name: String, + command: PathBuf, +} + +impl Formatter { + pub fn discover(binary_name: &str) -> Result> { + // first look in PATH + for source in std::env::var("PATH")?.split(":") { + let command = PathBuf::from(source).join(binary_name); + if command.exists() { + return Ok(Some(Self { + name: binary_name.to_owned(), + command, + })); + } + } + + // then search for node_modules up the cwd tree + let cwd = std::env::current_dir()?; + let mut search = Some(cwd.as_path()); + loop { + match search { + Some(dir) => { + let command = dir.join("node_modules").join(".bin").join(binary_name); + if command.exists() { + return Ok(Some(Self { + name: binary_name.to_owned(), + command, + })); + } + + search = dir.parent() + } + + None => break, + } + } + + Ok(None) + } + + pub(crate) fn format(&self, args: &[&str], files: &[&Path]) -> Result<()> { + let process = Command::new(&self.command) + .args(args) + .args(files) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .wrap_err_with(|| format!("could not start `{}`", self.name))?; + + let out = process + .wait_with_output() + .wrap_err_with(|| format!("could not get output for `{}`", self.name))?; + + if !out.status.success() { + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + + return Err(eyre!("cmd exited with non-zero status code")) + .with_section(move || stdout.trim().to_string().header("Stdout:")) + .with_section(move || stderr.trim().to_string().header("Stderr:")); + } + + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index 4947d33..2075eb4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,10 @@ mod elm; +mod formatting; mod inflected_string; mod schema; mod typescript; +use crate::formatting::Formatter; use clap::Parser; use color_eyre::Result; use eyre::WrapErr; @@ -46,6 +48,7 @@ impl Cli { std::fs::write(&self.typescript_dest, schema.to_ts()?)?; println!("wrote {}", self.typescript_dest.display()); + let mut elm_files = Vec::new(); for (name, contents) in schema.to_elm()? { let dest = self.elm_dest.join(name); if let Some(parent) = dest.parent() { @@ -56,6 +59,17 @@ impl Cli { std::fs::write(&dest, contents)?; println!("wrote {}", dest.display()); + elm_files.push(dest); + } + + if !self.no_format { + if let Some(ts_formatter) = Formatter::discover(&self.ts_formatter)? { + ts_formatter + .format(&["-w"], &[&self.typescript_dest]) + .wrap_err("could not format TypeScript")?; + + println!("formatted TypeScript") + } } Ok(()) From cfed8e38ceef66435dc2e8d4b97288a9ef81331c Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Tue, 30 Apr 2024 05:56:51 -0500 Subject: [PATCH 26/43] format Elm files with elm-format --- src/formatting.rs | 2 +- src/main.rs | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/formatting.rs b/src/formatting.rs index ef1231a..c1e8c30 100644 --- a/src/formatting.rs +++ b/src/formatting.rs @@ -45,7 +45,7 @@ impl Formatter { Ok(None) } - pub(crate) fn format(&self, args: &[&str], files: &[&Path]) -> Result<()> { + pub(crate) fn format(&self, args: &[&str], files: &Vec) -> Result<()> { let process = Command::new(&self.command) .args(args) .args(files) diff --git a/src/main.rs b/src/main.rs index 2075eb4..a2d8b31 100644 --- a/src/main.rs +++ b/src/main.rs @@ -65,11 +65,22 @@ impl Cli { if !self.no_format { if let Some(ts_formatter) = Formatter::discover(&self.ts_formatter)? { ts_formatter - .format(&["-w"], &[&self.typescript_dest]) + // this is a silly clone but it doesn't matter much from a performance + // perspective. If it bugs you, feel free to refactor it but know in advance + // it'll just be for ergonomics or cleanliness. + .format(&["-w"], &Vec::from([self.typescript_dest.clone()])) .wrap_err("could not format TypeScript")?; println!("formatted TypeScript") } + + if let Some(elm_formatter) = Formatter::discover(&self.elm_formatter)? { + elm_formatter + .format(&["--yes"], &elm_files) + .wrap_err("could not format Elm")?; + + println!("formatted Elm") + } } Ok(()) From d89b3d2d0748443d20fe3666ed8d8133243c655e Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Tue, 30 Apr 2024 06:00:34 -0500 Subject: [PATCH 27/43] handle the case where the discriminator is the only field --- src/elm.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/elm.rs b/src/elm.rs index 61d2c76..e10e9c7 100644 --- a/src/elm.rs +++ b/src/elm.rs @@ -746,7 +746,11 @@ impl Type { } if let Some((discriminator_name, discriminator_value)) = discriminator_field_opt { - out.push_str(" , ( \""); + if fields.is_empty() { + out.push_str(" [ ( \""); + } else { + out.push_str(" , ( \""); + } out.push_str(discriminator_name); out.push_str("\", Json.Encode.string \""); out.push_str(discriminator_value); From 89a70105feb0481f8d93fda0492e7c088227221f Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Tue, 30 Apr 2024 06:01:07 -0500 Subject: [PATCH 28/43] regenerate examples --- README.md | 59 ++++++++++++++++-------------- examples/all_in_one.ts | 35 ++++++++++-------- examples/all_in_one/Main/Ports.elm | 3 +- examples/jwt_schema.ts | 17 ++++----- examples/jwt_schema/Main/Flags.elm | 2 +- 5 files changed, 60 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 98b008a..590e6fc 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ $ elm-duet examples/jwt_schema.yaml --typescript-dest examples/jwt_schema.ts --e wrote examples/jwt_schema.ts wrote examples/jwt_schema/Main/Flags.elm wrote examples/jwt_schema/Main/Ports.elm +formatted TypeScript +formatted Elm ``` @@ -94,8 +96,8 @@ declare module Elm { namespace Main { type Flags = { currentJwt: string | null; - } - + }; + type Ports = { logout: { subscribe: (callback: (value: Record) => void) => void; @@ -103,16 +105,14 @@ declare module Elm { newJwt: { subscribe: (callback: (value: string) => void) => void; }; - } - - function init(config: { - flags: Flags; - node: HTMLElement; - }): { + }; + + function init(config: { flags: Flags; node: HTMLElement }): { ports: Ports; - } + }; } } + ``` This should be flexible enough to use both if you're embedding your Elm app (e.g. with `esbuild`) or referring to it as an external JS file. @@ -148,7 +148,7 @@ encodeFlags flags = , case flags.currentJwt of Just value -> Json.Encode.string value - + Nothing -> Json.Encode.null ) @@ -271,6 +271,8 @@ Again, we generate everything in `examples`: $ elm-duet examples/all_in_one.yaml --typescript-dest examples/all_in_one.ts --elm-dest examples/all_in_one wrote examples/all_in_one.ts wrote examples/all_in_one/Main/Ports.elm +formatted TypeScript +formatted Elm ``` @@ -281,27 +283,31 @@ We get this in TypeScript: declare module Elm { namespace Main { - type Flags = Record - + type Flags = Record; + type Ports = { toWorld: { - subscribe: (callback: (value: { - tag: "logout"; - } | { - tag: "newJwt"; - value: string; - }) => void) => void; + subscribe: ( + callback: ( + value: + | { + tag: "logout"; + } + | { + tag: "newJwt"; + value: string; + }, + ) => void, + ) => void; }; - } - - function init(config: { - flags: Flags; - node: HTMLElement; - }): { + }; + + function init(config: { flags: Flags; node: HTMLElement }): { ports: Ports; - } + }; } } + ``` And this Elm: @@ -329,7 +335,7 @@ tagLogoutDecoder = encodeTagLogout : TagLogout -> Json.Encode.Value encodeTagLogout tagLogout = Json.Encode.object - , ( "tag", Json.Encode.string "logout" ) + [ ( "tag", Json.Encode.string "logout" ) ] @@ -357,7 +363,6 @@ type ToWorld | NewJwt NewJwt - toWorldDecoder : Decoder ToWorld toWorldDecoder = Json.Decode.andThen diff --git a/examples/all_in_one.ts b/examples/all_in_one.ts index 556afd6..48055ba 100644 --- a/examples/all_in_one.ts +++ b/examples/all_in_one.ts @@ -2,24 +2,27 @@ declare module Elm { namespace Main { - type Flags = Record - + type Flags = Record; + type Ports = { toWorld: { - subscribe: (callback: (value: { - tag: "logout"; - } | { - tag: "newJwt"; - value: string; - }) => void) => void; + subscribe: ( + callback: ( + value: + | { + tag: "logout"; + } + | { + tag: "newJwt"; + value: string; + }, + ) => void, + ) => void; }; - } - - function init(config: { - flags: Flags; - node: HTMLElement; - }): { + }; + + function init(config: { flags: Flags; node: HTMLElement }): { ports: Ports; - } + }; } -} \ No newline at end of file +} diff --git a/examples/all_in_one/Main/Ports.elm b/examples/all_in_one/Main/Ports.elm index 392ba75..edf9145 100644 --- a/examples/all_in_one/Main/Ports.elm +++ b/examples/all_in_one/Main/Ports.elm @@ -20,7 +20,7 @@ tagLogoutDecoder = encodeTagLogout : TagLogout -> Json.Encode.Value encodeTagLogout tagLogout = Json.Encode.object - , ( "tag", Json.Encode.string "logout" ) + [ ( "tag", Json.Encode.string "logout" ) ] @@ -48,7 +48,6 @@ type ToWorld | NewJwt NewJwt - toWorldDecoder : Decoder ToWorld toWorldDecoder = Json.Decode.andThen diff --git a/examples/jwt_schema.ts b/examples/jwt_schema.ts index 3dffedd..f9f6197 100644 --- a/examples/jwt_schema.ts +++ b/examples/jwt_schema.ts @@ -4,8 +4,8 @@ declare module Elm { namespace Main { type Flags = { currentJwt: string | null; - } - + }; + type Ports = { logout: { subscribe: (callback: (value: Record) => void) => void; @@ -13,13 +13,10 @@ declare module Elm { newJwt: { subscribe: (callback: (value: string) => void) => void; }; - } - - function init(config: { - flags: Flags; - node: HTMLElement; - }): { + }; + + function init(config: { flags: Flags; node: HTMLElement }): { ports: Ports; - } + }; } -} \ No newline at end of file +} diff --git a/examples/jwt_schema/Main/Flags.elm b/examples/jwt_schema/Main/Flags.elm index 32656bc..4c19b29 100644 --- a/examples/jwt_schema/Main/Flags.elm +++ b/examples/jwt_schema/Main/Flags.elm @@ -26,7 +26,7 @@ encodeFlags flags = , case flags.currentJwt of Just value -> Json.Encode.string value - + Nothing -> Json.Encode.null ) From 8515b8d7977220406a4d29e3630e57a6c16dbb08 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Tue, 30 Apr 2024 06:05:17 -0500 Subject: [PATCH 29/43] accept automatic clippy fixes --- src/formatting.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/formatting.rs b/src/formatting.rs index c1e8c30..64bc45e 100644 --- a/src/formatting.rs +++ b/src/formatting.rs @@ -1,6 +1,6 @@ use color_eyre::{Help, SectionExt}; use eyre::{eyre, Result, WrapErr}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::process::{Command, Stdio}; pub struct Formatter { @@ -11,7 +11,7 @@ pub struct Formatter { impl Formatter { pub fn discover(binary_name: &str) -> Result> { // first look in PATH - for source in std::env::var("PATH")?.split(":") { + for source in std::env::var("PATH")?.split(':') { let command = PathBuf::from(source).join(binary_name); if command.exists() { return Ok(Some(Self { From 0497d4dbbbee1f426a0df902e213cf8a1cac4d9b Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Tue, 30 Apr 2024 06:05:58 -0500 Subject: [PATCH 30/43] use a while..let loop --- src/formatting.rs | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/src/formatting.rs b/src/formatting.rs index 64bc45e..c6c2861 100644 --- a/src/formatting.rs +++ b/src/formatting.rs @@ -24,22 +24,16 @@ impl Formatter { // then search for node_modules up the cwd tree let cwd = std::env::current_dir()?; let mut search = Some(cwd.as_path()); - loop { - match search { - Some(dir) => { - let command = dir.join("node_modules").join(".bin").join(binary_name); - if command.exists() { - return Ok(Some(Self { - name: binary_name.to_owned(), - command, - })); - } - - search = dir.parent() - } - - None => break, + while let Some(dir) = search { + let command = dir.join("node_modules").join(".bin").join(binary_name); + if command.exists() { + return Ok(Some(Self { + name: binary_name.to_owned(), + command, + })); } + + search = dir.parent() } Ok(None) From 51223c7fed7fb3b2cd82228241cf44b15a856abd Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Tue, 30 Apr 2024 06:09:03 -0500 Subject: [PATCH 31/43] set up node for formatters --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ef2185..771c526 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,10 @@ jobs: - uses: actions/checkout@v4 - name: Setup Rust and Cargo uses: moonrepo/setup-rust@v1.1.0 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' - name: Test run: cargo test - name: Test README files From 07e11b22a7863d3918791a412f4188be21de6571 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Tue, 30 Apr 2024 06:11:10 -0500 Subject: [PATCH 32/43] install deps --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 771c526..4b4f52e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ jobs: with: node-version: 20 cache: 'npm' + - run: npm install - name: Test run: cargo test - name: Test README files From 09d89cd12e66cde93c24b23ace821b9cd7cc5a90 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Tue, 30 Apr 2024 06:11:16 -0500 Subject: [PATCH 33/43] add section headers --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b4f52e..75f3258 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,13 +13,19 @@ jobs: steps: - uses: actions/checkout@v4 + + # Set up Rust - name: Setup Rust and Cargo uses: moonrepo/setup-rust@v1.1.0 + + # Set up formatters - uses: actions/setup-node@v4 with: node-version: 20 cache: 'npm' - run: npm install + + # Test - name: Test run: cargo test - name: Test README files From 95440f548c5cae845982b3651adff03a14e60694 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Tue, 30 Apr 2024 06:17:01 -0500 Subject: [PATCH 34/43] add node_modules/.bin to tests --- tests/cli_tests.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index f19f060..74448c4 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1,6 +1,18 @@ #[test] fn cli_tests() { trycmd::TestCases::new() + .env( + "PATH", + format!( + "{}:{}", + std::env::current_dir() + .expect("a current dir should exist") + .join("node_modules") + .join(".bin") + .display(), + std::env!("PATH") + ), + ) .case("tests/cmd/*.toml") .case("README.md"); } From 442c97f29eb98e1bdef3dcd5340e576594c35bd4 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Tue, 30 Apr 2024 06:18:01 -0500 Subject: [PATCH 35/43] regenerate examples without syntax errors --- tests/cmd/huge_record.out/elm.ts | 17 +++-- tests/cmd/huge_record.stdout | 2 + tests/cmd/multi_module.out/elm.ts | 49 ++++++--------- tests/cmd/multi_module.stdout | 2 + tests/cmd/port_roundtrip.out/elm.ts | 25 +++----- tests/cmd/tinyping.out/elm.ts | 76 ++++++++++++----------- tests/cmd/tinyping.out/src/Main/Flags.elm | 1 - 7 files changed, 81 insertions(+), 91 deletions(-) diff --git a/tests/cmd/huge_record.out/elm.ts b/tests/cmd/huge_record.out/elm.ts index c40a709..0d864c8 100644 --- a/tests/cmd/huge_record.out/elm.ts +++ b/tests/cmd/huge_record.out/elm.ts @@ -15,15 +15,12 @@ declare module Elm { three: string; twelve: string; two: string; - } - - type Ports = Record - - function init(config: { - flags: Flags; - node: HTMLElement; - }): { + }; + + type Ports = Record; + + function init(config: { flags: Flags; node: HTMLElement }): { ports: Ports; - } + }; } -} \ No newline at end of file +} diff --git a/tests/cmd/huge_record.stdout b/tests/cmd/huge_record.stdout index 06abd7c..e49b390 100644 --- a/tests/cmd/huge_record.stdout +++ b/tests/cmd/huge_record.stdout @@ -1,2 +1,4 @@ wrote elm.ts wrote src/Main/Flags.elm +formatted TypeScript +formatted Elm diff --git a/tests/cmd/multi_module.out/elm.ts b/tests/cmd/multi_module.out/elm.ts index 75d1fcc..d2e911c 100644 --- a/tests/cmd/multi_module.out/elm.ts +++ b/tests/cmd/multi_module.out/elm.ts @@ -2,41 +2,32 @@ declare module Elm { namespace A { - type Flags = string - - type Ports = Record - - function init(config: { - flags: Flags; - node: HTMLElement; - }): { + type Flags = string; + + type Ports = Record; + + function init(config: { flags: Flags; node: HTMLElement }): { ports: Ports; - } + }; } namespace B { - type Flags = string - - type Ports = Record - - function init(config: { - flags: Flags; - node: HTMLElement; - }): { + type Flags = string; + + type Ports = Record; + + function init(config: { flags: Flags; node: HTMLElement }): { ports: Ports; - } - + }; + namespace B2 { - type Flags = string - - type Ports = Record - - function init(config: { - flags: Flags; - node: HTMLElement; - }): { + type Flags = string; + + type Ports = Record; + + function init(config: { flags: Flags; node: HTMLElement }): { ports: Ports; - } + }; } } -} \ No newline at end of file +} diff --git a/tests/cmd/multi_module.stdout b/tests/cmd/multi_module.stdout index c8abc40..e67cbbd 100644 --- a/tests/cmd/multi_module.stdout +++ b/tests/cmd/multi_module.stdout @@ -2,3 +2,5 @@ wrote elm.ts wrote src/A/Flags.elm wrote src/B/B2/Flags.elm wrote src/B/Flags.elm +formatted TypeScript +formatted Elm diff --git a/tests/cmd/port_roundtrip.out/elm.ts b/tests/cmd/port_roundtrip.out/elm.ts index 7bb6ce7..0f9d42f 100644 --- a/tests/cmd/port_roundtrip.out/elm.ts +++ b/tests/cmd/port_roundtrip.out/elm.ts @@ -2,26 +2,19 @@ declare module Elm { namespace Main { - type Flags = Record - + type Flags = Record; + type Ports = { elmToJs: { - subscribe: (callback: (value: { - a: string; - }) => void) => void; + subscribe: (callback: (value: { a: string }) => void) => void; }; jsToElm: { - send: (value: { - a: string; - }) => void; + send: (value: { a: string }) => void; }; - } - - function init(config: { - flags: Flags; - node: HTMLElement; - }): { + }; + + function init(config: { flags: Flags; node: HTMLElement }): { ports: Ports; - } + }; } -} \ No newline at end of file +} diff --git a/tests/cmd/tinyping.out/elm.ts b/tests/cmd/tinyping.out/elm.ts index 41afb95..9783c5d 100644 --- a/tests/cmd/tinyping.out/elm.ts +++ b/tests/cmd/tinyping.out/elm.ts @@ -5,30 +5,37 @@ declare module Elm { type Flags = { currentTimeMillis: number; notificationPermission: "default" | "denied" | "granted"; - } - + }; + type Ports = { changeDocument: { - subscribe: (callback: (value: { - tag: "AddNewPingAt"; - value: number; - } | { - tag: "SetMinutesPerPing"; - value: number; - } | { - index: number; - tag: "SetTagForPing"; - value: string | null; - }) => void) => void; + subscribe: ( + callback: ( + value: + | { + tag: "AddNewPingAt"; + value: number; + } + | { + tag: "SetMinutesPerPing"; + value: number; + } + | { + index: number; + tag: "SetTagForPing"; + value: string | null; + }, + ) => void, + ) => void; }; docFromAutomerge: { send: (value: { - pings: ({ + pings: { custom: Record; tag: string | null; time: number; version: "v1"; - })[]; + }[]; settings: { minutesPerPing: number; version: "v1"; @@ -37,18 +44,20 @@ declare module Elm { }) => void; }; newNotification: { - subscribe: (callback: (value: { - options: { - badge: string | null; - body: string | null; - icon: string | null; - lang: string | null; - requireInteraction: boolean | null; - silent: boolean | null; - tag: string | null; - }; - title: string; - }) => void) => void; + subscribe: ( + callback: (value: { + options: { + badge: string | null; + body: string | null; + icon: string | null; + lang: string | null; + requireInteraction: boolean | null; + silent: boolean | null; + tag: string | null; + }; + title: string; + }) => void, + ) => void; }; notificationPermission: { send: (value: "default" | "denied" | "granted") => void; @@ -56,13 +65,10 @@ declare module Elm { requestNotificationPermission: { subscribe: (callback: (value: Record) => void) => void; }; - } - - function init(config: { - flags: Flags; - node: HTMLElement; - }): { + }; + + function init(config: { flags: Flags; node: HTMLElement }): { ports: Ports; - } + }; } -} \ No newline at end of file +} diff --git a/tests/cmd/tinyping.out/src/Main/Flags.elm b/tests/cmd/tinyping.out/src/Main/Flags.elm index dbff8a6..bde5f7c 100644 --- a/tests/cmd/tinyping.out/src/Main/Flags.elm +++ b/tests/cmd/tinyping.out/src/Main/Flags.elm @@ -14,7 +14,6 @@ type NotificationPermission | Granted - notificationPermissionDecoder : Decoder NotificationPermission notificationPermissionDecoder = Json.Decode.andThen From 6ae359a2ee5777e47e3742fda930189623724173 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Tue, 30 Apr 2024 06:21:25 -0500 Subject: [PATCH 36/43] add missing ) in subscriptions --- src/elm.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/elm.rs b/src/elm.rs index e10e9c7..e76bcc4 100644 --- a/src/elm.rs +++ b/src/elm.rs @@ -851,7 +851,7 @@ impl Port { out.push_str(&self.name); out.push_str(" (\\value -> toMsg (Json.Decode.decodeValue value "); out.push_str(&self.type_.decoder_name()?); - out.push(')'); + out.push_str("))"); } } From 9a23507b44abf8ab13eb60d910a7913d36d84a21 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Tue, 30 Apr 2024 06:21:48 -0500 Subject: [PATCH 37/43] regenerate examples with subscriptions --- .../cmd/port_roundtrip.out/src/Main/Ports.elm | 2 +- tests/cmd/port_roundtrip.stdout | 2 ++ tests/cmd/tinyping.out/src/Main/Ports.elm | 27 ++++++++----------- tests/cmd/tinyping.stdout | 2 ++ 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/tests/cmd/port_roundtrip.out/src/Main/Ports.elm b/tests/cmd/port_roundtrip.out/src/Main/Ports.elm index 74e4c3b..e2705cb 100644 --- a/tests/cmd/port_roundtrip.out/src/Main/Ports.elm +++ b/tests/cmd/port_roundtrip.out/src/Main/Ports.elm @@ -57,4 +57,4 @@ port jsToElm : (Value -> msg) -> Sub msg subscribeToJsToElm : (Result Json.Decode.Error JsToElm -> msg) -> Sub msg subscribeToJsToElm toMsg = - jsToElm (/value -> toMsg (Json.Decode.decodeValue value jsToElmDecoder) + jsToElm (/value -> toMsg (Json.Decode.decodeValue value jsToElmDecoder)) diff --git a/tests/cmd/port_roundtrip.stdout b/tests/cmd/port_roundtrip.stdout index 9d0c2b9..d58ffbf 100644 --- a/tests/cmd/port_roundtrip.stdout +++ b/tests/cmd/port_roundtrip.stdout @@ -1,2 +1,4 @@ wrote elm.ts wrote src/Main/Ports.elm +formatted TypeScript +formatted Elm diff --git a/tests/cmd/tinyping.out/src/Main/Ports.elm b/tests/cmd/tinyping.out/src/Main/Ports.elm index 2dddd6b..ee21771 100644 --- a/tests/cmd/tinyping.out/src/Main/Ports.elm +++ b/tests/cmd/tinyping.out/src/Main/Ports.elm @@ -67,7 +67,7 @@ encodeSetTagForPing setTagForPing = , case setTagForPing.value of Just value -> Json.Encode.string value - + Nothing -> Json.Encode.null ) @@ -81,7 +81,6 @@ type ChangeDocument | SetTagForPing SetTagForPing - changeDocumentDecoder : Decoder ChangeDocument changeDocumentDecoder = Json.Decode.andThen @@ -135,7 +134,7 @@ encodePingV1 pingV1 = , case pingV1.tag of Just value -> Json.Encode.string value - + Nothing -> Json.Encode.null ) @@ -148,7 +147,6 @@ type PingsElements = PingV1 PingV1 - pingsElementsDecoder : Decoder PingsElements pingsElementsDecoder = Json.Decode.andThen @@ -190,7 +188,6 @@ type Settings = SettingsV1 SettingsV1 - settingsDecoder : Decoder Settings settingsDecoder = Json.Decode.andThen @@ -235,7 +232,6 @@ type DocFromAutomerge = DocV1 DocV1 - docFromAutomergeDecoder : Decoder DocFromAutomerge docFromAutomergeDecoder = Json.Decode.andThen @@ -284,7 +280,7 @@ encodeNotificationOptions notificationOptions = , case notificationOptions.badge of Just value -> Json.Encode.string value - + Nothing -> Json.Encode.null ) @@ -292,7 +288,7 @@ encodeNotificationOptions notificationOptions = , case notificationOptions.body of Just value -> Json.Encode.string value - + Nothing -> Json.Encode.null ) @@ -300,7 +296,7 @@ encodeNotificationOptions notificationOptions = , case notificationOptions.icon of Just value -> Json.Encode.string value - + Nothing -> Json.Encode.null ) @@ -308,7 +304,7 @@ encodeNotificationOptions notificationOptions = , case notificationOptions.lang of Just value -> Json.Encode.string value - + Nothing -> Json.Encode.null ) @@ -316,7 +312,7 @@ encodeNotificationOptions notificationOptions = , case notificationOptions.requireInteraction of Just value -> Json.Encode.bool value - + Nothing -> Json.Encode.null ) @@ -324,7 +320,7 @@ encodeNotificationOptions notificationOptions = , case notificationOptions.silent of Just value -> Json.Encode.bool value - + Nothing -> Json.Encode.null ) @@ -332,7 +328,7 @@ encodeNotificationOptions notificationOptions = , case notificationOptions.tag of Just value -> Json.Encode.string value - + Nothing -> Json.Encode.null ) @@ -366,7 +362,6 @@ type NotificationPermission | Granted - notificationPermissionDecoder : Decoder NotificationPermission notificationPermissionDecoder = Json.Decode.andThen @@ -424,7 +419,7 @@ port docFromAutomerge : (Value -> msg) -> Sub msg subscribeToDocFromAutomerge : (Result Json.Decode.Error DocFromAutomerge -> msg) -> Sub msg subscribeToDocFromAutomerge toMsg = - docFromAutomerge (/value -> toMsg (Json.Decode.decodeValue value docFromAutomergeDecoder) + docFromAutomerge (/value -> toMsg (Json.Decode.decodeValue value docFromAutomergeDecoder)) port newNotification : Value -> Cmd msg @@ -440,7 +435,7 @@ port notificationPermission : (Value -> msg) -> Sub msg subscribeToNotificationPermission : (Result Json.Decode.Error NotificationPermission -> msg) -> Sub msg subscribeToNotificationPermission toMsg = - notificationPermission (/value -> toMsg (Json.Decode.decodeValue value notificationPermissionDecoder) + notificationPermission (/value -> toMsg (Json.Decode.decodeValue value notificationPermissionDecoder)) port requestNotificationPermission : Value -> Cmd msg diff --git a/tests/cmd/tinyping.stdout b/tests/cmd/tinyping.stdout index 85131cf..9541b6d 100644 --- a/tests/cmd/tinyping.stdout +++ b/tests/cmd/tinyping.stdout @@ -1,3 +1,5 @@ wrote elm.ts wrote src/Main/Flags.elm wrote src/Main/Ports.elm +formatted TypeScript +formatted Elm From 54bb669c4638e227bb84d9c0dd6734d94f75cc18 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Tue, 30 Apr 2024 06:23:14 -0500 Subject: [PATCH 38/43] clarify this comment --- README.md | 7 ++++--- examples/jwt_schema.yaml | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 590e6fc..c9fcdda 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,10 @@ modules: direction: ElmToJs ref: jwt - # Finally, logout is a bit odd: we really don't need a payload. JTD - # handles this by defining an "empty" case, which we specify by omitting - # any type information. We still need the direction metadata, though! + # Logout is a bit different: we really don't need a payload, just a + # signal. JTD handles this by defining an "empty" case, which we specify + # by omitting any type information. We still need to know which direction + # the port has to move, though, so we still include the metadata. logout: metadata: direction: ElmToJs diff --git a/examples/jwt_schema.yaml b/examples/jwt_schema.yaml index 318c59c..91740da 100644 --- a/examples/jwt_schema.yaml +++ b/examples/jwt_schema.yaml @@ -43,9 +43,10 @@ modules: direction: ElmToJs ref: jwt - # Finally, logout is a bit odd: we really don't need a payload. JTD - # handles this by defining an "empty" case, which we specify by omitting - # any type information. We still need the direction metadata, though! + # Logout is a bit different: we really don't need a payload, just a + # signal. JTD handles this by defining an "empty" case, which we specify + # by omitting any type information. We still need to know which direction + # the port has to move, though, so we still include the metadata. logout: metadata: direction: ElmToJs From 4bfdb2cb6f8583ec7f5dae6b4c4180341685e9ec Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Tue, 30 Apr 2024 06:23:27 -0500 Subject: [PATCH 39/43] this is a schema, not an app --- README.md | 4 ++-- examples/jwt_schema.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c9fcdda..3d5602b 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@ I'll call those out as we get to them. Here's an example for an app that stores [JWTs](https://jwt.io/) in `localStorage`: ```yaml {source=examples/jwt_schema.yaml} -# An example app that uses JWTs to manage authentication. Imagine that the JWTs -# are stored in localStorage so that they can persist across sessions. The +# An example schema that uses JWTs to manage authentication. Imagine that the +# JWTs are stored in localStorage so that they can persist across sessions. The # lifecycle of this app might look like: # # 1. On init, the JS side passes the Elm runtime the current value of the JWT diff --git a/examples/jwt_schema.yaml b/examples/jwt_schema.yaml index 91740da..29f8e73 100644 --- a/examples/jwt_schema.yaml +++ b/examples/jwt_schema.yaml @@ -1,5 +1,5 @@ -# An example app that uses JWTs to manage authentication. Imagine that the JWTs -# are stored in localStorage so that they can persist across sessions. The +# An example schema that uses JWTs to manage authentication. Imagine that the +# JWTs are stored in localStorage so that they can persist across sessions. The # lifecycle of this app might look like: # # 1. On init, the JS side passes the Elm runtime the current value of the JWT From bc6c1870d683fc377407e249a7adf135b5a27160 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Tue, 30 Apr 2024 06:24:18 -0500 Subject: [PATCH 40/43] fix missing sentence --- README.md | 2 +- examples/jwt_schema.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3d5602b..31d9cb7 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ definitions: modules: Main: # First we'll define flags. As we mentioned above, that's either a JWT or - # null. We'll define it by referring to + # null. We'll define it by referring to `jwt` from above. # # If your app doesn't use flags, you can omit this key. flags: diff --git a/examples/jwt_schema.yaml b/examples/jwt_schema.yaml index 29f8e73..8e9a8b7 100644 --- a/examples/jwt_schema.yaml +++ b/examples/jwt_schema.yaml @@ -21,7 +21,7 @@ definitions: modules: Main: # First we'll define flags. As we mentioned above, that's either a JWT or - # null. We'll define it by referring to + # null. We'll define it by referring to `jwt` from above. # # If your app doesn't use flags, you can omit this key. flags: From a6a5452328f1cea82066d0020356bf48d23f0147 Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Tue, 30 Apr 2024 06:25:46 -0500 Subject: [PATCH 41/43] simplify the first example by omitting logout --- README.md | 35 ------------------------------ examples/jwt_schema.ts | 3 --- examples/jwt_schema.yaml | 10 --------- examples/jwt_schema/Main/Ports.elm | 22 ------------------- 4 files changed, 70 deletions(-) diff --git a/README.md b/README.md index 31d9cb7..2a789ff 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,6 @@ Here's an example for an app that stores [JWTs](https://jwt.io/) in `localStorag # (or `null`, if unset) # 2. Elm is responsible for authentication and for telling JS when it gets a # new JWT (for example, when the user logs in) -# 3. Elm is also responsible for telling JS if someone logs out, in which case -# we should clear the JWT from localStorage. # For elm-duet, we start by defining our data types. In this case, we're just # going to keep things simple and define a "jwt" that will just be an alias to @@ -64,14 +62,6 @@ modules: direction: ElmToJs ref: jwt - # Logout is a bit different: we really don't need a payload, just a - # signal. JTD handles this by defining an "empty" case, which we specify - # by omitting any type information. We still need to know which direction - # the port has to move, though, so we still include the metadata. - logout: - metadata: - direction: ElmToJs - ``` (We're using YAML in this example so we can use comments, but JSON schemas also work just fine.) @@ -100,9 +90,6 @@ declare module Elm { }; type Ports = { - logout: { - subscribe: (callback: (value: Record) => void) => void; - }; newJwt: { subscribe: (callback: (value: string) => void) => void; }; @@ -176,20 +163,6 @@ import Json.Decode.Pipeline import Json.Encode -type alias Logout = - () - - -logoutDecoder : Decoder Logout -logoutDecoder = - Json.Decode.null () - - -encodeLogout : Logout -> Json.Encode.Value -encodeLogout logout = - Json.Encode.null - - type alias NewJwt = String @@ -204,14 +177,6 @@ encodeNewJwt newJwt = Json.Encode.string newJwt -port logout : Value -> Cmd msg - - -sendLogout : Logout -> Cmd msg -sendLogout value = - logout (encodeLogout value) - - port newJwt : Value -> Cmd msg diff --git a/examples/jwt_schema.ts b/examples/jwt_schema.ts index f9f6197..c117c94 100644 --- a/examples/jwt_schema.ts +++ b/examples/jwt_schema.ts @@ -7,9 +7,6 @@ declare module Elm { }; type Ports = { - logout: { - subscribe: (callback: (value: Record) => void) => void; - }; newJwt: { subscribe: (callback: (value: string) => void) => void; }; diff --git a/examples/jwt_schema.yaml b/examples/jwt_schema.yaml index 8e9a8b7..fb25d7d 100644 --- a/examples/jwt_schema.yaml +++ b/examples/jwt_schema.yaml @@ -6,8 +6,6 @@ # (or `null`, if unset) # 2. Elm is responsible for authentication and for telling JS when it gets a # new JWT (for example, when the user logs in) -# 3. Elm is also responsible for telling JS if someone logs out, in which case -# we should clear the JWT from localStorage. # For elm-duet, we start by defining our data types. In this case, we're just # going to keep things simple and define a "jwt" that will just be an alias to @@ -42,11 +40,3 @@ modules: metadata: direction: ElmToJs ref: jwt - - # Logout is a bit different: we really don't need a payload, just a - # signal. JTD handles this by defining an "empty" case, which we specify - # by omitting any type information. We still need to know which direction - # the port has to move, though, so we still include the metadata. - logout: - metadata: - direction: ElmToJs diff --git a/examples/jwt_schema/Main/Ports.elm b/examples/jwt_schema/Main/Ports.elm index 9a174ae..c752460 100644 --- a/examples/jwt_schema/Main/Ports.elm +++ b/examples/jwt_schema/Main/Ports.elm @@ -8,20 +8,6 @@ import Json.Decode.Pipeline import Json.Encode -type alias Logout = - () - - -logoutDecoder : Decoder Logout -logoutDecoder = - Json.Decode.null () - - -encodeLogout : Logout -> Json.Encode.Value -encodeLogout logout = - Json.Encode.null - - type alias NewJwt = String @@ -36,14 +22,6 @@ encodeNewJwt newJwt = Json.Encode.string newJwt -port logout : Value -> Cmd msg - - -sendLogout : Logout -> Cmd msg -sendLogout value = - logout (encodeLogout value) - - port newJwt : Value -> Cmd msg From f10ccf37746ed19670f36cd863f72d3aea96872b Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Tue, 30 Apr 2024 06:52:47 -0500 Subject: [PATCH 42/43] remove long Elm example from ports --- README.md | 190 +++++++++++----------- examples/all_in_one.ts | 35 ++++- examples/all_in_one.yaml | 67 ++++++-- examples/all_in_one/Main/Ports.elm | 244 ++++++++++++++++++++++++----- 4 files changed, 385 insertions(+), 151 deletions(-) diff --git a/README.md b/README.md index 2a789ff..59d8a91 100644 --- a/README.md +++ b/README.md @@ -195,9 +195,15 @@ Some people like to define an all-in-one port for their application to make sure `elm-duet` and JDT support this with discriminators and mappings: ```yaml {source=examples/all_in_one.yaml} -# We're going to define a port named `toWorld` that sends all our messages to -# the JS in the same place. You can do the same thing for `fromWorld` for -# subscriptions, but we're leaving that off to keep things succinct. +# Let's imagine that we're writing an app which handles some websocket messages +# that we want Elm to react to. We'll leave it up to Elm to interpret the data +# inside the messages, but we can use elm-duet to ensure that we have all the +# right types for each port event set up. +# +# For this example, we're going to define a port named `toWorld` that sends all +# our messages to JS in the same place, and the same for `fromWorld` for +# subscriptions. We could do this across many ports as well, of course, but if +# you prefer to put all your communication in one port, here's how you do it! modules: Main: ports: @@ -215,19 +221,56 @@ modules: # example, but all in one port. Note that all the options need to be # non-nullable objects, since we can't set a tag otherwise. mapping: - newJwt: + connect: properties: - value: - ref: jwt + url: + type: string + protocols: + elements: + type: string + nullable: true - logout: - # in cases where we don't want any payload, we specify an empty - # object aside from the tag. + send: properties: + message: + type: string -definitions: - jwt: - type: string + close: + properties: + code: + type: uint8 + reason: + type: string + + # since we're managing the websocket in JS, we need to pass events back + # into Elm. Like `toWorld`, we'll use discriminator/mapping from JTD. + fromWorld: + metadata: + direction: JsToElm + discriminator: tag + + mapping: + # There isn't any data in the `open` or `error` events, but we still + # care about knowing that it happened. In this case, we specify an + # empty object to signify that there is no additional data. + open: {} + error: {} + + close: + properties: + code: + type: uint32 + reason: + type: string + wasClean: + type: boolean + + message: + properties: + data: + type: string + origin: + type: string ``` @@ -252,16 +295,45 @@ declare module Elm { type Flags = Record; type Ports = { + fromWorld: { + send: ( + value: + | { + code: number; + reason: string; + tag: "close"; + wasClean: boolean; + } + | { + tag: "error"; + } + | { + data: string; + origin: string; + tag: "message"; + } + | { + tag: "open"; + }, + ) => void; + }; toWorld: { subscribe: ( callback: ( value: | { - tag: "logout"; + code: number; + reason: string; + tag: "close"; } | { - tag: "newJwt"; - value: string; + protocols: string[] | null; + tag: "connect"; + url: string; + } + | { + message: string; + tag: "send"; }, ) => void, ) => void; @@ -276,91 +348,9 @@ declare module Elm { ``` -And this Elm: - -```elm {source=examples/all_in_one/Main/Ports.elm} -port module Main.Ports exposing (..) - -{-| Warning: this file is automatically generated. Don't edit by hand! --} - -import Json.Decode -import Json.Decode.Pipeline -import Json.Encode - - -type alias TagLogout = - {} - - -tagLogoutDecoder : Decoder TagLogout -tagLogoutDecoder = - Json.Decode.succeed TagLogout - - -encodeTagLogout : TagLogout -> Json.Encode.Value -encodeTagLogout tagLogout = - Json.Encode.object - [ ( "tag", Json.Encode.string "logout" ) - ] - - -type alias NewJwt = - { value : String - } - - -newJwtDecoder : Decoder NewJwt -newJwtDecoder = - Json.Decode.succeed NewJwt - |> Json.Decode.Pipeline.required "value" Json.Decode.string - - -encodeNewJwt : NewJwt -> Json.Encode.Value -encodeNewJwt newJwt = - Json.Encode.object - [ ( "value", Json.Encode.string newJwt.value ) - , ( "tag", Json.Encode.string "newJwt" ) - ] - - -type ToWorld - = Logout TagLogout - | NewJwt NewJwt - - -toWorldDecoder : Decoder ToWorld -toWorldDecoder = - Json.Decode.andThen - (\tag -> - case tag of - "logout" -> - Json.Decode.map Logout tagLogoutDecoder - - "newJwt" -> - Json.Decode.map NewJwt newJwtDecoder - ) - (Json.Decode.field "tag" Json.Decode.string) - - -encodeToWorld : ToWorld -> Json.Encode.Value -encodeToWorld toWorld = - case toWorld of - Logout logout -> - encodeTagLogout logout - - NewJwt newJwt -> - encodeNewJwt newJwt - - -port toWorld : Value -> Cmd msg - - -sendToWorld : ToWorld -> Cmd msg -sendToWorld value = - toWorld (encodeToWorld value) - -``` +The Elm for this example is too long to reasonably include in a README. +See it at `examples/all_in_one/Main/Ports.elm`. +Like the previous example, you get all the data types and ports you need, plus some wrappers around the ports that will do the decoding for you. ## The Full Help diff --git a/examples/all_in_one.ts b/examples/all_in_one.ts index 48055ba..f725d5e 100644 --- a/examples/all_in_one.ts +++ b/examples/all_in_one.ts @@ -5,16 +5,45 @@ declare module Elm { type Flags = Record; type Ports = { + fromWorld: { + send: ( + value: + | { + code: number; + reason: string; + tag: "close"; + wasClean: boolean; + } + | { + tag: "error"; + } + | { + data: string; + origin: string; + tag: "message"; + } + | { + tag: "open"; + }, + ) => void; + }; toWorld: { subscribe: ( callback: ( value: | { - tag: "logout"; + code: number; + reason: string; + tag: "close"; } | { - tag: "newJwt"; - value: string; + protocols: string[] | null; + tag: "connect"; + url: string; + } + | { + message: string; + tag: "send"; }, ) => void, ) => void; diff --git a/examples/all_in_one.yaml b/examples/all_in_one.yaml index e3e594a..b2d608f 100644 --- a/examples/all_in_one.yaml +++ b/examples/all_in_one.yaml @@ -1,6 +1,12 @@ -# We're going to define a port named `toWorld` that sends all our messages to -# the JS in the same place. You can do the same thing for `fromWorld` for -# subscriptions, but we're leaving that off to keep things succinct. +# Let's imagine that we're writing an app which handles some websocket messages +# that we want Elm to react to. We'll leave it up to Elm to interpret the data +# inside the messages, but we can use elm-duet to ensure that we have all the +# right types for each port event set up. +# +# For this example, we're going to define a port named `toWorld` that sends all +# our messages to JS in the same place, and the same for `fromWorld` for +# subscriptions. We could do this across many ports as well, of course, but if +# you prefer to put all your communication in one port, here's how you do it! modules: Main: ports: @@ -18,16 +24,53 @@ modules: # example, but all in one port. Note that all the options need to be # non-nullable objects, since we can't set a tag otherwise. mapping: - newJwt: + connect: properties: - value: - ref: jwt + url: + type: string + protocols: + elements: + type: string + nullable: true - logout: - # in cases where we don't want any payload, we specify an empty - # object aside from the tag. + send: properties: + message: + type: string -definitions: - jwt: - type: string + close: + properties: + code: + type: uint8 + reason: + type: string + + # since we're managing the websocket in JS, we need to pass events back + # into Elm. Like `toWorld`, we'll use discriminator/mapping from JTD. + fromWorld: + metadata: + direction: JsToElm + discriminator: tag + + mapping: + # There isn't any data in the `open` or `error` events, but we still + # care about knowing that it happened. In this case, we specify an + # empty object to signify that there is no additional data. + open: {} + error: {} + + close: + properties: + code: + type: uint32 + reason: + type: string + wasClean: + type: boolean + + message: + properties: + data: + type: string + origin: + type: string diff --git a/examples/all_in_one/Main/Ports.elm b/examples/all_in_one/Main/Ports.elm index edf9145..5f3b948 100644 --- a/examples/all_in_one/Main/Ports.elm +++ b/examples/all_in_one/Main/Ports.elm @@ -8,44 +8,202 @@ import Json.Decode.Pipeline import Json.Encode -type alias TagLogout = - {} - - -tagLogoutDecoder : Decoder TagLogout -tagLogoutDecoder = - Json.Decode.succeed TagLogout - - -encodeTagLogout : TagLogout -> Json.Encode.Value -encodeTagLogout tagLogout = - Json.Encode.object - [ ( "tag", Json.Encode.string "logout" ) - ] - - -type alias NewJwt = - { value : String +type alias Close = + { code : Int + , reason : String + , wasClean : Bool } -newJwtDecoder : Decoder NewJwt -newJwtDecoder = - Json.Decode.succeed NewJwt - |> Json.Decode.Pipeline.required "value" Json.Decode.string +closeDecoder : Decoder Close +closeDecoder = + Json.Decode.succeed Close + |> Json.Decode.Pipeline.required "code" Json.Decode.int + |> Json.Decode.Pipeline.required "reason" Json.Decode.string + |> Json.Decode.Pipeline.required "wasClean" Json.Decode.bool -encodeNewJwt : NewJwt -> Json.Encode.Value -encodeNewJwt newJwt = +encodeClose : Close -> Json.Encode.Value +encodeClose close = Json.Encode.object - [ ( "value", Json.Encode.string newJwt.value ) - , ( "tag", Json.Encode.string "newJwt" ) + [ ( "code", Json.Encode.int close.code ) + , ( "reason", Json.Encode.string close.reason ) + , ( "wasClean", Json.Encode.bool close.wasClean ) + , ( "tag", Json.Encode.string "close" ) + ] + + +type alias TagError = + {} + + +tagErrorDecoder : Decoder TagError +tagErrorDecoder = + Json.Decode.succeed TagError + + +encodeTagError : TagError -> Json.Encode.Value +encodeTagError tagError = + Json.Encode.object + [ ( "tag", Json.Encode.string "error" ) + ] + + +type alias Message = + { data : String + , origin : String + } + + +messageDecoder : Decoder Message +messageDecoder = + Json.Decode.succeed Message + |> Json.Decode.Pipeline.required "data" Json.Decode.string + |> Json.Decode.Pipeline.required "origin" Json.Decode.string + + +encodeMessage : Message -> Json.Encode.Value +encodeMessage message = + Json.Encode.object + [ ( "data", Json.Encode.string message.data ) + , ( "origin", Json.Encode.string message.origin ) + , ( "tag", Json.Encode.string "message" ) + ] + + +type alias TagOpen = + {} + + +tagOpenDecoder : Decoder TagOpen +tagOpenDecoder = + Json.Decode.succeed TagOpen + + +encodeTagOpen : TagOpen -> Json.Encode.Value +encodeTagOpen tagOpen = + Json.Encode.object + [ ( "tag", Json.Encode.string "open" ) + ] + + +type FromWorld + = Close Close + | Error TagError + | Message Message + | Open TagOpen + + +fromWorldDecoder : Decoder FromWorld +fromWorldDecoder = + Json.Decode.andThen + (\tag -> + case tag of + "close" -> + Json.Decode.map Close closeDecoder + + "error" -> + Json.Decode.map Error tagErrorDecoder + + "message" -> + Json.Decode.map Message messageDecoder + + "open" -> + Json.Decode.map Open tagOpenDecoder + ) + (Json.Decode.field "tag" Json.Decode.string) + + +encodeFromWorld : FromWorld -> Json.Encode.Value +encodeFromWorld fromWorld = + case fromWorld of + Close close -> + encodeClose close + + Error error -> + encodeTagError error + + Message message -> + encodeMessage message + + Open open -> + encodeTagOpen open + + +type alias Close = + { code : Int + , reason : String + } + + +closeDecoder : Decoder Close +closeDecoder = + Json.Decode.succeed Close + |> Json.Decode.Pipeline.required "code" Json.Decode.int + |> Json.Decode.Pipeline.required "reason" Json.Decode.string + + +encodeClose : Close -> Json.Encode.Value +encodeClose close = + Json.Encode.object + [ ( "code", Json.Encode.int close.code ) + , ( "reason", Json.Encode.string close.reason ) + , ( "tag", Json.Encode.string "close" ) + ] + + +type alias Connect = + { protocols : Maybe (List String) + , url : String + } + + +connectDecoder : Decoder Connect +connectDecoder = + Json.Decode.succeed Connect + |> Json.Decode.Pipeline.required "protocols" (Json.Decode.nullable (Json.Decode.list Json.Decode.string)) + |> Json.Decode.Pipeline.required "url" Json.Decode.string + + +encodeConnect : Connect -> Json.Encode.Value +encodeConnect connect = + Json.Encode.object + [ ( "protocols" + , case connect.protocols of + Just value -> + Json.Encode.list (\value -> Json.Encode.string value) value + + Nothing -> + Json.Encode.null + ) + , ( "url", Json.Encode.string connect.url ) + , ( "tag", Json.Encode.string "connect" ) + ] + + +type alias Send = + { message : String + } + + +sendDecoder : Decoder Send +sendDecoder = + Json.Decode.succeed Send + |> Json.Decode.Pipeline.required "message" Json.Decode.string + + +encodeSend : Send -> Json.Encode.Value +encodeSend send = + Json.Encode.object + [ ( "message", Json.Encode.string send.message ) + , ( "tag", Json.Encode.string "send" ) ] type ToWorld - = Logout TagLogout - | NewJwt NewJwt + = Close Close + | Connect Connect + | Send Send toWorldDecoder : Decoder ToWorld @@ -53,11 +211,14 @@ toWorldDecoder = Json.Decode.andThen (\tag -> case tag of - "logout" -> - Json.Decode.map Logout tagLogoutDecoder + "close" -> + Json.Decode.map Close closeDecoder - "newJwt" -> - Json.Decode.map NewJwt newJwtDecoder + "connect" -> + Json.Decode.map Connect connectDecoder + + "send" -> + Json.Decode.map Send sendDecoder ) (Json.Decode.field "tag" Json.Decode.string) @@ -65,11 +226,22 @@ toWorldDecoder = encodeToWorld : ToWorld -> Json.Encode.Value encodeToWorld toWorld = case toWorld of - Logout logout -> - encodeTagLogout logout + Close close -> + encodeClose close - NewJwt newJwt -> - encodeNewJwt newJwt + Connect connect -> + encodeConnect connect + + Send send -> + encodeSend send + + +port fromWorld : (Value -> msg) -> Sub msg + + +subscribeToFromWorld : (Result Json.Decode.Error FromWorld -> msg) -> Sub msg +subscribeToFromWorld toMsg = + fromWorld (\value -> toMsg (Json.Decode.decodeValue value fromWorldDecoder)) port toWorld : Value -> Cmd msg From f459ce499197dbddd69ab24fd27e69cb2a7fbcff Mon Sep 17 00:00:00 2001 From: Brian Hicks Date: Tue, 30 Apr 2024 06:55:50 -0500 Subject: [PATCH 43/43] talk a little about helpers --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 59d8a91..e17adb0 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,9 @@ sendNewJwt value = You'll notice that in addition to decoders and encoders, `elm-duet` generates type-safe wrappers around the ports. This is, again, to let you send custom types through the ports in a way we control: if you specify an `enum`, for example, we ensure that both Elm and TypeScript have enough information to take advantage of the best parts of their respective type systems without falling back to plain strings. +Other than those helpers, we don't try to generate any particular helpers for constructing values. +Every app is going to have different needs there, and we expose everything you need to construct your own. + ## Example 2: An All-in-One Port Some people like to define an all-in-one port for their application to make sure that they only have a single place to hook up new messages.