diff options
author | Hayleigh Thompson <me@hayleigh.dev> | 2024-02-13 21:13:57 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-02-13 21:13:57 +0000 |
commit | 1be73ee8d2206a7f1520257d81c8a5a4f3cc195e (patch) | |
tree | c18785b9eac4612277ab70be164476ebd95a7303 | |
parent | 7960593c4feee21b65171afa220c2b32b43dd788 (diff) | |
download | lustre-1be73ee8d2206a7f1520257d81c8a5a4f3cc195e.tar.gz lustre-1be73ee8d2206a7f1520257d81c8a5a4f3cc195e.zip |
🚧 Begin working on CLI things. (#45)
* :recycle: Rename http ffi to lustre_try_ffi.
* :wrench: Add any files under lustre/cli as internal modules.
* :recyle: Move lustre/try command into cli subdirectory.
* :heavy_plus_sign: Add justin, simplifile, and tom as dependencies.
* :sparkles: Write a 'lustre add' command for downloading esbuild.
* :construction: Begin work on a 'lustre build' command for bundling apps and components.
* :sparkles: Add 'main' function as CLI entrypoint to lustre.
* :bug: Fix `no-styles` flag's name
* :bug: Use consistent path for error reporting in lustre add
* :construction: Project module
* :truck: Move esbuild functions to their own module
* :construction: Use a temporary file to bundle components
* :construction: Build app and update to glint rc
* :heavy_plus_sign: Add filepath dependency
* :bug: Fix wrong paths in esbuild code
---------
Co-authored-by: Giacomo Cavalieri <giacomo.cavalieri@icloud.com>
-rw-r--r-- | gleam.toml | 8 | ||||
-rw-r--r-- | manifest.toml | 19 | ||||
-rw-r--r-- | src/lustre.gleam | 31 | ||||
-rw-r--r-- | src/lustre/cli/add.gleam | 67 | ||||
-rw-r--r-- | src/lustre/cli/build.gleam | 226 | ||||
-rw-r--r-- | src/lustre/cli/esbuild.gleam | 155 | ||||
-rw-r--r-- | src/lustre/cli/project.gleam | 63 | ||||
-rw-r--r-- | src/lustre/cli/try.gleam | 108 | ||||
-rw-r--r-- | src/lustre/try.gleam | 100 | ||||
-rw-r--r-- | src/lustre_add_ffi.erl | 49 | ||||
-rw-r--r-- | src/lustre_build_ffi.erl | 9 | ||||
-rw-r--r-- | src/lustre_try_ffi.erl (renamed from src/http_ffi.erl) | 24 |
12 files changed, 738 insertions, 121 deletions
@@ -14,6 +14,7 @@ internal_modules = [ "lustre/internals", "lustre/internals/*", "lustre/try", + "lustre/cli/*", ] [dependencies] @@ -22,8 +23,13 @@ gleam_json = "~> 0.7" gleam_otp = "~> 0.8" gleam_stdlib = "~> 0.34" gleam_community_ansi = "~> 1.4" -glint = "~> 0.14" +glint = "~> 0.15.0-rc1" argv = "~> 1.0" +simplifile = "~> 1.1" +tom = "~> 0.3" +justin = "~> 1.0" +shellout = "~> 1.5" +filepath = "~> 0.1" [dev-dependencies] birdie = "~> 1.0" diff --git a/manifest.toml b/manifest.toml index ebe0a90..75b71f8 100644 --- a/manifest.toml +++ b/manifest.toml @@ -3,32 +3,39 @@ packages = [ { name = "argv", version = "1.0.1", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "A6E9009E50BBE863EB37D963E4315398D41A3D87D0075480FC244125808F964A" }, - { name = "birdie", version = "1.0.1", build_tools = ["gleam"], requirements = ["rank", "gleeunit", "argv", "justin", "gleam_erlang", "simplifile", "filepath", "gleam_stdlib", "gleam_community_ansi", "glam", "gap"], otp_app = "birdie", source = "hex", outer_checksum = "3B09999F6F8131EF497B920F743C61B6EF594C570A6B478D18984BA7860022DD" }, + { name = "birdie", version = "1.0.2", build_tools = ["gleam"], requirements = ["rank", "simplifile", "gleam_erlang", "gleam_stdlib", "gap", "gleeunit", "glam", "gleam_community_ansi", "justin", "argv", "filepath"], otp_app = "birdie", source = "hex", outer_checksum = "93EA5D784F79FF0ACB58E055BFF8D8733D604CC3510F3CA863719A8EFF63FD61" }, { name = "filepath", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "534E8161A0DE192A9A105EFEC34369E9FD5834BB58ED449B5ACAEE8704358588" }, { name = "gap", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_community_ansi"], otp_app = "gap", source = "hex", outer_checksum = "2EE1B0A17E85CF73A0C1D29DA315A2699117A8F549C8E8D89FA8261BE41EDEB1" }, { name = "glam", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glam", source = "hex", outer_checksum = "02E0311862B9669C3E8CE73FA5A95D8FA457C6ACB48D95FBE808ABFAE0A1CEB0" }, { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_community_colour"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, { name = "gleam_community_colour", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "A49A5E3AE8B637A5ACBA80ECB9B1AFE89FD3D5351FF6410A42B84F666D40D7D5" }, { 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 = "0.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB405BD93A8828BCD870463DE29375E7B2D252D9D124C109E5B618AAC00B86FC" }, - { name = "gleam_otp", version = "0.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_erlang"], otp_app = "gleam_otp", source = "hex", outer_checksum = "5FADBBEC5ECF3F8B6BE91101D432758503192AE2ADBAD5602158977341489F71" }, + { name = "gleam_json", version = "0.7.0", build_tools = ["gleam"], requirements = ["thoas", "gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB405BD93A8828BCD870463DE29375E7B2D252D9D124C109E5B618AAC00B86FC" }, + { 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_stdlib", version = "0.34.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1FB8454D2991E9B4C0C804544D8A9AD0F6184725E20D63C3155F0AEB4230B016" }, { name = "gleeunit", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "D364C87AFEB26BDB4FB8A5ABDE67D635DC9FA52D6AB68416044C35B096C6882D" }, - { name = "glint", version = "0.14.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "21AB16D5A50D4EF34DF935915FDBEE06B2DAEDEE3FCC8584C6E635A866566B38" }, + { name = "glint", version = "0.15.0-rc1", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib", "snag", "gleam_community_ansi"], otp_app = "glint", source = "hex", outer_checksum = "3EE3C064D8A8B8E1D6202BEE3B417F68CFE046FBBCD16936A159F993480AF642" }, { 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 = "shellout", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "7B5DE499DBB3DDC25051FC1BB3770DD5466938B6A2AFA91A6FB4A4D49F4CB0D4" }, { name = "simplifile", version = "1.3.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "059AEB3632D1EBF4C943E8C231DBD8861A8BBF2984B78C1FE49159F28338A1FF" }, { name = "snag", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "54D32E16E33655346AA3E66CBA7E191DE0A8793D2C05284E3EFB90AD2CE92BCC" }, { 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.23" } gleam_json = { version = "~> 0.7" } gleam_otp = { version = "~> 0.8" } gleam_stdlib = { version = "~> 0.34" } -gleeunit = { version = "~> 1.0"} -glint = { version = "~> 0.14" } +gleeunit = { version = "~> 1.0" } +glint = { version = "~> 0.15.0-rc1" } +justin = { version = "~> 1.0" } +shellout = { version = "~> 1.5" } +simplifile = { version = "~> 1.1" } +tom = { version = "~> 0.3" } diff --git a/src/lustre.gleam b/src/lustre.gleam index 3151a1a..7aeb6b4 100644 --- a/src/lustre.gleam +++ b/src/lustre.gleam @@ -158,16 +158,45 @@ // IMPORTS --------------------------------------------------------------------- +import argv import gleam/bool import gleam/dict.{type Dict} -import gleam/dynamic.{type Decoder} +import gleam/dynamic.{type Decoder, type Dynamic} import gleam/erlang/process.{type Subject} import gleam/otp/actor.{type StartError} import gleam/result +import glint +import lustre/cli/add +import lustre/cli/build +import lustre/cli/try import lustre/effect.{type Effect} import lustre/element.{type Element, type 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`. For a proper help message run: +/// +/// ```sh +/// gleam run -m lustre -- --help +/// ``` +/// +/// 🚨 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.add(at: ["add", "esbuild"], do: add.esbuild()) + |> glint.add(at: ["build", "app"], do: build.app()) + |> glint.add(at: ["build", "component"], do: build.component()) + |> glint.add(at: ["try"], do: try.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 new file mode 100644 index 0000000..f1a426a --- /dev/null +++ b/src/lustre/cli/add.gleam @@ -0,0 +1,67 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam/io +import gleam/option +import gleam/result +import gleam/string +import glint.{type Command, CommandInput} +import glint/flag +import lustre/cli/esbuild + +// MAIN ------------------------------------------------------------------------ + +pub fn esbuild() -> Command(Nil) { + glint.command(fn(input) { + let CommandInput(args: _, flags: flags, named_args: _) = input + let os = option.from_result(flag.get_string(flags, os_flag_name)) + let cpu = option.from_result(flag.get_string(flags, cpu_flag_name)) + + esbuild.download(os, cpu) + |> result.map_error(explain) + |> result.replace(Nil) + |> result.unwrap_both + }) + |> glint.flag(os_flag_name, os_flag()) + |> glint.flag(cpu_flag_name, cpu_flag()) +} + +// GLINT FLAGS ----------------------------------------------------------------- + +const os_flag_name = "os" + +fn os_flag() { + flag.string() + |> flag.description("The host to run the server on") +} + +const cpu_flag_name = "cpu" + +fn cpu_flag() { + flag.string() + |> flag.description("The port to run the server on") +} + +// UTILS ----------------------------------------------------------------------- + +fn explain(error: esbuild.Error) -> Nil { + case error { + esbuild.NetworkError(_) -> + "🚨 A network error occured. Check your connection and try again " + |> string.pad_right(78, ".") + |> string.append(" ❌") + esbuild.SimplifileError(_error, path) -> + "🚨 An unknown error occured while writing the executable to `{path}` " + |> string.replace(each: "{path}", with: path) + |> string.pad_right(78, ".") + |> string.append(" ❌") + esbuild.UnknownPlatform(os, cpu) -> + { "🚨 Could not locate download url for " <> os <> "/" <> cpu <> " " } + |> string.pad_right(78, ".") + |> string.append(" ❌") + esbuild.UnzipError(_error) -> + "🚨 An unknown error occured while extracting the archive " + |> string.pad_right(78, ".") + |> string.append(" ❌") + } + |> io.println +} diff --git a/src/lustre/cli/build.gleam b/src/lustre/cli/build.gleam new file mode 100644 index 0000000..e5a45c4 --- /dev/null +++ b/src/lustre/cli/build.gleam @@ -0,0 +1,226 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam/dict.{type Dict} +import gleam/io +import gleam/option.{type Option, None} +import gleam/result +import gleam/list +import gleam/string +import glint.{type Command, CommandInput} +import glint/flag.{type Flag} +import filepath +import simplifile +import lustre/cli/project +import lustre/cli/esbuild + +// TYPES ----------------------------------------------------------------------- + +type Error { + MissingComponentPath + MissingModule(module: String) + /// In case a module is provided that doesn't expose a register function. + MissingRegisterFunction(module: String) + /// In case a module exposes a register function, but with an unexpected type. + /// TODO: for now the type is just a string, later it's going to be a type + /// provided by the package interface decoder. + RegisterFunctionWithWrongType(type_: String) + CompileError + CannotDownloadEsbuild(error: esbuild.Error) + CannotBundleComponents(error: esbuild.Error) + CannotPerformCleanup(temp_file: String) + CannotWriteTempFile(reason: simplifile.FileError, temp_file: String) +} + +// MAIN ------------------------------------------------------------------------ + +pub fn component() -> Command(Nil) { + glint.command(fn(input) { + let CommandInput(args: args, flags: flags, named_args: _) = input + let assert Ok(minify) = flag.get_bool(flags, minify_flag_name) + + build_component(args, minify) + |> result.map_error(explain) + |> result.unwrap(Nil) + }) + |> glint.flag(minify_flag_name, minify_flag()) + |> glint.count_args(glint.MinArgs(1)) +} + +pub fn app() -> Command(Nil) { + glint.command(fn(input) { + let CommandInput(args: _, flags: flags, named_args: named_args) = input + let assert Ok(minify) = flag.get_bool(flags, minify_flag_name) + let module = option.from_result(dict.get(named_args, module_named_arg)) + + build_app(module, minify) + |> result.map_error(explain) + |> result.unwrap(Nil) + }) + |> glint.flag(minify_flag_name, minify_flag()) + |> glint.named_args([module_named_arg]) +} + +// GLINT FLAGS ----------------------------------------------------------------- + +const module_named_arg = "module" + +const minify_flag_name = "minify" + +fn minify_flag() -> flag.FlagBuilder(Bool) { + flag.bool() + |> flag.default(False) + |> flag.description(string.join( + [ + "A minified bundle renames variables to shorter names and obfuscates the code.", + "Minified bundles are always emitted with the `.min.mjs` extension.", + ], + " ", + )) +} + +// BUILD COMPONENT ------------------------------------------------------------- + +fn build_component(modules: List(String), minify: Bool) -> Result(Nil, Error) { + // Build the project to make sure it doesn't have any compile errors. + let compile = result.replace_error(project.build(), CompileError) + use compiled <- result.try(compile) + let configuration = project.read_configuration(compiled) + + // Ensure that all modules we're going to bundle actually expose the correct + // `register` function needed to register a component. + use _ <- result.try(list.try_each(modules, check_can_be_bundled)) + + // Figure out the outfile name based on the number of modules to bundle. + let output_file_name = case modules { + [module] -> { + let component_name = string.replace(module, each: "/", with: "_") + configuration.name <> "-" <> component_name + } + _ -> configuration.name <> "-components" + } + + let priv = filepath.join(project.root_folder(), "priv") + + components_script(configuration.name, modules) + |> bundle_script(minify, in: priv, named: output_file_name) +} + +fn check_can_be_bundled(_module_name: String) -> Result(Nil, Error) { + // let package_interface = todo + // let find_module = + // dict.get(package_interface.modules, module_name) + // |> result.replace_error(MissingModule(module_name)) + // use module <- result.try(find_module) + // let find_register_function = + // dict.get(module.functions, "register") + // |> result.replace_error(MissingRegisterFunction(module_name)) + // use register_function <- result.try(find_register_function) + // check_returns_component(register_function) + + // ^^^ We will be able to do all the above once the compiler exposes the + // package interface, for now we just assume everything's ok. + Ok(Nil) +} + +/// Generates the script that will be bundled, exposing the `register` function +/// of each one of the provided modules. +/// +fn components_script(project_name: String, modules: List(String)) -> String { + use script, module <- list.fold(over: modules, from: "") + let module_path = + "../build/dev/javascript/" <> project_name <> "/" <> module <> ".mjs" + + let alias = "register-" <> string.replace(each: "\\", with: "-", in: module) + let export = + "export { register as " <> alias <> " } from \"" <> module_path <> "\"" + let register = alias <> "();" + script <> "\n" <> export <> "\n" <> register +} + +// BUILD APP ------------------------------------------------------------------- + +fn build_app(module: Option(String), minify: Bool) -> Result(Nil, Error) { + // Build the project to make sure it doesn't have any compile errors. + let compile = result.replace_error(project.build(), CompileError) + use compiled <- result.try(compile) + let configuration = project.read_configuration(compiled) + + // If the module is missing we use the module with the same name as the + // project as a fallback. + let module = option.lazy_unwrap(module, fn() { configuration.name }) + use _ <- result.try(check_app_can_be_bundled(module)) + + let priv = filepath.join(project.root_folder(), "priv") + + app_script(configuration.name, module) + |> bundle_script(minify, in: priv, named: module <> "-app") +} + +fn check_app_can_be_bundled(_module: String) -> Result(Nil, Error) { + // Once we have the package interface we'll be able to check if the module + // actually exposes a function with the appropriate type etc. + // For now we just assume the user knows what they're doing. + Ok(Nil) +} + +fn app_script(project_name: String, module: String) -> String { + let module_path = + "../build/dev/javascript/" <> project_name <> "/" <> module <> ".mjs" + let import_main = "import { main } from \"" <> module_path <> "\"" + let invoke_main = "main();" + + import_main <> "\n" <> invoke_main +} + +// UTILS ----------------------------------------------------------------------- + +fn explain(error: Error) -> Nil { + case error { + _ -> { + io.debug(error) + todo as "explain the error" + } + } + |> string.pad_right(78, ".") + |> string.append(" ❌") + |> io.println +} + +fn bundle_script( + script: String, + minify: Bool, + in folder: String, + named output_file: String, +) -> Result(Nil, Error) { + // First, let's make sure there's the esbuild executable that can be used. + use esbuild <- result.try( + esbuild.download(None, None) + |> result.map_error(CannotDownloadEsbuild), + ) + + let output_file = filepath.join(folder, output_file) + + // Write the script to bundle to a temporary file that is going to be bundled + // by escript. + let temp_file = filepath.join(folder, "temp-bundle-input") + use _ <- result.try( + simplifile.write(script, to: temp_file) + |> result.map_error(CannotWriteTempFile(_, temp_file)), + ) + + let bundle_result = + esbuild.bundle(esbuild, temp_file, output_file, minify) + |> result.map_error(CannotBundleComponents) + + // Regardless of the result of the bundling process we delete the temporary + // input file. + case bundle_result { + Ok(Nil) -> + simplifile.delete(temp_file) + |> result.replace_error(CannotPerformCleanup(temp_file)) + Error(error) -> { + let _ = simplifile.delete(temp_file) + Error(error) + } + } +} diff --git a/src/lustre/cli/esbuild.gleam b/src/lustre/cli/esbuild.gleam new file mode 100644 index 0000000..b0e9fd2 --- /dev/null +++ b/src/lustre/cli/esbuild.gleam @@ -0,0 +1,155 @@ +import gleam/dynamic.{type Dynamic} +import gleam/option.{type Option} +import gleam/result +import gleam/set +import gleam/string +import filepath +import shellout +import simplifile.{type FilePermissions, Execute, FilePermissions, Read, Write} +import lustre/cli/project + +// CONSTANTS ------------------------------------------------------------------- + +pub const executable_name = "esbuild" + +// TYPES ----------------------------------------------------------------------- + +pub type Error { + NetworkError(Dynamic) + SimplifileError(reason: simplifile.FileError, path: String) + UnknownPlatform(os: String, cpu: String) + UnzipError(Dynamic) + BundleError(message: String) +} + +pub opaque type Executable { + Executable +} + +// DOWNLOAD ESBUILD ------------------------------------------------------------ + +/// Download the esbuild executable for the given os and cpu. If those options +/// are not provided it tries detecting the system's os and cpu. +/// +/// The executable will be at the project's root in the `priv/bin/esbuild` +/// folder. +/// +/// Returns a proof that esbuild was successfully downloaded that can be used +/// to run all sort of things. +/// +pub fn download( + os: Option(String), + cpu: Option(String), +) -> Result(Executable, Error) { + let os = option.unwrap(os, get_os()) + let cpu = option.unwrap(cpu, get_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", "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)) + } + use url <- result.try(result.map(path, string.append(base, _))) + use tarball <- result.try(get_esbuild(url)) + + let destination_folder = + project.root_folder() + |> filepath.join("priv") + |> filepath.join("bin") + let esbuild_path = filepath.join(destination_folder, executable_name) + + let _ = simplifile.create_directory_all(destination_folder) + use esbuild <- result.try(unzip_esbuild(tarball)) + use _ <- result.try( + esbuild_path + |> simplifile.write_bits(esbuild) + |> result.map_error(SimplifileError(_, esbuild_path)), + ) + + let permissions = + FilePermissions( + user: set.from_list([Read, Write, Execute]), + group: set.from_list([Read, Execute]), + other: set.from_list([Read, Execute]), + ) + use _ <- result.try( + esbuild_path + |> simplifile.set_permissions(permissions) + |> result.map_error(SimplifileError(_, esbuild_path)), + ) + + Ok(Executable) +} + +// BUNDLE ---------------------------------------------------------------------- + +/// Bundles the given contents. The command will run in the project's root. +/// +pub fn bundle( + _esbuild: Executable, + input_file: String, + output_file: String, + minify: Bool, +) -> Result(Nil, Error) { + let flags = [ + "--bundle", + // The format is always "esm", we're not encouraging "cjs". + "--format=esm", + "--outfile=" <> output_file, + ] + + let options = case minify { + True -> [input_file, "--minify", ..flags] + False -> [input_file, ..flags] + } + + shellout.command( + run: filepath.join(".", "priv") + |> filepath.join("bin") + |> filepath.join(executable_name), + in: project.root_folder(), + with: options, + opt: [], + ) + |> result.replace(Nil) + |> result.map_error(fn(error) { BundleError(error.1) }) +} + +// EXTERNALS ------------------------------------------------------------------- + +@external(erlang, "lustre_add_ffi", "get_os") +fn get_os() -> String + +@external(erlang, "lustre_add_ffi", "get_cpu") +fn get_cpu() -> String + +@external(erlang, "lustre_add_ffi", "get_esbuild") +fn get_esbuild(url: String) -> Result(BitArray, Error) + +@external(erlang, "lustre_add_ffi", "unzip_esbuild") +fn unzip_esbuild(tarball: BitArray) -> Result(BitArray, Error) diff --git a/src/lustre/cli/project.gleam b/src/lustre/cli/project.gleam new file mode 100644 index 0000000..0b7287e --- /dev/null +++ b/src/lustre/cli/project.gleam @@ -0,0 +1,63 @@ +import gleam/dict.{type Dict} +import gleam/result +import filepath +import shellout +import simplifile +import tom.{type Toml} + +/// The configuration of a Gleam project +pub type Configuration { + Configuration(name: String, version: String, toml: Dict(String, Toml)) +} + +/// A proof that the project was compiled successfully. +/// +pub opaque type Compiled { + Compiled +} + +/// Compile the current project running the `gleam build` command. +/// +pub fn build() -> Result(Compiled, Nil) { + shellout.command( + run: "gleam", + in: ".", + with: ["build", "--target=js"], + opt: [], + ) + |> result.nil_error() + |> result.replace(Compiled) +} + +/// Read the project configuration in the `gleam.toml` file. +/// To call this function it's necessary to provide a proof that the project +/// was successfully built. To do so you can use the `build` function. +/// +pub fn read_configuration(_compiled: Compiled) -> Configuration { + // Since we have proof 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 `recursive_lookup` without + // it looping indefinitely. + let configuration_path = filepath.join(root_folder(), "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"]) + Configuration(name: name, version: version, toml: toml) +} + +/// Finds the path leading to the project's root folder. +/// +pub fn root_folder() -> String { + do_root_folder(".") +} + +fn do_root_folder(path: String) -> String { + let toml = filepath.join(path, "gleam.toml") + case simplifile.verify_is_file(toml) { + Ok(False) | Error(_) -> do_root_folder(filepath.join("..", path)) + Ok(True) -> path + } +} diff --git a/src/lustre/cli/try.gleam b/src/lustre/cli/try.gleam new file mode 100644 index 0000000..42f3854 --- /dev/null +++ b/src/lustre/cli/try.gleam @@ -0,0 +1,108 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam_community/ansi +import gleam/int +import gleam/io +import gleam/result +import glint.{type Command, CommandInput} +import glint/flag +import lustre/cli/project + +// TYPES ----------------------------------------------------------------------- + +type Options { + /// It's important to remember that for Erlang, Gleam records have their field + /// names erased and they degenerate to tuples. This means that the order of + /// the fields is important! + Options(name: String, host: String, port: Int, no_styles: Bool) +} + +type Error { + CompileError +} + +// MAIN ------------------------------------------------------------------------ + +pub fn run() -> Command(Nil) { + glint.command(fn(input) { + let CommandInput(_, flags, _) = input + let assert Ok(port) = flag.get_int(flags, port_flag_name) + let assert Ok(host) = flag.get_string(flags, host_flag_name) + let assert Ok(no_styles) = flag.get_bool(flags, no_styles_flag_name) + + let result = { + let compile = result.replace_error(project.build(), CompileError) + use compiled <- result.try(compile) + let configuration = project.read_configuration(compiled) + let options = Options(configuration.name, host, port, no_styles) + serve(options, on_start(host, _), on_port_taken) + Ok(Nil) + } + + case result { + Ok(_) -> Nil + Error(error) -> explain(error) + } + }) + |> glint.flag(host_flag_name, host_flag()) + |> glint.flag(port_flag_name, port_flag()) + |> glint.flag(no_styles_flag_name, no_styles_flag()) +} + +// UTILS ----------------------------------------------------------------------- + +fn explain(error: Error) -> Nil { + case error { + CompileError -> Nil + } +} + +// GLINT FLAGS ----------------------------------------------------------------- + +const host_flag_name = "host" + +fn host_flag() { + flag.string() + |> flag.default("localhost") + |> flag.description("The host to run the server on.") +} + +const port_flag_name = "port" + +fn port_flag() { + flag.int() + |> flag.default(1234) + |> flag.description("The port to run the server on.") +} + +const no_styles_flag_name = "no-styles" + +fn no_styles_flag() { + flag.bool() + |> flag.default(False) + |> flag.description("When false, lustre/ui's styles will not be included.") +} + +// UTILS ----------------------------------------------------------------------- + +fn on_start(host: String, port: Int) -> Nil { + let address = "http://" <> host <> ":" <> int.to_string(port) + io.println("✨ Server has been started at " <> ansi.bold(address)) +} + +fn on_port_taken(port) -> Nil { + io.println( + "🚨 Port " + <> ansi.bold(int.to_string(port)) + <> " already in use, using next available port", + ) +} + +// EXTERNALS ------------------------------------------------------------------- + +@external(erlang, "lustre_try_ffi", "serve") +fn serve( + options: Options, + on_start: fn(Int) -> Nil, + on_port_taken: fn(Int) -> Nil, +) -> Nil diff --git a/src/lustre/try.gleam b/src/lustre/try.gleam deleted file mode 100644 index 177da97..0000000 --- a/src/lustre/try.gleam +++ /dev/null @@ -1,100 +0,0 @@ -// IMPORTS --------------------------------------------------------------------- - -import argv -import gleam_community/ansi -import gleam/int -import gleam/io -import glint.{CommandInput} -import glint/flag - -// TYPES ----------------------------------------------------------------------- - -type Options { - /// It's important to remember that for Erlang, Gleam records have their field - /// names erased and they degenerate to tuples. This means that the order of - /// the fields is important! - Options(host: String, port: Int, include_styles: Bool) -} - -// MAIN ------------------------------------------------------------------------ - -pub fn main() { - let args = argv.load().arguments - let program = - glint.new() - // There's an open issue on the glint repo to have the generated help text - // include the `gleam run -m ` prefix. If/until that's addressed, we can kind - // of hack it by telling glint the name of the program is the full command. - // - // See: https://github.com/TanklesXL/glint/issues/23 - // - |> glint.with_name("gleam run -m lustre/try") - |> glint.with_pretty_help(glint.default_pretty_help()) - |> glint.add( - at: [], - do: glint.command(fn(input) { - let CommandInput(_, flags) = input - let assert Ok(port) = flag.get_int(flags, "port") - let assert Ok(host) = flag.get_string(flags, "host") - let assert Ok(include_styles) = flag.get_bool(flags, "include-styles") - let options = Options(host, port, include_styles) - - exec("gleam build --target js") - serve(options, on_start(host, _), on_port_taken) - }) - |> glint.flag("host", host_flag()) - |> glint.flag("port", port_flag()) - |> glint.flag("include-styles", include_styles_flag()), - ) - - glint.run(program, args) -} - -// GLINT FLAGS ----------------------------------------------------------------- - -fn host_flag() { - flag.string() - |> flag.default("localhost") - |> flag.description("The host to run the server on") -} - -fn port_flag() { - flag.int() - |> flag.default(1234) - |> flag.description("The port to run the server on") -} - -fn include_styles_flag() { - flag.bool() - |> flag.default(False) - |> flag.description("Include lustre_ui's default stylesheet in your app.") -} - -// UTILS ----------------------------------------------------------------------- - -fn on_start(host: String, port: Int) -> Nil { - let address = "http://" <> host <> ":" <> int.to_string(port) - io.println("✨ Server has been started at " <> ansi.bold(address)) -} - -fn on_port_taken(port) -> Nil { - io.println( - "🚨 Port " - <> ansi.bold(int.to_string(port)) - <> " already in use, using next available port", - ) -} - -// EXTERNALS ------------------------------------------------------------------- - -@external(erlang, "http_ffi", "serve") -@external(javascript, "../http.ffi.mjs", "serve") -fn serve( - options: Options, - on_start: fn(Int) -> Nil, - on_port_taken: fn(Int) -> Nil, -) -> Nil - -@external(erlang, "http_ffi", "exec") -@external(javascript, "node:child_process", "execSync") -fn exec(command: String) -> String diff --git a/src/lustre_add_ffi.erl b/src/lustre_add_ffi.erl new file mode 100644 index 0000000..d7c5030 --- /dev/null +++ b/src/lustre_add_ffi.erl @@ -0,0 +1,49 @@ +-module(lustre_add_ffi). +-export([ + get_cpu/0, + get_esbuild/1, + get_os/0, + unzip_esbuild/1 +]). + +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, {network_error, Res}}; + {error, Err} -> {error, {network_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, {unzip_error, Res}}; + {error, Err} -> {error, {unzip_error, Err}} + end. diff --git a/src/lustre_build_ffi.erl b/src/lustre_build_ffi.erl new file mode 100644 index 0000000..9a7e280 --- /dev/null +++ b/src/lustre_build_ffi.erl @@ -0,0 +1,9 @@ +-module(lustre_build_ffi). +-export([ + exec/1 +]). + +exec(Cmd) -> + Stdout = os:cmd(unicode:characters_to_list(Cmd)), + + unicode:characters_to_binary(Stdout). diff --git a/src/http_ffi.erl b/src/lustre_try_ffi.erl index 1261b13..0209c14 100644 --- a/src/http_ffi.erl +++ b/src/lustre_try_ffi.erl @@ -1,14 +1,7 @@ --module(http_ffi). --export([exec/1, serve/3, response_default_headers/0]). - -exec(Command) -> - os:cmd(binary_to_list(Command)). - -serve({options, Host, Port, IncludeStyles}, OnStart, OnPortTaken) -> - {ok, Pattern} = re:compile("name *= *\"(?<Name>.+)\""), - {ok, Toml} = file:read_file("gleam.toml"), - {match, [Name]} = re:run(Toml, Pattern, [{capture, all_names, binary}]), +-module(lustre_try_ffi). +-export([serve/3, response_default_headers/0, exec/1]). +serve({options, Name, Host, Port, NoStyles}, OnStart, OnPortTaken) -> Html = << "<!DOCTYPE html>\n" @@ -17,11 +10,11 @@ serve({options, Host, Port, IncludeStyles}, OnStart, OnPortTaken) -> " <meta charset=\"UTF-8\">\n" " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n" " <title>Lustre preview server</title>\n", - case IncludeStyles of + case NoStyles of true -> - <<" <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/gh/lustre-labs/ui/priv/styles.css\">\n">>; + <<"">>; false -> - <<"">> + <<" <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/gh/lustre-labs/ui/priv/styles.css\">\n">> end/binary, " <script type=\"module\">\n" " import { main } from './", @@ -115,3 +108,8 @@ response_default_headers() -> {"cache-control", "no-store, no-cache, must-revalidate, private"}, {"pragma", "no-cache"} ]. + +exec(Cmd) -> + Stdout = os:cmd(unicode:characters_to_list(Cmd)), + + unicode:characters_to_binary(Stdout). |