aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorHayleigh Thompson <me@hayleigh.dev>2024-02-13 21:13:57 +0000
committerGitHub <noreply@github.com>2024-02-13 21:13:57 +0000
commit1be73ee8d2206a7f1520257d81c8a5a4f3cc195e (patch)
treec18785b9eac4612277ab70be164476ebd95a7303 /src
parent7960593c4feee21b65171afa220c2b32b43dd788 (diff)
downloadlustre-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>
Diffstat (limited to 'src')
-rw-r--r--src/lustre.gleam31
-rw-r--r--src/lustre/cli/add.gleam67
-rw-r--r--src/lustre/cli/build.gleam226
-rw-r--r--src/lustre/cli/esbuild.gleam155
-rw-r--r--src/lustre/cli/project.gleam63
-rw-r--r--src/lustre/cli/try.gleam108
-rw-r--r--src/lustre/try.gleam100
-rw-r--r--src/lustre_add_ffi.erl49
-rw-r--r--src/lustre_build_ffi.erl9
-rw-r--r--src/lustre_try_ffi.erl (renamed from src/http_ffi.erl)24
10 files changed, 718 insertions, 114 deletions
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).