Lustre's CLI and development tooling: zero-config dev server, bundling, and scaffolding.
at main 12 kB view raw
1// IMPORTS --------------------------------------------------------------------- 2 3import filepath 4import gleam/bool 5import gleam/dict 6import gleam/list 7import gleam/package_interface.{type Type, Named, Variable} 8import gleam/result 9import gleam/string 10import glint.{type Command} 11import lustre_dev_tools/cli.{type Cli, do, try} 12import lustre_dev_tools/cli/flag 13import lustre_dev_tools/cmd 14import lustre_dev_tools/error.{ 15 type Error, BundleError, ComponentMissing, MainMissing, ModuleMissing, 16 NameIncorrectType, NameMissing, 17} 18import lustre_dev_tools/esbuild 19import lustre_dev_tools/project.{type Module} 20import lustre_dev_tools/tailwind 21import simplifile 22 23// DESCRIPTION ----------------------------------------------------------------- 24pub const description: String = " 25Commands to build different kinds of Lustre application. These commands go beyond 26just running `gleam build` and handle features like bundling, minification, and 27integration with other build tools. 28" 29 30// COMMANDS -------------------------------------------------------------------- 31 32pub fn app() -> Command(Nil) { 33 let description = 34 " 35Build and bundle an entire Lustre application. The generated JavaScript module 36calls your app's `main` function on page load and can be included in any Web 37page without Gleam or Lustre being present. 38 39 40This is different from using `gleam build` directly because it produces a single 41JavaScript module for you to host or distribute. 42" 43 use <- glint.command_help(description) 44 use <- glint.unnamed_args(glint.EqArgs(0)) 45 use minify <- glint.flag(flag.minify()) 46 use detect_tailwind <- glint.flag(flag.detect_tailwind()) 47 use _tailwind_entry <- glint.flag(flag.tailwind_entry()) 48 use _outdir <- glint.flag(flag.outdir()) 49 use _ext <- glint.flag(flag.ext()) 50 use _, _, flags <- glint.command() 51 let script = { 52 use minify <- do(cli.get_bool("minify", False, ["build"], minify)) 53 use detect_tailwind <- do(cli.get_bool( 54 "detect-tailwind", 55 True, 56 ["build"], 57 detect_tailwind, 58 )) 59 60 do_app(minify, detect_tailwind) 61 } 62 63 case cli.run(script, flags) { 64 Ok(_) -> Nil 65 Error(error) -> error.explain(error) 66 } 67} 68 69pub fn do_app(minify: Bool, detect_tailwind: Bool) -> Cli(Nil) { 70 use <- cli.log("Building your project") 71 use project_name <- do(cli.get_name()) 72 73 use <- cli.success("Project compiled successfully") 74 use <- cli.log("Checking if I can bundle your application") 75 use module <- try(get_module_interface(project_name)) 76 use _ <- try(check_main_function(project_name, module)) 77 78 use <- cli.log("Creating the bundle entry file") 79 let root = project.root() 80 let tempdir = filepath.join(root, "build/.lustre") 81 let default_outdir = filepath.join(root, "priv/static") 82 use outdir <- cli.do( 83 cli.get_string( 84 "outdir", 85 default_outdir, 86 ["build"], 87 glint.get_flag(_, flag.outdir()), 88 ), 89 ) 90 let _ = simplifile.create_directory_all(tempdir) 91 let _ = simplifile.create_directory_all(outdir) 92 use template <- cli.template("entry-with-main.mjs") 93 let entry = string.replace(template, "{app_name}", project_name) 94 95 let entryfile = filepath.join(tempdir, "entry.mjs") 96 use ext <- cli.do( 97 cli.get_string("ext", "mjs", ["build"], glint.get_flag(_, flag.ext())), 98 ) 99 let ext = case minify { 100 True -> ".min." <> ext 101 False -> "." <> ext 102 } 103 104 let outfile = 105 project_name 106 |> string.append(ext) 107 |> filepath.join(outdir, _) 108 109 let assert Ok(_) = simplifile.write(entryfile, entry) 110 use _ <- do(bundle(entry, tempdir, outfile, minify)) 111 use <- bool.guard(!detect_tailwind, cli.return(Nil)) 112 113 use entry <- cli.template("entry.css") 114 let outfile = 115 filepath.strip_extension(outfile) 116 |> string.append(".css") 117 118 use _ <- do(bundle_tailwind(entry, tempdir, outfile, minify)) 119 120 cli.return(Nil) 121} 122 123pub fn component() -> Command(Nil) { 124 let description = 125 " 126Build a Lustre component as a portable Web Component. The generated JavaScript 127module can be included in any Web page and used without Gleam or Lustre being 128present. 129 130 131For a module to be built as a component, it must expose a `name` constant that 132will be the name of the component's HTML tag, and contain a public function that 133returns a suitable Lustre `App`. 134 " 135 136 use <- glint.command_help(description) 137 use module_path <- glint.named_arg("module_path") 138 use <- glint.unnamed_args(glint.EqArgs(0)) 139 use minify <- glint.flag(flag.minify()) 140 use _outdir <- glint.flag(flag.outdir()) 141 use args, _, flags <- glint.command 142 let module_path = module_path(args) 143 144 let script = { 145 use minify <- do(cli.get_bool("minifiy", False, ["build"], minify)) 146 147 use <- cli.log("Building your project") 148 use module <- try(get_module_interface(module_path)) 149 use <- cli.success("Project compiled successfully") 150 use <- cli.log("Checking if I can bundle your component") 151 use _ <- try(check_component_name(module_path, module)) 152 use component <- try(find_component(module_path, module)) 153 154 use <- cli.log("Creating the bundle entry file") 155 let root = project.root() 156 let tempdir = filepath.join(root, "build/.lustre") 157 let default_outdir = filepath.join(root, "priv/static") 158 use outdir <- cli.do( 159 cli.get_string( 160 "outdir", 161 default_outdir, 162 ["build"], 163 glint.get_flag(_, flag.outdir()), 164 ), 165 ) 166 let _ = simplifile.create_directory_all(tempdir) 167 let _ = simplifile.create_directory_all(outdir) 168 169 use project_name <- do(cli.get_name()) 170 171 // Esbuild bundling 172 use template <- cli.template("component-entry.mjs") 173 let entry = 174 template 175 |> string.replace("{component_name}", importable_name(component)) 176 |> string.replace("{app_name}", project_name) 177 |> string.replace("{module_path}", module_path) 178 179 let entryfile = filepath.join(tempdir, "entry.mjs") 180 let ext = case minify { 181 True -> ".min.mjs" 182 False -> ".mjs" 183 } 184 let assert Ok(outfile) = 185 string.split(module_path, "/") 186 |> list.last 187 |> result.map(string.append(_, ext)) 188 |> result.map(filepath.join(outdir, _)) 189 190 let assert Ok(_) = simplifile.write(entryfile, entry) 191 use _ <- do(bundle(entry, tempdir, outfile, minify)) 192 193 // Tailwind bundling 194 use entry <- cli.template("entry.css") 195 let outfile = 196 filepath.strip_extension(outfile) 197 |> string.append(".css") 198 199 use _ <- do(bundle_tailwind(entry, tempdir, outfile, minify)) 200 201 cli.return(Nil) 202 } 203 204 case cli.run(script, flags) { 205 Ok(_) -> Nil 206 Error(error) -> error.explain(error) 207 } 208} 209 210// STEPS ----------------------------------------------------------------------- 211 212fn get_module_interface(module_path: String) -> Result(Module, Error) { 213 project.interface() 214 |> result.then(fn(interface) { 215 dict.get(interface.modules, module_path) 216 |> result.replace_error(ModuleMissing(module_path)) 217 }) 218} 219 220fn check_main_function( 221 module_path: String, 222 module: Module, 223) -> Result(Nil, Error) { 224 case dict.has_key(module.functions, "main") { 225 True -> Ok(Nil) 226 False -> Error(MainMissing(module_path)) 227 } 228} 229 230fn check_component_name( 231 module_path: String, 232 module: Module, 233) -> Result(Nil, Error) { 234 dict.get(module.constants, "name") 235 |> result.replace_error(NameMissing(module_path)) 236 |> result.then(fn(component_name) { 237 case is_string_type(component_name) { 238 True -> Ok(Nil) 239 False -> Error(NameIncorrectType(module_path, component_name)) 240 } 241 }) 242} 243 244fn find_component(module_path: String, module: Module) -> Result(String, Error) { 245 let functions = dict.to_list(module.functions) 246 let error = Error(ComponentMissing(module_path)) 247 248 use _, #(name, t) <- list.fold_until(functions, error) 249 case t.parameters, is_compatible_app_type(t.return) { 250 [], True -> list.Stop(Ok(name)) 251 _, _ -> list.Continue(error) 252 } 253} 254 255fn bundle( 256 entry: String, 257 tempdir: String, 258 outfile: String, 259 minify: Bool, 260) -> Cli(Nil) { 261 let entryfile = filepath.join(tempdir, "entry.mjs") 262 let assert Ok(_) = simplifile.write(entryfile, entry) 263 use _ <- do(esbuild.bundle(entryfile, outfile, minify)) 264 265 cli.return(Nil) 266} 267 268fn bundle_tailwind( 269 entry: String, 270 tempdir: String, 271 outfile: String, 272 minify: Bool, 273) -> Cli(Nil) { 274 // We first check if there's a `tailwind.config.js` at the project's root. 275 // If not present we do nothing; otherwise we go on with bundling. 276 let root = project.root() 277 let tailwind_config_file = filepath.join(root, "tailwind.config.js") 278 let has_tailwind_config = 279 simplifile.is_file(tailwind_config_file) 280 |> result.unwrap(False) 281 use <- bool.guard(when: !has_tailwind_config, return: cli.return(Nil)) 282 283 use _ <- do(tailwind.setup(get_os(), get_cpu())) 284 285 use <- cli.log("Bundling with Tailwind") 286 let default_entryfile = filepath.join(tempdir, "entry.css") 287 use entryfile <- cli.do( 288 cli.get_string( 289 "tailwind-entry", 290 default_entryfile, 291 ["build"], 292 glint.get_flag(_, flag.tailwind_entry()), 293 ), 294 ) 295 296 let assert Ok(_) = case entryfile == default_entryfile { 297 True -> simplifile.write(entryfile, entry) 298 False -> Ok(Nil) 299 } 300 301 let flags = ["--input=" <> entryfile, "--output=" <> outfile] 302 let options = case minify { 303 True -> ["--minify", ..flags] 304 False -> flags 305 } 306 use _ <- try(exec_tailwind(root, options)) 307 use <- cli.success("Bundle produced at `" <> outfile <> "`") 308 309 cli.return(Nil) 310} 311 312fn exec_tailwind(root: String, options: List(String)) -> Result(String, Error) { 313 cmd.exec("./build/.lustre/bin/tailwind", in: root, with: options) 314 |> result.map_error(fn(pair) { BundleError(pair.1) }) 315} 316 317// UTILS ----------------------------------------------------------------------- 318 319fn is_string_type(t: Type) -> Bool { 320 case t { 321 Named(name: "String", package: "", module: "gleam", parameters: []) -> True 322 _ -> False 323 } 324} 325 326fn is_nil_type(t: Type) -> Bool { 327 case t { 328 Named(name: "Nil", package: "", module: "gleam", parameters: []) -> True 329 _ -> False 330 } 331} 332 333fn is_type_variable(t: Type) -> Bool { 334 case t { 335 Variable(..) -> True 336 _ -> False 337 } 338} 339 340fn is_compatible_app_type(t: Type) -> Bool { 341 case t { 342 Named( 343 name: "App", 344 package: "lustre", 345 module: "lustre", 346 parameters: [flags, ..], 347 ) -> is_nil_type(flags) || is_type_variable(flags) 348 _ -> False 349 } 350} 351 352/// Turns a Gleam identifier into a name that can be imported in an mjs module 353/// from Gleam's generated code. 354/// 355fn importable_name(identifier: String) -> String { 356 case is_reserved_keyword(identifier) { 357 True -> identifier <> "$" 358 False -> identifier 359 } 360} 361 362fn is_reserved_keyword(name: String) -> Bool { 363 // This list is taken directly from Gleam's compiler: there's some identifiers 364 // that are not technically keywords (like `then`) but Gleam will still append 365 // a "$" to those. 366 case name { 367 "await" 368 | "arguments" 369 | "break" 370 | "case" 371 | "catch" 372 | "class" 373 | "const" 374 | "continue" 375 | "debugger" 376 | "default" 377 | "delete" 378 | "do" 379 | "else" 380 | "enum" 381 | "export" 382 | "extends" 383 | "eval" 384 | "false" 385 | "finally" 386 | "for" 387 | "function" 388 | "if" 389 | "implements" 390 | "import" 391 | "in" 392 | "instanceof" 393 | "interface" 394 | "let" 395 | "new" 396 | "null" 397 | "package" 398 | "private" 399 | "protected" 400 | "public" 401 | "return" 402 | "static" 403 | "super" 404 | "switch" 405 | "this" 406 | "throw" 407 | "true" 408 | "try" 409 | "typeof" 410 | "var" 411 | "void" 412 | "while" 413 | "with" 414 | "yield" 415 | "undefined" 416 | "then" -> True 417 _ -> False 418 } 419} 420 421// EXTERNALS ------------------------------------------------------------------- 422 423@external(erlang, "lustre_dev_tools_ffi", "get_os") 424fn get_os() -> String 425 426@external(erlang, "lustre_dev_tools_ffi", "get_cpu") 427fn get_cpu() -> String