Merge pull request #13 from BrianHicks/cleanup

cleanup
This commit is contained in:
Brian Hicks 2024-04-30 06:58:16 -05:00 committed by GitHub
commit 803a2719c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1345 additions and 492 deletions

View File

@ -13,8 +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

5
.gitignore vendored
View File

@ -1,6 +1,3 @@
/node_modules
/result
# Added by cargo
/target

20
Cargo.lock generated
View File

@ -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"

View File

@ -14,6 +14,7 @@ eyre = "0.6.12"
jtd = "0.3.1"
serde = { version = "1.0.199", features = ["derive"] }
serde_json = "1.0.116"
serde_yaml = "0.9.34"
tracing = "0.1.40"
[profile.dev.package.backtrace]

13
LICENSE Normal file
View File

@ -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.

347
README.md
View File

@ -1,61 +1,84 @@
# 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!)
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.
```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"
}
}
}
}
}
}
## Example 1: JWTs
Here's an example for an app that stores [JWTs](https://jwt.io/) in `localStorage`:
```yaml {source=examples/jwt_schema.yaml}
# 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
# (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)
# 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 `jwt` from above.
#
# If your app doesn't use flags, you can omit this key.
flags:
properties:
currentJwt:
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
ref: jwt
```
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
formatted TypeScript
formatted 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!
@ -64,28 +87,25 @@ declare module Elm {
namespace Main {
type Flags = {
currentJwt: string | null;
}
};
type Ports = {
logout: {
subscribe: (callback: (value: Record<string, never>) => void) => void;
};
newJwt: {
subscribe: (callback: (value: string) => void) => void;
};
}
function init(config: {
flags: Flags;
node: HTMLElement;
}): {
};
function init(config: { flags: Flags; node: HTMLElement }): {
ports: Ports;
}
};
}
}
```
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 (..)
@ -116,7 +136,7 @@ encodeFlags flags =
, case flags.currentJwt of
Just value ->
Json.Encode.string value
Nothing ->
Json.Encode.null
)
@ -124,7 +144,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 (..)
@ -137,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
@ -165,23 +177,186 @@ encodeNewJwt newJwt =
Json.Encode.string newJwt
port logout : Value -> Cmd msg
logout_ : Logout -> Cmd msg
logout_ value =
logout (encodeLogout value)
port newJwt : Value -> Cmd msg
newJwt_ : NewJwt -> Cmd msg
newJwt_ value =
sendNewJwt : NewJwt -> Cmd msg
sendNewJwt value =
newJwt (encodeNewJwt 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.
`elm-duet` and JDT support this with discriminators and mappings:
```yaml {source=examples/all_in_one.yaml}
# 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:
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:
connect:
properties:
url:
type: string
protocols:
elements:
type: string
nullable: true
send:
properties:
message:
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
```
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
wrote examples/all_in_one.ts
wrote examples/all_in_one/Main/Ports.elm
formatted TypeScript
formatted Elm
```
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<string, never>;
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:
| {
code: number;
reason: string;
tag: "close";
}
| {
protocols: string[] | null;
tag: "connect";
url: string;
}
| {
message: string;
tag: "send";
},
) => void,
) => void;
};
};
function init(config: { flags: Flags; node: HTMLElement }): {
ports: Ports;
};
}
}
```
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
Here's the full help to give you an idea of what you can do with the tool:
```console
@ -194,9 +369,23 @@ Arguments:
<SOURCE> Location of the definition file
Options:
--typescript-dest <TYPESCRIPT_DEST> Destination for TypeScript types [default: elm.ts]
--elm-dest <ELM_DEST> Destination for Elm types [default: src/]
-h, --help Print help
-V, --version Print version
--typescript-dest <TYPESCRIPT_DEST>
Destination for TypeScript types [default: elm.ts]
--elm-dest <ELM_DEST>
Destination for Elm types [default: src/]
--no-format
Turn off automatic formatting discovery
--ts-formatter <TS_FORMATTER>
What formatter should I use for TypeScript? (Assumed to take a `-w` flag to modify files in place.) [default: prettier]
--elm-formatter <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
```
## License
BSD 3-Clause, same as Elm.

59
elm.ts
View File

@ -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<string, never>) => 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
}
}
}
}

57
examples/all_in_one.ts Normal file
View File

@ -0,0 +1,57 @@
// Warning: this file is automatically generated. Don't edit by hand!
declare module Elm {
namespace Main {
type Flags = Record<string, never>;
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:
| {
code: number;
reason: string;
tag: "close";
}
| {
protocols: string[] | null;
tag: "connect";
url: string;
}
| {
message: string;
tag: "send";
},
) => void,
) => void;
};
};
function init(config: { flags: Flags; node: HTMLElement }): {
ports: Ports;
};
}
}

76
examples/all_in_one.yaml Normal file
View File

@ -0,0 +1,76 @@
# 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:
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:
connect:
properties:
url:
type: string
protocols:
elements:
type: string
nullable: true
send:
properties:
message:
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

View File

@ -0,0 +1,252 @@
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 Close =
{ code : Int
, reason : String
, wasClean : Bool
}
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
encodeClose : Close -> Json.Encode.Value
encodeClose close =
Json.Encode.object
[ ( "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
= Close Close
| Connect Connect
| Send Send
toWorldDecoder : Decoder ToWorld
toWorldDecoder =
Json.Decode.andThen
(\tag ->
case tag of
"close" ->
Json.Decode.map Close closeDecoder
"connect" ->
Json.Decode.map Connect connectDecoder
"send" ->
Json.Decode.map Send sendDecoder
)
(Json.Decode.field "tag" Json.Decode.string)
encodeToWorld : ToWorld -> Json.Encode.Value
encodeToWorld toWorld =
case toWorld of
Close close ->
encodeClose close
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
sendToWorld : ToWorld -> Cmd msg
sendToWorld value =
toWorld (encodeToWorld value)

View File

@ -1,27 +0,0 @@
{
"modules": {
"Main": {
"flags": {
"properties": {
"currentJwt": {
"type": "string",
"nullable": true
}
}
},
"ports": {
"newJwt": {
"metadata": {
"direction": "ElmToJs"
},
"type": "string"
},
"logout": {
"metadata": {
"direction": "ElmToJs"
}
}
}
}
}
}

View File

@ -4,22 +4,16 @@ declare module Elm {
namespace Main {
type Flags = {
currentJwt: string | null;
}
};
type Ports = {
logout: {
subscribe: (callback: (value: Record<string, never>) => void) => void;
};
newJwt: {
subscribe: (callback: (value: string) => void) => void;
};
}
function init(config: {
flags: Flags;
node: HTMLElement;
}): {
};
function init(config: { flags: Flags; node: HTMLElement }): {
ports: Ports;
}
};
}
}
}

42
examples/jwt_schema.yaml Normal file
View File

@ -0,0 +1,42 @@
# 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
# (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)
# 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 `jwt` from above.
#
# If your app doesn't use flags, you can omit this key.
flags:
properties:
currentJwt:
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
ref: jwt

View File

@ -26,7 +26,7 @@ encodeFlags flags =
, case flags.currentJwt of
Just value ->
Json.Encode.string value
Nothing ->
Json.Encode.null
)

View File

@ -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,17 +22,9 @@ encodeNewJwt newJwt =
Json.Encode.string newJwt
port logout : Value -> Cmd msg
logout_ : Logout -> Cmd msg
logout_ value =
logout (encodeLogout value)
port newJwt : Value -> Cmd msg
newJwt_ : NewJwt -> Cmd msg
newJwt_ value =
sendNewJwt : NewJwt -> Cmd msg
sendNewJwt value =
newJwt (encodeNewJwt value)

View File

@ -20,9 +20,11 @@
pkgs.rustfmt
# formatters
pkgs.elmPackages.elm-format
pkgs.nodePackages.prettier
pkgs.typos
# formatters
pkgs.nodePackages.npm
pkgs.nodejs
];
};
}

114
package-lock.json generated Normal file
View File

@ -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"
}
}
}
}

18
package.json Normal file
View File

@ -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"
}
}

View File

@ -267,11 +267,12 @@ impl Type {
schema: Schema,
name_suggestion: Option<String>,
globals: &BTreeMap<String, Schema>,
discriminator: Option<(String, String)>,
) -> Result<(Self, Vec<Decl>)> {
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))
@ -710,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);
@ -739,18 +779,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_,
}
}
@ -765,11 +797,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()?);
@ -791,8 +835,8 @@ impl Port {
}
}
out.push_str(&self.name);
out.push_str("_ ");
out.push_str(&type_safe_name);
out.push(' ');
match self.direction {
PortDirection::Send => {
@ -807,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("))");
}
}
@ -837,7 +881,7 @@ impl Module {
name_suggestion: Option<String>,
globals: &BTreeMap<String, Schema>,
) -> Result<Decl> {
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);
@ -923,7 +967,7 @@ mod tests {
}
fn from_schema(value: Value) -> (Type, Vec<Decl>) {
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")
}
@ -1078,6 +1122,7 @@ mod tests {
})),
None,
&BTreeMap::new(),
None,
)
.unwrap_err();
@ -1124,6 +1169,7 @@ mod tests {
})),
None,
&BTreeMap::new(),
None,
)
.unwrap_err();
@ -1236,6 +1282,7 @@ mod tests {
})),
None,
&BTreeMap::from([("foo".into(), from_json(json!({"type": "string"})))]),
None,
)
.unwrap();
@ -1251,6 +1298,7 @@ mod tests {
})),
None,
&BTreeMap::from([("foo".into(), from_json(json!({"properties": {}})))]),
None,
)
.unwrap();

66
src/formatting.rs Normal file
View File

@ -0,0 +1,66 @@
use color_eyre::{Help, SectionExt};
use eyre::{eyre, Result, WrapErr};
use std::path::PathBuf;
use std::process::{Command, Stdio};
pub struct Formatter {
name: String,
command: PathBuf,
}
impl Formatter {
pub fn discover(binary_name: &str) -> Result<Option<Self>> {
// 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());
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)
}
pub(crate) fn format(&self, args: &[&str], files: &Vec<PathBuf>) -> 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(())
}
}

View File

@ -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;
@ -21,6 +23,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 {
@ -29,9 +45,10 @@ 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());
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() {
@ -42,6 +59,28 @@ 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
// 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(())

View File

@ -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,21 @@ pub enum PortDirection {
impl Schema {
pub fn from_fs(path: &Path) -> Result<Schema> {
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("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()
),
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<BTreeMap<String, jtd::Schema>> {
@ -61,7 +74,8 @@ impl Schema {
Ok(out)
}
pub fn flags_to_ts(&self) -> Result<String> {
// TODO: audit how much work this does and consider moving responsibility into the TS module
pub fn to_ts(&self) -> Result<String> {
let mut builder = NamespaceBuilder::root("Elm");
let globals = self.globals()?;
@ -184,12 +198,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(

View File

@ -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")?;

View File

@ -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");
}

View File

@ -15,15 +15,12 @@ declare module Elm {
three: string;
twelve: string;
two: string;
}
type Ports = Record<string, never>
function init(config: {
flags: Flags;
node: HTMLElement;
}): {
};
type Ports = Record<string, never>;
function init(config: { flags: Flags; node: HTMLElement }): {
ports: Ports;
}
};
}
}
}

View File

@ -1,2 +1,4 @@
wrote elm.ts
wrote src/Main/Flags.elm
formatted TypeScript
formatted Elm

View File

@ -2,41 +2,32 @@
declare module Elm {
namespace A {
type Flags = string
type Ports = Record<string, never>
function init(config: {
flags: Flags;
node: HTMLElement;
}): {
type Flags = string;
type Ports = Record<string, never>;
function init(config: { flags: Flags; node: HTMLElement }): {
ports: Ports;
}
};
}
namespace B {
type Flags = string
type Ports = Record<string, never>
function init(config: {
flags: Flags;
node: HTMLElement;
}): {
type Flags = string;
type Ports = Record<string, never>;
function init(config: { flags: Flags; node: HTMLElement }): {
ports: Ports;
}
};
namespace B2 {
type Flags = string
type Ports = Record<string, never>
function init(config: {
flags: Flags;
node: HTMLElement;
}): {
type Flags = string;
type Ports = Record<string, never>;
function init(config: { flags: Flags; node: HTMLElement }): {
ports: Ports;
}
};
}
}
}
}

View File

@ -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

View File

@ -2,26 +2,19 @@
declare module Elm {
namespace Main {
type Flags = Record<string, never>
type Flags = Record<string, never>;
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;
}
};
}
}
}

View File

@ -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 =
jsToElm (/value -> toMsg (Json.Decode.decodeValue value jsToElmDecoder)
subscribeToJsToElm : (Result Json.Decode.Error JsToElm -> msg) -> Sub msg
subscribeToJsToElm toMsg =
jsToElm (/value -> toMsg (Json.Decode.decodeValue value jsToElmDecoder))

View File

@ -1,2 +1,4 @@
wrote elm.ts
wrote src/Main/Ports.elm
formatted TypeScript
formatted Elm

View File

@ -31,7 +31,7 @@
"granted"
]
},
"sendNotification": {
"newNotification": {
"metadata": {
"direction": "ElmToJs"
},

View File

@ -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<string, string>;
tag: string | null;
time: number;
version: "v1";
})[];
}[];
settings: {
minutesPerPing: number;
version: "v1";
@ -36,33 +43,32 @@ declare module Elm {
version: "v1";
}) => 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;
};
notificationPermission: {
send: (value: "default" | "denied" | "granted") => void;
};
requestNotificationPermission: {
subscribe: (callback: (value: Record<string, never>) => void) => void;
};
sendNotification: {
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;
};
}
function init(config: {
flags: Flags;
node: HTMLElement;
}): {
};
function init(config: { flags: Flags; node: HTMLElement }): {
ports: Ports;
}
};
}
}
}

View File

@ -14,7 +14,6 @@ type NotificationPermission
| Granted
notificationPermissionDecoder : Decoder NotificationPermission
notificationPermissionDecoder =
Json.Decode.andThen

View File

@ -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
@ -254,13 +250,118 @@ encodeDocFromAutomerge docFromAutomerge =
encodeDocV1 docV1
type alias NotificationOptions =
{ badge : Maybe String
, body : Maybe String
, icon : Maybe String
, lang : Maybe String
, requireInteraction : Maybe Bool
, silent : Maybe Bool
, tag : Maybe String
}
notificationOptionsDecoder : Decoder NotificationOptions
notificationOptionsDecoder =
Json.Decode.succeed NotificationOptions
|> Json.Decode.Pipeline.required "badge" (Json.Decode.nullable Json.Decode.string)
|> Json.Decode.Pipeline.required "body" (Json.Decode.nullable Json.Decode.string)
|> Json.Decode.Pipeline.required "icon" (Json.Decode.nullable Json.Decode.string)
|> Json.Decode.Pipeline.required "lang" (Json.Decode.nullable Json.Decode.string)
|> Json.Decode.Pipeline.required "requireInteraction" (Json.Decode.nullable Json.Decode.bool)
|> Json.Decode.Pipeline.required "silent" (Json.Decode.nullable Json.Decode.bool)
|> Json.Decode.Pipeline.required "tag" (Json.Decode.nullable Json.Decode.string)
encodeNotificationOptions : NotificationOptions -> Json.Encode.Value
encodeNotificationOptions notificationOptions =
Json.Encode.object
[ ( "badge"
, case notificationOptions.badge of
Just value ->
Json.Encode.string value
Nothing ->
Json.Encode.null
)
, ( "body"
, case notificationOptions.body of
Just value ->
Json.Encode.string value
Nothing ->
Json.Encode.null
)
, ( "icon"
, case notificationOptions.icon of
Just value ->
Json.Encode.string value
Nothing ->
Json.Encode.null
)
, ( "lang"
, case notificationOptions.lang of
Just value ->
Json.Encode.string value
Nothing ->
Json.Encode.null
)
, ( "requireInteraction"
, case notificationOptions.requireInteraction of
Just value ->
Json.Encode.bool value
Nothing ->
Json.Encode.null
)
, ( "silent"
, case notificationOptions.silent of
Just value ->
Json.Encode.bool value
Nothing ->
Json.Encode.null
)
, ( "tag"
, case notificationOptions.tag of
Just value ->
Json.Encode.string value
Nothing ->
Json.Encode.null
)
]
type alias NewNotification =
{ options : NotificationOptions
, title : String
}
newNotificationDecoder : Decoder NewNotification
newNotificationDecoder =
Json.Decode.succeed NewNotification
|> Json.Decode.Pipeline.required "options" notificationOptionsDecoder
|> Json.Decode.Pipeline.required "title" Json.Decode.string
encodeNewNotification : NewNotification -> Json.Encode.Value
encodeNewNotification newNotification =
Json.Encode.object
[ ( "options", encodeNotificationOptions newNotification.options )
, ( "title", Json.Encode.string newNotification.title )
]
type NotificationPermission
= Default
| Denied
| Granted
notificationPermissionDecoder : Decoder NotificationPermission
notificationPermissionDecoder =
Json.Decode.andThen
@ -305,147 +406,41 @@ encodeRequestNotificationPermission requestNotificationPermission =
Json.Encode.null
type alias NotificationOptions =
{ badge : Maybe String
, body : Maybe String
, icon : Maybe String
, lang : Maybe String
, requireInteraction : Maybe Bool
, silent : Maybe Bool
, tag : Maybe String
}
notificationOptionsDecoder : Decoder NotificationOptions
notificationOptionsDecoder =
Json.Decode.succeed NotificationOptions
|> Json.Decode.Pipeline.required "badge" (Json.Decode.nullable Json.Decode.string)
|> Json.Decode.Pipeline.required "body" (Json.Decode.nullable Json.Decode.string)
|> Json.Decode.Pipeline.required "icon" (Json.Decode.nullable Json.Decode.string)
|> Json.Decode.Pipeline.required "lang" (Json.Decode.nullable Json.Decode.string)
|> Json.Decode.Pipeline.required "requireInteraction" (Json.Decode.nullable Json.Decode.bool)
|> Json.Decode.Pipeline.required "silent" (Json.Decode.nullable Json.Decode.bool)
|> Json.Decode.Pipeline.required "tag" (Json.Decode.nullable Json.Decode.string)
encodeNotificationOptions : NotificationOptions -> Json.Encode.Value
encodeNotificationOptions notificationOptions =
Json.Encode.object
[ ( "badge"
, case notificationOptions.badge of
Just value ->
Json.Encode.string value
Nothing ->
Json.Encode.null
)
, ( "body"
, case notificationOptions.body of
Just value ->
Json.Encode.string value
Nothing ->
Json.Encode.null
)
, ( "icon"
, case notificationOptions.icon of
Just value ->
Json.Encode.string value
Nothing ->
Json.Encode.null
)
, ( "lang"
, case notificationOptions.lang of
Just value ->
Json.Encode.string value
Nothing ->
Json.Encode.null
)
, ( "requireInteraction"
, case notificationOptions.requireInteraction of
Just value ->
Json.Encode.bool value
Nothing ->
Json.Encode.null
)
, ( "silent"
, case notificationOptions.silent of
Just value ->
Json.Encode.bool value
Nothing ->
Json.Encode.null
)
, ( "tag"
, case notificationOptions.tag of
Just value ->
Json.Encode.string value
Nothing ->
Json.Encode.null
)
]
type alias SendNotification =
{ options : NotificationOptions
, title : String
}
sendNotificationDecoder : Decoder SendNotification
sendNotificationDecoder =
Json.Decode.succeed SendNotification
|> Json.Decode.Pipeline.required "options" notificationOptionsDecoder
|> Json.Decode.Pipeline.required "title" Json.Decode.string
encodeSendNotification : SendNotification -> Json.Encode.Value
encodeSendNotification sendNotification =
Json.Encode.object
[ ( "options", encodeNotificationOptions sendNotification.options )
, ( "title", Json.Encode.string sendNotification.title )
]
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 =
docFromAutomerge (/value -> toMsg (Json.Decode.decodeValue value docFromAutomergeDecoder)
subscribeToDocFromAutomerge : (Result Json.Decode.Error DocFromAutomerge -> msg) -> Sub msg
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
notificationPermission_ : (Result Json.Decode.Error NotificationPermission -> msg) -> Sub msg
notificationPermission_ toMsg =
notificationPermission (/value -> toMsg (Json.Decode.decodeValue value notificationPermissionDecoder)
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 =
sendNotification (encodeSendNotification value)

View File

@ -1,3 +1,5 @@
wrote elm.ts
wrote src/Main/Flags.elm
wrote src/Main/Ports.elm
formatted TypeScript
formatted Elm