diff options
author | Hayleigh Thompson <me@hayleigh.dev> | 2024-02-18 10:45:00 +0000 |
---|---|---|
committer | Hayleigh Thompson <me@hayleigh.dev> | 2024-02-18 10:45:00 +0000 |
commit | 69b36cf003212e98800d5f27af74bb59f3bbca5d (patch) | |
tree | 11a198e64017a5ac4207c1311b1d9ac3b0ee92de | |
parent | 8128c25b6d8e597e5c652007f483c22047425c5c (diff) | |
download | lustre-69b36cf003212e98800d5f27af74bb59f3bbca5d.tar.gz lustre-69b36cf003212e98800d5f27af74bb59f3bbca5d.zip |
:recycle: Big refactor of CLI-related things.
-rw-r--r-- | gleam.toml | 1 | ||||
-rwxr-xr-x | priv/bin/esbuild | bin | 9756978 -> 0 bytes | |||
-rw-r--r-- | src/cli_ffi.erl (renamed from src/lustre_add_ffi.erl) | 10 | ||||
-rw-r--r-- | src/lustre/cli/add.gleam | 94 | ||||
-rw-r--r-- | src/lustre/cli/build.gleam | 420 | ||||
-rw-r--r-- | src/lustre/cli/dev.gleam | 211 | ||||
-rw-r--r-- | src/lustre/cli/esbuild.gleam | 238 | ||||
-rw-r--r-- | src/lustre/cli/project.gleam | 185 | ||||
-rw-r--r-- | src/lustre/cli/try.gleam | 108 | ||||
-rw-r--r-- | src/lustre/cli/utils.gleam | 53 | ||||
-rw-r--r-- | src/lustre_build_ffi.erl | 9 | ||||
-rw-r--r-- | src/lustre_try_ffi.erl | 115 |
12 files changed, 865 insertions, 579 deletions
@@ -11,6 +11,7 @@ links = [ gleam = ">= 0.33.0" internal_modules = [ + "lustre/cli", "lustre/cli/*", "lustre/internals", "lustre/internals/*", diff --git a/priv/bin/esbuild b/priv/bin/esbuild Binary files differdeleted file mode 100755 index 8c5cb93..0000000 --- a/priv/bin/esbuild +++ /dev/null diff --git a/src/lustre_add_ffi.erl b/src/cli_ffi.erl index d7c5030..1c1b6f0 100644 --- a/src/lustre_add_ffi.erl +++ b/src/cli_ffi.erl @@ -1,4 +1,4 @@ --module(lustre_add_ffi). +-module(cli_ffi). -export([ get_cpu/0, get_esbuild/1, @@ -32,8 +32,8 @@ get_esbuild(Url) -> 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}} + {ok, Res} -> {error, Res}; + {error, Err} -> {error, Err} end. unzip_esbuild(Zip) -> @@ -44,6 +44,6 @@ unzip_esbuild(Zip) -> case Result of {ok, [{_, Esbuild}]} -> {ok, Esbuild}; - {ok, Res} -> {error, {unzip_error, Res}}; - {error, Err} -> {error, {unzip_error, Err}} + {ok, Res} -> {error, Res}; + {error, Err} -> {error, Err} end. diff --git a/src/lustre/cli/add.gleam b/src/lustre/cli/add.gleam index 1a6ebe4..5323e8d 100644 --- a/src/lustre/cli/add.gleam +++ b/src/lustre/cli/add.gleam @@ -1,71 +1,53 @@ // 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 ------------------------------------------------------------------------ +// COMMANDS -------------------------------------------------------------------- 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)) + let description = + " +Download a platform-appropriate version of the esbuild binary. Lustre uses this +to bundle applications and act as a development server. + " - esbuild.download(os, cpu) - |> result.map_error(explain) - |> result.replace(Nil) - |> result.unwrap_both + 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 result = esbuild.download(os, cpu) + + case result { + Ok(_) -> Nil + Error(error) -> esbuild.explain(error) + } }) - |> glint.flag(os_flag_name, os_flag()) - |> glint.flag(cpu_flag_name, cpu_flag()) -} - -// GLINT FLAGS ----------------------------------------------------------------- - -const os_flag_name = "os" + |> glint.description(description) + |> glint.count_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() -fn os_flag() { - flag.string() - |> flag.description("The host to run the server on") + flag.string() + |> flag.default(default) + |> flag.description(description) + }) } -const cpu_flag_name = "cpu" +// EXTERNALS ------------------------------------------------------------------- -fn cpu_flag() { - flag.string() - |> flag.description("The port to run the server on") -} +@external(erlang, "cli_ffi", "get_os") +fn get_os() -> String -// 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(" ❌") - esbuild.BundleError(message) -> - { "🚨 " <> message } - |> string.pad_right(78, ".") - |> string.append(" ❌") - } - |> io.println -} +@external(erlang, "cli_ffi", "get_cpu") +fn get_cpu() -> String diff --git a/src/lustre/cli/build.gleam b/src/lustre/cli/build.gleam index e5a45c4..2b939f3 100644 --- a/src/lustre/cli/build.gleam +++ b/src/lustre/cli/build.gleam @@ -1,226 +1,298 @@ // IMPORTS --------------------------------------------------------------------- -import gleam/dict.{type Dict} +import filepath +import gleam/dict import gleam/io -import gleam/option.{type Option, None} -import gleam/result import gleam/list +import gleam/result import gleam/string import glint.{type Command, CommandInput} -import glint/flag.{type Flag} -import filepath -import simplifile -import lustre/cli/project +import glint/flag import lustre/cli/esbuild +import lustre/cli/project.{type Module, type Type, Named, Variable} +import lustre/cli/utils.{map, replace, try} +import simplifile -// TYPES ----------------------------------------------------------------------- +// COMMANDS -------------------------------------------------------------------- -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) -} +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. -// MAIN ------------------------------------------------------------------------ +This is different from using `gleam build` directly because it produces a single +JavaScript module for you to host or distribute. + " -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) + let CommandInput(flags: flags, ..) = input + let assert Ok(minify) = flag.get_bool(flags, "minify") + + let result = { + use _ <- result.try(prepare_esbuild()) + + io.println("\nPreparing build...") + io.println(" ├ reading project config") + use project_name <- result.try(get_project_name()) + use module <- result.try(get_module_interface(project_name)) + use _ <- result.try(check_main_function(project_name, module)) + + io.println(" ├ generating 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 = + "import { main } from '../dev/javascript/${project_name}/${project_name}.mjs'; + + main(); + " + |> string.replace("${project_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 _ <- result.try(bundle(entry, tempdir, outfile, minify)) + + Ok(Nil) + } - build_component(args, minify) - |> result.map_error(explain) - |> result.unwrap(Nil) + case result { + Ok(_) -> Nil + Error(error) -> explain(error) + } + }) + |> glint.description(description) + |> glint.count_args(glint.EqArgs(0)) + |> glint.flag("minify", { + let description = "Minify the output" + let default = False + + flag.bool() + |> flag.default(default) + |> flag.description(description) }) - |> glint.flag(minify_flag_name, minify_flag()) - |> glint.count_args(glint.MinArgs(1)) } -pub fn app() -> Command(Nil) { +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(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)) + 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 result = { + io.println("\nPreparing build...") + io.println(" ├ reading project config") + use module <- result.try(get_module_interface(module_path)) + use _ <- result.try(check_component_name(module_path, module)) + use component <- result.try(find_component(module_path, module)) + + io.println(" ├ generating 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 <- result.try(get_project_name()) + let entry = + "import { register } from '../dev/javascript/lustre/client-component.ffi.mjs'; + import { name, ${component} as component } from '../dev/javascript/${project_name}/${module_path}.mjs'; + + register(component(), name); + " + |> string.replace("${component}", component) + |> string.replace("${project_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 _ <- result.try(bundle(entry, tempdir, outfile, minify)) + + Ok(Nil) + } - build_app(module, minify) - |> result.map_error(explain) - |> result.unwrap(Nil) + case result { + Ok(_) -> Nil + Error(error) -> explain(error) + } + }) + |> glint.description(description) + |> glint.named_args(["module_path"]) + |> glint.count_args(glint.EqArgs(1)) + |> glint.flag("minify", { + let description = "Minify the output" + let default = False + + flag.bool() + |> flag.default(default) + |> flag.description(description) }) - |> glint.flag(minify_flag_name, minify_flag()) - |> glint.named_args([module_named_arg]) } -// GLINT FLAGS ----------------------------------------------------------------- - -const module_named_arg = "module" +// ERROR HANDLING -------------------------------------------------------------- -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.", - ], - " ", - )) +type Error { + BuildError + BundleError(esbuild.Error) + ComponentMissing(module: String) + MainMissing(module: String) + ModuleMissing(module: String) + NameIncorrectType(module: String, got: project.Type) + NameMissing(module: String) } -// BUILD COMPONENT ------------------------------------------------------------- +fn explain(error: Error) -> Nil { + error + |> string.inspect + |> io.println +} -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) +// STEPS ----------------------------------------------------------------------- - // 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)) +fn prepare_esbuild() -> Result(Nil, Error) { + esbuild.download(get_os(), get_cpu()) + |> result.replace_error(BuildError) +} - // 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" - } +fn get_project_name() -> Result(String, Error) { + use config <- try(project.config(), replace(with: BuildError)) + Ok(config.name) +} - let priv = filepath.join(project.root_folder(), "priv") +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)), + ) - components_script(configuration.name, modules) - |> bundle_script(minify, in: priv, named: output_file_name) + Ok(module) } -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) +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)) + } } -/// 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 -} +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)), + ) -// BUILD APP ------------------------------------------------------------------- + case is_string_type(component_name) { + True -> Ok(Nil) + False -> Error(NameIncorrectType(module_path, component_name)) + } +} -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) +fn find_component(module_path: String, module: Module) -> Result(String, Error) { + let functions = dict.to_list(module.functions) + let error = Error(ComponentMissing(module_path)) - // 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)) + 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) + } +} - let priv = filepath.join(project.root_folder(), "priv") +fn bundle( + entry: String, + tempdir: String, + outfile: String, + minify: Bool, +) -> Result(Nil, Error) { + let entryfile = filepath.join(tempdir, "entry.mjs") + let assert Ok(_) = simplifile.write(entryfile, entry) - app_script(configuration.name, module) - |> bundle_script(minify, in: priv, named: module <> "-app") -} + use _ <- try( + esbuild.bundle(entryfile, outfile, minify), + map(with: BundleError), + ) -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();" +// UTILS ----------------------------------------------------------------------- - import_main <> "\n" <> invoke_main +fn is_string_type(t: Type) -> Bool { + case t { + Named(name: "String", package: "", module: "gleam", parameters: []) -> True + _ -> False + } } -// UTILS ----------------------------------------------------------------------- +fn is_nil_type(t: Type) -> Bool { + case t { + Named(name: "Nil", package: "", module: "gleam", parameters: []) -> True + _ -> False + } +} -fn explain(error: Error) -> Nil { - case error { - _ -> { - io.debug(error) - todo as "explain the error" - } +fn is_type_variable(t: Type) -> Bool { + case t { + Variable(..) -> True + _ -> False } - |> 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), - ) +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 + } +} - let output_file = filepath.join(folder, output_file) +// EXTERNALS ------------------------------------------------------------------- - // 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)), - ) +@external(erlang, "cli_ffi", "get_os") +fn get_os() -> String - 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) - } - } -} +@external(erlang, "cli_ffi", "get_cpu") +fn get_cpu() -> String diff --git a/src/lustre/cli/dev.gleam b/src/lustre/cli/dev.gleam new file mode 100644 index 0000000..75711bc --- /dev/null +++ b/src/lustre/cli/dev.gleam @@ -0,0 +1,211 @@ +// IMPORTS --------------------------------------------------------------------- + +import filepath +import gleam/dict +import gleam/io +import gleam/result +import gleam/string +import glint.{type Command, CommandInput} +import glint/flag +import lustre/attribute.{attribute} +import lustre/cli/esbuild +import lustre/cli/project.{type Module, type Type, Fn, Named, Variable} +import lustre/cli/utils.{guard, map, replace, try} +import lustre/element +import lustre/element/html.{html} +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(include_styles) = flag.get_bool(flags, "include-styles") + + let result = { + use interface <- try(project.interface(), replace(BuildError)) + use module <- try( + dict.get(interface.modules, interface.name), + replace(ModuleMissing(interface.name)), + ) + use is_app <- result.try(check_is_lustre_app(interface.name, module)) + + let root = project.root() + let tempdir = filepath.join(root, "build/.lustre") + let _ = simplifile.create_directory_all(tempdir) + + let entry = + case is_app { + True -> + " import { start } from '../dev/javascript/lustre/lustre.mjs'; + import { main } from '../dev/javascript/${app_name}/${app_name}.mjs'; + + start(main(), ${container_id}); + " + False -> + " import { main } from '../dev/javascript/${app_name}/${app_name}.mjs'; + + main(); + " + } + |> string.replace("${app_name}", interface.name) + |> string.replace("${container_id}", "app") + + let html = index_html(interface.name, "app", include_styles) + + let assert Ok(_) = simplifile.write(tempdir <> "/entry.mjs", entry) + let assert Ok(_) = simplifile.write(tempdir <> "/index.html", html) + + use _ <- try( + esbuild.bundle( + filepath.join(tempdir, "entry.mjs"), + filepath.join(tempdir, "index.mjs"), + False, + ), + map(BundleError), + ) + use _ <- try(esbuild.serve(host, port), map(BundleError)) + + Ok(Nil) + } + + case result { + Ok(_) -> Nil + Error(error) -> explain(error) + } + }) + |> glint.description(description) + |> glint.count_args(glint.EqArgs(0)) + |> glint.flag("host", { + let description = "" + let default = "0.0.0.0" + + 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("include-styles", { + let description = "" + let default = False + + flag.bool() + |> flag.default(default) + |> flag.description(description) + }) +} + +// ERROR HANDLING -------------------------------------------------------------- + +type Error { + BuildError + BundleError(esbuild.Error) + MainMissing(module: String) + MainIncorrectType(module: String, got: project.Type) + MainBadAppType(module: String, got: project.Type) + ModuleMissing(module: String) +} + +fn explain(error: Error) -> Nil { + error + |> string.inspect + |> io.println +} + +// 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 index_html( + app_name: String, + container_id: String, + include_styles: Bool, +) -> String { + let styles = case include_styles { + True -> + html.link([ + attribute.rel("stylesheet"), + attribute.href( + "https://cdn.jsdelivr.net/gh/lustre-labs/ui/priv/styles.css", + ), + ]) + False -> element.none() + } + + html([], [ + html.head([], [ + html.meta([attribute("charset", "utf-8")]), + html.meta([ + attribute("name", "viewport"), + attribute("content", "width=device-width, initial-scale=1"), + ]), + html.title([], app_name), + html.script([attribute.type_("module"), attribute.src("./index.mjs")], ""), + styles, + ]), + html.body([], [html.div([attribute.id(container_id)], [])]), + ]) + |> element.to_string + |> string.append("<!DOCTYPE html>", _) +} + +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 index b0e9fd2..c98f5f3 100644 --- a/src/lustre/cli/esbuild.gleam +++ b/src/lustre/cli/esbuild.gleam @@ -1,48 +1,114 @@ +// IMPORTS --------------------------------------------------------------------- + +import filepath +import gleam/bool import gleam/dynamic.{type Dynamic} -import gleam/option.{type Option} +import gleam/function +import gleam/io +import gleam/list +import gleam/pair import gleam/result import gleam/set import gleam/string -import filepath +import lustre/cli/project +import lustre/cli/utils.{keep, map, replace, try} import shellout import simplifile.{type FilePermissions, Execute, FilePermissions, Read, Write} -import lustre/cli/project -// CONSTANTS ------------------------------------------------------------------- +// COMMANDS -------------------------------------------------------------------- -pub const executable_name = "esbuild" +pub fn download(os: String, cpu: String) -> Result(Nil, Error) { + let outdir = filepath.join(project.root(), "build/.lustre/bin") + let outfile = filepath.join(outdir, "esbuild") -// TYPES ----------------------------------------------------------------------- + use <- bool.guard(check_esbuild_exists(outfile), Ok(Nil)) -pub type Error { - NetworkError(Dynamic) - SimplifileError(reason: simplifile.FileError, path: String) - UnknownPlatform(os: String, cpu: String) - UnzipError(Dynamic) - BundleError(message: String) + io.println("\nInstalling esbuild...") + io.println(" ├ detecting platform") + use url <- result.try(get_download_url(os, cpu)) + + io.println(" ├ downloading from " <> url) + use tarball <- try(get_esbuild(url), NetworkError) + + io.println(" ├ unpacking") + use bin <- try(unzip_esbuild(tarball), UnzipError) + use _ <- try(write_esbuild(bin, outdir, outfile), SimplifileError(_, outfile)) + use _ <- try(set_filepermissions(outfile), SimplifileError(_, outfile)) + + io.println(" ├ installed!") + Ok(Nil) } -pub opaque type Executable { - Executable +pub fn bundle( + input_file: String, + output_file: String, + minify: Bool, +) -> Result(Nil, Error) { + use _ <- try(download(get_os(), get_cpu()), keep) + use _ <- try(project.build(), replace(BuildError)) + + io.println("\nBundling with esbuild...") + io.println(" ├ configuring tree shaking") + let root = project.root() + use _ <- try(configure_node_tree_shaking(root), map(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 _ <- try( + shellout.command( + run: "./build/.lustre/bin/esbuild", + in: root, + with: options, + opt: [], + ), + on_error: map(function.compose(pair.second, BundleError)), + ) + + io.println(" ├ bundle produced at " <> output_file) + Ok(Nil) } -// 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()) +pub fn serve(host: String, port: String) -> Result(Nil, Error) { + use _ <- try(download(get_os(), get_cpu()), keep) + let root = project.root() + let flags = [ + "--serve=" <> host <> ":" <> port, + "--servedir=" <> filepath.join(root, "build/.lustre"), + ] + + io.println("\nStarting dev server at " <> host <> ":" <> port <> "...") + use _ <- try( + shellout.command( + run: "./build/.lustre/bin/esbuild", + in: root, + with: flags, + opt: [], + ), + on_error: map(function.compose(pair.second, BundleError)), + ) + + Ok(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") @@ -74,82 +140,78 @@ pub fn download( _, _ -> 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)), - ) + 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]), ) - use _ <- result.try( - esbuild_path - |> simplifile.set_permissions(permissions) - |> result.map_error(SimplifileError(_, esbuild_path)), - ) - Ok(Executable) + simplifile.set_permissions(file, permissions) } -// BUNDLE ---------------------------------------------------------------------- +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 `shellout` that import 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", "shellout"] + + list.try_each(pure_deps, fn(dep) { + root + |> filepath.join("build/dev/javascript/" <> dep) + |> filepath.join("package.json") + |> simplifile.write(force_tree_shaking) + }) +} -/// 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, - ] +// ERROR HANDLING -------------------------------------------------------------- - let options = case minify { - True -> [input_file, "--minify", ..flags] - False -> [input_file, ..flags] - } +pub type Error { + BuildError + BundleError(message: String) + NetworkError(Dynamic) + SimplifileError(reason: simplifile.FileError, path: String) + UnknownPlatform(os: String, cpu: String) + UnzipError(Dynamic) +} - 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) }) +pub fn explain(error: Error) -> Nil { + error + |> string.inspect + |> io.println } // EXTERNALS ------------------------------------------------------------------- -@external(erlang, "lustre_add_ffi", "get_os") +@external(erlang, "cli_ffi", "get_os") fn get_os() -> String -@external(erlang, "lustre_add_ffi", "get_cpu") +@external(erlang, "cli_ffi", "get_cpu") fn get_cpu() -> String -@external(erlang, "lustre_add_ffi", "get_esbuild") -fn get_esbuild(url: String) -> Result(BitArray, Error) +@external(erlang, "cli_ffi", "get_esbuild") +fn get_esbuild(url: String) -> Result(BitArray, Dynamic) -@external(erlang, "lustre_add_ffi", "unzip_esbuild") -fn unzip_esbuild(tarball: BitArray) -> Result(BitArray, Error) +@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 index 0b7287e..ad6adb5 100644 --- a/src/lustre/cli/project.gleam +++ b/src/lustre/cli/project.gleam @@ -1,63 +1,200 @@ +// IMPORTS --------------------------------------------------------------------- + +import filepath import gleam/dict.{type Dict} +import gleam/dynamic.{type DecodeError, type Dynamic, DecodeError} +import gleam/io +import gleam/json +import gleam/pair import gleam/result -import filepath +import gleam/string +import lustre/cli/utils.{map, try} 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)) +// TYPES ----------------------------------------------------------------------- + +pub type Config { + Config(name: String, version: String, toml: Dict(String, Toml)) } -/// A proof that the project was compiled successfully. -/// -pub opaque type Compiled { - Compiled +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) } +pub type Type { + Named(name: String, package: String, module: String, parameters: List(Type)) + Variable(id: Int) + Fn(parameters: List(Type), return: Type) +} + +// COMMANDS -------------------------------------------------------------------- + /// 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: [], +pub fn build() -> Result(Nil, String) { + use _ <- try( + shellout.command( + run: "gleam", + in: ".", + with: ["build", "--target=js"], + opt: [], + ), + on_error: map(with: pair.second), ) - |> result.nil_error() - |> result.replace(Compiled) + + 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( + shellout.command( + run: "gleam", + in: ".", + with: ["export", "package-interface", "--out", out], + opt: [], + ), + on_error: map(with: pair.second), + ) + + let assert Ok(json) = simplifile.read(out) + let assert Ok(interface) = json.decode(json, decode_interface) + + Ok(interface) } /// 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 { +pub fn config() -> Result(Config, String) { + use _ <- result.try(build()) + // 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") + 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"]) - Configuration(name: name, version: version, toml: toml) + + Ok(Config(name: name, version: version, toml: toml)) +} + +// ERROR HANDLING -------------------------------------------------------------- + +/// +/// +pub type Error { + BuildError +} + +pub fn explain(error: Error) -> Nil { + error + |> string.inspect + |> io.println } -/// Finds the path leading to the project's root folder. +// 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_folder() -> String { - do_root_folder(".") +pub fn root() -> String { + find_root(".") } -fn do_root_folder(path: String) -> String { +fn find_root(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(False) | Error(_) -> find_root(filepath.join("..", path)) Ok(True) -> path } } + +// DECODERS -------------------------------------------------------------------- + +fn decode_interface(dyn: Dynamic) -> Result(Interface, List(DecodeError)) { + dynamic.decode3( + Interface, + dynamic.field("name", dynamic.string), + dynamic.field("version", dynamic.string), + dynamic.field("modules", dynamic.dict(dynamic.string, decode_module)), + )(dyn) +} + +fn decode_module(dyn: Dynamic) -> Result(Module, List(DecodeError)) { + dynamic.decode2( + Module, + dynamic.field( + "constants", + dynamic.dict(dynamic.string, dynamic.field("type", decode_type)), + ), + dynamic.field("functions", dynamic.dict(dynamic.string, decode_function)), + )(dyn) +} + +fn decode_function(dyn: Dynamic) -> Result(Function, List(DecodeError)) { + dynamic.decode2( + Function, + dynamic.field("parameters", dynamic.list(decode_type)), + dynamic.field("return", decode_type), + )(dyn) +} + +fn decode_type(dyn: Dynamic) -> Result(Type, List(DecodeError)) { + use kind <- result.try(dynamic.field("kind", dynamic.string)(dyn)) + + case kind { + "named" -> decode_named_type(dyn) + "variable" -> decode_variable_type(dyn) + "fn" -> decode_fn_type(dyn) + + _ -> + Error([ + DecodeError(found: kind, expected: "'named' | 'variable' | 'fn'", path: [ + "kind", + ]), + ]) + } +} + +fn decode_named_type(dyn: Dynamic) -> Result(Type, List(DecodeError)) { + dynamic.decode4( + Named, + dynamic.field("name", dynamic.string), + dynamic.field("package", dynamic.string), + dynamic.field("module", dynamic.string), + dynamic.field("parameters", dynamic.list(decode_type)), + )(dyn) +} + +fn decode_variable_type(dyn: Dynamic) -> Result(Type, List(DecodeError)) { + dynamic.decode1(Variable, dynamic.field("id", dynamic.int))(dyn) +} + +fn decode_fn_type(dyn: Dynamic) -> Result(Type, List(DecodeError)) { + dynamic.decode2( + Fn, + dynamic.field("parameters", dynamic.list(decode_type)), + dynamic.field("return", decode_type), + )(dyn) +} diff --git a/src/lustre/cli/try.gleam b/src/lustre/cli/try.gleam deleted file mode 100644 index 42f3854..0000000 --- a/src/lustre/cli/try.gleam +++ /dev/null @@ -1,108 +0,0 @@ -// 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/cli/utils.gleam b/src/lustre/cli/utils.gleam new file mode 100644 index 0000000..ddf62e3 --- /dev/null +++ b/src/lustre/cli/utils.gleam @@ -0,0 +1,53 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam/function + +// 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 const keep: ErrorStrategy(e, e) = function.identity + +// 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/src/lustre_build_ffi.erl b/src/lustre_build_ffi.erl deleted file mode 100644 index 9a7e280..0000000 --- a/src/lustre_build_ffi.erl +++ /dev/null @@ -1,9 +0,0 @@ --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/lustre_try_ffi.erl b/src/lustre_try_ffi.erl deleted file mode 100644 index 0209c14..0000000 --- a/src/lustre_try_ffi.erl +++ /dev/null @@ -1,115 +0,0 @@ --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" - "<html lang=\"en\">\n" - "<head>\n" - " <meta charset=\"UTF-8\">\n" - " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n" - " <title>Lustre preview server</title>\n", - case NoStyles of - true -> - <<"">>; - 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 './", - Name/binary, - "/", - Name/binary, - ".mjs'\n" - "\n" - " document.addEventListener(\"DOMContentLoaded\", () => {\n" - " main();\n" - " });\n" - " </script>\n" - "</head>\n" - "<body>\n" - " <div id=\"app\"></div>\n" - "</body>\n" - "</html>" - >>, - - file:write_file("build/dev/javascript/index.html", Html), - - AbsPath = - string:trim( - filename:absname("build/dev/javascript"), trailing, "/." - ), - - inets:start(), - Address = {127, 0, 0, 1}, - - ActualPort = - case port_available(Port) of - true -> - Port; - false -> - OnPortTaken(Port), - first_available_port(Port + 1) - end, - - {ok, Pid} = - httpd:start_service([ - {bind_address, Address}, - {document_root, AbsPath}, - {server_root, AbsPath}, - {directory_index, ["index.html"]}, - {server_name, binary_to_list(Host)}, - {port, ActualPort}, - {default_type, "text/html"}, - {mime_types, mime_types()}, - {customize, ?MODULE}, - {modules, [mod_alias, mod_dir, mod_get]} - ]), - - OnStart(ActualPort), - - receive - {From, shutdown} -> - ok = httpd:stop_service(Pid), - From ! done - end. - -port_available(Port) -> - case gen_tcp:listen(Port, []) of - {ok, Sock} -> - ok = gen_tcp:close(Sock), - true; - _ -> - false - end. - -first_available_port(Port) -> - case port_available(Port) of - true -> Port; - false -> first_available_port(Port + 1) - end. - -mime_types() -> - [ - {"html", "text/html"}, - {"htm", "text/html"}, - {"js", "text/javascript"}, - {"mjs", "text/javascript"}, - {"css", "text/css"}, - {"gif", "image/gif"}, - {"jpg", "image/jpeg"}, - {"jpeg", "image/jpeg"}, - {"png", "image/png"} - ]. - -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). |