import argv import glance.{ type Expression, Call, Discarded, Expression, Field, FieldAccess, List, Named, String, Variable, } import gleam/bool import gleam/io import gleam/list import gleam/option.{type Option, None, Some} import gleam/pair import gleam/result import gleam/string import glint import simplifile import sketch/css/error /// Definition of a Gleam styles definitions file. `path` is up to you, to /// retrieve your module easily. `content` should be a valid Gleam source file. pub type Module { Module(path: String, content: String, ast: Option(glance.Module)) } /// Definition of a StyleSheet. `classes` ensures a mapping between functions /// and class names. `content` is the corresponding CSS classes. pub type Css { Css(classes: List(#(String, String)), content: List(String)) } /// Assumes dir is a directory. Should be checked before calling the function. fn recursive_modules_read(dir: String) { use dir_content <- result.map(simplifile.read_directory(dir)) list.flatten({ use path <- list.filter_map(dir_content) let path = string.join([dir, path], "/") use is_dir <- result.try(simplifile.is_directory(path)) use <- bool.guard(when: is_dir, return: recursive_modules_read(path)) use content <- result.map(simplifile.read(path)) [Module(path: path, content: content, ast: None)] }) } fn parse_modules(modules: List(Module)) { use module <- list.filter_map(modules) use ast <- result.map(glance.module(module.content) |> error.glance) Module(..module, ast: Some(ast)) } fn select_css_files(modules: List(Module), interface: String) { use module <- list.filter(modules) !string.contains(module.path, interface) && { string.ends_with(module.path, "_styles.gleam") || string.ends_with(module.path, "_css.gleam") || string.ends_with(module.path, "_sketch.gleam") } } fn find_sketch_imports(imports: List(glance.Definition(glance.Import))) { let imports = list.filter(imports, fn(i) { i.definition.module == "sketch" }) let aliases = list.map(imports, fn(i) { case i.definition.alias { None -> i.definition.module Some(Discarded(_)) -> i.definition.module Some(Named(s)) -> s } }) |> list.unique let exposed = { use i <- list.flat_map(imports) use value <- list.filter_map(i.definition.unqualified_values) use <- bool.guard(when: value.name != "class", return: Error(Nil)) option.map(value.alias, Ok) |> option.unwrap(Ok(value.name)) } let properties = { use i <- list.flat_map(imports) use value <- list.filter_map(i.definition.unqualified_values) use <- bool.guard(when: value.name == "class", return: Error(Nil)) Ok(#(value.alias |> option.unwrap(value.name), value.name)) } #(aliases, exposed, properties) } fn parse_css_modules( styles_modules: List(Module), _modules: List(Module), ) -> List(#(Module, Css)) { use styles_module <- list.filter_map(styles_modules) case styles_module.ast { None -> Error(Nil) Some(ast) -> Ok({ #(styles_module, { let #(imports, exposed, properties) = find_sketch_imports(ast.imports) use css, function <- list.fold(ast.functions, Css([], [])) function_definition(imports, exposed, properties, function.definition) |> result.map(fn(content) { let classes = list.key_set(css.classes, content.0, content.1) let content = list.prepend(css.content, content.2) Css(classes:, content:) }) |> result.unwrap(css) }) }) } } fn keep_fn_call(function: glance.Function) { case function.body { [Expression(Call(call, [Field(_, List(body, _))]))] -> Ok(#(call, body)) _ -> Error(Nil) } } fn keep_valid_class( imports: List(String), exposed: List(String), call: glance.Expression, ) { case call { Variable(fcall) -> { let is_class = !list.contains(exposed, fcall) use <- bool.guard(when: is_class, return: Error(Nil)) Ok(Nil) } FieldAccess(Variable(module), fcall) -> { let not_class = fcall != "class" let not_sketch_class = !list.contains(imports, module) || not_class use <- bool.guard(when: not_sketch_class, return: Error(Nil)) Ok(Nil) } _ -> Error(Nil) } } fn function_definition( imports: List(String), exposed: List(String), properties: List(#(String, String)), function: glance.Function, ) -> Result(#(String, String, String), Nil) { use #(call, body) <- result.try(keep_fn_call(function)) use _ <- result.try(keep_valid_class(imports, exposed, call)) let name = string.replace(function.name, each: "_", with: "-") let #(all_classes, medias) = split_body(imports, properties, body) let body = compile_classes(imports, properties, name, all_classes, "") let composes = all_classes |> list.flat_map(pair.second) |> select_composes(properties, _) let body = { let medias = list.map(medias, fn(media) { let #(media, all_classes) = media let body = compile_classes(imports, properties, name, all_classes, " ") let head = "@media " <> media <> " {\n" head <> body <> "\n}" }) |> string.join("\n\n") use <- bool.guard(when: medias == "", return: body) body <> "\n\n" <> medias } let name = [name, ..composes] |> list.reverse |> string.join(" ") Ok(#(function.name, name, body)) } fn select_composes(properties, expressions: List(Expression)) { use classes, expression <- list.fold(expressions, []) case rewrite_pipe(expression) { Call(Variable(v), [Field(None, Call(Variable(t), _))]) | Call(Variable(v), [Field(None, Call(FieldAccess(_, t), _))]) -> { let f = list.key_find(properties, v) |> result.unwrap("") use <- bool.guard(when: f != "compose", return: classes) string.replace(t, "_", "-") |> list.prepend(classes, _) } Call(FieldAccess(_, f), [Field(None, Call(Variable(t), _))]) | Call(FieldAccess(_, f), [Field(None, Call(FieldAccess(_, t), _))]) -> { use <- bool.guard(when: f != "compose", return: classes) string.replace(t, "_", "-") |> list.prepend(classes, _) } _ -> classes } } fn compile_classes(imports, properties, name, all_classes, prefix: String) { list.map(all_classes, fn(class) { let #(selector, body) = class let body = class_body(imports, properties, body) |> list.map(fn(s) { prefix <> s }) |> string.join("\n") let head = prefix <> "." <> name <> selector <> " {\n" head <> body <> "\n" <> prefix <> "}" }) |> string.join("\n\n") } fn split_body( imports, properties, body, ) -> #( List(#(String, List(Expression))), List(#(String, List(#(String, List(Expression))))), ) { use results, item <- list.fold(body, #([], [])) case item { Call(Variable(name), param) -> push_in_subclass(imports, results, item, name, param, properties, True) Call(FieldAccess(Variable(module_name), name), param) -> { let is_sketch = list.contains(imports, module_name) use <- bool.guard(when: !is_sketch, return: Error(Nil)) push_in_subclass(imports, results, item, name, param, properties, False) } _ -> Error(Nil) } |> result.unwrap(results) } fn push_in_subclass( imports, results, item, name, param, properties, unqualified, ) -> Result( #( List(#(String, List(Expression))), List(#(String, List(#(String, List(Expression))))), ), Nil, ) { let is_media = name == "media" let add_media = add_media(imports, properties, results, param) use <- bool.lazy_guard(when: is_media, return: add_media) use name <- result.try(case unqualified { True -> list.key_find(properties, name) False -> Ok(name) }) let is_pseudo = list.key_find(pseudo, name) let add_body = fn() { Ok(add_expressions(results, "", [item])) } use <- bool.lazy_guard(when: result.is_error(is_pseudo), return: add_body) let assert Ok(name) = is_pseudo case name, param { ":pseudo-selector", [Field(None, String(selector)), Field(None, List(expressions, _))] -> { Ok(add_expressions(results, selector, expressions)) } ":nth-child", [Field(None, String(selector)), Field(None, List(expressions, _))] -> { Ok(add_expressions(results, name <> "(" <> selector <> ")", expressions)) } ":nth-last-child", [Field(None, String(selector)), Field(None, List(expressions, _))] -> { Ok(add_expressions(results, name <> "(" <> selector <> ")", expressions)) } ":nth-of-type", [Field(None, String(selector)), Field(None, List(expressions, _))] -> { Ok(add_expressions(results, name <> "(" <> selector <> ")", expressions)) } ":nth-last-of-type", [Field(None, String(selector)), Field(None, List(expressions, _))] -> { Ok(add_expressions(results, name <> "(" <> selector <> ")", expressions)) } _, [Field(None, List(expressions, _))] -> { Ok(add_expressions(results, name, expressions)) } _, _ -> Error(Nil) } } fn add_media( imports, properties, results: #( List(#(String, List(Expression))), List(#(String, List(#(String, List(Expression))))), ), param, ) { fn() { let #(results, medias) = results case param { [Field(None, media), Field(None, List(content, _))] -> { use media <- result.try(media_to_string(properties, media)) let #(content, _) = split_body(imports, properties, content) Ok(#(results, list.key_set(medias, media, content))) } _ -> Ok(#(results, medias)) } } } fn media_to_string(properties, media) -> Result(String, Nil) { case rewrite_pipe(media) { Call(Variable(media), p) -> { use name <- result.try(list.key_find(properties, media)) use <- bool.guard(when: name != "max_width", return: Error(Nil)) Ok("(max-width: " <> property_body(p)) } Call(FieldAccess(Variable(_), media), p) -> { case media { "max_width" | "min_width" | "max_height" | "min_height" -> { Ok( "(" <> string.replace(media, "_", "-") <> ": " <> property_body(p) <> ")", ) } "not" -> case p { [Field(None, m)] -> { use left <- result.try(media_to_string(properties, m)) Ok("not(" <> left <> ")") } _ -> Error(Nil) } "and" | "or" -> case p { [Field(None, m), Field(None, n)] -> { use left <- result.try(media_to_string(properties, m)) use right <- result.try(media_to_string(properties, n)) Ok(left <> " " <> media <> " " <> right) } _ -> Error(Nil) } "landscape" | "portait" -> Ok("(orientation: " <> media <> ")") "dark_theme" | "light_theme" -> Ok( "(prefers-color-scheme: " <> string.replace(media, "_", "-") <> ")", ) _ -> Error(Nil) } } _ -> Error(Nil) } } fn add_expressions(results, name, expressions) { let #(results, medias) = results #( list.key_find(results, name) |> result.unwrap([]) |> list.append(expressions) |> list.key_set(results, name, _), medias, ) } const pseudo = [ #("placeholder", "::placeholder"), #("hover", ":hover"), #("active", ":active"), #("focus", ":focus"), #("focus_visible", ":focus_visible"), #("focus_within", ":focus-within"), #("enabled", ":enabled"), #("disabled", ":disabled"), #("read_only", ":read-only"), #("read_write", ":read-write"), #("checked", ":checked"), #("blank", ":blank"), #("valid", ":valid"), #("invalid", ":invalid"), #("required", ":required"), #("optional", ":optional"), #("link", ":link"), #("visited", ":visited"), #("target", ":target"), #("nth_child", ":nth-child"), #("nth_last_child", ":nth-last-child"), #("nth_of_type", ":nth-of-type"), #("nth_last_of_type", ":nth-last-of-type"), #("first_child", ":first-child"), #("last_child", ":last-child"), #("only_child", ":only-child"), #("first_of_type", ":first-of-type"), #("last_of_type", ":last-of-type"), #("only_of_type", ":only-of-type"), #("pseudo_selector", ":pseudo-selector"), ] const skippable = ["compose", "none", "media"] fn skip_not_css_properties(name) { case list.contains(skippable, name) || result.is_ok(list.key_find(pseudo, name)) { True -> Error(Nil) False -> Ok(name) } } fn class_body( imports: List(String), properties: List(#(String, String)), body: List(Expression), ) -> List(String) { use property <- list.filter_map(body) case property { Call(Variable(name), param) -> { use name <- result.try(list.key_find(properties, name)) compute_css_property(name, param) } Call(FieldAccess(Variable(module_name), name), param) -> { let is_sketch = list.contains(imports, module_name) use <- bool.guard(when: !is_sketch, return: Error(Nil)) compute_css_property(name, param) } _ -> Error(Nil) } } fn compute_css_property(name, param) { use name <- result.try(skip_not_css_properties(name)) let is_property = name == "property" let is_areas = name == "grid_template_areas" use <- bool.guard(when: is_property, return: property_to_string(param)) use <- bool.guard(when: is_areas, return: template_areas_to_string(param)) css_property(name, param) } fn css_property(name, param) { let prop = string.split(name, "-") |> list.filter(fn(a) { a != "" }) |> string.join("-") let body = property_body(param) Ok(" " <> prop <> ": " <> body <> ";") } fn property_to_string(param) { case param { [Field(None, String(param)), Field(None, p)] -> Ok(" " <> param <> ": " <> string_to_string(p) <> ";") _ -> Error(Nil) } } fn template_areas_to_string(param) { case param { [Field(None, List(content, _))] -> Ok( " grid-template-areas:\n" <> list.map(content, string_to_string) |> list.map(fn(a) { " \"" <> a <> "\"" }) |> string.join("\n") <> ";", ) _ -> Error(Nil) } } fn string_to_string(param) { case param { glance.String(m) -> m glance.Variable(v) -> "var(--" <> string.replace(v, each: "_", with: "-") <> ")" _ -> "" } } fn property_body(param) { case param { [Field(None, p)] -> { case rewrite_pipe(p) { Call(FieldAccess(Variable(_), size), [Field(None, value)]) -> size_to_string(value) <> size Call(Variable(size), [Field(None, value)]) -> size_to_string(value) <> size p -> string_to_string(p) } } _ -> string.inspect(param) } } fn size_to_string(value) { case rewrite_pipe(value) { glance.Int(i) -> i glance.Float(f) -> f _ -> "" } } /// Generate stylesheets from Gleam style definitions files. Recursively extract /// all files ending with `_styles.gleam`, `_css.gleam` or `_sketch.gleam` to /// proper stylesheets, and output some files interfaces to interact with them. /// /// `src` should be a relative path containing the source files. /// `dst` should be a relative path where to output CSS files. /// `interface` should be a relative path where to output Gleam files. pub fn generate_stylesheets( src src: String, dst dst: String, interface src_interfaces: String, ) { use is_dir <- result.try(simplifile.is_directory(src) |> error.simplifile) use <- bool.guard(when: !is_dir, return: error.not_a_directory(src)) use modules <- result.map(recursive_modules_read(src) |> error.simplifile) let css_modules = compute_styles_modules(modules, src_interfaces) let _ = simplifile.create_directory_all(dst) use #(module, css_module) <- list.each(css_modules) let dst_path = string.replace(module.path, each: src, with: dst) let dst_path = string.replace(dst_path, each: ".gleam", with: ".css") let parent_dst_path = remove_file(dst_path) let _ = simplifile.create_directory_all(parent_dst_path) let _ = simplifile.write(dst_path, string.join(css_module.content, "\n\n")) let src_styles_path = string.replace(module.path, each: src, with: src_interfaces) let parent_src_styles_path = remove_file(src_styles_path) let _ = simplifile.create_directory_all(parent_src_styles_path) let _ = simplifile.write(src_styles_path, { list.map(css_module.classes, fn(c) { "pub const " <> c.0 <> " = \"" <> c.1 <> "\"" }) |> string.join("\n\n") }) } /// Compute the content of a bunch of Gleam Styles modules. /// This function is designed to be used outside of the CLI, if you need it in /// your frontend for example. pub fn compute_modules(modules: List(Module)) { let styles_modules = parse_modules(modules) parse_css_modules(styles_modules, modules) } fn compute_styles_modules(modules: List(Module), interface: String) { let modules = parse_modules(modules) let styles_modules = select_css_files(modules, interface) parse_css_modules(styles_modules, modules) } fn remove_file(dst_path) { dst_path |> string.split("/") |> list.reverse |> list.drop(1) |> list.reverse() |> string.join("/") } fn rewrite_pipe(expression) { case expression { glance.BinaryOperator(glance.Pipe, left, right) -> { case right { Call(f, args) -> Call(f, list.prepend(args, Field(None, left))) Variable(v) -> glance.Call(Variable(v), [Field(None, left)]) _ -> expression } } _ -> expression } } fn dst_flag() { glint.string_flag("dest") |> glint.flag_default("styles") |> glint.flag_help("Define the directory in which styles should be output.") } fn src_flag() { glint.string_flag("src") |> glint.flag_default("src") |> glint.flag_help( "Define the directory in which styles should be read. Default to src.", ) } fn interface_flag() { glint.string_flag("interface") |> glint.flag_default("src/sketch/styles") |> glint.flag_help( "Define the directory in which interfaces should be output. Default to src/sketch/styles.", ) } fn css() -> glint.Command(Nil) { use <- glint.command_help("Generate CSS for your gleam_styles.gleam files!") use dst <- glint.flag(dst_flag()) use src <- glint.flag(src_flag()) use interface <- glint.flag(interface_flag()) use _, _, flags <- glint.command() let assert Ok(dst) = dst(flags) let assert Ok(src) = src(flags) let assert Ok(interface) = interface(flags) let assert Ok(cwd) = simplifile.current_directory() let src = string.join([cwd, src], "/") let dst = string.join([cwd, dst], "/") let interface = string.join([cwd, interface], "/") io.println("Compiling Gleam styles files in " <> src) io.println("Writing CSS files to " <> dst) io.println("Writing interfaces files to " <> interface) let _ = generate_stylesheets(src:, dst:, interface:) io.println("Done!") Nil } /// The `main` function is used as an entrypoint for Sketch CSS. That function /// not meant to be used in your code, but is called when you use `gleam run` /// from the command line. /// /// ``` /// gleam run -m sketch/css /// ``` pub fn main() { glint.new() |> glint.with_name("Sketch CSS") |> glint.pretty_help(glint.default_pretty_help()) |> glint.add(at: ["generate"], do: css()) |> glint.run(argv.load().arguments) }