mirror of
https://github.com/BrianHicks/elm-duet.git
synced 2024-09-17 16:38:35 +03:00
commit
803a2719c9
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
@ -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
5
.gitignore
vendored
@ -1,6 +1,3 @@
|
||||
/node_modules
|
||||
/result
|
||||
|
||||
|
||||
# Added by cargo
|
||||
|
||||
/target
|
||||
|
20
Cargo.lock
generated
20
Cargo.lock
generated
@ -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"
|
||||
|
@ -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
13
LICENSE
Normal 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
347
README.md
@ -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
59
elm.ts
@ -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
57
examples/all_in_one.ts
Normal 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
76
examples/all_in_one.yaml
Normal 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
|
252
examples/all_in_one/Main/Ports.elm
Normal file
252
examples/all_in_one/Main/Ports.elm
Normal 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)
|
@ -1,27 +0,0 @@
|
||||
{
|
||||
"modules": {
|
||||
"Main": {
|
||||
"flags": {
|
||||
"properties": {
|
||||
"currentJwt": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"ports": {
|
||||
"newJwt": {
|
||||
"metadata": {
|
||||
"direction": "ElmToJs"
|
||||
},
|
||||
"type": "string"
|
||||
},
|
||||
"logout": {
|
||||
"metadata": {
|
||||
"direction": "ElmToJs"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
42
examples/jwt_schema.yaml
Normal 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
|
@ -26,7 +26,7 @@ encodeFlags flags =
|
||||
, case flags.currentJwt of
|
||||
Just value ->
|
||||
Json.Encode.string value
|
||||
|
||||
|
||||
Nothing ->
|
||||
Json.Encode.null
|
||||
)
|
||||
|
@ -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)
|
||||
|
@ -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
114
package-lock.json
generated
Normal 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
18
package.json
Normal 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"
|
||||
}
|
||||
}
|
132
src/elm.rs
132
src/elm.rs
@ -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
66
src/formatting.rs
Normal 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(())
|
||||
}
|
||||
}
|
41
src/main.rs
41
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;
|
||||
@ -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(())
|
||||
|
@ -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(
|
||||
|
@ -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")?;
|
||||
|
@ -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");
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,2 +1,4 @@
|
||||
wrote elm.ts
|
||||
wrote src/Main/Flags.elm
|
||||
formatted TypeScript
|
||||
formatted Elm
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
||||
|
@ -1,2 +1,4 @@
|
||||
wrote elm.ts
|
||||
wrote src/Main/Ports.elm
|
||||
formatted TypeScript
|
||||
formatted Elm
|
||||
|
@ -31,7 +31,7 @@
|
||||
"granted"
|
||||
]
|
||||
},
|
||||
"sendNotification": {
|
||||
"newNotification": {
|
||||
"metadata": {
|
||||
"direction": "ElmToJs"
|
||||
},
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -14,7 +14,6 @@ type NotificationPermission
|
||||
| Granted
|
||||
|
||||
|
||||
|
||||
notificationPermissionDecoder : Decoder NotificationPermission
|
||||
notificationPermissionDecoder =
|
||||
Json.Decode.andThen
|
||||
|
@ -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)
|
||||
|
@ -1,3 +1,5 @@
|
||||
wrote elm.ts
|
||||
wrote src/Main/Flags.elm
|
||||
wrote src/Main/Ports.elm
|
||||
formatted TypeScript
|
||||
formatted Elm
|
||||
|
Loading…
Reference in New Issue
Block a user