Lustre's CLI and development tooling: zero-config dev server, bundling, and scaffolding.

:truck: Migrate lustre cli to fresh repo.

+23
.github/workflows/test.yml
··· 1 + name: test 2 + 3 + on: 4 + push: 5 + branches: 6 + - master 7 + - main 8 + pull_request: 9 + 10 + jobs: 11 + test: 12 + runs-on: ubuntu-latest 13 + steps: 14 + - uses: actions/checkout@v3 15 + - uses: erlef/setup-beam@v1 16 + with: 17 + otp-version: "26.0.2" 18 + gleam-version: "1.0.0" 19 + rebar3-version: "3" 20 + # elixir-version: "1.15.4" 21 + - run: gleam deps download 22 + - run: gleam test 23 + - run: gleam format --check src test
+4
.gitignore
··· 1 + *.beam 2 + *.ez 3 + build 4 + erl_crash.dump
+25
README.md
··· 1 + # lustre_dev_tools 2 + 3 + [![Package Version](https://img.shields.io/hexpm/v/lustre_dev_tools)](https://hex.pm/packages/lustre_dev_tools) 4 + [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/lustre_dev_tools/) 5 + 6 + ```sh 7 + gleam add lustre_dev_tools 8 + ``` 9 + ```gleam 10 + import lustre_dev_tools 11 + 12 + pub fn main() { 13 + // TODO: An example of the project in use 14 + } 15 + ``` 16 + 17 + Further documentation can be found at <https://hexdocs.pm/lustre_dev_tools>. 18 + 19 + ## Development 20 + 21 + ```sh 22 + gleam run # Run the project 23 + gleam test # Run the tests 24 + gleam shell # Run an Erlang shell 25 + ```
+30
gleam.toml
··· 1 + name = "lustre_dev_tools" 2 + version = "1.0.0" 3 + 4 + description = "Lustre's official CLI and development tooling." 5 + repository = { type = "github", user = "lustre-labs", repo = "dev-tools" } 6 + licences = ["MIT"] 7 + 8 + links = [ 9 + { title = "Sponsor", href = "https://github.com/sponsors/hayleigh-dot-dev" }, 10 + ] 11 + 12 + internal_modules = ["lusre_dev_tools", "lusre_dev_tools/*"] 13 + 14 + [dependencies] 15 + gleam_stdlib = "~> 0.34 or ~> 1.0" 16 + glint = "0.16.0" 17 + spinner = "~> 1.1" 18 + gleam_package_interface = "~> 1.0" 19 + tom = "~> 0.3" 20 + gleam_erlang = "~> 0.25" 21 + gleam_otp = "~> 0.10" 22 + fs = "~> 8.6" 23 + gleam_community_ansi = "~> 1.4" 24 + filepath = "~> 0.1" 25 + simplifile = "~> 1.5" 26 + gleam_json = "~> 1.0" 27 + argv = "~> 1.0" 28 + 29 + [dev-dependencies] 30 + gleeunit = "~> 1.0"
+40
manifest.toml
··· 1 + # This file was generated by Gleam 2 + # You typically do not need to edit this file 3 + 4 + packages = [ 5 + { name = "argv", version = "1.0.1", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "A6E9009E50BBE863EB37D963E4315398D41A3D87D0075480FC244125808F964A" }, 6 + { name = "filepath", version = "0.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "FC1B1B29438A5BA6C990F8047A011430BEC0C5BA638BFAA62718C4EAEFE00435" }, 7 + { name = "fs", version = "8.6.1", build_tools = ["rebar3"], requirements = [], otp_app = "fs", source = "hex", outer_checksum = "61EA2BDAEDAE4E2024D0D25C63E44DCCF65622D4402DB4A2DF12868D1546503F" }, 8 + { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, 9 + { name = "gleam_community_colour", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "A49A5E3AE8B637A5ACBA80ECB9B1AFE89FD3D5351FF6410A42B84F666D40D7D5" }, 10 + { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, 11 + { name = "gleam_json", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "8B197DD5D578EA6AC2C0D4BDC634C71A5BCA8E7DB5F47091C263ECB411A60DF3" }, 12 + { name = "gleam_otp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "0B04FE915ACECE539B317F9652CAADBBC0F000184D586AAAF2D94C100945D72B" }, 13 + { name = "gleam_package_interface", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_package_interface", source = "hex", outer_checksum = "52A721BCA972C8099BB881195D821AAA64B9F2655BECC102165D5A1097731F01" }, 14 + { name = "gleam_stdlib", version = "0.36.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "C0D14D807FEC6F8A08A7C9EF8DFDE6AE5C10E40E21325B2B29365965D82EB3D4" }, 15 + { name = "glearray", version = "0.2.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "908154F695D330E06A37FAB2C04119E8F315D643206F8F32B6A6C14A8709FFF4" }, 16 + { name = "gleeunit", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "D364C87AFEB26BDB4FB8A5ABDE67D635DC9FA52D6AB68416044C35B096C6882D" }, 17 + { name = "glint", version = "0.16.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "61B7E85CBB0CCD2FD8A9C7AE06CA97A80BF6537716F34362A39DF9C74967BBBC" }, 18 + { name = "repeatedly", version = "2.1.1", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "38808C3EC382B0CD981336D5879C24ECB37FCB9C1D1BD128F7A80B0F74404D79" }, 19 + { name = "simplifile", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "EB9AA8E65E5C1E3E0FDCFC81BC363FD433CB122D7D062750FFDF24DE4AC40116" }, 20 + { name = "snag", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "54D32E16E33655346AA3E66CBA7E191DE0A8793D2C05284E3EFB90AD2CE92BCC" }, 21 + { name = "spinner", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_erlang", "gleam_stdlib", "glearray", "repeatedly"], otp_app = "spinner", source = "hex", outer_checksum = "200BA3D4A04D468898E63C0D316E23F526E02514BC46454091975CB5BAE41E8F" }, 22 + { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" }, 23 + { name = "tom", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0831C73E45405A2153091226BF98FB485ED16376988602CC01A5FD086B82D577" }, 24 + ] 25 + 26 + [requirements] 27 + argv = { version = "~> 1.0" } 28 + filepath = { version = "~> 0.1" } 29 + fs = { version = "~> 8.6"} 30 + gleam_community_ansi = { version = "~> 1.4" } 31 + gleam_erlang = { version = "~> 0.25" } 32 + gleam_json = { version = "~> 1.0" } 33 + gleam_otp = { version = "~> 0.10" } 34 + gleam_package_interface = { version = "~> 1.0" } 35 + gleam_stdlib = { version = "~> 0.34 or ~> 1.0" } 36 + gleeunit = { version = "~> 1.0" } 37 + glint = { version = "0.16.0" } 38 + simplifile = { version = "~> 1.5" } 39 + spinner = { version = "~> 1.1" } 40 + tom = { version = "~> 0.3" }
+4
priv/component-entry.mjs
··· 1 + import { register } from '../dev/javascript/lustre/client-component.ffi.mjs'; 2 + import { name, {component_name} as component } from '../dev/javascript/{app_name}/{module_path}.mjs'; 3 + 4 + register(component(), name);
+3
priv/entry-with-main.mjs
··· 1 + import { main } from "../dev/javascript/{app_name}/{app_name}.mjs"; 2 + 3 + main();
+4
priv/entry-with-start.mjs
··· 1 + import { start } from "../dev/javascript/lustre/lustre.mjs"; 2 + import { main } from "../dev/javascript/{app_name}/{app_name}.mjs"; 3 + 4 + start(main(), "#app", undefined);
+19
priv/index-with-lustre-ui.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + 7 + <title>🚧 {app_name}</title> 8 + 9 + <link 10 + rel="stylesheet" 11 + href="https://cdn.jsdelivr.net/gh/lustre-labs/ui/priv/styles.css" 12 + /> 13 + <script type="module" src="./index.mjs"></script> 14 + </head> 15 + 16 + <body> 17 + <div id="app"></div> 18 + </body> 19 + </html>
+15
priv/index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + 7 + <title>🚧 {app_name}</title> 8 + 9 + <script type="module" src="./index.mjs"></script> 10 + </head> 11 + 12 + <body> 13 + <div id="app"></div> 14 + </body> 15 + </html>
+7
priv/tailwind.config.js
··· 1 + module.exports = { 2 + content: ["./src/**/*.{gleam,mjs}"], 3 + theme: { 4 + extend: {}, 5 + }, 6 + plugins: [], 7 + };
+23
src/lustre/dev.gleam
··· 1 + // IMPORTS --------------------------------------------------------------------- 2 + 3 + import argv 4 + import glint 5 + import lustre_dev_tools/cli/add 6 + import lustre_dev_tools/cli/start 7 + 8 + // MAIN ------------------------------------------------------------------------ 9 + 10 + pub fn main() { 11 + let args = argv.load().arguments 12 + 13 + glint.new() 14 + |> glint.as_gleam_module 15 + |> glint.with_name("lustre/dev") 16 + |> glint.with_pretty_help(glint.default_pretty_help()) 17 + |> glint.add(at: ["add", "esbuild"], do: add.esbuild()) 18 + |> glint.add(at: ["add", "tailwind"], do: add.tailwind()) 19 + // |> glint.add(at: ["build", "app"], do: build.app()) 20 + // |> glint.add(at: ["build", "component"], do: build.component()) 21 + |> glint.add(at: ["start"], do: start.run()) 22 + |> glint.run(args) 23 + }
+265
src/lustre_dev_tools/cli.gleam
··· 1 + // IMPORTS --------------------------------------------------------------------- 2 + 3 + import gleam_community/ansi 4 + import gleam/erlang 5 + import gleam/result 6 + import gleam/io 7 + import gleam/string 8 + import simplifile 9 + import spinner.{type Spinner} 10 + 11 + // TYPES ----------------------------------------------------------------------- 12 + 13 + pub opaque type Env { 14 + Env(spinner: SpinnerStatus) 15 + } 16 + 17 + type SpinnerStatus { 18 + Waiting 19 + Running(spinner: Spinner, message: String) 20 + Paused(spinner: Spinner) 21 + } 22 + 23 + /// 24 + /// 25 + pub type Cli(state, a, e) { 26 + Cli(run: fn(Env, state) -> #(Env, state, Result(a, e))) 27 + } 28 + 29 + // 30 + 31 + /// 32 + /// 33 + pub fn run(step: Cli(state, a, e), with state: state) -> Result(a, e) { 34 + let env = Env(spinner: Waiting) 35 + let #(env, _, result) = step.run(env, state) 36 + 37 + case env.spinner { 38 + Waiting -> Nil 39 + Running(spinner, _) -> spinner.stop(spinner) 40 + Paused(spinner) -> spinner.stop(spinner) 41 + } 42 + 43 + result 44 + } 45 + 46 + // 47 + 48 + /// 49 + /// 50 + pub fn return(value: a) -> Cli(state, a, e) { 51 + use env, state <- Cli 52 + 53 + #(env, state, Ok(value)) 54 + } 55 + 56 + /// 57 + /// 58 + pub fn throw(error: e, message: String) -> Cli(state, a, e) { 59 + use env, state <- Cli 60 + 61 + case env.spinner { 62 + Waiting -> #(env, state, Error(error)) 63 + Running(spinner, _) -> { 64 + spinner.stop(spinner) 65 + io.println("❌ " <> ansi.red(message)) 66 + 67 + #(Env(Paused(spinner)), state, Error(error)) 68 + } 69 + Paused(spinner) -> { 70 + io.println("❌ " <> ansi.red(message)) 71 + 72 + #(Env(Paused(spinner)), state, Error(error)) 73 + } 74 + } 75 + } 76 + 77 + pub fn from_result(result: Result(a, e)) -> Cli(state, a, e) { 78 + use env, state <- Cli 79 + 80 + #(env, state, result) 81 + } 82 + 83 + // 84 + 85 + /// 86 + /// 87 + pub fn do( 88 + step: Cli(state, a, e), 89 + then next: fn(a) -> Cli(state, b, e), 90 + ) -> Cli(state, b, e) { 91 + use env, state <- Cli 92 + let #(env, state, result) = step.run(env, state) 93 + 94 + case result { 95 + Ok(value) -> next(value).run(env, state) 96 + Error(error) -> { 97 + case env.spinner { 98 + Waiting -> Nil 99 + Paused(_) -> Nil 100 + Running(spinner, message) -> { 101 + spinner.stop(spinner) 102 + io.println("❌ " <> ansi.red(message)) 103 + } 104 + } 105 + 106 + #(env, state, Error(error)) 107 + } 108 + } 109 + } 110 + 111 + pub fn map(step: Cli(state, a, e), then next: fn(a) -> b) -> Cli(state, b, e) { 112 + use env, state <- Cli 113 + let #(env, state, result) = step.run(env, state) 114 + let result = result.map(result, next) 115 + 116 + #(env, state, result) 117 + } 118 + 119 + pub fn map_error( 120 + step: Cli(state, a, e), 121 + then next: fn(e) -> f, 122 + ) -> Cli(state, a, f) { 123 + use env, state <- Cli 124 + let #(env, state, result) = step.run(env, state) 125 + let result = result.map_error(result, next) 126 + 127 + #(env, state, result) 128 + } 129 + 130 + /// 131 + /// 132 + pub fn do_result( 133 + result: Result(a, e), 134 + then next: fn(a) -> Cli(state, b, e), 135 + ) -> Cli(state, b, e) { 136 + use env, state <- Cli 137 + 138 + case result { 139 + Ok(a) -> next(a).run(env, state) 140 + Error(e) -> #(env, state, Error(e)) 141 + } 142 + } 143 + 144 + /// 145 + /// 146 + pub fn try( 147 + step: Result(a, x), 148 + catch recover: fn(x) -> e, 149 + then next: fn(a) -> Cli(state, b, e), 150 + ) -> Cli(state, b, e) { 151 + use env, state <- Cli 152 + 153 + case step { 154 + Ok(value) -> next(value).run(env, state) 155 + Error(error) -> { 156 + case env.spinner { 157 + Waiting -> Nil 158 + Paused(_) -> Nil 159 + Running(spinner, message) -> { 160 + spinner.stop(spinner) 161 + io.println("❌ " <> ansi.red(message)) 162 + } 163 + } 164 + 165 + #(env, state, Error(recover(error))) 166 + } 167 + } 168 + } 169 + 170 + // 171 + 172 + /// 173 + /// 174 + pub fn log( 175 + message: String, 176 + then next: fn() -> Cli(state, a, e), 177 + ) -> Cli(state, a, e) { 178 + use env, state <- Cli 179 + let env = 180 + Env(spinner: case env.spinner { 181 + Waiting -> 182 + Running( 183 + spinner.new(message) 184 + |> spinner.with_frames(spinner.snake_frames) 185 + |> spinner.start, 186 + message, 187 + ) 188 + 189 + Running(spinner, _) -> { 190 + spinner.set_text(spinner, message) 191 + Running(spinner, message) 192 + } 193 + 194 + Paused(spinner) -> { 195 + spinner.set_text(spinner, message) 196 + Running(spinner, message) 197 + } 198 + }) 199 + 200 + next().run(env, state) 201 + } 202 + 203 + pub fn success( 204 + message: String, 205 + then next: fn() -> Cli(state, a, e), 206 + ) -> Cli(state, a, e) { 207 + use env, state <- Cli 208 + let env = 209 + Env(spinner: case env.spinner { 210 + Waiting -> Waiting 211 + Paused(spinner) -> Paused(spinner) 212 + Running(spinner, _) -> { 213 + spinner.stop(spinner) 214 + Paused(spinner) 215 + } 216 + }) 217 + 218 + io.println("✅ " <> ansi.green(message)) 219 + next().run(env, state) 220 + } 221 + 222 + // 223 + 224 + /// 225 + /// 226 + pub fn get_state() -> Cli(state, state, e) { 227 + use env, state <- Cli 228 + 229 + #(env, state, Ok(state)) 230 + } 231 + 232 + /// 233 + /// 234 + pub fn set_state(value: state) -> Cli(state, Nil, e) { 235 + use env, _ <- Cli 236 + 237 + #(env, value, Ok(Nil)) 238 + } 239 + 240 + // 241 + 242 + @external(erlang, "lustre_dev_tools_ffi", "exec") 243 + pub fn exec( 244 + run command: String, 245 + with args: List(String), 246 + in in: String, 247 + ) -> Result(String, #(Int, String)) 248 + 249 + pub fn template( 250 + name: String, 251 + on_error: fn(String) -> e, 252 + then next: fn(String) -> Cli(state, a, e), 253 + ) -> Cli(state, a, e) { 254 + use env, state <- Cli 255 + let assert Ok(priv) = erlang.priv_directory("lustre_dev_tools") 256 + 257 + case simplifile.read(priv <> "/" <> name) { 258 + Ok(template) -> next(template).run(env, state) 259 + Error(error) -> #( 260 + env, 261 + state, 262 + Error(on_error(name <> ": " <> string.inspect(error))), 263 + ) 264 + } 265 + }
+92
src/lustre_dev_tools/cli/add.gleam
··· 1 + // IMPORTS --------------------------------------------------------------------- 2 + 3 + import glint.{type Command, CommandInput} 4 + import glint/flag 5 + import lustre_dev_tools/esbuild 6 + import lustre_dev_tools/cli 7 + import lustre_dev_tools/tailwind 8 + 9 + // COMMANDS -------------------------------------------------------------------- 10 + 11 + pub fn esbuild() -> Command(Nil) { 12 + let description = 13 + " 14 + Download a platform-appropriate version of the esbuild binary. Lustre uses this 15 + to bundle applications and act as a development server. 16 + " 17 + 18 + glint.command(fn(input) { 19 + let CommandInput(flags: flags, ..) = input 20 + let assert Ok(os) = flag.get_string(flags, "os") 21 + let assert Ok(cpu) = flag.get_string(flags, "cpu") 22 + let script = esbuild.download(os, cpu) 23 + 24 + case cli.run(script, Nil) { 25 + Ok(_) -> Nil 26 + Error(error) -> esbuild.explain(error) 27 + } 28 + }) 29 + |> glint.description(description) 30 + |> glint.unnamed_args(glint.EqArgs(0)) 31 + |> glint.flag("os", { 32 + let description = "" 33 + let default = get_os() 34 + 35 + flag.string() 36 + |> flag.default(default) 37 + |> flag.description(description) 38 + }) 39 + |> glint.flag("cpu", { 40 + let description = "" 41 + let default = get_cpu() 42 + 43 + flag.string() 44 + |> flag.default(default) 45 + |> flag.description(description) 46 + }) 47 + } 48 + 49 + pub fn tailwind() -> Command(Nil) { 50 + let description = 51 + " 52 + Download a platform-appropriate version of the Tailwind binary. 53 + " 54 + 55 + glint.command(fn(input) { 56 + let CommandInput(flags: flags, ..) = input 57 + let assert Ok(os) = flag.get_string(flags, "os") 58 + let assert Ok(cpu) = flag.get_string(flags, "cpu") 59 + let script = tailwind.setup(os, cpu) 60 + 61 + case cli.run(script, Nil) { 62 + Ok(_) -> Nil 63 + Error(error) -> tailwind.explain(error) 64 + } 65 + }) 66 + |> glint.description(description) 67 + |> glint.unnamed_args(glint.EqArgs(0)) 68 + |> glint.flag("os", { 69 + let description = "" 70 + let default = get_os() 71 + 72 + flag.string() 73 + |> flag.default(default) 74 + |> flag.description(description) 75 + }) 76 + |> glint.flag("cpu", { 77 + let description = "" 78 + let default = get_cpu() 79 + 80 + flag.string() 81 + |> flag.default(default) 82 + |> flag.description(description) 83 + }) 84 + } 85 + 86 + // EXTERNALS ------------------------------------------------------------------- 87 + 88 + @external(erlang, "lustre_dev_tools_ffi", "get_os") 89 + fn get_os() -> String 90 + 91 + @external(erlang, "lustre_dev_tools_ffi", "get_cpu") 92 + fn get_cpu() -> String
+384
src/lustre_dev_tools/cli/build.gleam
··· 1 + // IMPORTS --------------------------------------------------------------------- 2 + 3 + import filepath 4 + import gleam/bool 5 + import gleam/dict 6 + import gleam/io 7 + import gleam/list 8 + import gleam/package_interface.{type Type, Named, Variable} 9 + import gleam/result 10 + import gleam/string 11 + import glint.{type Command, CommandInput} 12 + import glint/flag 13 + import lustre_dev_tools/esbuild 14 + import lustre_dev_tools/project.{type Module} 15 + import lustre_dev_tools/cli.{type Cli} 16 + import lustre_dev_tools/tailwind 17 + import simplifile 18 + 19 + // COMMANDS -------------------------------------------------------------------- 20 + 21 + pub fn app() -> Command(Nil) { 22 + let description = 23 + " 24 + Build and bundle an entire Lustre application. The generated JavaScript module 25 + calls your app's `main` function on page load and can be included in any Web 26 + page without Gleam or Lustre being present. 27 + 28 + This is different from using `gleam build` directly because it produces a single 29 + JavaScript module for you to host or distribute. 30 + " 31 + 32 + glint.command(fn(input) { 33 + let CommandInput(flags: flags, ..) = input 34 + let assert Ok(minify) = flag.get_bool(flags, "minify") 35 + 36 + let script = { 37 + use <- cli.log("Building your project") 38 + use project_name <- cli.do_result(get_project_name()) 39 + use <- cli.success("Project compiled successfully") 40 + use <- cli.log("Checking if I can bundle your application") 41 + use module <- cli.do_result(get_module_interface(project_name)) 42 + use _ <- cli.do_result(check_main_function(project_name, module)) 43 + 44 + use <- cli.log("Creating the bundle entry file") 45 + let root = project.root() 46 + let tempdir = filepath.join(root, "build/.lustre") 47 + let outdir = filepath.join(root, "priv/static") 48 + let _ = simplifile.create_directory_all(tempdir) 49 + let _ = simplifile.create_directory_all(outdir) 50 + use template <- cli.template("entry-with-main.mjs", InternalError) 51 + let entry = string.replace(template, "{app_name}", project_name) 52 + 53 + let entryfile = filepath.join(tempdir, "entry.mjs") 54 + let ext = case minify { 55 + True -> ".min.mjs" 56 + False -> ".mjs" 57 + } 58 + 59 + let outfile = 60 + project_name 61 + |> string.append(ext) 62 + |> filepath.join(outdir, _) 63 + 64 + let assert Ok(_) = simplifile.write(entryfile, entry) 65 + 66 + use _ <- cli.do(bundle(entry, tempdir, outfile, minify)) 67 + use entry <- cli.template("entry.css", InternalError) 68 + let outfile = 69 + filepath.strip_extension(outfile) 70 + |> string.append(".css") 71 + 72 + let bundle = bundle_tailwind(entry, tempdir, outfile, minify) 73 + use _ <- cli.do(cli.map_error(bundle, TailwindBundleError)) 74 + 75 + cli.return(Nil) 76 + } 77 + 78 + case cli.run(script, Nil) { 79 + Ok(_) -> Nil 80 + Error(error) -> explain(error) 81 + } 82 + }) 83 + |> glint.description(description) 84 + |> glint.unnamed_args(glint.EqArgs(0)) 85 + |> glint.flag("minify", { 86 + let description = "Minify the output" 87 + let default = False 88 + 89 + flag.bool() 90 + |> flag.default(default) 91 + |> flag.description(description) 92 + }) 93 + } 94 + 95 + pub fn component() -> Command(Nil) { 96 + let description = 97 + " 98 + Build a Lustre component as a portable Web Component. The generated JavaScript 99 + module can be included in any Web page and used without Gleam or Lustre being 100 + present. 101 + " 102 + 103 + glint.command(fn(input) { 104 + let CommandInput(flags: flags, named_args: args, ..) = input 105 + let assert Ok(module_path) = dict.get(args, "module_path") 106 + let assert Ok(minify) = flag.get_bool(flags, "minify") 107 + 108 + let script = { 109 + use <- cli.log("Building your project") 110 + use module <- cli.do_result(get_module_interface(module_path)) 111 + use <- cli.success("Project compiled successfully") 112 + use <- cli.log("Checking if I can bundle your component") 113 + use _ <- cli.do_result(check_component_name(module_path, module)) 114 + use component <- cli.do_result(find_component(module_path, module)) 115 + 116 + use <- cli.log("Creating the bundle entry file") 117 + let root = project.root() 118 + let tempdir = filepath.join(root, "build/.lustre") 119 + let outdir = filepath.join(root, "priv/static") 120 + let _ = simplifile.create_directory_all(tempdir) 121 + let _ = simplifile.create_directory_all(outdir) 122 + 123 + use project_name <- cli.do_result(get_project_name()) 124 + 125 + // Esbuild bundling 126 + use template <- cli.template("component-entry.mjs", InternalError) 127 + let entry = 128 + template 129 + |> string.replace("{component_name}", component) 130 + |> string.replace("{app_name}", project_name) 131 + |> string.replace("{module_path}", module_path) 132 + 133 + let entryfile = filepath.join(tempdir, "entry.mjs") 134 + let ext = case minify { 135 + True -> ".min.mjs" 136 + False -> ".mjs" 137 + } 138 + let assert Ok(outfile) = 139 + string.split(module_path, "/") 140 + |> list.last 141 + |> result.map(string.append(_, ext)) 142 + |> result.map(filepath.join(outdir, _)) 143 + 144 + let assert Ok(_) = simplifile.write(entryfile, entry) 145 + use _ <- cli.do(bundle(entry, tempdir, outfile, minify)) 146 + 147 + // Tailwind bundling 148 + use entry <- cli.template("entry.css", InternalError) 149 + let outfile = 150 + filepath.strip_extension(outfile) 151 + |> string.append(".css") 152 + 153 + let bundle = bundle_tailwind(entry, tempdir, outfile, minify) 154 + use _ <- cli.do(cli.map_error(bundle, TailwindBundleError)) 155 + 156 + cli.return(Nil) 157 + } 158 + 159 + case cli.run(script, Nil) { 160 + Ok(_) -> Nil 161 + Error(error) -> explain(error) 162 + } 163 + }) 164 + |> glint.description(description) 165 + |> glint.named_args(["module_path"]) 166 + |> glint.unnamed_args(glint.EqArgs(0)) 167 + |> glint.flag("minify", { 168 + let description = "Minify the output" 169 + let default = False 170 + 171 + flag.bool() 172 + |> flag.default(default) 173 + |> flag.description(description) 174 + }) 175 + } 176 + 177 + // ERROR HANDLING -------------------------------------------------------------- 178 + 179 + type Error { 180 + BuildError 181 + BundleError(esbuild.Error) 182 + TailwindBundleError(tailwind.Error) 183 + ComponentMissing(module: String) 184 + MainMissing(module: String) 185 + ModuleMissing(module: String) 186 + NameIncorrectType(module: String, got: Type) 187 + NameMissing(module: String) 188 + InternalError(message: String) 189 + } 190 + 191 + fn explain(error: Error) -> Nil { 192 + case error { 193 + BuildError -> project.explain(project.BuildError) 194 + 195 + BundleError(error) -> esbuild.explain(error) 196 + 197 + TailwindBundleError(error) -> tailwind.explain(error) 198 + 199 + ComponentMissing(module) -> io.println(" 200 + Module `" <> module <> "` doesn't have any public function I can use to bundle 201 + a component. 202 + 203 + To bundle a component your module should have a public function that returns a 204 + Lustre `App`: 205 + 206 + import lustre.{type App} 207 + pub fn my_component() -> App(flags, model, msg) { 208 + todo as \"your Lustre component to bundle\" 209 + } 210 + ") 211 + 212 + MainMissing(module) -> io.println(" 213 + Module `" <> module <> "` doesn't have a public `main` function I can use as 214 + the bundle entry point.") 215 + 216 + ModuleMissing(module) -> io.println(" 217 + I couldn't find a public module called `" <> module <> "` in your project.") 218 + 219 + NameIncorrectType(module, type_) -> io.println(" 220 + I can't use the `name` constant exposed by module `" <> module <> "` 221 + to give a name to the component I'm bundling. 222 + I was expecting `name` to be a `String`, 223 + but it has type `" <> project.type_to_string(type_) <> "`.") 224 + 225 + NameMissing(module) -> io.println(" 226 + Module `" <> module <> "` doesn't have a public `name` constant. 227 + That is required so that I can give a proper name to the component I'm bundling. 228 + 229 + Try adding a `name` constant to your module like this: 230 + 231 + const name: String = \"component-name\"") 232 + 233 + InternalError(message) -> io.println(" 234 + I ran into an error I wasn't expecting. Please open an issue on GitHub at 235 + https://github.com/lustre-labs/cli with the following message: 236 + " <> message) 237 + } 238 + } 239 + 240 + // STEPS ----------------------------------------------------------------------- 241 + 242 + fn get_project_name() -> Result(String, Error) { 243 + project.config() 244 + |> result.replace_error(BuildError) 245 + |> result.map(fn(confg) { confg.name }) 246 + } 247 + 248 + fn get_module_interface(module_path: String) -> Result(Module, Error) { 249 + project.interface() 250 + |> result.replace_error(BuildError) 251 + |> result.then(fn(interface) { 252 + dict.get(interface.modules, module_path) 253 + |> result.replace_error(ModuleMissing(module_path)) 254 + }) 255 + } 256 + 257 + fn check_main_function( 258 + module_path: String, 259 + module: Module, 260 + ) -> Result(Nil, Error) { 261 + case dict.has_key(module.functions, "main") { 262 + True -> Ok(Nil) 263 + False -> Error(MainMissing(module_path)) 264 + } 265 + } 266 + 267 + fn check_component_name( 268 + module_path: String, 269 + module: Module, 270 + ) -> Result(Nil, Error) { 271 + dict.get(module.constants, "name") 272 + |> result.replace_error(NameMissing(module_path)) 273 + |> result.then(fn(component_name) { 274 + case is_string_type(component_name) { 275 + True -> Ok(Nil) 276 + False -> Error(NameIncorrectType(module_path, component_name)) 277 + } 278 + }) 279 + } 280 + 281 + fn find_component(module_path: String, module: Module) -> Result(String, Error) { 282 + let functions = dict.to_list(module.functions) 283 + let error = Error(ComponentMissing(module_path)) 284 + 285 + use _, #(name, t) <- list.fold_until(functions, error) 286 + case t.parameters, is_compatible_app_type(t.return) { 287 + [], True -> list.Stop(Ok(name)) 288 + _, _ -> list.Continue(error) 289 + } 290 + } 291 + 292 + fn bundle( 293 + entry: String, 294 + tempdir: String, 295 + outfile: String, 296 + minify: Bool, 297 + ) -> Cli(any, Nil, Error) { 298 + let entryfile = filepath.join(tempdir, "entry.mjs") 299 + let assert Ok(_) = simplifile.write(entryfile, entry) 300 + use _ <- cli.do( 301 + esbuild.bundle(entryfile, outfile, minify) 302 + |> cli.map_error(BundleError), 303 + ) 304 + 305 + cli.return(Nil) 306 + } 307 + 308 + fn bundle_tailwind( 309 + entry: String, 310 + tempdir: String, 311 + outfile: String, 312 + minify: Bool, 313 + ) -> Cli(any, Nil, tailwind.Error) { 314 + // We first check if there's a `tailwind.config.js` at the project's root. 315 + // If not present we do nothing; otherwise we go on with bundling. 316 + let root = project.root() 317 + let tailwind_config_file = filepath.join(root, "tailwind.config.js") 318 + let has_tailwind_config = 319 + simplifile.verify_is_file(tailwind_config_file) 320 + |> result.unwrap(False) 321 + use <- bool.guard(when: !has_tailwind_config, return: cli.return(Nil)) 322 + 323 + use _ <- cli.do(tailwind.setup(get_os(), get_cpu())) 324 + 325 + use <- cli.log("Bundling with Tailwind") 326 + let entryfile = filepath.join(tempdir, "entry.css") 327 + let assert Ok(_) = simplifile.write(entryfile, entry) 328 + 329 + let flags = ["--watch", "--input=" <> entryfile, "--output=" <> outfile] 330 + let options = case minify { 331 + True -> ["--minify", ..flags] 332 + False -> flags 333 + } 334 + use _ <- cli.try( 335 + cli.exec("./build/.lustre/bin/tailwind", in: root, with: options), 336 + fn(pair) { tailwind.BundleError(pair.1) }, 337 + ) 338 + use <- cli.success("Bundle produced at `" <> outfile <> "`") 339 + 340 + cli.return(Nil) 341 + } 342 + 343 + // UTILS ----------------------------------------------------------------------- 344 + 345 + fn is_string_type(t: Type) -> Bool { 346 + case t { 347 + Named(name: "String", package: "", module: "gleam", parameters: []) -> True 348 + _ -> False 349 + } 350 + } 351 + 352 + fn is_nil_type(t: Type) -> Bool { 353 + case t { 354 + Named(name: "Nil", package: "", module: "gleam", parameters: []) -> True 355 + _ -> False 356 + } 357 + } 358 + 359 + fn is_type_variable(t: Type) -> Bool { 360 + case t { 361 + Variable(..) -> True 362 + _ -> False 363 + } 364 + } 365 + 366 + fn is_compatible_app_type(t: Type) -> Bool { 367 + case t { 368 + Named( 369 + name: "App", 370 + package: "lustre", 371 + module: "lustre", 372 + parameters: [flags, ..], 373 + ) -> is_nil_type(flags) || is_type_variable(flags) 374 + _ -> False 375 + } 376 + } 377 + 378 + // EXTERNALS ------------------------------------------------------------------- 379 + 380 + @external(erlang, "cli_ffi", "get_os") 381 + fn get_os() -> String 382 + 383 + @external(erlang, "cli_ffi", "get_cpu") 384 + fn get_cpu() -> String
+257
src/lustre_dev_tools/cli/start.gleam
··· 1 + // IMPORTS --------------------------------------------------------------------- 2 + 3 + import filepath 4 + import gleam/dict 5 + import gleam/io 6 + import gleam/package_interface.{type Type, Fn, Named, Variable} 7 + import gleam/result 8 + import gleam/string 9 + import glint.{type Command, CommandInput} 10 + import glint/flag 11 + import lustre_dev_tools/cli 12 + import lustre_dev_tools/esbuild 13 + import lustre_dev_tools/project.{type Module} 14 + import simplifile 15 + 16 + // COMMANDS -------------------------------------------------------------------- 17 + 18 + pub fn run() -> Command(Nil) { 19 + let description = 20 + " 21 + " 22 + 23 + glint.command(fn(input) { 24 + let CommandInput(flags: flags, ..) = input 25 + let assert Ok(host) = flag.get_string(flags, "host") 26 + let assert Ok(port) = flag.get_string(flags, "port") 27 + let assert Ok(use_lustre_ui) = flag.get_bool(flags, "use-lustre-ui") 28 + let assert Ok(spa) = flag.get_bool(flags, "spa") 29 + let custom_html = flag.get_string(flags, "html") 30 + 31 + let script = { 32 + use <- cli.log("Building your project") 33 + use interface <- cli.try(project.interface(), fn(_) { BuildError }) 34 + use module <- cli.try(dict.get(interface.modules, interface.name), fn(_) { 35 + ModuleMissing(interface.name) 36 + }) 37 + use is_app <- cli.do_result(check_is_lustre_app(interface.name, module)) 38 + use <- cli.success("Project compiled successfully") 39 + 40 + use <- cli.log("Creating the application entry point") 41 + let root = project.root() 42 + let tempdir = filepath.join(root, "build/.lustre") 43 + let _ = simplifile.create_directory_all(tempdir) 44 + use template <- cli.template( 45 + case is_app { 46 + True -> "entry-with-start.mjs" 47 + False -> "entry-with-main.mjs" 48 + }, 49 + InternalError, 50 + ) 51 + 52 + let entry = string.replace(template, "{app_name}", interface.name) 53 + use html <- cli.do(case custom_html { 54 + Ok(custom_html_path) -> 55 + custom_html_path 56 + |> simplifile.read 57 + |> result.map_error(CouldntOpenCustomHtml(_, custom_html_path)) 58 + |> result.map(string.replace( 59 + _, 60 + "<script type=\"application/lustre\">", 61 + "<script type=\"module\" src=\"./index.mjs\">", 62 + )) 63 + |> cli.from_result 64 + 65 + Error(_) if use_lustre_ui -> { 66 + let name = "index-with-lustre-ui.html" 67 + use template <- cli.template(name, InternalError) 68 + let html = string.replace(template, "{app_name}", interface.name) 69 + 70 + cli.return(html) 71 + } 72 + 73 + _ -> { 74 + let name = "index.html" 75 + use template <- cli.template(name, InternalError) 76 + let html = string.replace(template, "{app_name}", interface.name) 77 + 78 + cli.return(html) 79 + } 80 + }) 81 + 82 + let assert Ok(_) = simplifile.write(tempdir <> "/entry.mjs", entry) 83 + let assert Ok(_) = simplifile.write(tempdir <> "/index.html", html) 84 + 85 + use _ <- cli.do( 86 + esbuild.bundle( 87 + filepath.join(tempdir, "entry.mjs"), 88 + filepath.join(tempdir, "index.mjs"), 89 + False, 90 + ) 91 + |> cli.map_error(BundleError), 92 + ) 93 + 94 + use _ <- cli.do( 95 + esbuild.serve(host, port, spa) 96 + |> cli.map_error(BundleError), 97 + ) 98 + 99 + cli.return(Nil) 100 + } 101 + 102 + case cli.run(script, Nil) { 103 + Ok(_) -> Nil 104 + Error(error) -> explain(error) 105 + } 106 + }) 107 + |> glint.description(description) 108 + |> glint.unnamed_args(glint.EqArgs(0)) 109 + |> glint.flag("host", { 110 + let description = "" 111 + let default = "localhost" 112 + 113 + flag.string() 114 + |> flag.default(default) 115 + |> flag.description(description) 116 + }) 117 + |> glint.flag("port", { 118 + let description = "" 119 + let default = "1234" 120 + 121 + flag.string() 122 + |> flag.default(default) 123 + |> flag.description(description) 124 + }) 125 + |> glint.flag("use-lustre-ui", { 126 + let description = "Inject lustre/ui's stylesheet. Ignored if --html is set." 127 + let default = False 128 + 129 + flag.bool() 130 + |> flag.default(default) 131 + |> flag.description(description) 132 + }) 133 + |> glint.flag("spa", { 134 + let description = 135 + "Serve your app on any route. Useful for apps that do client-side routing." 136 + let default = False 137 + 138 + flag.bool() 139 + |> flag.default(default) 140 + |> flag.description(description) 141 + }) 142 + |> glint.flag("html", { 143 + let description = 144 + "Supply a custom HTML file to use as the entry point. 145 + To inject the Lustre bundle, make sure it includes the following empty script: 146 + <script type=\"application/lustre\"></script> 147 + " 148 + |> string.trim_right 149 + 150 + flag.string() 151 + |> flag.description(description) 152 + }) 153 + } 154 + 155 + // ERROR HANDLING -------------------------------------------------------------- 156 + 157 + type Error { 158 + BuildError 159 + BundleError(esbuild.Error) 160 + CouldntOpenCustomHtml(error: simplifile.FileError, path: String) 161 + MainMissing(module: String) 162 + MainIncorrectType(module: String, got: Type) 163 + MainBadAppType(module: String, got: Type) 164 + ModuleMissing(module: String) 165 + InternalError(message: String) 166 + } 167 + 168 + fn explain(error: Error) -> Nil { 169 + case error { 170 + BuildError -> project.explain(project.BuildError) 171 + 172 + BundleError(error) -> esbuild.explain(error) 173 + 174 + CouldntOpenCustomHtml(_, path) -> io.println(" 175 + I couldn't open the custom HTML file at `" <> path <> "`.") 176 + 177 + MainMissing(module) -> io.println(" 178 + Module `" <> module <> "` doesn't have a public `main` function I can preview.") 179 + 180 + MainIncorrectType(module, type_) -> io.println(" 181 + I cannot preview the `main` function exposed by module `" <> module <> "`. 182 + To start a preview server I need it to take no arguments and return a Lustre 183 + `App`. 184 + The one I found has type `" <> project.type_to_string(type_) <> "`.") 185 + 186 + // TODO: maybe this could have useful links to `App`/flags... 187 + MainBadAppType(module, type_) -> io.println(" 188 + I cannot preview the `main` function exposed by module `" <> module <> "`. 189 + To start a preview server I need it to return a Lustre `App` that doesn't need 190 + any flags. 191 + The one I found has type `" <> project.type_to_string(type_) <> "`. 192 + 193 + Its return type should look something like this: 194 + 195 + import lustre.{type App} 196 + pub fn main() -> App(flags, model, msg) { 197 + todo as \"your Lustre application to preview\" 198 + }") 199 + 200 + ModuleMissing(module) -> io.println(" 201 + I couldn't find a public module called `" <> module <> "` in your project.") 202 + 203 + InternalError(message) -> io.println(" 204 + I ran into an error I wasn't expecting. Please open an issue on GitHub at 205 + https://github.com/lustre-labs/cli with the following message: 206 + " <> message) 207 + } 208 + } 209 + 210 + // STEPS ----------------------------------------------------------------------- 211 + 212 + fn check_is_lustre_app( 213 + module_path: String, 214 + module: Module, 215 + ) -> Result(Bool, Error) { 216 + dict.get(module.functions, "main") 217 + |> result.replace_error(MainMissing(module_path)) 218 + |> result.then(fn(main) { 219 + case main.parameters, main.return { 220 + [_, ..], _ -> 221 + Error(MainIncorrectType(module_path, Fn(main.parameters, main.return))) 222 + 223 + [], Named( 224 + name: "App", 225 + package: "lustre", 226 + module: "lustre", 227 + parameters: [flags, ..], 228 + ) -> 229 + case is_compatible_flags_type(flags) { 230 + True -> Ok(True) 231 + False -> Error(MainBadAppType(module_path, main.return)) 232 + } 233 + 234 + [], _ -> Ok(False) 235 + } 236 + }) 237 + } 238 + 239 + // UTILS ----------------------------------------------------------------------- 240 + 241 + fn is_nil_type(t: Type) -> Bool { 242 + case t { 243 + Named(name: "Nil", package: "", module: "gleam", parameters: []) -> True 244 + _ -> False 245 + } 246 + } 247 + 248 + fn is_type_variable(t: Type) -> Bool { 249 + case t { 250 + Variable(..) -> True 251 + _ -> False 252 + } 253 + } 254 + 255 + fn is_compatible_flags_type(t: Type) -> Bool { 256 + is_nil_type(t) || is_type_variable(t) 257 + }
+247
src/lustre_dev_tools/esbuild.gleam
··· 1 + // IMPORTS --------------------------------------------------------------------- 2 + 3 + import filepath 4 + import gleam/dynamic.{type Dynamic} 5 + import gleam/io 6 + import gleam/list 7 + import gleam/result 8 + import gleam/set 9 + import gleam/string 10 + import lustre_dev_tools/cli.{type Cli} 11 + import lustre_dev_tools/project 12 + import simplifile.{type FilePermissions, Execute, FilePermissions, Read, Write} 13 + 14 + // COMMANDS -------------------------------------------------------------------- 15 + 16 + pub fn download(os: String, cpu: String) -> Cli(any, Nil, Error) { 17 + use <- cli.log("Downloading esbuild") 18 + 19 + let outdir = filepath.join(project.root(), "build/.lustre/bin") 20 + let outfile = filepath.join(outdir, "esbuild") 21 + 22 + case check_esbuild_exists(outfile) { 23 + True -> cli.success("Esbuild already installed!", fn() { cli.return(Nil) }) 24 + False -> { 25 + use <- cli.log("Detecting platform") 26 + use url <- cli.do_result(get_download_url(os, cpu)) 27 + 28 + use <- cli.log("Downloading from " <> url) 29 + use tarball <- cli.try(get_esbuild(url), NetworkError) 30 + 31 + use <- cli.log("Unzipping esbuild") 32 + use bin <- cli.try(unzip_esbuild(tarball), UnzipError) 33 + use _ <- cli.try(write_esbuild(bin, outdir, outfile), SimplifileError( 34 + _, 35 + outfile, 36 + )) 37 + use _ <- cli.try(set_filepermissions(outfile), SimplifileError(_, outfile)) 38 + 39 + use <- cli.success("Esbuild installed!") 40 + cli.return(Nil) 41 + } 42 + } 43 + } 44 + 45 + pub fn bundle( 46 + input_file: String, 47 + output_file: String, 48 + minify: Bool, 49 + ) -> Cli(any, Nil, Error) { 50 + use _ <- cli.do(download(get_os(), get_cpu())) 51 + use _ <- cli.try(project.build(), fn(_) { BuildError }) 52 + use <- cli.log("Getting everything ready for tree shaking") 53 + 54 + let root = project.root() 55 + use _ <- cli.try(configure_node_tree_shaking(root), SimplifileError(_, root)) 56 + 57 + let flags = [ 58 + "--bundle", 59 + "--external:node:*", 60 + "--format=esm", 61 + "--outfile=" <> output_file, 62 + ] 63 + let options = case minify { 64 + True -> [input_file, "--minify", ..flags] 65 + False -> [input_file, ..flags] 66 + } 67 + 68 + use <- cli.log("Bundling with esbuild") 69 + use _ <- cli.try( 70 + cli.exec(run: "./build/.lustre/bin/esbuild", in: root, with: options), 71 + fn(pair) { BundleError(pair.1) }, 72 + ) 73 + 74 + use <- cli.success("Bundle produced at `" <> output_file <> "`") 75 + cli.return(Nil) 76 + } 77 + 78 + pub fn serve(host: String, port: String, spa: Bool) -> Cli(any, Nil, Error) { 79 + use _ <- cli.do(download(get_os(), get_cpu())) 80 + let root = project.root() 81 + let flags = [ 82 + "--serve=" <> host <> ":" <> port, 83 + "--servedir=" <> filepath.join(root, "build/.lustre"), 84 + ] 85 + 86 + let options = case spa { 87 + True -> [ 88 + "--serve-fallback=" <> filepath.join(root, "build/.lustre/index.html"), 89 + ..flags 90 + ] 91 + False -> flags 92 + } 93 + 94 + use <- cli.success("Starting dev server at " <> host <> ":" <> port <> "...") 95 + use _ <- cli.try( 96 + cli.exec(run: "./build/.lustre/bin/esbuild", in: root, with: options), 97 + fn(pair) { BundleError(pair.1) }, 98 + ) 99 + 100 + cli.return(Nil) 101 + } 102 + 103 + // STEPS ----------------------------------------------------------------------- 104 + 105 + fn check_esbuild_exists(path) { 106 + case simplifile.verify_is_file(path) { 107 + Ok(True) -> True 108 + Ok(False) | Error(_) -> False 109 + } 110 + } 111 + 112 + fn get_download_url(os, cpu) { 113 + let base = "https://registry.npmjs.org/@esbuild/" 114 + let path = case os, cpu { 115 + "android", "arm" -> Ok("android-arm/-/android-arm-0.19.10.tgz") 116 + "android", "arm64" -> Ok("android-arm64/-/android-arm64-0.19.10.tgz") 117 + "android", "x64" -> Ok("android-x64/-/android-x64-0.19.10.tgz") 118 + 119 + "darwin", "aarch64" -> Ok("darwin-arm64/-/darwin-arm64-0.19.10.tgz") 120 + "darwin", "amd64" -> Ok("darwin-arm64/-/darwin-arm64-0.19.10.tgz") 121 + "darwin", "arm64" -> Ok("darwin-arm64/-/darwin-arm64-0.19.10.tgz") 122 + "darwin", "x86_64" -> Ok("darwin-x64/-/darwin-x64-0.19.10.tgz") 123 + 124 + "freebsd", "arm64" -> Ok("freebsd-arm64/-/freebsd-arm64-0.19.10.tgz") 125 + "freebsd", "x64" -> Ok("freebsd-x64/-/freebsd-x64-0.19.10.tgz") 126 + 127 + "linux", "aarch64" -> Ok("linux-arm64/-/linux-arm64-0.19.10.tgz") 128 + "linux", "arm" -> Ok("linux-arm/-/linux-arm-0.19.10.tgz") 129 + "linux", "arm64" -> Ok("linux-arm64/-/linux-arm64-0.19.10.tgz") 130 + "linux", "ia32" -> Ok("linux-ia32/-/linux-ia32-0.19.10.tgz") 131 + "linux", "x64" -> Ok("linux-x64/-/linux-x64-0.19.10.tgz") 132 + "linux", "x86_64" -> Ok("linux-x64/-/linux-x64-0.19.10.tgz") 133 + 134 + "win32", "arm64" -> Ok("win32-arm64/-/win32-arm64-0.19.10.tgz") 135 + "win32", "ia32" -> Ok("win32-ia32/-/win32-ia32-0.19.10.tgz") 136 + "win32", "x64" -> Ok("win32-x64/-/win32-x64-0.19.10.tgz") 137 + "win32", "x86_64" -> Ok("win32-x64/-/win32-x64-0.19.10.tgz") 138 + 139 + "netbsd", "x64" -> Ok("netbsd-x64/-/netbsd-x64-0.19.10.tgz") 140 + "openbsd", "x64" -> Ok("openbsd-x64/-/openbsd-x64-0.19.10.tgz") 141 + "sunos", "x64" -> Ok("sunos-x64/-/sunos-x64-0.19.10.tgz") 142 + 143 + _, _ -> Error(UnknownPlatform(os, cpu)) 144 + } 145 + 146 + result.map(path, string.append(base, _)) 147 + } 148 + 149 + fn write_esbuild(bin, outdir, outfile) { 150 + let _ = simplifile.create_directory_all(outdir) 151 + 152 + simplifile.write_bits(outfile, bin) 153 + } 154 + 155 + fn set_filepermissions(file) { 156 + let permissions = 157 + FilePermissions( 158 + user: set.from_list([Read, Write, Execute]), 159 + group: set.from_list([Read, Execute]), 160 + other: set.from_list([Read, Execute]), 161 + ) 162 + 163 + simplifile.set_permissions(file, permissions) 164 + } 165 + 166 + fn configure_node_tree_shaking(root) { 167 + // This whole chunk of code is to force tree shaking on dependencies that esbuild 168 + // has a habit of including because it thinks their imports might have side 169 + // effects. 170 + // 171 + // This is a really grim hack but it's the only way I've found to get esbuild to 172 + // ignore unused deps like `glint` that imports node stuff but aren't used in 173 + // app code. 174 + let force_tree_shaking = "{ \"sideEffects\": false }" 175 + let assert Ok(_) = 176 + simplifile.write( 177 + filepath.join(root, "build/dev/javascript/package.json"), 178 + force_tree_shaking, 179 + ) 180 + let pure_deps = ["lustre", "glint", "simplifile"] 181 + 182 + list.try_each(pure_deps, fn(dep) { 183 + root 184 + |> filepath.join("build/dev/javascript/" <> dep) 185 + |> filepath.join("package.json") 186 + |> simplifile.write(force_tree_shaking) 187 + }) 188 + } 189 + 190 + // ERROR HANDLING -------------------------------------------------------------- 191 + 192 + pub type Error { 193 + BuildError 194 + BundleError(message: String) 195 + NetworkError(Dynamic) 196 + SimplifileError(reason: simplifile.FileError, path: String) 197 + UnknownPlatform(os: String, cpu: String) 198 + UnzipError(Dynamic) 199 + } 200 + 201 + pub fn explain(error: Error) -> Nil { 202 + case error { 203 + BuildError -> project.explain(project.BuildError) 204 + 205 + BundleError(message) -> io.println(" 206 + I ran into an error while trying to create a bundle with esbuild: 207 + " <> message) 208 + 209 + // TODO: Is there a better way to deal with this dynamic error? 210 + NetworkError(_dynamic) -> 211 + io.println( 212 + " 213 + There was a network error!", 214 + ) 215 + 216 + // TODO: this could give a better error for some common reason like Enoent. 217 + SimplifileError(reason, path) -> io.println(" 218 + I ran into the following error at path `" <> path <> "`:" <> string.inspect( 219 + reason, 220 + ) <> ".") 221 + 222 + UnknownPlatform(os, cpu) -> io.println(" 223 + I couldn't figure out the correct esbuild version for your 224 + os (" <> os <> ") and cpu (" <> cpu <> ").") 225 + 226 + // TODO: Is there a better way to deal with this dynamic error? 227 + UnzipError(_dynamic) -> 228 + io.println( 229 + " 230 + I couldn't unzip the esbuild executable!", 231 + ) 232 + } 233 + } 234 + 235 + // EXTERNALS ------------------------------------------------------------------- 236 + 237 + @external(erlang, "lustre_dev_tools_ffi", "get_os") 238 + fn get_os() -> String 239 + 240 + @external(erlang, "lustre_dev_tools_ffi", "get_cpu") 241 + fn get_cpu() -> String 242 + 243 + @external(erlang, "lustre_dev_tools_ffi", "get_esbuild") 244 + fn get_esbuild(url: String) -> Result(BitArray, Dynamic) 245 + 246 + @external(erlang, "lustre_dev_tools_ffi", "unzip_esbuild") 247 + fn unzip_esbuild(tarball: BitArray) -> Result(BitArray, Dynamic)
+179
src/lustre_dev_tools/project.gleam
··· 1 + // IMPORTS --------------------------------------------------------------------- 2 + 3 + import filepath 4 + import gleam/dict.{type Dict} 5 + import gleam/dynamic.{type DecodeError, type Decoder, type Dynamic, DecodeError} 6 + import gleam/int 7 + import gleam/io 8 + import gleam/json 9 + import gleam/list 10 + import gleam/package_interface.{type Type, Fn, Named, Tuple, Variable} 11 + import gleam/pair 12 + import gleam/result 13 + import gleam/string 14 + import lustre_dev_tools/cli 15 + import simplifile 16 + import tom.{type Toml} 17 + 18 + // TYPES ----------------------------------------------------------------------- 19 + 20 + pub type Config { 21 + Config(name: String, version: String, toml: Dict(String, Toml)) 22 + } 23 + 24 + pub type Interface { 25 + Interface(name: String, version: String, modules: Dict(String, Module)) 26 + } 27 + 28 + pub type Module { 29 + Module(constants: Dict(String, Type), functions: Dict(String, Function)) 30 + } 31 + 32 + pub type Function { 33 + Function(parameters: List(Type), return: Type) 34 + } 35 + 36 + // COMMANDS -------------------------------------------------------------------- 37 + 38 + /// Compile the current project running the `gleam build` command. 39 + /// 40 + pub fn build() -> Result(Nil, String) { 41 + cli.exec(run: "gleam", in: ".", with: ["build", "--target=js"]) 42 + |> result.map_error(pair.second) 43 + |> result.replace(Nil) 44 + } 45 + 46 + pub fn interface() -> Result(Interface, String) { 47 + let dir = filepath.join(root(), "build/.lustre") 48 + let out = filepath.join(dir, "package-interface.json") 49 + let args = ["export", "package-interface", "--out", out] 50 + 51 + cli.exec(run: "gleam", in: ".", with: args) 52 + |> result.map_error(pair.second) 53 + |> result.then(fn(_) { 54 + let assert Ok(json) = simplifile.read(out) 55 + let assert Ok(interface) = json.decode(json, interface_decoder) 56 + 57 + Ok(interface) 58 + }) 59 + } 60 + 61 + /// Read the project configuration in the `gleam.toml` file. 62 + /// 63 + pub fn config() -> Result(Config, String) { 64 + use _ <- result.try(build()) 65 + 66 + // Since we made sure that the project could compile we're sure that there is 67 + // bound to be a `gleam.toml` file somewhere in the current directory (or in 68 + // its parent directories). So we can safely call `root()` without 69 + // it looping indefinitely. 70 + let configuration_path = filepath.join(root(), "gleam.toml") 71 + 72 + // All these operations are safe to assert because the Gleam project wouldn't 73 + // compile if any of this stuff was invalid. 74 + let assert Ok(configuration) = simplifile.read(configuration_path) 75 + let assert Ok(toml) = tom.parse(configuration) 76 + let assert Ok(name) = tom.get_string(toml, ["name"]) 77 + let assert Ok(version) = tom.get_string(toml, ["version"]) 78 + 79 + Ok(Config(name: name, version: version, toml: toml)) 80 + } 81 + 82 + // ERROR HANDLING -------------------------------------------------------------- 83 + 84 + /// 85 + /// 86 + pub type Error { 87 + BuildError 88 + } 89 + 90 + pub fn explain(error: Error) -> Nil { 91 + case error { 92 + BuildError -> 93 + " 94 + It looks like your project has some compilation errors that need to be addressed 95 + before I can do anything." 96 + |> io.println 97 + } 98 + } 99 + 100 + // UTILS ----------------------------------------------------------------------- 101 + 102 + /// Finds the path leading to the project's root folder. This recursively walks 103 + /// up from the current directory until it finds a `gleam.toml`. 104 + /// 105 + pub fn root() -> String { 106 + find_root(".") 107 + } 108 + 109 + fn find_root(path: String) -> String { 110 + let toml = filepath.join(path, "gleam.toml") 111 + 112 + case simplifile.verify_is_file(toml) { 113 + Ok(False) | Error(_) -> find_root(filepath.join("..", path)) 114 + Ok(True) -> path 115 + } 116 + } 117 + 118 + pub fn type_to_string(type_: Type) -> String { 119 + case type_ { 120 + Tuple(elements) -> { 121 + let elements = list.map(elements, type_to_string) 122 + "#(" <> string.join(elements, with: ", ") <> ")" 123 + } 124 + 125 + Fn(params, return) -> { 126 + let params = list.map(params, type_to_string) 127 + let return = type_to_string(return) 128 + "fn(" <> string.join(params, with: ", ") <> ") -> " <> return 129 + } 130 + 131 + Named(name, _package, _module, []) -> name 132 + Named(name, _package, _module, params) -> { 133 + let params = list.map(params, type_to_string) 134 + name <> "(" <> string.join(params, with: ", ") <> ")" 135 + } 136 + 137 + Variable(id) -> "a_" <> int.to_string(id) 138 + } 139 + } 140 + 141 + // DECODERS -------------------------------------------------------------------- 142 + 143 + fn interface_decoder(dyn: Dynamic) -> Result(Interface, List(DecodeError)) { 144 + dynamic.decode3( 145 + Interface, 146 + dynamic.field("name", dynamic.string), 147 + dynamic.field("version", dynamic.string), 148 + dynamic.field("modules", string_dict(module_decoder)), 149 + )(dyn) 150 + } 151 + 152 + fn module_decoder(dyn: Dynamic) -> Result(Module, List(DecodeError)) { 153 + dynamic.decode2( 154 + Module, 155 + dynamic.field( 156 + "constants", 157 + string_dict(dynamic.field("type", package_interface.type_decoder)), 158 + ), 159 + dynamic.field("functions", string_dict(function_decoder)), 160 + )(dyn) 161 + } 162 + 163 + fn function_decoder(dyn: Dynamic) -> Result(Function, List(DecodeError)) { 164 + dynamic.decode2( 165 + Function, 166 + dynamic.field("parameters", dynamic.list(labelled_argument_decoder)), 167 + dynamic.field("return", package_interface.type_decoder), 168 + )(dyn) 169 + } 170 + 171 + fn labelled_argument_decoder(dyn: Dynamic) -> Result(Type, List(DecodeError)) { 172 + // In this case we don't really care about the label, so we're just ignoring 173 + // it and returning the argument's type. 174 + dynamic.field("type", package_interface.type_decoder)(dyn) 175 + } 176 + 177 + fn string_dict(values: Decoder(a)) -> Decoder(Dict(String, a)) { 178 + dynamic.dict(dynamic.string, values) 179 + }
+179
src/lustre_dev_tools/tailwind.gleam
··· 1 + // IMPORTS --------------------------------------------------------------------- 2 + 3 + import filepath 4 + import gleam/bool 5 + import gleam/dynamic.{type Dynamic} 6 + import gleam/io 7 + import gleam/result 8 + import gleam/set 9 + import gleam/string 10 + import lustre_dev_tools/project 11 + import lustre_dev_tools/cli.{type Cli} 12 + import simplifile.{type FilePermissions, Execute, FilePermissions, Read, Write} 13 + 14 + const tailwind_version = "v3.4.1" 15 + 16 + // COMMANDS -------------------------------------------------------------------- 17 + 18 + pub fn setup(os: String, cpu: String) -> Cli(any, Nil, Error) { 19 + use _ <- cli.do(download(os, cpu, tailwind_version)) 20 + use _ <- cli.do(write_tailwind_config()) 21 + 22 + cli.return(Nil) 23 + } 24 + 25 + fn download(os: String, cpu: String, version: String) -> Cli(any, Nil, Error) { 26 + use <- cli.log("Downloading Tailwind") 27 + 28 + let root = project.root() 29 + let outdir = filepath.join(root, "build/.lustre/bin") 30 + let outfile = filepath.join(outdir, "tailwind") 31 + 32 + case check_tailwind_exists(outfile) { 33 + True -> cli.success("Tailwind already installed!", fn() { cli.return(Nil) }) 34 + False -> { 35 + use <- cli.log("Detecting platform") 36 + use url <- cli.do_result(get_download_url(os, cpu, version)) 37 + 38 + use <- cli.log("Downloading from " <> url) 39 + use bin <- cli.try(get_tailwind(url), NetworkError) 40 + 41 + use _ <- cli.try(write_tailwind(bin, outdir, outfile), fn(reason) { 42 + CannotWriteTailwind(reason, outfile) 43 + }) 44 + use _ <- cli.try(set_filepermissions(outfile), fn(reason) { 45 + CannotSetPermissions(reason, outfile) 46 + }) 47 + 48 + use <- cli.success("Tailwind installed!") 49 + 50 + cli.return(Nil) 51 + } 52 + } 53 + } 54 + 55 + fn write_tailwind_config() -> Cli(any, Nil, Error) { 56 + let config_filename = "tailwind.config.js" 57 + let config_outfile = filepath.join(project.root(), config_filename) 58 + let config_already_exists = 59 + simplifile.verify_is_file(config_outfile) 60 + |> result.unwrap(False) 61 + 62 + // If there already is a configuration file, we make sure not to override it. 63 + use <- bool.guard(when: config_already_exists, return: cli.return(Nil)) 64 + use <- cli.log("Writing `" <> config_filename <> "`") 65 + use config <- cli.template("tailwind.config.js", InternalError) 66 + use _ <- cli.try( 67 + simplifile.write(to: config_outfile, contents: config), 68 + CannotWriteConfig(_, config_outfile), 69 + ) 70 + use <- cli.success("Written `" <> config_outfile <> "`") 71 + 72 + cli.return(Nil) 73 + } 74 + 75 + // STEPS ----------------------------------------------------------------------- 76 + 77 + fn check_tailwind_exists(path) { 78 + case simplifile.verify_is_file(path) { 79 + Ok(True) -> True 80 + Ok(False) | Error(_) -> False 81 + } 82 + } 83 + 84 + fn get_download_url(os, cpu, version) { 85 + let base = 86 + "https://github.com/tailwindlabs/tailwindcss/releases/download/" 87 + <> version 88 + <> "/tailwindcss-" 89 + 90 + let path = case os, cpu { 91 + "linux", "armv7" -> Ok("linux-armv7") 92 + "linux", "arm64" -> Ok("linux-arm64") 93 + "linux", "x64" | "linux", "x86_64" -> Ok("linux-x64") 94 + 95 + "win32", "arm64" -> Ok("windows-arm64.exe") 96 + "win32", "x64" | "win32", "x86_64" -> Ok("windows-x64.exe") 97 + 98 + "darwin", "arm64" | "darwin", "aarch64" -> Ok("macos-arm64") 99 + "darwin", "x64" | "darwin", "x86_64" -> Ok("macos-x64") 100 + 101 + _, _ -> Error(UnknownPlatform(os, cpu)) 102 + } 103 + 104 + result.map(path, string.append(base, _)) 105 + } 106 + 107 + fn write_tailwind(bin, outdir, outfile) { 108 + let _ = simplifile.create_directory_all(outdir) 109 + 110 + simplifile.write_bits(outfile, bin) 111 + } 112 + 113 + fn set_filepermissions(file) { 114 + let permissions = 115 + FilePermissions( 116 + user: set.from_list([Read, Write, Execute]), 117 + group: set.from_list([Read, Execute]), 118 + other: set.from_list([Read, Execute]), 119 + ) 120 + 121 + simplifile.set_permissions(file, permissions) 122 + } 123 + 124 + // ERROR HANDLING -------------------------------------------------------------- 125 + 126 + pub type Error { 127 + NetworkError(Dynamic) 128 + CannotWriteTailwind(reason: simplifile.FileError, path: String) 129 + CannotSetPermissions(reason: simplifile.FileError, path: String) 130 + CannotWriteConfig(reason: simplifile.FileError, path: String) 131 + UnknownPlatform(os: String, cpu: String) 132 + BundleError(reason: String) 133 + InternalError(message: String) 134 + } 135 + 136 + pub fn explain(error: Error) -> Nil { 137 + case error { 138 + // TODO: Is there a better way to deal with this dynamic error? 139 + NetworkError(_dynamic) -> 140 + io.println( 141 + " 142 + There was a network error!", 143 + ) 144 + 145 + UnknownPlatform(os, cpu) -> io.println(" 146 + I couldn't figure out the correct Tailwind version for your 147 + os (" <> os <> ") and cpu (" <> cpu <> ").") 148 + 149 + CannotSetPermissions(reason, _) -> io.println(" 150 + I ran into an error (" <> string.inspect(reason) <> ") when trying 151 + to set permissions for the Tailwind executable. 152 + ") 153 + 154 + CannotWriteConfig(reason, _) -> io.println(" 155 + I ran into an error (" <> string.inspect(reason) <> ") when trying 156 + to write the `tailwind.config.js` file to the project's root. 157 + ") 158 + 159 + CannotWriteTailwind(reason, path) -> io.println(" 160 + I ran into an error (" <> string.inspect(reason) <> ") when trying 161 + to write the Tailwind binary to 162 + `" <> path <> "`. 163 + ") 164 + 165 + BundleError(reason) -> io.println(" 166 + I ran into an error while trying to create a bundle with Tailwind: 167 + " <> reason) 168 + 169 + InternalError(message) -> io.println(" 170 + I ran into an error I wasn't expecting. Please open an issue on GitHub at 171 + https://github.com/lustre-labs/cli with the following message: 172 + " <> message) 173 + } 174 + } 175 + 176 + // EXTERNALS ------------------------------------------------------------------- 177 + 178 + @external(erlang, "lustre_dev_tools_ffi", "get_esbuild") 179 + fn get_tailwind(url: String) -> Result(BitArray, Dynamic)
+86
src/lustre_dev_tools_ffi.erl
··· 1 + -module(lustre_dev_tools_ffi). 2 + -export([ 3 + get_cpu/0, 4 + get_esbuild/1, 5 + get_tailwind/1, 6 + get_os/0, 7 + unzip_esbuild/1, 8 + exec/3 9 + ]). 10 + 11 + get_os() -> 12 + case os:type() of 13 + {win32, _} -> <<"win32">>; 14 + {unix, darwin} -> <<"darwin">>; 15 + {unix, linux} -> <<"linux">>; 16 + {_, Unknown} -> atom_to_binary(Unknown, utf8) 17 + end. 18 + 19 + get_cpu() -> 20 + case erlang:system_info(os_type) of 21 + {unix, _} -> 22 + [Arch, _] = string:split(erlang:system_info(system_architecture), "-"), 23 + list_to_binary(Arch); 24 + {win32, _} -> 25 + case erlang:system_info(wordsize) of 26 + 4 -> {ok, <<"ia32">>}; 27 + 8 -> {ok, <<"x64">>} 28 + end 29 + end. 30 + 31 + get_esbuild(Url) -> 32 + inets:start(), 33 + ssl:start(), 34 + 35 + case httpc:request(get, {Url, []}, [], [{body_format, binary}]) of 36 + {ok, {{_, 200, _}, _, Zip}} -> {ok, Zip}; 37 + {ok, Res} -> {error, Res}; 38 + {error, Err} -> {error, Err} 39 + end. 40 + 41 + get_tailwind(Url) -> 42 + inets:start(), 43 + ssl:start(), 44 + 45 + case httpc:request(get, {Url, []}, [], [{body_format, binary}]) of 46 + {ok, {{_, 200, _}, _, Bin}} -> {ok, Bin}; 47 + {ok, Res} -> {error, Res}; 48 + {error, Err} -> {error, Err} 49 + end. 50 + 51 + unzip_esbuild(Zip) -> 52 + Result = 53 + erl_tar:extract({binary, Zip}, [ 54 + memory, compressed, {files, ["package/bin/esbuild"]} 55 + ]), 56 + 57 + case Result of 58 + {ok, [{_, Esbuild}]} -> {ok, Esbuild}; 59 + {ok, Res} -> {error, Res}; 60 + {error, Err} -> {error, Err} 61 + end. 62 + 63 + exec(Command, Args, Cwd) -> 64 + Command_ = binary_to_list(Command), 65 + Args_ = lists:map(fun(Arg) -> binary_to_list(Arg) end, Args), 66 + Cwd_ = binary_to_list(Cwd), 67 + 68 + Name = case Command_ of 69 + "./" ++ _ -> {spawn_executable, Command_}; 70 + "/" ++ _ -> {spawn_executable, Command_}; 71 + _ -> {spawn_executable, os:find_executable(Command_)} 72 + end, 73 + 74 + Port = open_port(Name, [exit_status, binary, hide, stream, eof, 75 + {args, Args_}, 76 + {cd, Cwd_} 77 + ]), 78 + 79 + do_exec(Port, []). 80 + 81 + do_exec(Port, Acc) -> 82 + receive 83 + {Port, {data, Data}} -> do_exec(Port, [Data | Acc]); 84 + {Port, {exit_status, 0}} -> {ok, list_to_binary(lists:reverse(Acc))}; 85 + {Port, {exit_status, Code}} -> {error, {Code, list_to_binary(lists:reverse(Acc))}} 86 + end.
+12
test/lustre_dev_tools_test.gleam
··· 1 + import gleeunit 2 + import gleeunit/should 3 + 4 + pub fn main() { 5 + gleeunit.main() 6 + } 7 + 8 + // gleeunit test functions end in `_test` 9 + pub fn hello_world_test() { 10 + 1 11 + |> should.equal(1) 12 + }