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