diff options
author | Hayleigh Thompson <me@hayleigh.dev> | 2024-03-22 01:42:26 +0000 |
---|---|---|
committer | Hayleigh Thompson <me@hayleigh.dev> | 2024-03-22 01:42:26 +0000 |
commit | e85bc4729a15d9af5af5345151178afe849036ba (patch) | |
tree | cc229a534fff73b6cd0c1ea4b290a12cedb7aa08 | |
parent | e9b765a757b610ea970f80e7c0092bf4899acfa6 (diff) | |
download | lustre-e85bc4729a15d9af5af5345151178afe849036ba.tar.gz lustre-e85bc4729a15d9af5af5345151178afe849036ba.zip |
:truck: Move cli code to lustre-labs/dev-tools.
-rw-r--r-- | gleam.toml | 10 | ||||
-rw-r--r-- | manifest.toml | 15 | ||||
-rw-r--r-- | src/cli_ffi.erl | 87 | ||||
-rw-r--r-- | src/lustre.gleam | 42 | ||||
-rw-r--r-- | src/lustre/cli/add.gleam | 91 | ||||
-rw-r--r-- | src/lustre/cli/build.gleam | 374 | ||||
-rw-r--r-- | src/lustre/cli/dev.gleam | 245 | ||||
-rw-r--r-- | src/lustre/cli/esbuild.gleam | 249 | ||||
-rw-r--r-- | src/lustre/cli/project.gleam | 186 | ||||
-rw-r--r-- | src/lustre/cli/step.gleam | 100 | ||||
-rw-r--r-- | src/lustre/cli/tailwind.gleam | 185 | ||||
-rw-r--r-- | src/lustre/cli/utils.gleam | 68 | ||||
-rw-r--r-- | test/lustre_test.gleam | 16 |
13 files changed, 9 insertions, 1659 deletions
@@ -40,21 +40,13 @@ pages = [ ] [dependencies] -argv = "~> 1.0" -filepath = "~> 0.1" -gleam_community_ansi = "~> 1.4" gleam_erlang = "~> 0.24" gleam_json = "~> 1.0" gleam_otp = "~> 0.9" gleam_stdlib = "~> 0.34 or ~> 1.0" -glint = "0.16.0" -justin = "~> 1.0" -simplifile = "~> 1.4" -spinner = "~> 1.1" -tom = "~> 0.3" -gleam_package_interface = "~> 1.0" [dev-dependencies] birdie = "~> 1.0" gleeunit = "~> 1.0" shellout = "~> 1.6" +simplifile = "~> 1.4" diff --git a/manifest.toml b/manifest.toml index 31e4e21..6f29957 100644 --- a/manifest.toml +++ b/manifest.toml @@ -11,36 +11,21 @@ packages = [ { name = "gleam_erlang", version = "0.24.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "26BDB52E61889F56A291CB34167315780EE4AA20961917314446542C90D1C1A0" }, { name = "gleam_json", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "8B197DD5D578EA6AC2C0D4BDC634C71A5BCA8E7DB5F47091C263ECB411A60DF3" }, { name = "gleam_otp", version = "0.9.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "5FADBBEC5ECF3F8B6BE91101D432758503192AE2ADBAD5602158977341489F71" }, - { 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" }, { name = "gleam_stdlib", version = "0.34.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1FB8454D2991E9B4C0C804544D8A9AD0F6184725E20D63C3155F0AEB4230B016" }, - { name = "glearray", version = "0.2.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "908154F695D330E06A37FAB2C04119E8F315D643206F8F32B6A6C14A8709FFF4" }, { name = "gleeunit", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "D364C87AFEB26BDB4FB8A5ABDE67D635DC9FA52D6AB68416044C35B096C6882D" }, - { 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" }, { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, { name = "rank", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "5660E361F0E49CBB714CC57CC4C89C63415D8986F05B2DA0C719D5642FAD91C9" }, - { name = "repeatedly", version = "2.1.0", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "AF58F2AF775BAAD1C4B4C74F9B5D963E50B71736C97A7323DBA40F809CF93E5A" }, { name = "shellout", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "E2FCD18957F0E9F67E1F497FC9FF57393392F8A9BAEAEA4779541DE7A68DD7E0" }, { name = "simplifile", version = "1.4.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "AAFCF154F69B237D269FF2764890F61ABC4A7EF2A592D44D67627B99694539D9" }, - { name = "snag", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "54D32E16E33655346AA3E66CBA7E191DE0A8793D2C05284E3EFB90AD2CE92BCC" }, - { 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" }, { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" }, - { name = "tom", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0831C73E45405A2153091226BF98FB485ED16376988602CC01A5FD086B82D577" }, ] [requirements] -argv = { version = "~> 1.0" } birdie = { version = "~> 1.0" } -filepath = { version = "~> 0.1" } -gleam_community_ansi = { version = "~> 1.4" } gleam_erlang = { version = "~> 0.24" } gleam_json = { version = "~> 1.0" } gleam_otp = { version = "~> 0.9" } -gleam_package_interface = { version = "~> 1.0"} gleam_stdlib = { version = "~> 0.34 or ~> 1.0" } gleeunit = { version = "~> 1.0" } -glint = { version = "0.16.0" } -justin = { version = "~> 1.0" } shellout = { version = "~> 1.6" } simplifile = { version = "~> 1.4" } -spinner = { version = "~> 1.1" } -tom = { version = "~> 0.3" } diff --git a/src/cli_ffi.erl b/src/cli_ffi.erl deleted file mode 100644 index 43e9c78..0000000 --- a/src/cli_ffi.erl +++ /dev/null @@ -1,87 +0,0 @@ --module(cli_ffi). --export([ - get_cpu/0, - get_esbuild/1, - get_tailwind/1, - get_os/0, - unzip_esbuild/1, - exec/3 -]). - -get_os() -> - case os:type() of - {win32, _} -> <<"win32">>; - {unix, darwin} -> <<"darwin">>; - {unix, linux} -> <<"linux">>; - {_, Unknown} -> atom_to_binary(Unknown, utf8) - end. - -get_cpu() -> - case erlang:system_info(os_type) of - {unix, _} -> - [Arch, _] = string:split(erlang:system_info(system_architecture), "-"), - list_to_binary(Arch); - {win32, _} -> - case erlang:system_info(wordsize) of - 4 -> {ok, <<"ia32">>}; - 8 -> {ok, <<"x64">>} - end - end. - -get_esbuild(Url) -> - inets:start(), - ssl:start(), - - case httpc:request(get, {Url, []}, [], [{body_format, binary}]) of - {ok, {{_, 200, _}, _, Zip}} -> {ok, Zip}; - {ok, Res} -> {error, Res}; - {error, Err} -> {error, Err} - end. - -get_tailwind(Url) -> - inets:start(), - ssl:start(), - - case httpc:request(get, {Url, []}, [], [{body_format, binary}]) of - {ok, {{_, 200, _}, _, Bin}} -> {ok, Bin}; - {ok, Res} -> {error, Res}; - {error, Err} -> {error, Err} - end. - - -unzip_esbuild(Zip) -> - Result = - erl_tar:extract({binary, Zip}, [ - memory, compressed, {files, ["package/bin/esbuild"]} - ]), - - case Result of - {ok, [{_, Esbuild}]} -> {ok, Esbuild}; - {ok, Res} -> {error, Res}; - {error, Err} -> {error, Err} - end. - -exec(Command, Args, Cwd) -> - Command_ = binary_to_list(Command), - Args_ = lists:map(fun(Arg) -> binary_to_list(Arg) end, Args), - Cwd_ = binary_to_list(Cwd), - - Name = case Command_ of - "./" ++ _ -> {spawn_executable, Command_}; - "/" ++ _ -> {spawn_executable, Command_}; - _ -> {spawn_executable, os:find_executable(Command_)} - end, - - Port = open_port(Name, [exit_status, binary, hide, stream, eof, - {args, Args_}, - {cd, Cwd_} - ]), - - do_exec(Port, []). - -do_exec(Port, Acc) -> - receive - {Port, {data, Data}} -> do_exec(Port, [Data | Acc]); - {Port, {exit_status, 0}} -> {ok, list_to_binary(lists:reverse(Acc))}; - {Port, {exit_status, Code}} -> {error, {Code, list_to_binary(lists:reverse(Acc))}} - end. diff --git a/src/lustre.gleam b/src/lustre.gleam index 40b8059..ff4fcdd 100644 --- a/src/lustre.gleam +++ b/src/lustre.gleam @@ -163,7 +163,6 @@ // IMPORTS --------------------------------------------------------------------- -import argv import gleam/bool import gleam/dict.{type Dict} import gleam/dynamic.{type Decoder} @@ -171,52 +170,11 @@ import gleam/erlang/process.{type Subject} import gleam/option.{type Option, None, Some} import gleam/otp/actor.{type StartError} import gleam/result -import glint -import lustre/cli/add -import lustre/cli/build -import lustre/cli/dev import lustre/effect.{type Effect} import lustre/element.{type Element} import lustre/internals/patch import lustre/internals/runtime -// MAIN ------------------------------------------------------------------------ - -/// This function exists so you can run helpful Lustre utilities from the command -/// line using `gleam run -m lustre`. Here's a brief overview of the different -/// subcommands you can run: -/// -/// - `add` can be used to add useful binaries like `esbuild` to your project. -/// -/// - `build` has additional subcommands for building Lustre applications or -/// packaging Web Components. -/// -/// - `dev` starts a development server that automatically loads your Lustre app. -/// -/// For a proper help message run: -/// -/// ```sh -/// gleam run -m lustre -- --help -/// ``` -/// -/// **Note**: If you're just using Lustre as a library, *you can ignore this -/// function*. -/// -pub fn main() { - let args = argv.load().arguments - - glint.new() - |> glint.as_gleam_module - |> glint.with_name("lustre") - |> glint.with_pretty_help(glint.default_pretty_help()) - |> glint.add(at: ["add", "esbuild"], do: add.esbuild()) - |> glint.add(at: ["add", "tailwind"], do: add.tailwind()) - |> glint.add(at: ["build", "app"], do: build.app()) - |> glint.add(at: ["build", "component"], do: build.component()) - |> glint.add(at: ["dev"], do: dev.run()) - |> glint.run(args) -} - // TYPES ----------------------------------------------------------------------- /// Represents a constructed Lustre application that is ready to be started. diff --git a/src/lustre/cli/add.gleam b/src/lustre/cli/add.gleam deleted file mode 100644 index ddacf2e..0000000 --- a/src/lustre/cli/add.gleam +++ /dev/null @@ -1,91 +0,0 @@ -// IMPORTS --------------------------------------------------------------------- - -import glint.{type Command, CommandInput} -import glint/flag -import lustre/cli/esbuild -import lustre/cli/step -import lustre/cli/tailwind - -// COMMANDS -------------------------------------------------------------------- - -pub fn esbuild() -> Command(Nil) { - let description = - " -Download a platform-appropriate version of the esbuild binary. Lustre uses this -to bundle applications and act as a development server. - " - - glint.command(fn(input) { - let CommandInput(flags: flags, ..) = input - let assert Ok(os) = flag.get_string(flags, "os") - let assert Ok(cpu) = flag.get_string(flags, "cpu") - let script = esbuild.download(os, cpu) - case step.execute(script) { - Ok(_) -> Nil - Error(error) -> esbuild.explain(error) - } - }) - |> glint.description(description) - |> glint.unnamed_args(glint.EqArgs(0)) - |> glint.flag("os", { - let description = "" - let default = get_os() - - flag.string() - |> flag.default(default) - |> flag.description(description) - }) - |> glint.flag("cpu", { - let description = "" - let default = get_cpu() - - flag.string() - |> flag.default(default) - |> flag.description(description) - }) -} - -pub fn tailwind() -> Command(Nil) { - let description = - " -Download a platform-appropriate version of the Tailwind binary. - " - - glint.command(fn(input) { - let CommandInput(flags: flags, ..) = input - let assert Ok(os) = flag.get_string(flags, "os") - let assert Ok(cpu) = flag.get_string(flags, "cpu") - - let script = tailwind.setup(os, cpu) - case step.execute(script) { - Ok(_) -> Nil - Error(error) -> tailwind.explain(error) - } - }) - |> glint.description(description) - |> glint.unnamed_args(glint.EqArgs(0)) - |> glint.flag("os", { - let description = "" - let default = get_os() - - flag.string() - |> flag.default(default) - |> flag.description(description) - }) - |> glint.flag("cpu", { - let description = "" - let default = get_cpu() - - flag.string() - |> flag.default(default) - |> flag.description(description) - }) -} - -// EXTERNALS ------------------------------------------------------------------- - -@external(erlang, "cli_ffi", "get_os") -fn get_os() -> String - -@external(erlang, "cli_ffi", "get_cpu") -fn get_cpu() -> String diff --git a/src/lustre/cli/build.gleam b/src/lustre/cli/build.gleam deleted file mode 100644 index b899f21..0000000 --- a/src/lustre/cli/build.gleam +++ /dev/null @@ -1,374 +0,0 @@ -// IMPORTS --------------------------------------------------------------------- - -import filepath -import gleam/bool -import gleam/dict -import gleam/io -import gleam/list -import gleam/package_interface.{type Type, Named, Variable} -import gleam/result -import gleam/string -import glint.{type Command, CommandInput} -import glint/flag -import lustre/cli/esbuild -import lustre/cli/project.{type Module} -import lustre/cli/utils.{exec, keep, replace, template, try} -import lustre/cli/step.{type Step} -import lustre/cli/tailwind -import simplifile - -// COMMANDS -------------------------------------------------------------------- - -pub fn app() -> Command(Nil) { - let description = - " -Build and bundle an entire Lustre application. The generated JavaScript module -calls your app's `main` function on page load and can be included in any Web -page without Gleam or Lustre being present. - -This is different from using `gleam build` directly because it produces a single -JavaScript module for you to host or distribute. - " - - glint.command(fn(input) { - let CommandInput(flags: flags, ..) = input - let assert Ok(minify) = flag.get_bool(flags, "minify") - - let script = { - use <- step.new("Building your project") - use project_name <- step.try(get_project_name(), keep) - use <- step.done("✅ Project compiled successfully") - use <- step.new("Checking if I can bundle your application") - use module <- step.try(get_module_interface(project_name), keep) - use _ <- step.try(check_main_function(project_name, module), keep) - - use <- step.new("Creating the bundle entry file") - let root = project.root() - let tempdir = filepath.join(root, "build/.lustre") - let outdir = filepath.join(root, "priv/static") - let _ = simplifile.create_directory_all(tempdir) - let _ = simplifile.create_directory_all(outdir) - let entry = - template("entry-with-main.mjs") - |> string.replace("{app_name}", project_name) - - let entryfile = filepath.join(tempdir, "entry.mjs") - let ext = case minify { - True -> ".min.mjs" - False -> ".mjs" - } - - let outfile = - project_name - |> string.append(ext) - |> filepath.join(outdir, _) - - let assert Ok(_) = simplifile.write(entryfile, entry) - - use _ <- step.run(bundle(entry, tempdir, outfile, minify), keep) - - // Tailwind bundling - let entry = template("entry.css") - let outfile = - filepath.strip_extension(outfile) - |> string.append(".css") - - let bundle = bundle_tailwind(entry, tempdir, outfile, minify) - use _ <- step.run(bundle, on_error: TailwindBundleError) - - step.return(Nil) - } - - case step.execute(script) { - Ok(_) -> Nil - Error(error) -> explain(error) - } - }) - |> glint.description(description) - |> glint.unnamed_args(glint.EqArgs(0)) - |> glint.flag("minify", { - let description = "Minify the output" - let default = False - - flag.bool() - |> flag.default(default) - |> flag.description(description) - }) -} - -pub fn component() -> Command(Nil) { - let description = - " -Build a Lustre component as a portable Web Component. The generated JavaScript -module can be included in any Web page and used without Gleam or Lustre being -present. - " - - glint.command(fn(input) { - let CommandInput(flags: flags, named_args: args, ..) = input - let assert Ok(module_path) = dict.get(args, "module_path") - let assert Ok(minify) = flag.get_bool(flags, "minify") - - let script = { - use <- step.new("Building your project") - use module <- step.try(get_module_interface(module_path), keep) - use <- step.done("✅ Project compiled successfully") - use <- step.new("Checking if I can bundle your component") - use _ <- step.try(check_component_name(module_path, module), keep) - use component <- step.try(find_component(module_path, module), keep) - - use <- step.new("Creating the bundle entry file") - let root = project.root() - let tempdir = filepath.join(root, "build/.lustre") - let outdir = filepath.join(root, "priv/static") - let _ = simplifile.create_directory_all(tempdir) - let _ = simplifile.create_directory_all(outdir) - - use project_name <- step.try(get_project_name(), keep) - - // Esbuild bundling - let entry = - template("component-entry.mjs") - |> string.replace("{component_name}", component) - |> string.replace("{app_name}", project_name) - |> string.replace("{module_path}", module_path) - - let entryfile = filepath.join(tempdir, "entry.mjs") - let ext = case minify { - True -> ".min.mjs" - False -> ".mjs" - } - let assert Ok(outfile) = - string.split(module_path, "/") - |> list.last - |> result.map(string.append(_, ext)) - |> result.map(filepath.join(outdir, _)) - - let assert Ok(_) = simplifile.write(entryfile, entry) - use _ <- step.run(bundle(entry, tempdir, outfile, minify), keep) - - // Tailwind bundling - let entry = template("entry.css") - let outfile = - filepath.strip_extension(outfile) - |> string.append(".css") - - let bundle = bundle_tailwind(entry, tempdir, outfile, minify) - use _ <- step.run(bundle, on_error: TailwindBundleError) - step.return(Nil) - } - - case step.execute(script) { - Ok(_) -> Nil - Error(error) -> explain(error) - } - }) - |> glint.description(description) - |> glint.named_args(["module_path"]) - |> glint.unnamed_args(glint.EqArgs(0)) - |> glint.flag("minify", { - let description = "Minify the output" - let default = False - - flag.bool() - |> flag.default(default) - |> flag.description(description) - }) -} - -// ERROR HANDLING -------------------------------------------------------------- - -type Error { - BuildError - BundleError(esbuild.Error) - TailwindBundleError(tailwind.Error) - ComponentMissing(module: String) - MainMissing(module: String) - ModuleMissing(module: String) - NameIncorrectType(module: String, got: Type) - NameMissing(module: String) -} - -fn explain(error: Error) -> Nil { - case error { - BuildError -> project.explain(project.BuildError) - - BundleError(error) -> esbuild.explain(error) - - TailwindBundleError(error) -> tailwind.explain(error) - - ComponentMissing(module) -> io.println(" -Module `" <> module <> "` doesn't have any public function I can use to bundle -a component. - -To bundle a component your module should have a public function that returns a -Lustre `App`: - - import lustre.{type App} - pub fn my_component() -> App(flags, model, msg) { - todo as \"your Lustre component to bundle\" - } -") - - MainMissing(module) -> io.println(" -Module `" <> module <> "` doesn't have a public `main` function I can use as -the bundle entry point.") - - ModuleMissing(module) -> io.println(" -I couldn't find a public module called `" <> module <> "` in your project.") - - NameIncorrectType(module, type_) -> io.println(" -I can't use the `name` constant exposed by module `" <> module <> "` -to give a name to the component I'm bundling. -I was expecting `name` to be a `String`, -but it has type `" <> project.type_to_string(type_) <> "`.") - - NameMissing(module) -> io.println(" -Module `" <> module <> "` doesn't have a public `name` constant. -That is required so that I can give a proper name to the component I'm bundling. - -Try adding a `name` constant to your module like this: - - const name: String = \"component-name\"") - } -} - -// STEPS ----------------------------------------------------------------------- - -fn get_project_name() -> Result(String, Error) { - use config <- try(project.config(), replace(with: BuildError)) - Ok(config.name) -} - -fn get_module_interface(module_path: String) -> Result(Module, Error) { - use interface <- try(project.interface(), replace(with: BuildError)) - use module <- try( - dict.get(interface.modules, module_path), - replace(with: ModuleMissing(module_path)), - ) - - Ok(module) -} - -fn check_main_function( - module_path: String, - module: Module, -) -> Result(Nil, Error) { - case dict.has_key(module.functions, "main") { - True -> Ok(Nil) - False -> Error(MainMissing(module_path)) - } -} - -fn check_component_name( - module_path: String, - module: Module, -) -> Result(Nil, Error) { - use component_name <- try( - dict.get(module.constants, "name"), - replace(with: NameMissing(module_path)), - ) - - case is_string_type(component_name) { - True -> Ok(Nil) - False -> Error(NameIncorrectType(module_path, component_name)) - } -} - -fn find_component(module_path: String, module: Module) -> Result(String, Error) { - let functions = dict.to_list(module.functions) - let error = Error(ComponentMissing(module_path)) - - use _, #(name, t) <- list.fold_until(functions, error) - case t.parameters, is_compatible_app_type(t.return) { - [], True -> list.Stop(Ok(name)) - _, _ -> list.Continue(error) - } -} - -fn bundle( - entry: String, - tempdir: String, - outfile: String, - minify: Bool, -) -> Step(Nil, Error) { - let entryfile = filepath.join(tempdir, "entry.mjs") - let assert Ok(_) = simplifile.write(entryfile, entry) - use _ <- step.run(esbuild.bundle(entryfile, outfile, minify), BundleError) - step.return(Nil) -} - -fn bundle_tailwind( - entry: String, - tempdir: String, - outfile: String, - minify: Bool, -) -> Step(Nil, tailwind.Error) { - // We first check if there's a `tailwind.config.js` at the project's root. - // If not present we do nothing; otherwise we go on with bundling. - let root = project.root() - let tailwind_config_file = filepath.join(root, "tailwind.config.js") - let has_tailwind_config = - simplifile.verify_is_file(tailwind_config_file) - |> result.unwrap(False) - use <- bool.guard(when: !has_tailwind_config, return: step.return(Nil)) - - use _ <- step.run(tailwind.setup(get_os(), get_cpu()), keep) - - use <- step.new("Bundling with Tailwind") - let entryfile = filepath.join(tempdir, "entry.css") - let assert Ok(_) = simplifile.write(entryfile, entry) - - let flags = ["--input=" <> entryfile, "--output=" <> outfile] - let options = case minify { - True -> ["--minify", ..flags] - False -> flags - } - let bundle = exec("./build/.lustre/bin/tailwind", in: root, with: options) - use _ <- step.try(bundle, on_error: fn(pair) { tailwind.BundleError(pair.1) }) - use <- step.done("✅ Bundle produced at `" <> outfile <> "`") - step.return(Nil) -} - -// UTILS ----------------------------------------------------------------------- - -fn is_string_type(t: Type) -> Bool { - case t { - Named(name: "String", package: "", module: "gleam", parameters: []) -> True - _ -> False - } -} - -fn is_nil_type(t: Type) -> Bool { - case t { - Named(name: "Nil", package: "", module: "gleam", parameters: []) -> True - _ -> False - } -} - -fn is_type_variable(t: Type) -> Bool { - case t { - Variable(..) -> True - _ -> False - } -} - -fn is_compatible_app_type(t: Type) -> Bool { - case t { - Named( - name: "App", - package: "lustre", - module: "lustre", - parameters: [flags, ..], - ) -> is_nil_type(flags) || is_type_variable(flags) - _ -> False - } -} - -// EXTERNALS ------------------------------------------------------------------- - -@external(erlang, "cli_ffi", "get_os") -fn get_os() -> String - -@external(erlang, "cli_ffi", "get_cpu") -fn get_cpu() -> String diff --git a/src/lustre/cli/dev.gleam b/src/lustre/cli/dev.gleam deleted file mode 100644 index f7e12c2..0000000 --- a/src/lustre/cli/dev.gleam +++ /dev/null @@ -1,245 +0,0 @@ -// IMPORTS --------------------------------------------------------------------- - -import filepath -import gleam/dict -import gleam/io -import gleam/package_interface.{type Type, Fn, Named, Variable} -import gleam/result -import gleam/string -import glint.{type Command, CommandInput} -import glint/flag -import lustre/cli/esbuild -import lustre/cli/project.{type Module} -import lustre/cli/step -import lustre/cli/utils.{guard, keep, map, replace, template, try} -import simplifile - -// COMMANDS -------------------------------------------------------------------- - -pub fn run() -> Command(Nil) { - let description = - " - " - - glint.command(fn(input) { - let CommandInput(flags: flags, ..) = input - let assert Ok(host) = flag.get_string(flags, "host") - let assert Ok(port) = flag.get_string(flags, "port") - let assert Ok(use_lustre_ui) = flag.get_bool(flags, "use-lustre-ui") - let assert Ok(spa) = flag.get_bool(flags, "spa") - let custom_html = flag.get_string(flags, "html") - - let script = { - use <- step.new("Building your project") - use interface <- step.try(project.interface(), replace(BuildError)) - use module <- step.try( - dict.get(interface.modules, interface.name), - replace(ModuleMissing(interface.name)), - ) - use is_app <- step.try(check_is_lustre_app(interface.name, module), keep) - use <- step.done("✅ Project compiled successfully") - - use <- step.new("Creating the application entry point") - let root = project.root() - let tempdir = filepath.join(root, "build/.lustre") - let _ = simplifile.create_directory_all(tempdir) - - let entry = - template(case is_app { - True -> "entry-with-start.mjs" - False -> "entry-with-main.mjs" - }) - |> string.replace("{app_name}", interface.name) - - use html <- step.try( - case custom_html { - Ok(custom_html_path) -> - custom_html_path - |> simplifile.read - |> result.map_error(CouldntOpenCustomHtml(_, custom_html_path)) - |> result.map(string.replace( - _, - "<script type=\"application/lustre\">", - "<script type=\"module\" src=\"./index.mjs\">", - )) - - Error(_) if use_lustre_ui -> - template("index-with-lustre-ui.html") - |> string.replace("{app_name}", interface.name) - |> Ok - - _ -> - template("index.html") - |> string.replace("{app_name}", interface.name) - |> Ok - }, - keep, - ) - - let assert Ok(_) = simplifile.write(tempdir <> "/entry.mjs", entry) - let assert Ok(_) = simplifile.write(tempdir <> "/index.html", html) - - use _ <- step.run( - esbuild.bundle( - filepath.join(tempdir, "entry.mjs"), - filepath.join(tempdir, "index.mjs"), - False, - ), - map(BundleError), - ) - use _ <- step.run(esbuild.serve(host, port, spa), map(BundleError)) - step.return(Nil) - } - - case step.execute(script) { - Ok(_) -> Nil - Error(error) -> explain(error) - } - }) - |> glint.description(description) - |> glint.unnamed_args(glint.EqArgs(0)) - |> glint.flag("host", { - let description = "" - let default = "localhost" - - flag.string() - |> flag.default(default) - |> flag.description(description) - }) - |> glint.flag("port", { - let description = "" - let default = "1234" - - flag.string() - |> flag.default(default) - |> flag.description(description) - }) - |> glint.flag("use-lustre-ui", { - let description = "Inject lustre/ui's stylesheet. Ignored if --html is set." - let default = False - - flag.bool() - |> flag.default(default) - |> flag.description(description) - }) - |> glint.flag("spa", { - let description = - "Serve your app on any route. Useful for apps that do client-side routing." - let default = False - - flag.bool() - |> flag.default(default) - |> flag.description(description) - }) - |> glint.flag("html", { - let description = - "Supply a custom HTML file to use as the entry point. -To inject the Lustre bundle, make sure it includes the following empty script: -<script type=\"application/lustre\"></script> - " - |> string.trim_right - - flag.string() - |> flag.description(description) - }) -} - -// ERROR HANDLING -------------------------------------------------------------- - -type Error { - BuildError - BundleError(esbuild.Error) - CouldntOpenCustomHtml(error: simplifile.FileError, path: String) - MainMissing(module: String) - MainIncorrectType(module: String, got: Type) - MainBadAppType(module: String, got: Type) - ModuleMissing(module: String) -} - -fn explain(error: Error) -> Nil { - case error { - BuildError -> project.explain(project.BuildError) - - BundleError(error) -> esbuild.explain(error) - - CouldntOpenCustomHtml(_, path) -> io.println(" -I couldn't open the custom HTML file at `" <> path <> "`.") - - MainMissing(module) -> io.println(" -Module `" <> module <> "` doesn't have a public `main` function I can preview.") - - MainIncorrectType(module, type_) -> io.println(" -I cannot preview the `main` function exposed by module `" <> module <> "`. -To start a preview server I need it to take no arguments and return a Lustre -`App`. -The one I found has type `" <> project.type_to_string(type_) <> "`.") - - // TODO: maybe this could have useful links to `App`/flags... - MainBadAppType(module, type_) -> io.println(" -I cannot preview the `main` function exposed by module `" <> module <> "`. -To start a preview server I need it to return a Lustre `App` that doesn't need -any flags. -The one I found has type `" <> project.type_to_string(type_) <> "`. - -Its return type should look something like this: - - import lustre.{type App} - pub fn main() -> App(flags, model, msg) { - todo as \"your Lustre application to preview\" - }") - - ModuleMissing(module) -> io.println(" -I couldn't find a public module called `" <> module <> "` in your project.") - } -} - -// STEPS ----------------------------------------------------------------------- - -fn check_is_lustre_app( - module_path: String, - module: Module, -) -> Result(Bool, Error) { - use main <- try( - dict.get(module.functions, "main"), - replace(MainMissing(module_path)), - ) - use <- guard( - main.parameters != [], - MainIncorrectType(module_path, Fn(main.parameters, main.return)), - ) - - case main.return { - Named( - name: "App", - package: "lustre", - module: "lustre", - parameters: [flags, ..], - ) -> - case is_compatible_flags_type(flags) { - True -> Ok(True) - False -> Error(MainBadAppType(module_path, main.return)) - } - - _ -> Ok(False) - } -} - -// UTILS ----------------------------------------------------------------------- - -fn is_nil_type(t: Type) -> Bool { - case t { - Named(name: "Nil", package: "", module: "gleam", parameters: []) -> True - _ -> False - } -} - -fn is_type_variable(t: Type) -> Bool { - case t { - Variable(..) -> True - _ -> False - } -} - -fn is_compatible_flags_type(t: Type) -> Bool { - is_nil_type(t) || is_type_variable(t) -} diff --git a/src/lustre/cli/esbuild.gleam b/src/lustre/cli/esbuild.gleam deleted file mode 100644 index 2161a2f..0000000 --- a/src/lustre/cli/esbuild.gleam +++ /dev/null @@ -1,249 +0,0 @@ -// IMPORTS --------------------------------------------------------------------- - -import filepath -import gleam/bool -import gleam/dynamic.{type Dynamic} -import gleam/io -import gleam/list -import gleam/result -import gleam/set -import gleam/string -import lustre/cli/project -import lustre/cli/step.{type Step} -import lustre/cli/utils.{exec, keep, replace} -import simplifile.{type FilePermissions, Execute, FilePermissions, Read, Write} - -// COMMANDS -------------------------------------------------------------------- - -pub fn download(os: String, cpu: String) -> Step(Nil, Error) { - use <- step.new("Downloading esbuild") - - let outdir = filepath.join(project.root(), "build/.lustre/bin") - let outfile = filepath.join(outdir, "esbuild") - - use <- bool.guard(check_esbuild_exists(outfile), { - use <- step.done("✅ Esbuild already installed!") - step.return(Nil) - }) - - use <- step.new("Detecting platform") - use url <- step.try(get_download_url(os, cpu), keep) - - use <- step.new("Downloading from " <> url) - use tarball <- step.try(get_esbuild(url), NetworkError) - - use <- step.new("Unzipping esbuild") - use bin <- step.try(unzip_esbuild(tarball), UnzipError) - let write_esbuild = - write_esbuild(bin, outdir, outfile) - |> result.map_error(SimplifileError(_, outfile)) - use _ <- step.try(write_esbuild, keep) - use _ <- step.try(set_filepermissions(outfile), SimplifileError(_, outfile)) - - use <- step.done("✅ Esbuild installed!") - step.return(Nil) -} - -pub fn bundle( - input_file: String, - output_file: String, - minify: Bool, -) -> Step(Nil, Error) { - use _ <- step.run(download(get_os(), get_cpu()), keep) - use _ <- step.try(project.build(), replace(BuildError)) - use <- step.new("Getting everything ready for tree shaking") - - let root = project.root() - use _ <- step.try(configure_node_tree_shaking(root), SimplifileError(_, root)) - - let flags = [ - "--bundle", - "--external:node:*", - "--format=esm", - "--outfile=" <> output_file, - ] - let options = case minify { - True -> [input_file, "--minify", ..flags] - False -> [input_file, ..flags] - } - - use <- step.new("Bundling with esbuild") - use _ <- step.try( - exec(run: "./build/.lustre/bin/esbuild", in: root, with: options), - on_error: fn(pair) { BundleError(pair.1) }, - ) - - use <- step.done("✅ Bundle produced at `" <> output_file <> "`") - step.return(Nil) -} - -pub fn serve(host: String, port: String, spa: Bool) -> Step(Nil, Error) { - use _ <- step.run(download(get_os(), get_cpu()), keep) - let root = project.root() - let flags = [ - "--serve=" <> host <> ":" <> port, - "--servedir=" <> filepath.join(root, "build/.lustre"), - ] - - let options = case spa { - True -> [ - "--serve-fallback=" <> filepath.join(root, "build/.lustre/index.html"), - ..flags - ] - False -> flags - } - - use <- step.done("\nStarting dev server at " <> host <> ":" <> port <> "...") - use _ <- step.try( - exec(run: "./build/.lustre/bin/esbuild", in: root, with: options), - on_error: fn(pair) { BundleError(pair.1) }, - ) - - step.return(Nil) -} - -// STEPS ----------------------------------------------------------------------- - -fn check_esbuild_exists(path) { - case simplifile.verify_is_file(path) { - Ok(True) -> True - Ok(False) | Error(_) -> False - } -} - -fn get_download_url(os, cpu) { - let base = "https://registry.npmjs.org/@esbuild/" - let path = case os, cpu { - "android", "arm" -> Ok("android-arm/-/android-arm-0.19.10.tgz") - "android", "arm64" -> Ok("android-arm64/-/android-arm64-0.19.10.tgz") - "android", "x64" -> Ok("android-x64/-/android-x64-0.19.10.tgz") - - "darwin", "aarch64" -> Ok("darwin-arm64/-/darwin-arm64-0.19.10.tgz") - "darwin", "amd64" -> Ok("darwin-arm64/-/darwin-arm64-0.19.10.tgz") - "darwin", "arm64" -> Ok("darwin-arm64/-/darwin-arm64-0.19.10.tgz") - "darwin", "x86_64" -> Ok("darwin-x64/-/darwin-x64-0.19.10.tgz") - - "freebsd", "arm64" -> Ok("freebsd-arm64/-/freebsd-arm64-0.19.10.tgz") - "freebsd", "x64" -> Ok("freebsd-x64/-/freebsd-x64-0.19.10.tgz") - - "linux", "aarch64" -> Ok("linux-arm64/-/linux-arm64-0.19.10.tgz") - "linux", "arm" -> Ok("linux-arm/-/linux-arm-0.19.10.tgz") - "linux", "arm64" -> Ok("linux-arm64/-/linux-arm64-0.19.10.tgz") - "linux", "ia32" -> Ok("linux-ia32/-/linux-ia32-0.19.10.tgz") - "linux", "x64" -> Ok("linux-x64/-/linux-x64-0.19.10.tgz") - "linux", "x86_64" -> Ok("linux-x64/-/linux-x64-0.19.10.tgz") - - "win32", "arm64" -> Ok("win32-arm64/-/win32-arm64-0.19.10.tgz") - "win32", "ia32" -> Ok("win32-ia32/-/win32-ia32-0.19.10.tgz") - "win32", "x64" -> Ok("win32-x64/-/win32-x64-0.19.10.tgz") - "win32", "x86_64" -> Ok("win32-x64/-/win32-x64-0.19.10.tgz") - - "netbsd", "x64" -> Ok("netbsd-x64/-/netbsd-x64-0.19.10.tgz") - "openbsd", "x64" -> Ok("openbsd-x64/-/openbsd-x64-0.19.10.tgz") - "sunos", "x64" -> Ok("sunos-x64/-/sunos-x64-0.19.10.tgz") - - _, _ -> Error(UnknownPlatform(os, cpu)) - } - - result.map(path, string.append(base, _)) -} - -fn write_esbuild(bin, outdir, outfile) { - let _ = simplifile.create_directory_all(outdir) - - simplifile.write_bits(outfile, bin) -} - -fn set_filepermissions(file) { - let permissions = - FilePermissions( - user: set.from_list([Read, Write, Execute]), - group: set.from_list([Read, Execute]), - other: set.from_list([Read, Execute]), - ) - - simplifile.set_permissions(file, permissions) -} - -fn configure_node_tree_shaking(root) { - // This whole chunk of code is to force tree shaking on dependencies that esbuild - // has a habit of including because it thinks their imports might have side - // effects. - // - // This is a really grim hack but it's the only way I've found to get esbuild to - // ignore unused deps like `glint` that imports node stuff but aren't used in - // app code. - let force_tree_shaking = "{ \"sideEffects\": false }" - let assert Ok(_) = - simplifile.write( - filepath.join(root, "build/dev/javascript/package.json"), - force_tree_shaking, - ) - let pure_deps = ["lustre", "glint", "simplifile"] - - list.try_each(pure_deps, fn(dep) { - root - |> filepath.join("build/dev/javascript/" <> dep) - |> filepath.join("package.json") - |> simplifile.write(force_tree_shaking) - }) -} - -// ERROR HANDLING -------------------------------------------------------------- - -pub type Error { - BuildError - BundleError(message: String) - NetworkError(Dynamic) - SimplifileError(reason: simplifile.FileError, path: String) - UnknownPlatform(os: String, cpu: String) - UnzipError(Dynamic) -} - -pub fn explain(error: Error) -> Nil { - case error { - BuildError -> project.explain(project.BuildError) - - BundleError(message) -> io.println(" -I ran into an error while trying to create a bundle with esbuild: -" <> message) - - // TODO: Is there a better way to deal with this dynamic error? - NetworkError(_dynamic) -> - io.println( - " -There was a network error!", - ) - - // TODO: this could give a better error for some common reason like Enoent. - SimplifileError(reason, path) -> io.println(" -I ran into the following error at path `" <> path <> "`:" <> string.inspect( - reason, - ) <> ".") - - UnknownPlatform(os, cpu) -> io.println(" -I couldn't figure out the correct esbuild version for your -os (" <> os <> ") and cpu (" <> cpu <> ").") - - // TODO: Is there a better way to deal with this dynamic error? - UnzipError(_dynamic) -> - io.println( - " -I couldn't unzip the esbuild executable!", - ) - } -} - -// EXTERNALS ------------------------------------------------------------------- - -@external(erlang, "cli_ffi", "get_os") -fn get_os() -> String - -@external(erlang, "cli_ffi", "get_cpu") -fn get_cpu() -> String - -@external(erlang, "cli_ffi", "get_esbuild") -fn get_esbuild(url: String) -> Result(BitArray, Dynamic) - -@external(erlang, "cli_ffi", "unzip_esbuild") -fn unzip_esbuild(tarball: BitArray) -> Result(BitArray, Dynamic) diff --git a/src/lustre/cli/project.gleam b/src/lustre/cli/project.gleam deleted file mode 100644 index f52a847..0000000 --- a/src/lustre/cli/project.gleam +++ /dev/null @@ -1,186 +0,0 @@ -// IMPORTS --------------------------------------------------------------------- - -import filepath -import gleam/dict.{type Dict} -import gleam/dynamic.{type DecodeError, type Decoder, type Dynamic, DecodeError} -import gleam/int -import gleam/io -import gleam/json -import gleam/list -import gleam/package_interface.{type Type, Fn, Named, Tuple, Variable} -import gleam/pair -import gleam/result -import gleam/string -import lustre/cli/utils.{exec, map, try} -import simplifile -import tom.{type Toml} - -// TYPES ----------------------------------------------------------------------- - -pub type Config { - Config(name: String, version: String, toml: Dict(String, Toml)) -} - -pub type Interface { - Interface(name: String, version: String, modules: Dict(String, Module)) -} - -pub type Module { - Module(constants: Dict(String, Type), functions: Dict(String, Function)) -} - -pub type Function { - Function(parameters: List(Type), return: Type) -} - -// COMMANDS -------------------------------------------------------------------- - -/// Compile the current project running the `gleam build` command. -/// -pub fn build() -> Result(Nil, String) { - use _ <- try( - exec(run: "gleam", in: ".", with: ["build", "--target=js"]), - on_error: map(with: pair.second), - ) - - Ok(Nil) -} - -pub fn interface() -> Result(Interface, String) { - let dir = filepath.join(root(), "build/.lustre") - let out = filepath.join(dir, "package-interface.json") - - use _ <- try( - exec(run: "gleam", in: ".", with: [ - "export", - "package-interface", - "--out", - out, - ]), - on_error: map(with: pair.second), - ) - - let assert Ok(json) = simplifile.read(out) - let assert Ok(interface) = json.decode(json, interface_decoder) - Ok(interface) -} - -/// Read the project configuration in the `gleam.toml` file. -/// -pub fn config() -> Result(Config, String) { - use _ <- result.try(build()) - - // Since we made sure that the project could compile we're sure that there is - // bound to be a `gleam.toml` file somewhere in the current directory (or in - // its parent directories). So we can safely call `root()` without - // it looping indefinitely. - let configuration_path = filepath.join(root(), "gleam.toml") - - // All these operations are safe to assert because the Gleam project wouldn't - // compile if any of this stuff was invalid. - let assert Ok(configuration) = simplifile.read(configuration_path) - let assert Ok(toml) = tom.parse(configuration) - let assert Ok(name) = tom.get_string(toml, ["name"]) - let assert Ok(version) = tom.get_string(toml, ["version"]) - - Ok(Config(name: name, version: version, toml: toml)) -} - -// ERROR HANDLING -------------------------------------------------------------- - -/// -/// -pub type Error { - BuildError -} - -pub fn explain(error: Error) -> Nil { - case error { - BuildError -> - " -It looks like your project has some compilation errors that need to be addressed -before I can do anything." - |> io.println - } -} - -// UTILS ----------------------------------------------------------------------- - -/// Finds the path leading to the project's root folder. This recursively walks -/// up from the current directory until it finds a `gleam.toml`. -/// -pub fn root() -> String { - find_root(".") -} - -fn find_root(path: String) -> String { - let toml = filepath.join(path, "gleam.toml") - - case simplifile.verify_is_file(toml) { - Ok(False) | Error(_) -> find_root(filepath.join("..", path)) - Ok(True) -> path - } -} - -pub fn type_to_string(type_: Type) -> String { - case type_ { - Tuple(elements) -> { - let elements = list.map(elements, type_to_string) - "#(" <> string.join(elements, with: ", ") <> ")" - } - - Fn(params, return) -> { - let params = list.map(params, type_to_string) - let return = type_to_string(return) - "fn(" <> string.join(params, with: ", ") <> ") -> " <> return - } - - Named(name, _package, _module, []) -> name - Named(name, _package, _module, params) -> { - let params = list.map(params, type_to_string) - name <> "(" <> string.join(params, with: ", ") <> ")" - } - - Variable(id) -> "a_" <> int.to_string(id) - } -} - -// DECODERS -------------------------------------------------------------------- - -fn interface_decoder(dyn: Dynamic) -> Result(Interface, List(DecodeError)) { - dynamic.decode3( - Interface, - dynamic.field("name", dynamic.string), - dynamic.field("version", dynamic.string), - dynamic.field("modules", string_dict(module_decoder)), - )(dyn) -} - -fn module_decoder(dyn: Dynamic) -> Result(Module, List(DecodeError)) { - dynamic.decode2( - Module, - dynamic.field( - "constants", - string_dict(dynamic.field("type", package_interface.type_decoder)), - ), - dynamic.field("functions", string_dict(function_decoder)), - )(dyn) -} - -fn function_decoder(dyn: Dynamic) -> Result(Function, List(DecodeError)) { - dynamic.decode2( - Function, - dynamic.field("parameters", dynamic.list(labelled_argument_decoder)), - dynamic.field("return", package_interface.type_decoder), - )(dyn) -} - -fn labelled_argument_decoder(dyn: Dynamic) -> Result(Type, List(DecodeError)) { - // In this case we don't really care about the label, so we're just ignoring - // it and returning the argument's type. - dynamic.field("type", package_interface.type_decoder)(dyn) -} - -fn string_dict(values: Decoder(a)) -> Decoder(Dict(String, a)) { - dynamic.dict(dynamic.string, values) -} diff --git a/src/lustre/cli/step.gleam b/src/lustre/cli/step.gleam deleted file mode 100644 index d3f8c8a..0000000 --- a/src/lustre/cli/step.gleam +++ /dev/null @@ -1,100 +0,0 @@ -import gleam/io -import gleam_community/ansi -import spinner.{type Spinner} - -type SpinnerStatus { - Running(message: String) - Stopped -} - -type Env { - Env(spinner: Spinner, spinner_status: SpinnerStatus) -} - -pub opaque type Step(a, e) { - Step(run: fn(Env) -> #(Env, Result(a, e))) -} - -/// Replace the current spinner label with a new one. -/// -pub fn new(message: String, then continue: fn() -> Step(a, e)) -> Step(a, e) { - use Env(spinner, spinner_status) <- Step - case spinner_status { - Running(_) -> { - spinner.set_text(spinner, message) - continue().run(Env(spinner, Running(message))) - } - Stopped -> { - let new_spinner = - spinner.new(message) - |> spinner.with_frames(spinner.snake_frames) - |> spinner.start - continue().run(Env(new_spinner, Running(message))) - } - } -} - -/// Stops the current spinner and prints out the given message. -/// -pub fn done(message: String, then continue: fn() -> Step(b, e)) -> Step(b, e) { - use Env(spinner, spinner_status) <- Step - case spinner_status { - Running(_) -> spinner.stop(spinner) - Stopped -> Nil - } - io.println(ansi.green(message)) - continue().run(Env(spinner, Stopped)) -} - -/// Runs another step as part of this one. The step will use the same spinner -/// as the previous one overriding its content. -/// -pub fn run( - step: Step(a, e), - on_error map_error: fn(e) -> e1, - then continue: fn(a) -> Step(b, e1), -) -> Step(b, e1) { - use env <- Step - case step.run(env) { - #(new_env, Ok(res)) -> continue(res).run(new_env) - #(new_env, Error(e)) -> #(new_env, Error(map_error(e))) - } -} - -/// If the result is `Ok` will continue by passing its wrapped value to the -/// `continue` function; otherwise will result in an error stopping the stepwise -/// execution. -/// -pub fn try( - result: Result(a, e), - on_error map_error: fn(e) -> e1, - then continue: fn(a) -> Step(b, e1), -) -> Step(b, e1) { - Step(fn(env) { #(env, result) }) - |> run(map_error, continue) -} - -/// Returns a value without changing the state of any spinner. -/// Any running spinner will still be running. -/// -pub fn return(value: a) -> Step(a, e) { - use env <- Step - #(env, Ok(value)) -} - -pub fn execute(step: Step(a, e)) -> Result(a, e) { - let initial_spinner = - spinner.new("") - |> spinner.with_frames(spinner.snake_frames) - |> spinner.start - - let #(Env(spinner, status), res) = step.run(Env(initial_spinner, Running(""))) - case status { - Running(message) -> { - spinner.stop(spinner) - io.println("❌ " <> ansi.red(message)) - } - Stopped -> Nil - } - res -} diff --git a/src/lustre/cli/tailwind.gleam b/src/lustre/cli/tailwind.gleam deleted file mode 100644 index 5f4b4ec..0000000 --- a/src/lustre/cli/tailwind.gleam +++ /dev/null @@ -1,185 +0,0 @@ -// IMPORTS --------------------------------------------------------------------- - -import filepath -import gleam/bool -import gleam/dynamic.{type Dynamic} -import gleam/io -import gleam/result -import gleam/set -import gleam/string -import lustre/cli/project -import lustre/cli/step.{type Step} -import lustre/cli/utils.{keep} -import simplifile.{type FilePermissions, Execute, FilePermissions, Read, Write} - -const tailwind_version = "v3.4.1" - -// COMMANDS -------------------------------------------------------------------- - -pub fn setup(os: String, cpu: String) -> Step(Nil, Error) { - use _ <- step.run(download(os, cpu, tailwind_version), on_error: keep) - use _ <- step.run(write_tailwind_config(), on_error: keep) - step.return(Nil) -} - -fn download(os: String, cpu: String, version: String) -> Step(Nil, Error) { - use <- step.new("Downloading Tailwind") - - let root = project.root() - let outdir = filepath.join(root, "build/.lustre/bin") - let outfile = filepath.join(outdir, "tailwind") - - //todo as "do something with the version and see if its different from the one we already have" - - use <- bool.guard(check_tailwind_exists(outfile), { - use <- step.done("✅ Tailwind already installed!") - step.return(Nil) - }) - - use <- step.new("Detecting platform") - use url <- step.try(get_download_url(os, cpu, version), keep) - - use <- step.new("Downloading from " <> url) - use bin <- step.try(get_tailwind(url), NetworkError) - - let write_tailwind = - write_tailwind(bin, outdir, outfile) - |> result.map_error(CannotWriteTailwind(_, outfile)) - use _ <- step.try(write_tailwind, keep) - use _ <- step.try(set_filepermissions(outfile), fn(reason) { - CannotSetPermissions(reason, outfile) - }) - - use <- step.done("✅ Tailwind installed!") - - step.return(Nil) -} - -fn write_tailwind_config() -> Step(Nil, Error) { - let config_filename = "tailwind.config.js" - let config_outfile = filepath.join(project.root(), config_filename) - let config_already_exists = - simplifile.verify_is_file(config_outfile) - |> result.unwrap(False) - - // If there already is a configuration file, we make sure not to override it. - use <- bool.guard(when: config_already_exists, return: step.return(Nil)) - use <- step.new("Writing `" <> config_filename <> "`") - let write_config = - simplifile.write( - to: config_outfile, - contents: "/** @type {import('tailwindcss').Config} */ -module.exports = { - content: [\"./src/**/*.{gleam,mjs}\"], - theme: { - extend: {}, - }, - plugins: [], -} -", - ) - - use _ <- step.try(write_config, CannotWriteConfig(_, config_outfile)) - use <- step.done("✅ Written `" <> config_outfile <> "`") - step.return(Nil) -} - -// STEPS ----------------------------------------------------------------------- - -fn check_tailwind_exists(path) { - case simplifile.verify_is_file(path) { - Ok(True) -> True - Ok(False) | Error(_) -> False - } -} - -fn get_download_url(os, cpu, version) { - let base = - "https://github.com/tailwindlabs/tailwindcss/releases/download/" - <> version - <> "/tailwindcss-" - - let path = case os, cpu { - "linux", "armv7" -> Ok("linux-armv7") - "linux", "arm64" -> Ok("linux-arm64") - "linux", "x64" | "linux", "x86_64" -> Ok("linux-x64") - - "win32", "arm64" -> Ok("windows-arm64.exe") - "win32", "x64" | "win32", "x86_64" -> Ok("windows-x64.exe") - - "darwin", "arm64" | "darwin", "aarch64" -> Ok("macos-arm64") - "darwin", "x64" | "darwin", "x86_64" -> Ok("macos-x64") - - _, _ -> Error(UnknownPlatform(os, cpu)) - } - - result.map(path, string.append(base, _)) -} - -fn write_tailwind(bin, outdir, outfile) { - let _ = simplifile.create_directory_all(outdir) - - simplifile.write_bits(outfile, bin) -} - -fn set_filepermissions(file) { - let permissions = - FilePermissions( - user: set.from_list([Read, Write, Execute]), - group: set.from_list([Read, Execute]), - other: set.from_list([Read, Execute]), - ) - - simplifile.set_permissions(file, permissions) -} - -// ERROR HANDLING -------------------------------------------------------------- - -pub type Error { - NetworkError(Dynamic) - CannotWriteTailwind(reason: simplifile.FileError, path: String) - CannotSetPermissions(reason: simplifile.FileError, path: String) - CannotWriteConfig(reason: simplifile.FileError, path: String) - UnknownPlatform(os: String, cpu: String) - BundleError(reason: String) -} - -pub fn explain(error: Error) -> Nil { - case error { - // TODO: Is there a better way to deal with this dynamic error? - NetworkError(_dynamic) -> - io.println( - " -There was a network error!", - ) - - UnknownPlatform(os, cpu) -> io.println(" -I couldn't figure out the correct Tailwind version for your -os (" <> os <> ") and cpu (" <> cpu <> ").") - - CannotSetPermissions(reason, _) -> io.println(" -I ran into an error (" <> string.inspect(reason) <> ") when trying -to set permissions for the Tailwind executable. -") - - CannotWriteConfig(reason, _) -> io.println(" -I ran into an error (" <> string.inspect(reason) <> ") when trying -to write the `tailwind.config.js` file to the project's root. -") - - CannotWriteTailwind(reason, path) -> io.println(" -I ran into an error (" <> string.inspect(reason) <> ") when trying -to write the Tailwind binary to - `" <> path <> "`. -") - - BundleError(reason) -> io.println(" -I ran into an error while trying to create a bundle with Tailwind: -" <> reason) - } -} - -// EXTERNALS ------------------------------------------------------------------- - -@external(erlang, "cli_ffi", "get_esbuild") -fn get_tailwind(url: String) -> Result(BitArray, Dynamic) diff --git a/src/lustre/cli/utils.gleam b/src/lustre/cli/utils.gleam deleted file mode 100644 index a677e70..0000000 --- a/src/lustre/cli/utils.gleam +++ /dev/null @@ -1,68 +0,0 @@ -import simplifile -import gleam/erlang - -pub fn template(name: String) -> String { - let assert Ok(priv) = erlang.priv_directory("lustre") - let assert Ok(file) = simplifile.read(priv <> "/templates/" <> name) - - file -} - -@external(erlang, "cli_ffi", "exec") -pub fn exec( - run command: String, - with args: List(String), - in in: String, -) -> Result(String, #(Int, String)) - -// CHAINING RESULTS ------------------------------------------------------------ - -pub fn try( - result: Result(a, x), - on_error strategy: ErrorStrategy(x, e), - then f: fn(a) -> Result(b, e), -) -> Result(b, e) { - case result { - Ok(value) -> f(value) - Error(x) -> Error(strategy(x)) - } -} - -pub type ErrorStrategy(x, e) = - fn(x) -> e - -pub fn replace(with error: e) -> ErrorStrategy(x, e) { - fn(_) { error } -} - -pub fn map(with f: fn(x) -> e) -> ErrorStrategy(x, e) { - f -} - -pub fn keep(err: e) -> e { - err -} - -// BOOLEAN GUARDS -------------------------------------------------------------- - -pub fn guard( - condition: Bool, - consequence: e, - then: fn() -> Result(a, e), -) -> Result(a, e) { - case condition { - True -> Error(consequence) - False -> then() - } -} - -pub fn when( - condition: Bool, - consequence: a, - then: fn() -> Result(a, e), -) -> Result(a, e) { - case condition { - True -> Ok(consequence) - False -> then() - } -} diff --git a/test/lustre_test.gleam b/test/lustre_test.gleam index bae6c0f..9e47a01 100644 --- a/test/lustre_test.gleam +++ b/test/lustre_test.gleam @@ -35,8 +35,8 @@ pub fn counter_init_test() { process.call( runtime, function.curry2(process.send) - |> function.compose(View) - |> function.compose(Debug), + |> function.compose(View) + |> function.compose(Debug), 100, ) @@ -57,8 +57,8 @@ pub fn counter_update_test() { process.call( runtime, function.curry2(process.send) - |> function.compose(View) - |> function.compose(Debug), + |> function.compose(View) + |> function.compose(Debug), 100, ) @@ -75,8 +75,8 @@ pub fn counter_diff_test() { process.call( runtime, function.curry2(process.send) - |> function.compose(View) - |> function.compose(Debug), + |> function.compose(View) + |> function.compose(Debug), 100, ) @@ -88,8 +88,8 @@ pub fn counter_diff_test() { process.call( runtime, function.curry2(process.send) - |> function.compose(View) - |> function.compose(Debug), + |> function.compose(View) + |> function.compose(Debug), 100, ) |