diff --git a/apps/backend/gleam.toml b/apps/backend/gleam.toml index 019634a..5b4d239 100644 --- a/apps/backend/gleam.toml +++ b/apps/backend/gleam.toml @@ -12,13 +12,14 @@ gleam_erlang = "~> 0.25" gleam_hexpm = "~> 1.0" gleam_http = "~> 3.6" gleam_httpc = ">= 2.2.0 and < 3.0.0" -gleam_json = "~> 1.0" +gleam_json = ">= 2.1.0 and < 3.0.0" gleam_otp = "~> 0.10" gleam_package_interface = ">= 1.0.0 and < 2.0.0" gleam_regexp = ">= 1.0.0 and < 2.0.0" gleam_stdlib = ">= 0.44.0 and < 1.0.0" gleam_yielder = ">= 1.1.0 and < 2.0.0" glexer = ">= 1.0.1 and < 2.0.0" +interfaces = {path = "../../packages/interfaces"} mist = ">= 3.0.0 and < 4.0.0" pog = ">= 1.0.1 and < 2.0.0" prng = ">= 3.0.3 and < 4.0.0" diff --git a/apps/backend/manifest.toml b/apps/backend/manifest.toml index b6c8c5c..618c275 100644 --- a/apps/backend/manifest.toml +++ b/apps/backend/manifest.toml @@ -21,17 +21,18 @@ packages = [ { name = "gleam_hexpm", version = "1.1.0", build_tools = ["gleam"], requirements = ["birl", "gleam_stdlib"], otp_app = "gleam_hexpm", source = "hex", outer_checksum = "D32439FD6AD683FE1094922737904EC2091E2D7B1F236AD23815935694A5221A" }, { name = "gleam_http", version = "3.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "A9EE0722106FCCAB8AD3BF9D0A3EFF92BFE8561D59B83BAE96EB0BE1938D4E0F" }, { name = "gleam_httpc", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "CF6CDD88830CC9853F7638ECC0BE7D7CD9522640DA5FAB4C08CFAC8DEBD08028" }, - { name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" }, + { name = "gleam_json", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "0A57FB5666E695FD2BEE74C0428A98B0FC11A395D2C7B4CDF5E22C5DD32C74C6" }, { name = "gleam_otp", version = "0.14.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "5A8CE8DBD01C29403390A7BD5C0A63D26F865C83173CF9708E6E827E53159C65" }, { name = "gleam_package_interface", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_package_interface", source = "hex", outer_checksum = "CF3BFC5D0997750D9550D8D73A90F4B8D71C6C081B20ED4E70FFBE1E99AFC3C2" }, { name = "gleam_regexp", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "A3655FDD288571E90EE9C4009B719FEF59FA16AFCDF3952A76A125AF23CF1592" }, - { name = "gleam_stdlib", version = "0.44.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "A6E55E309A6778206AAD4038D9C49E15DF71027A1DB13C6ADA06BFDB6CF1260E" }, + { name = "gleam_stdlib", version = "0.45.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "206FCE1A76974AECFC55AEBCD0217D59EDE4E408C016E2CFCCC8FF51278F186E" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, { name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" }, { name = "glexer", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glexer", source = "hex", outer_checksum = "BD477AD657C2B637FEF75F2405FAEFFA533F277A74EF1A5E17B55B1178C228FB" }, { name = "glisten", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "912132751031473CB38F454120124FFC96AF6B0EA33D92C9C90DB16327A2A972" }, { name = "gramps", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "3CCAA6E081225180D95C79679D383BBF51C8D1FDC1B84DA1DA444F628C373793" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "interfaces", version = "1.0.0", build_tools = ["gleam"], requirements = ["birl", "gleam_json", "gleam_stdlib"], source = "local", path = "../../packages/interfaces" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, { name = "mist", version = "3.0.0", build_tools = ["gleam"], requirements = ["birl", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "CDA1A74E768419235E16886463EC4722EFF4AB3F8D820A76EAD45D7C167D7282" }, @@ -48,7 +49,6 @@ packages = [ { name = "simplifile", version = "2.2.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0DFABEF7DC7A9E2FF4BB27B108034E60C81BEBFCB7AB816B9E7E18ED4503ACD8" }, { name = "stoiridh_version", version = "0.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "stoiridh_version", source = "hex", outer_checksum = "EEF8ADAB9755BD33EB202F169376F1A7797AEF90823FDCA671D8590D04FBF56B" }, { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, - { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, { name = "tom", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "228E667239504B57AD05EC3C332C930391592F6C974D0EFECF32FFD0F3629A27" }, { name = "verl", version = "1.1.1", build_tools = ["rebar3"], requirements = [], otp_app = "verl", source = "hex", outer_checksum = "0925E51CD92A0A8BE271765B02430B2E2CFF8AC30EF24D123BD0D58511E8FB18" }, { name = "wisp", version = "1.3.0", build_tools = ["gleam"], requirements = ["directories", "exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "D12384EE63ADEE833B40A6D26EF8014A6E4BFC10EC2CDC8B57D325DD4B52740E" }, @@ -65,7 +65,7 @@ gleam_erlang = { version = "~> 0.25" } gleam_hexpm = { version = "~> 1.0" } gleam_http = { version = "~> 3.6" } gleam_httpc = { version = ">= 2.2.0 and < 3.0.0" } -gleam_json = { version = "~> 1.0" } +gleam_json = { version = ">= 2.1.0 and < 3.0.0" } gleam_otp = { version = "~> 0.10" } gleam_package_interface = { version = ">= 1.0.0 and < 2.0.0" } gleam_regexp = { version = ">= 1.0.0 and < 2.0.0" } @@ -73,6 +73,7 @@ gleam_stdlib = { version = ">= 0.44.0 and < 1.0.0" } gleam_yielder = { version = ">= 1.1.0 and < 2.0.0" } gleeunit = { version = "~> 1.0" } glexer = { version = ">= 1.0.1 and < 2.0.0" } +interfaces = { path = "../../packages/interfaces" } mist = { version = ">= 3.0.0 and < 4.0.0" } pog = { version = ">= 1.0.1 and < 2.0.0" } pprint = { version = ">= 1.0.3 and < 2.0.0" } diff --git a/apps/backend/src/backend/data/hex_read.gleam b/apps/backend/src/backend/data/hex_read.gleam index d4caa20..8f5c6dc 100644 --- a/apps/backend/src/backend/data/hex_read.gleam +++ b/apps/backend/src/backend/data/hex_read.gleam @@ -9,7 +9,7 @@ pub type HexRead { pub fn decode(data) { dynamic.decode2( HexRead, - dynamic.element(0, dynamic.int), - dynamic.element(1, helpers.decode_time), + dynamic.field("id", dynamic.int), + dynamic.field("last_check", helpers.decode_time), )(data) } diff --git a/apps/backend/src/backend/data/hex_user.gleam b/apps/backend/src/backend/data/hex_user.gleam index 04c5a2d..abed1a6 100644 --- a/apps/backend/src/backend/data/hex_user.gleam +++ b/apps/backend/src/backend/data/hex_user.gleam @@ -17,11 +17,11 @@ pub type HexUser { pub fn decode(data) { dynamic.decode6( HexUser, - dynamic.element(0, dynamic.int), - dynamic.element(1, dynamic.string), - dynamic.element(2, dynamic.optional(dynamic.string)), - dynamic.element(3, dynamic.string), - dynamic.element(4, helpers.decode_time), - dynamic.element(5, helpers.decode_time), + dynamic.field("id", dynamic.int), + dynamic.field("username", dynamic.string), + dynamic.field("email", dynamic.optional(dynamic.string)), + dynamic.field("url", dynamic.string), + dynamic.field("created_at", helpers.decode_time), + dynamic.field("updated_at", helpers.decode_time), )(data) } diff --git a/apps/backend/src/backend/error.gleam b/apps/backend/src/backend/error.gleam index f4bb5c0..bb85d12 100644 --- a/apps/backend/src/backend/error.gleam +++ b/apps/backend/src/backend/error.gleam @@ -42,15 +42,13 @@ pub fn log_dynamic_error(error: dynamic.DecodeError) { pub fn log_decode_error(error: json.DecodeError) { case error { json.UnexpectedEndOfInput -> wisp.log_warning("Unexpected end of input") - json.UnexpectedByte(byte, position) -> { + json.UnexpectedByte(byte) -> { wisp.log_warning("Unexpected byte") wisp.log_warning(" byte: " <> byte) - wisp.log_warning(" position: " <> int.to_string(position)) } - json.UnexpectedSequence(byte, position) -> { + json.UnexpectedSequence(byte) -> { wisp.log_warning("Unexpected sequence") wisp.log_warning(" byte: " <> byte) - wisp.log_warning(" position: " <> int.to_string(position)) } json.UnexpectedFormat(errors) -> { wisp.log_warning("Unexpected format") diff --git a/apps/backend/src/backend/gleam/generate/types.gleam b/apps/backend/src/backend/gleam/generate/types.gleam index 4c4f7d4..6967abd 100644 --- a/apps/backend/src/backend/gleam/generate/types.gleam +++ b/apps/backend/src/backend/gleam/generate/types.gleam @@ -122,14 +122,18 @@ fn type_to_json(ctx: Context, type_: Type) { } fn find_package_release(ctx: Context, package: String, requirement: String) { - "SELECT package_release.id, package_release.version + "SELECT package_release.id id, package_release.version version FROM package JOIN package_release ON package.id = package_release.package_id WHERE package.name = $1" |> pog.query |> pog.parameter(pog.text(package)) - |> pog.returning(dynamic.tuple2(dynamic.int, dynamic.string)) + |> pog.returning(dynamic.decode2( + pair.new, + dynamic.field("id", dynamic.int), + dynamic.field("version", dynamic.string), + )) |> pog.execute(ctx.db) |> result.map_error(error.DatabaseError) |> result.map(fn(response) { response.rows }) @@ -173,7 +177,7 @@ fn find_signature_from_release( ) { use acc, release <- list.fold(releases, error.empty()) use <- bool.guard(when: result.is_ok(acc), return: acc) - "SELECT release.version, signature.id + "SELECT release.version version, signature.id id FROM package_release release JOIN package_module module ON module.package_release_id = release.id @@ -186,7 +190,11 @@ fn find_signature_from_release( |> pog.parameter(pog.text(name)) |> pog.parameter(pog.text(module)) |> pog.parameter(pog.int(release)) - |> pog.returning(dynamic.tuple2(dynamic.string, dynamic.int)) + |> pog.returning(dynamic.decode2( + pair.new, + dynamic.field("version", dynamic.string), + dynamic.field("id", dynamic.int), + )) |> pog.execute(ctx.db) |> result.map_error(error.DatabaseError) |> result.try(fn(response) { diff --git a/apps/backend/src/backend/gleam/type_search/state.gleam b/apps/backend/src/backend/gleam/type_search/state.gleam index e8c057c..5825ee6 100644 --- a/apps/backend/src/backend/gleam/type_search/state.gleam +++ b/apps/backend/src/backend/gleam/type_search/state.gleam @@ -8,6 +8,7 @@ import gleam/function import gleam/list import gleam/option import gleam/otp/actor +import gleam/pair import gleam/result import pog @@ -101,7 +102,11 @@ fn compute_rows( OFFSET $1" |> pog.query |> pog.parameter(pog.int(offset)) - |> pog.returning(dynamic.tuple2(dynamic.string, dynamic.int)) + |> pog.returning(dynamic.decode2( + pair.new, + dynamic.field("signature_", dynamic.string), + dynamic.field("id", dynamic.int), + )) |> pog.execute(db) |> result.map(fn(r) { r.rows }) |> result.unwrap([]) diff --git a/apps/backend/src/backend/postgres/postgres.gleam b/apps/backend/src/backend/postgres/postgres.gleam index 45170e1..8ccc051 100644 --- a/apps/backend/src/backend/postgres/postgres.gleam +++ b/apps/backend/src/backend/postgres/postgres.gleam @@ -9,7 +9,9 @@ import pog.{Config} pub fn connect(database_url: String) { let assert Ok(config) = parse_database_url(database_url) - pog.connect(config) + config + |> pog.rows_as_map(True) + |> pog.connect } fn parse_database_url(database_url: String) { diff --git a/apps/backend/src/backend/postgres/queries.gleam b/apps/backend/src/backend/postgres/queries.gleam index edb7cab..77a4c5c 100644 --- a/apps/backend/src/backend/postgres/queries.gleam +++ b/apps/backend/src/backend/postgres/queries.gleam @@ -3,6 +3,9 @@ import backend/data/hex_user.{type HexUser} import backend/error import backend/gleam/context import birl.{type Time} +import data/analytics +import data/package +import data/type_search import gleam/bool import gleam/dict.{type Dict} import gleam/dynamic @@ -48,7 +51,7 @@ pub fn upsert_most_recent_hex_timestamp(db: pog.Connection, latest: Time) { VALUES (1, $1) ON CONFLICT (id) DO UPDATE SET last_check = $1 - RETURNING *" + RETURNING id, last_check" |> pog.query |> pog.parameter(helpers.convert_time(latest)) |> pog.returning(hex_read.decode) @@ -83,18 +86,18 @@ pub fn upsert_search_analytics(db: pog.Connection, query: String) { pub fn select_more_popular_packages(db: pog.Connection) { use ranked <- result.try({ - "SELECT name, repository, rank, (popularity -> 'github')::int + "SELECT name, repository, rank, (popularity -> 'github')::int AS popularity FROM package ORDER BY rank DESC LIMIT 22" |> pog.query - |> pog.returning(decode_popular_package) + |> pog.returning(analytics.decode_package) |> pog.execute(db) |> result.map(fn(r) { r.rows }) |> result.map_error(error.DatabaseError) }) use popular <- result.try({ - "SELECT name, repository, rank, (popularity -> 'github')::int + "SELECT name, repository, rank, (popularity -> 'github')::int AS popularity FROM package WHERE popularity -> 'github' IS NOT NULL AND name != 'funtil' @@ -102,7 +105,7 @@ pub fn select_more_popular_packages(db: pog.Connection) { ORDER BY popularity -> 'github' DESC LIMIT 23" |> pog.query - |> pog.returning(decode_popular_package) + |> pog.returning(analytics.decode_package) |> pog.execute(db) |> result.map(fn(r) { r.rows }) |> result.map_error(error.DatabaseError) @@ -110,15 +113,6 @@ pub fn select_more_popular_packages(db: pog.Connection) { Ok(#(ranked, popular)) } -fn decode_popular_package(dyn) { - dynamic.tuple4( - dynamic.string, - dynamic.string, - dynamic.int, - dynamic.optional(dynamic.int), - )(dyn) -} - pub fn select_last_day_search_analytics(db: pog.Connection) { let #(date, _) = birl.to_erlang_universal_datetime(birl.now()) let now = birl.from_erlang_universal_datetime(#(date, #(0, 0, 0))) @@ -127,7 +121,11 @@ pub fn select_last_day_search_analytics(db: pog.Connection) { WHERE updated_at >= $1" |> pog.query |> pog.parameter(helpers.convert_time(now)) - |> pog.returning(dynamic.tuple2(dynamic.string, dynamic.int)) + |> pog.returning(dynamic.decode2( + pair.new, + dynamic.field("query", dynamic.string), + dynamic.field("occurences", dynamic.int), + )) |> pog.execute(db) |> result.map(fn(r) { r.rows }) |> result.map_error(error.DatabaseError) @@ -170,7 +168,11 @@ pub fn get_timeseries_count(db: pog.Connection) { GROUP BY at.date ORDER BY date DESC" |> pog.query - |> pog.returning(dynamic.tuple2(dynamic.int, helpers.decode_time)) + |> pog.returning(dynamic.decode2( + pair.new, + dynamic.field("searches", dynamic.int), + dynamic.field("date", helpers.decode_time), + )) |> pog.execute(db) |> result.map_error(error.DatabaseError) |> result.map(fn(r) { r.rows }) @@ -200,39 +202,39 @@ fn upsert_package_owners(db: pog.Connection, owners: List(hexpm.PackageOwner)) { } fn get_current_package_owners(db: pog.Connection, package_id: Int) { - "SELECT package_owner.hex_user_id + "SELECT package_owner.hex_user_id AS user_id FROM package_owner WHERE package_owner.package_id = $1" |> pog.query |> pog.parameter(pog.int(package_id)) - |> pog.returning(dynamic.element(0, dynamic.int)) + |> pog.returning(dynamic.field("user_id", dynamic.int)) |> pog.execute(db) |> result.map(fn(r) { r.rows }) |> result.map_error(error.DatabaseError) } pub fn get_total_searches(db: pog.Connection) { - "SELECT SUM(occurences) FROM search_analytics" + "SELECT SUM(occurences) occurences FROM search_analytics" |> pog.query - |> pog.returning(dynamic.element(0, dynamic.int)) + |> pog.returning(dynamic.field("occurences", dynamic.int)) |> pog.execute(db) |> result.map(fn(r) { r.rows }) |> result.map_error(error.DatabaseError) } pub fn get_total_signatures(db: pog.Connection) { - "SELECT COUNT(*) FROM package_type_fun_signature" + "SELECT COUNT(*) c FROM package_type_fun_signature" |> pog.query - |> pog.returning(dynamic.element(0, dynamic.int)) + |> pog.returning(dynamic.field("c", dynamic.int)) |> pog.execute(db) |> result.map(fn(r) { r.rows }) |> result.map_error(error.DatabaseError) } pub fn get_total_packages(db: pog.Connection) { - "SELECT COUNT(*) FROM package" + "SELECT COUNT(*) c FROM package" |> pog.query - |> pog.returning(dynamic.element(0, dynamic.int)) + |> pog.returning(dynamic.field("c", dynamic.int)) |> pog.execute(db) |> result.map(fn(r) { r.rows }) |> result.map_error(error.DatabaseError) @@ -320,7 +322,7 @@ pub fn upsert_package(db: pog.Connection, package: hexpm.Package) { |> pog.parameter(package.meta.links |> helpers.json_dict() |> pog.text()) |> pog.parameter(package.meta.licenses |> helpers.json_list() |> pog.text()) |> pog.parameter(pog.nullable(pog.text, package.meta.description)) - |> pog.returning(dynamic.element(0, dynamic.int)) + |> pog.returning(dynamic.field("id", dynamic.int)) |> pog.execute(db) |> result.map_error(error.DatabaseError) |> result.try(fn(response) { @@ -363,10 +365,11 @@ pub fn upsert_release( |> birl.to_erlang_universal_datetime |> coerce }) - |> pog.returning(dynamic.tuple3( - dynamic.int, - dynamic.optional(dynamic.string), - dynamic.optional(dynamic.string), + |> pog.returning(dynamic.decode3( + fn(a, b, c) { #(a, b, c) }, + dynamic.field("id", dynamic.int), + dynamic.field("package_interface", dynamic.optional(dynamic.string)), + dynamic.field("gleam_toml", dynamic.optional(dynamic.string)), )) |> pog.execute(db) |> result.map_error(error.DatabaseError) @@ -383,10 +386,11 @@ pub fn lookup_release( |> pog.query |> pog.parameter(pog.int(package_id)) |> pog.parameter(pog.text(release.version)) - |> pog.returning(dynamic.tuple3( - dynamic.int, - dynamic.optional(dynamic.string), - dynamic.optional(dynamic.string), + |> pog.returning(dynamic.decode3( + fn(a, b, c) { #(a, b, c) }, + dynamic.field("id", dynamic.int), + dynamic.field("package_interface", dynamic.optional(dynamic.string)), + dynamic.field("gleam_toml", dynamic.optional(dynamic.string)), )) |> pog.execute(db) |> result.map_error(error.DatabaseError) @@ -456,7 +460,11 @@ pub fn get_package_release_ids( |> pog.query |> pog.parameter(pog.text(package.name)) |> pog.parameter(pog.text(package.version)) - |> pog.returning(dynamic.tuple2(dynamic.int, dynamic.int)) + |> pog.returning(dynamic.decode2( + pair.new, + dynamic.field("package_id", dynamic.int), + dynamic.field("package_release_id", dynamic.int), + )) |> pog.execute(db) |> result.map_error(error.DatabaseError) |> result.try(fn(response) { @@ -482,7 +490,7 @@ pub fn upsert_package_module(db: pog.Connection, module: context.Module) { |> pog.text() }) |> pog.parameter(pog.int(module.release_id)) - |> pog.returning(dynamic.element(0, dynamic.int)) + |> pog.returning(dynamic.field("id", dynamic.int)) |> pog.execute(db) |> result.map_error(error.DatabaseError) }) @@ -565,7 +573,7 @@ pub fn upsert_package_type_fun_signature( |> option.map(pog.text) |> option.unwrap(pog.null()) }) - |> pog.returning(dynamic.element(0, dynamic.int)) + |> pog.returning(dynamic.field("id", dynamic.int)) |> pog.execute(db) |> result.map_error(error.DatabaseError) |> result.map(fn(r) { r.rows }) @@ -600,7 +608,7 @@ pub fn find_similar_type_names(db: pog.Connection, name: String) { AND levenshtein_less_equal(name, $1, 2) <= 2" |> pog.query |> pog.parameter(pog.text(name)) - |> pog.returning(dynamic.element(0, dynamic.string)) + |> pog.returning(dynamic.field("name", dynamic.string)) |> pog.execute(db) |> result.map_error(error.DatabaseError) |> result.map(fn(r) { r.rows }) @@ -609,13 +617,13 @@ pub fn find_similar_type_names(db: pog.Connection, name: String) { pub fn name_search(db: pog.Connection, query: String) { "SELECT DISTINCT ON (package_rank, ordering, type_name, signature_kind, module_name) s.name type_name, - s.documentation, + s.documentation documentation, s.kind signature_kind, - s.metadata, + s.metadata metadata, s.json_signature, m.name module_name, - p.name, - r.version, + p.name package_name, + r.version version, p.rank package_rank, string_to_array(regexp_replace(r.version, '([0-9]+).([0-9]+).([0-9]+).*', '\\1.\\2.\\3'), '.')::int[] AS ordering FROM package_type_fun_signature s @@ -630,7 +638,7 @@ pub fn name_search(db: pog.Connection, query: String) { LIMIT 100" |> pog.query |> pog.parameter(pog.text(query)) - |> pog.returning(decode_type_search) + |> pog.returning(type_search.decode) |> pog.execute(db) |> result.map_error(error.DatabaseError) |> result.map(fn(r) { r.rows }) @@ -640,13 +648,13 @@ pub fn module_and_name_search(db: pog.Connection, query: String) { "WITH splitted_name AS (SELECT string_to_array($1, '.') AS full_name) SELECT DISTINCT ON (package_rank, ordering, type_name, signature_kind, module_name) s.name type_name, - s.documentation, + s.documentation documentation, s.kind signature_kind, - s.metadata, + s.metadata metadata, s.json_signature, m.name module_name, - p.name, - r.version, + p.name package_name, + r.version version, p.rank package_rank, string_to_array(regexp_replace(r.version, '([0-9]+).([0-9]+).([0-9]+).*', '\\1.\\2.\\3'), '.')::int[] AS ordering FROM package_type_fun_signature s @@ -664,7 +672,7 @@ pub fn module_and_name_search(db: pog.Connection, query: String) { LIMIT 100" |> pog.query |> pog.parameter(pog.text(query)) - |> pog.returning(decode_type_search) + |> pog.returning(type_search.decode) |> pog.execute(db) |> result.map_error(error.DatabaseError) |> result.map(fn(r) { r.rows }) @@ -684,13 +692,13 @@ fn transform_query(q: String) { pub fn content_search(db: pog.Connection, query: String) { "SELECT DISTINCT ON (package_rank, ordering, type_name, signature_kind, module_name) s.name type_name, - s.documentation, + s.documentation documentation, s.kind signature_kind, - s.metadata, + s.metadata metadata, s.json_signature, m.name module_name, - p.name, - r.version, + p.name package_name, + r.version version, p.rank package_rank, string_to_array(regexp_replace(r.version, '([0-9]+).([0-9]+).([0-9]+).*', '\\1.\\2.\\3'), '.')::int[] AS ordering FROM package_type_fun_signature s @@ -712,50 +720,22 @@ pub fn content_search(db: pog.Connection, query: String) { |> pog.query |> pog.parameter(pog.text(query)) |> pog.parameter(pog.text(transform_query(query))) - |> pog.returning(decode_type_search) + |> pog.returning(type_search.decode) |> pog.execute(db) |> result.map_error(error.DatabaseError) |> result.map(fn(r) { r.rows }) } -fn decode_type_search(dyn) { - dynamic.decode8( - fn(a, b, c, d, e, f, g, h) { #(a, b, c, d, e, f, g, h) }, - dynamic.element(0, dynamic.string), - dynamic.element(1, dynamic.string), - dynamic.element(2, dynamic.string), - dynamic.element(3, dynamic.dynamic), - dynamic.element(4, dynamic.dynamic), - dynamic.element(5, dynamic.string), - dynamic.element(6, dynamic.string), - dynamic.element(7, dynamic.string), - )(dyn) -} - -pub fn type_search_to_json(item) { - let #(a, b, c, d, e, f, g, h) = item - json.object([ - #("name", json.string(a)), - #("documentation", json.string(b)), - #("kind", json.string(c)), - #("metadata", coerce(d)), - #("json_signature", coerce(e)), - #("module_name", json.string(f)), - #("package_name", json.string(g)), - #("version", json.string(h)), - ]) -} - pub fn signature_search(db: pog.Connection, q: String) { "SELECT DISTINCT ON (package_rank, ordering, type_name, signature_kind, module_name) s.name type_name, - s.documentation, + s.documentation documentation, s.kind signature_kind, - s.metadata, + s.metadata metadata, s.json_signature, m.name module_name, - p.name, - r.version, + p.name package_name, + r.version version, p.rank package_rank, string_to_array(regexp_replace(r.version, '([0-9]+).([0-9]+).([0-9]+).*', '\\1.\\2.\\3'), '.')::int[] AS ordering FROM package_type_fun_signature s @@ -770,7 +750,7 @@ pub fn signature_search(db: pog.Connection, q: String) { LIMIT 100" |> pog.query |> pog.parameter(pog.text(q)) - |> pog.returning(decode_type_search) + |> pog.returning(type_search.decode) |> pog.execute(db) |> result.map_error(error.DatabaseError) |> result.map(fn(r) { r.rows }) @@ -779,13 +759,13 @@ pub fn signature_search(db: pog.Connection, q: String) { pub fn documentation_search(db: pog.Connection, q: String) { "SELECT DISTINCT ON (package_rank, ordering, type_name, signature_kind, module_name) s.name type_name, - s.documentation, + s.documentation documentation, s.kind signature_kind, - s.metadata, + s.metadata metadata, s.json_signature, m.name module_name, - p.name, - r.version, + p.name package_name, + r.version version, p.rank package_rank, string_to_array(regexp_replace(r.version, '([0-9]+).([0-9]+).([0-9]+).*', '\\1.\\2.\\3'), '.')::int[] AS ordering FROM package_type_fun_signature s @@ -800,7 +780,7 @@ pub fn documentation_search(db: pog.Connection, q: String) { LIMIT 100" |> pog.query |> pog.parameter(pog.text(q)) - |> pog.returning(decode_type_search) + |> pog.returning(type_search.decode) |> pog.execute(db) |> result.map_error(error.DatabaseError) |> result.map(fn(r) { r.rows }) @@ -809,13 +789,13 @@ pub fn documentation_search(db: pog.Connection, q: String) { pub fn module_search(db: pog.Connection, q: String) { "SELECT DISTINCT ON (package_rank, ordering, type_name, signature_kind, module_name) s.name type_name, - s.documentation, + s.documentation documentation, s.kind signature_kind, - s.metadata, + s.metadata metadata, s.json_signature, m.name module_name, - p.name, - r.version, + p.name package_name, + r.version version, p.rank package_rank, string_to_array(regexp_replace(r.version, '([0-9]+).([0-9]+).([0-9]+).*', '\\1.\\2.\\3'), '.')::int[] AS ordering FROM package_type_fun_signature s @@ -839,7 +819,7 @@ pub fn module_search(db: pog.Connection, q: String) { ORDER BY package_rank DESC, ordering DESC, type_name, signature_kind, module_name" |> pog.query |> pog.parameter(pog.text(q)) - |> pog.returning(decode_type_search) + |> pog.returning(type_search.decode) |> pog.execute(db) |> result.map_error(error.DatabaseError) |> result.map(fn(r) { r.rows }) @@ -853,13 +833,13 @@ pub fn exact_type_search(db: pog.Connection, q: List(Int)) { { "SELECT DISTINCT ON (package_rank, ordering, type_name, signature_kind, module_name) s.name type_name, - s.documentation, + s.documentation documentation, s.kind signature_kind, - s.metadata, + s.metadata metadata, s.json_signature, m.name module_name, - p.name, - r.version, + p.name package_name, + r.version version, p.rank package_rank, string_to_array(regexp_replace(r.version, '([0-9]+).([0-9]+).([0-9]+).*', '\\1.\\2.\\3'), '.')::int[] AS ordering FROM package_type_fun_signature s @@ -876,7 +856,7 @@ pub fn exact_type_search(db: pog.Connection, q: List(Int)) { } |> pog.query |> list.fold(q, _, fn(query, q) { pog.parameter(query, pog.int(q)) }) - |> pog.returning(decode_type_search) + |> pog.returning(type_search.decode) |> pog.execute(db) |> result.map_error(error.DatabaseError) |> result.map(fn(r) { r.rows }) @@ -891,7 +871,7 @@ pub fn select_gleam_toml(db: pog.Connection, offset: Int) { OFFSET $1" |> pog.query |> pog.parameter(pog.int(offset)) - |> pog.returning(dynamic.element(0, dynamic.string)) + |> pog.returning(dynamic.field("gleam_toml", dynamic.string)) |> pog.execute(db) |> result.map_error(error.DatabaseError) |> result.map(fn(r) { r.rows }) @@ -912,7 +892,11 @@ pub fn select_package_repository_address(db: pog.Connection, offset: Int) { |> pog.query |> pog.parameter(pog.int(offset)) |> pog.returning(fn(dyn) { - dynamic.tuple2(dynamic.int, dynamic.optional(dynamic.string))(dyn) + dynamic.decode2( + pair.new, + dynamic.field("id", dynamic.int), + dynamic.field("repository", dynamic.optional(dynamic.string)), + )(dyn) |> result.map(fn(content) { case content { #(id, Some(repo)) -> Some(#(id, repo)) @@ -963,28 +947,7 @@ pub fn select_package_by_popularity(db: pog.Connection, page: Int) { OFFSET $1" |> pog.query |> pog.parameter(pog.int(offset)) - |> pog.returning(dynamic.decode8( - fn(a, b, c, d, e, f, g, h) { - json.object([ - #("name", json.string(a)), - #("repository", json.nullable(b, json.string)), - #("documentation", json.nullable(c, json.string)), - #("hex-url", json.nullable(d, json.string)), - #("licenses", json.string(e)), - #("description", json.nullable(f, json.string)), - #("rank", json.int(g)), - #("popularity", json.nullable(h, json.string)), - ]) - }, - dynamic.element(0, dynamic.string), - dynamic.element(1, dynamic.optional(dynamic.string)), - dynamic.element(2, dynamic.optional(dynamic.string)), - dynamic.element(3, dynamic.optional(dynamic.string)), - dynamic.element(4, dynamic.string), - dynamic.element(5, dynamic.optional(dynamic.string)), - dynamic.element(6, dynamic.int), - dynamic.element(7, dynamic.optional(dynamic.string)), - )) + |> pog.returning(package.decode) |> pog.execute(db) |> result.map(fn(r) { r.rows }) |> result.map_error(error.DatabaseError) @@ -1003,28 +966,7 @@ pub fn select_package_by_updated_at(db: pog.Connection) { FROM package ORDER BY updated_at DESC" |> pog.query - |> pog.returning(dynamic.decode8( - fn(a, b, c, d, e, f, g, h) { - json.object([ - #("name", json.string(a)), - #("repository", json.nullable(b, json.string)), - #("documentation", json.nullable(c, json.string)), - #("hex-url", json.nullable(d, json.string)), - #("licenses", json.string(e)), - #("description", json.nullable(f, json.string)), - #("rank", json.int(g)), - #("popularity", json.nullable(h, json.string)), - ]) - }, - dynamic.element(0, dynamic.string), - dynamic.element(1, dynamic.optional(dynamic.string)), - dynamic.element(2, dynamic.optional(dynamic.string)), - dynamic.element(3, dynamic.optional(dynamic.string)), - dynamic.element(4, dynamic.string), - dynamic.element(5, dynamic.optional(dynamic.string)), - dynamic.element(6, dynamic.int), - dynamic.element(7, dynamic.optional(dynamic.string)), - )) + |> pog.returning(package.decode) |> pog.execute(db) |> result.map(fn(r) { r.rows }) |> result.map_error(error.DatabaseError) diff --git a/apps/backend/src/backend/router.gleam b/apps/backend/src/backend/router.gleam index 5c57d6e..5330d62 100644 --- a/apps/backend/src/backend/router.gleam +++ b/apps/backend/src/backend/router.gleam @@ -1,11 +1,13 @@ import api/hex import backend/context.{type Context} import backend/error -import backend/gleam/type_search/msg as type_search +import backend/gleam/type_search/msg import backend/postgres/queries import backend/web -import birl import cors_builder as cors +import data/analytics +import data/package +import data/type_search import gleam/erlang/process import gleam/http import gleam/int @@ -30,7 +32,7 @@ fn search(query: String, ctx: Context) { let exact_type_searches = option.then(ctx.type_search_subject, fn(subject) { - process.try_call(subject, type_search.Find(_, query), within: 25_000) + process.try_call(subject, msg.Find(_, query), within: 25_000) |> option.from_result |> option.flatten }) @@ -109,29 +111,14 @@ fn search(query: String, ctx: Context) { }) json.object([ - #( - "exact-type-matches", - json.array(exact_type_searches, queries.type_search_to_json), - ), - #("exact-matches", json.array(exact_matches, queries.type_search_to_json)), - #("matches", json.array(matches, queries.type_search_to_json)), - #("searches", json.array(signature_searches, queries.type_search_to_json)), + #("exact-type-matches", json.array(exact_type_searches, type_search.encode)), + #("exact-matches", json.array(exact_matches, type_search.encode)), + #("matches", json.array(matches, type_search.encode)), + #("searches", json.array(signature_searches, type_search.encode)), #("docs-searches", { - json.array(documentation_searches, queries.type_search_to_json) + json.array(documentation_searches, type_search.encode) }), - #("module-searches", { - json.array(module_searches, queries.type_search_to_json) - }), - ]) -} - -fn encode_package(package: #(String, String, Int, option.Option(Int))) { - let #(name, repository, rank, popularity) = package - json.object([ - #("name", json.string(name)), - #("repository", json.string(repository)), - #("rank", json.int(rank)), - #("popularity", json.nullable(popularity, json.int)), + #("module-searches", { json.array(module_searches, type_search.encode) }), ]) } @@ -141,8 +128,8 @@ pub fn handle_get(req: Request, ctx: Context) { ["packages"] -> queries.select_package_by_updated_at(ctx.db) |> result.unwrap([]) - |> json.preprocessed_array - |> json.to_string_builder + |> json.array(package.encode) + |> json.to_string_tree |> wisp.json_response(200) ["trendings"] -> wisp.get_query(req) @@ -153,44 +140,36 @@ pub fn handle_get(req: Request, ctx: Context) { |> queries.select_package_by_popularity(ctx.db, _) |> result.map(fn(content) { content - |> json.preprocessed_array() - |> json.to_string_builder() + |> json.array(package.encode) + |> json.to_string_tree |> wisp.json_response(200) }) |> result.unwrap(wisp.internal_server_error()) ["analytics"] -> { { use timeseries <- result.try(queries.get_timeseries_count(ctx.db)) - use total <- result.try(queries.get_total_searches(ctx.db)) + use total_searches <- result.try(queries.get_total_searches(ctx.db)) use signatures <- result.try(queries.get_total_signatures(ctx.db)) use packages <- result.try(queries.get_total_packages(ctx.db)) use #(ranked, popular) <- result.try({ queries.select_more_popular_packages(ctx.db) }) - let total = list.first(total) |> result.unwrap(0) - let signatures = list.first(signatures) |> result.unwrap(0) - let packages = list.first(packages) |> result.unwrap(0) - Ok(#(timeseries, total, signatures, packages, ranked, popular)) + let total_searches = list.first(total_searches) |> result.unwrap(0) + let total_signatures = list.first(signatures) |> result.unwrap(0) + let total_indexed = list.first(packages) |> result.unwrap(0) + Ok(analytics.Analytics( + timeseries:, + total_searches:, + total_signatures:, + total_indexed:, + ranked:, + popular:, + )) } |> result.map(fn(content) { - let #(timeseries, total, signatures, packages, ranked, popular) = - content - json.object([ - #("total", json.int(total)), - #("signatures", json.int(signatures)), - #("packages", json.int(packages)), - #("ranked", json.array(ranked, encode_package)), - #("popular", json.array(popular, encode_package)), - #("timeseries", { - json.array(timeseries, fn(row) { - json.object([ - #("count", json.int(row.0)), - #("date", json.string(birl.to_iso8601(row.1))), - ]) - }) - }), - ]) - |> json.to_string_builder + content + |> analytics.encode + |> json.to_string_tree |> wisp.json_response(200) }) |> result.map_error(error.debug_log) @@ -202,7 +181,7 @@ pub fn handle_get(req: Request, ctx: Context) { |> result.replace_error(error.EmptyError) |> result.map(fn(item) { search(item.1, ctx) }) |> result.unwrap(json.object([#("error", json.string("internal"))])) - |> json.to_string_builder() + |> json.to_string_tree |> wisp.json_response(200) } _ -> wisp.not_found() diff --git a/apps/frontend/gleam.toml b/apps/frontend/gleam.toml index 7002d5a..67e8da5 100644 --- a/apps/frontend/gleam.toml +++ b/apps/frontend/gleam.toml @@ -10,19 +10,20 @@ typescript_declarations = true [dependencies] birl = ">= 1.7.1 and < 2.0.0" -bright = ">= 0.1.0 and < 1.0.0" +bright = {path = "../../packages/bright"} +gleam_fetch = ">= 0.4.0 and < 1.0.0" +gleam_http = ">= 3.7.1 and < 4.0.0" gleam_javascript = ">= 0.13.0 and < 1.0.0" -gleam_json = ">= 1.0.1 and < 2.0.0" +gleam_json = ">= 2.1.0 and < 3.0.0" gleam_regexp = ">= 1.0.0 and < 2.0.0" gleam_stdlib = "~> 0.34 or ~> 1.0" +grille_pain = ">= 1.1.0 and < 2.0.0" +interfaces = {path = "../../packages/interfaces"} lustre = ">= 4.6.1 and < 5.0.0" modem = ">= 2.0.0 and < 3.0.0" sketch = ">= 3.0.0 and < 4.0.0" sketch_magic = {path = "../../packages/sketch_magic"} vitools = {path = "../../packages/vitools"} -grille_pain = ">= 1.1.0 and < 2.0.0" -gleam_fetch = ">= 0.4.0 and < 1.0.0" -gleam_http = ">= 3.7.1 and < 4.0.0" [dev-dependencies] gleeunit = "~> 1.0" diff --git a/apps/frontend/manifest.toml b/apps/frontend/manifest.toml index 485b88e..495dcfb 100644 --- a/apps/frontend/manifest.toml +++ b/apps/frontend/manifest.toml @@ -4,7 +4,7 @@ packages = [ { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, { name = "birl", version = "1.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" }, - { name = "bright", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre", "lustre_dev_tools"], otp_app = "bright", source = "hex", outer_checksum = "4E3044561BDDC3349E2D8A4B08D43C1D50EEC86EF686F2A141F2E06CFD3D3544" }, + { name = "bright", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre", "lustre_dev_tools"], source = "local", path = "../../packages/bright" }, { name = "conversation", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "conversation", source = "hex", outer_checksum = "908B46F60444442785A495197D482558AD8B849C3714A38FAA1940358CC8CCCD" }, { name = "directories", version = "1.1.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "BDA521A4EB9EE3A7894F0DC863797878E91FF5C7826F7084B2E731E208BDB076" }, { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, @@ -19,7 +19,7 @@ packages = [ { name = "gleam_http", version = "3.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "A9EE0722106FCCAB8AD3BF9D0A3EFF92BFE8561D59B83BAE96EB0BE1938D4E0F" }, { name = "gleam_httpc", version = "4.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "76FEEC99473E568EBA34336A37CF3D54629ACE77712950DC9BB097B5FD664664" }, { name = "gleam_javascript", version = "0.13.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "F98328FCF573DA6F3A35D7F6CB3F9FF19FD5224CCBA9151FCBEAA0B983AF2F58" }, - { name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" }, + { name = "gleam_json", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "0A57FB5666E695FD2BEE74C0428A98B0FC11A395D2C7B4CDF5E22C5DD32C74C6" }, { name = "gleam_otp", version = "0.14.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "5A8CE8DBD01C29403390A7BD5C0A63D26F865C83173CF9708E6E827E53159C65" }, { name = "gleam_package_interface", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_package_interface", source = "hex", outer_checksum = "CF3BFC5D0997750D9550D8D73A90F4B8D71C6C081B20ED4E70FFBE1E99AFC3C2" }, { name = "gleam_regexp", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "A3655FDD288571E90EE9C4009B719FEF59FA16AFCDF3952A76A125AF23CF1592" }, @@ -31,6 +31,7 @@ packages = [ { name = "gramps", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "3CCAA6E081225180D95C79679D383BBF51C8D1FDC1B84DA1DA444F628C373793" }, { name = "grille_pain", version = "1.1.0", build_tools = ["gleam"], requirements = ["birl", "gleam_stdlib", "lustre", "plinth", "sketch", "sketch_lustre"], otp_app = "grille_pain", source = "hex", outer_checksum = "718CB2468EF77EDECE148A98948EF8CC376D3CB96ACCE4983BC6C2F43A254D45" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "interfaces", version = "1.0.0", build_tools = ["gleam"], requirements = ["birl", "gleam_json", "gleam_stdlib"], source = "local", path = "../../packages/interfaces" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "lustre", version = "4.6.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "BDF833368F6C8F152F948D5B6A79866E9881CB80CB66C0685B3327E7DCBFB12F" }, { name = "lustre_dev_tools", version = "1.6.2", build_tools = ["gleam"], requirements = ["argv", "filepath", "fs", "gleam_community_ansi", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_package_interface", "gleam_regexp", "gleam_stdlib", "glint", "glisten", "lustre", "mist", "simplifile", "spinner", "term_size", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "A0CBC323AA7E03EC91D785CEB644776082D76BE46F1624FB920BB92BD79853F7" }, @@ -49,7 +50,6 @@ packages = [ { name = "spinner", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_stdlib", "glearray", "repeatedly"], otp_app = "spinner", source = "hex", outer_checksum = "9EE43AA33BE2DA5731B8F3F170AAB59AF1A815AFA5BF615F12C1B91F3B03F157" }, { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, - { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, { name = "tom", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "228E667239504B57AD05EC3C332C930391592F6C974D0EFECF32FFD0F3629A27" }, { name = "vitools", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../../packages/vitools" }, { name = "wisp", version = "1.3.0", build_tools = ["gleam"], requirements = ["directories", "exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "D12384EE63ADEE833B40A6D26EF8014A6E4BFC10EC2CDC8B57D325DD4B52740E" }, @@ -57,15 +57,16 @@ packages = [ [requirements] birl = { version = ">= 1.7.1 and < 2.0.0" } -bright = { version = ">= 0.1.0 and < 1.0.0" } +bright = { path = "../../packages/bright" } gleam_fetch = { version = ">= 0.4.0 and < 1.0.0" } gleam_http = { version = ">= 3.7.1 and < 4.0.0" } gleam_javascript = { version = ">= 0.13.0 and < 1.0.0" } -gleam_json = { version = ">= 1.0.1 and < 2.0.0" } +gleam_json = { version = ">= 2.1.0 and < 3.0.0" } gleam_regexp = { version = ">= 1.0.0 and < 2.0.0" } gleam_stdlib = { version = "~> 0.34 or ~> 1.0" } gleeunit = { version = "~> 1.0" } grille_pain = { version = ">= 1.1.0 and < 2.0.0" } +interfaces = { path = "../../packages/interfaces" } lustre = { version = ">= 4.6.1 and < 5.0.0" } modem = { version = ">= 2.0.0 and < 3.0.0" } sketch = { version = ">= 3.0.0 and < 4.0.0" } diff --git a/apps/frontend/src/data/metadata.gleam b/apps/frontend/src/data/metadata.gleam deleted file mode 100644 index ebff845..0000000 --- a/apps/frontend/src/data/metadata.gleam +++ /dev/null @@ -1,27 +0,0 @@ -import data/implementations.{type Implementations, Implementations} -import gleam/decoder_extra -import gleam/dynamic -import gleam/option.{type Option} - -pub type Metadata { - Metadata( - deprecation: Option(String), - implementations: Option(Implementations), - ) -} - -pub fn decode_metadata(dyn) { - dynamic.decode2( - Metadata, - decoder_extra.completely_option("deprecation"), - dynamic.optional_field( - "implementations", - dynamic.decode3( - Implementations, - dynamic.field("gleam", dynamic.bool), - dynamic.field("uses_erlang_externals", dynamic.bool), - dynamic.field("uses_javascript_externals", dynamic.bool), - ), - ), - )(dyn) -} diff --git a/apps/frontend/src/data/model.gleam b/apps/frontend/src/data/model.gleam index 791067b..13faa2f 100644 --- a/apps/frontend/src/data/model.gleam +++ b/apps/frontend/src/data/model.gleam @@ -1,8 +1,11 @@ import birl +import bright +import data/analytics import data/kind import data/msg.{type Msg} import data/package.{type Package} -import data/search_result.{type SearchResult, type SearchResults} +import data/search_result.{type SearchResults, SearchResults} +import data/type_search.{type TypeSearch} import frontend/router import frontend/view/body/cache import gleam/dict.{type Dict} @@ -18,8 +21,11 @@ import lustre/element.{type Element} pub type Index = List(#(#(String, String), List(#(String, String)))) -pub type Model { - Model( +pub type Model = + bright.Bright(Data, Computed) + +pub type Data { + Data( input: String, search_results: Dict(String, SearchResults), index: Index, @@ -41,18 +47,22 @@ pub type Model { total_signatures: Int, total_packages: Int, timeseries: List(#(Int, birl.Time)), - ranked: List(msg.Package), - popular: List(msg.Package), + ranked: List(analytics.Package), + popular: List(analytics.Package), ) } +pub type Computed { + Computed +} + @external(javascript, "../gloogle.ffi.mjs", "isMobile") fn is_mobile() -> Bool -pub fn init() { +pub fn init_data() { let search_results = search_result.Start let index = compute_index(search_results) - Model( + Data( input: "", search_results: dict.new(), index: index, @@ -79,36 +89,36 @@ pub fn init() { ) } -pub fn update_route(model: Model, route: router.Route) { - Model(..model, route: route) +pub fn update_route(model: Data, route: router.Route) { + Data(..model, route: route) } -pub fn update_submitted_input(model: Model) { - Model(..model, submitted_input: model.input) +pub fn update_submitted_input(model: Data) { + Data(..model, submitted_input: model.input) } -pub fn update_is_mobile(model: Model, is_mobile: Bool) { - Model(..model, is_mobile: is_mobile) +pub fn update_is_mobile(model: Data, is_mobile: Bool) { + Data(..model, is_mobile: is_mobile) } -pub fn update_trendings(model: Model, trendings: List(Package)) { +pub fn update_trendings(model: Data, trendings: List(Package)) { model.trendings |> option.unwrap([]) |> list.append(trendings) |> option.Some - |> fn(t) { Model(..model, trendings: t) } + |> fn(t) { Data(..model, trendings: t) } } -pub fn toggle_loading(model: Model) { - Model(..model, loading: !model.loading) +pub fn toggle_loading(model: Data) { + Data(..model, loading: !model.loading) } -pub fn update_input(model: Model, content: String) { - Model(..model, input: content) +pub fn update_input(model: Data, content: String) { + Data(..model, input: content) } -pub fn update_analytics(model: Model, analytics: msg.Analytics) { - Model( +pub fn update_analytics(model: Data, analytics: analytics.Analytics) { + Data( ..model, timeseries: analytics.timeseries, total_searches: analytics.total_searches, @@ -119,7 +129,7 @@ pub fn update_analytics(model: Model, analytics: msg.Analytics) { ) } -pub fn search_key(key key: String, model model: Model) { +pub fn search_key(key key: String, model model: Data) { key <> string.inspect([ model.keep_functions, @@ -137,7 +147,7 @@ fn default_search_key(key key: String) { } pub fn update_search_results( - model: Model, + model: Data, key: String, search_results: SearchResults, ) { @@ -145,7 +155,7 @@ pub fn update_search_results( let index = compute_index(search_results) let view_cache = case search_results { search_result.Start | search_result.InternalServerError -> model.view_cache - search_result.SearchResults(types, e, m, s, d, mods) -> + SearchResults(types, e, m, s, d, mods) -> cache.cache_search_results( model.submitted_input, index, @@ -158,7 +168,7 @@ pub fn update_search_results( ) |> dict.insert(model.view_cache, key, _) } - Model( + Data( ..model, search_results: dict.insert(model.search_results, key, search_results), index: index, @@ -186,7 +196,7 @@ fn is_higher(new: List(Int), old: List(Int)) { fn extract_package_version( acc: Dict(String, String), - search_result: search_result.SearchResult, + search_result: TypeSearch, ) -> Dict(String, String) { let assert Ok(re) = regexp.from_string("^[0-9]*.[0-9]*.[0-9]*$") case regexp.check(re, search_result.version) { @@ -218,7 +228,7 @@ fn extract_package_version( } } -pub fn update_search_results_filter(model: Model) { +pub fn update_search_results_filter(model: Data) { let default_key = default_search_key(model.submitted_input) let show_old = case model.show_old_packages { True -> fn(_) { True } @@ -229,7 +239,7 @@ pub fn update_search_results_filter(model: Model) { case search_results { search_result.Start | search_result.InternalServerError -> dict.new() - search_result.SearchResults(t, e, m, s, d, mods) -> { + SearchResults(t, e, m, s, d, mods) -> { dict.new() |> list.fold(t, _, extract_package_version) |> list.fold(e, _, extract_package_version) @@ -241,7 +251,7 @@ pub fn update_search_results_filter(model: Model) { } } } - fn(a: search_result.SearchResult) { + fn(a: TypeSearch) { case dict.get(last_versions, a.package_name) { Error(_) -> False Ok(content) -> content == a.version @@ -251,21 +261,21 @@ pub fn update_search_results_filter(model: Model) { } let or_filters = [ - #(model.keep_functions, fn(s: search_result.SearchResult) { - s.kind == kind.Function + #(model.keep_functions, fn(s: TypeSearch) { + s.signature_kind == kind.Function }), - #(model.keep_types, fn(s: search_result.SearchResult) { - s.kind == kind.TypeDefinition + #(model.keep_types, fn(s: TypeSearch) { + s.signature_kind == kind.TypeDefinition }), - #(model.keep_aliases, fn(s: search_result.SearchResult) { - s.kind == kind.TypeAlias + #(model.keep_aliases, fn(s: TypeSearch) { + s.signature_kind == kind.TypeAlias }), ] |> list.filter(fn(a) { a.0 }) |> list.map(pair.second) let and_filters = [ - #(model.keep_documented, fn(s: search_result.SearchResult) { + #(model.keep_documented, fn(s: TypeSearch) { string.length(s.documentation) > 0 }), ] @@ -289,8 +299,8 @@ pub fn update_search_results_filter(model: Model) { let search_results = case search_results { search_result.Start | search_result.InternalServerError -> search_results - search_result.SearchResults(t, e, m, s, d, mods) -> - search_result.SearchResults( + SearchResults(t, e, m, s, d, mods) -> + SearchResults( t |> list.filter(filter), e |> list.filter(filter), m |> list.filter(filter), @@ -309,7 +319,7 @@ pub fn update_search_results_filter(model: Model) { let view_cache = case search_results { search_result.Start | search_result.InternalServerError -> model.view_cache - search_result.SearchResults(types, e, m, s, d, mods) -> + SearchResults(types, e, m, s, d, mods) -> cache.cache_search_results( model.submitted_input, index, @@ -322,7 +332,7 @@ pub fn update_search_results_filter(model: Model) { ) |> dict.insert(model.view_cache, key, _) } - Model( + Data( ..model, search_results: dict.insert(model.search_results, key, search_results), index: index, @@ -332,8 +342,8 @@ pub fn update_search_results_filter(model: Model) { } } -pub fn reset(model: Model) { - Model( +pub fn reset(model: Data) { + Data( search_results: model.search_results, input: "", index: [], @@ -363,7 +373,7 @@ pub fn reset(model: Model) { fn compute_index(search_results: SearchResults) -> Index { case search_results { search_result.Start | search_result.InternalServerError -> [] - search_result.SearchResults(types, exact, others, searches, docs, modules) -> { + SearchResults(types, exact, others, searches, docs, modules) -> { [] |> insert_module_names(types) |> insert_module_names(exact) @@ -376,11 +386,11 @@ fn compute_index(search_results: SearchResults) -> Index { } } -fn insert_module_names(index: Index, search_results: List(SearchResult)) { +fn insert_module_names(index: Index, search_results: List(TypeSearch)) { use acc, val <- list.fold(search_results, index) let key = #(val.package_name, val.version) list.key_find(acc, key) |> result.unwrap([]) - |> fn(i) { list.prepend(i, #(val.module_name, val.name)) } + |> fn(i) { list.prepend(i, #(val.module_name, val.type_name)) } |> fn(i) { list.key_set(acc, key, i) } } diff --git a/apps/frontend/src/data/msg.gleam b/apps/frontend/src/data/msg.gleam index 8581ac1..4136bf3 100644 --- a/apps/frontend/src/data/msg.gleam +++ b/apps/frontend/src/data/msg.gleam @@ -1,10 +1,9 @@ -import birl +import data/analytics import data/package import data/search_result.{type SearchResults} import frontend/discuss import frontend/router import gleam/dynamic.{type Dynamic} -import gleam/option pub type Filter { Functions @@ -16,28 +15,8 @@ pub type Filter { VectorSearch } -pub type Package { - Package( - name: String, - repository: String, - rank: Int, - popularity: option.Option(Int), - ) -} - -pub type Analytics { - Analytics( - total_searches: Int, - total_signatures: Int, - total_indexed: Int, - timeseries: List(#(Int, birl.Time)), - ranked: List(Package), - popular: List(Package), - ) -} - pub type Msg { - ApiReturnedAnalytics(analytics: Analytics) + ApiReturnedAnalytics(analytics: analytics.Analytics) ApiReturnedPackages(packages: List(package.Package)) ApiReturnedSearchResults(input: String, search_results: SearchResults) ApiReturnedTrendings(trendings: List(package.Package)) diff --git a/apps/frontend/src/data/search_result.gleam b/apps/frontend/src/data/search_result.gleam index 82f487f..d5da578 100644 --- a/apps/frontend/src/data/search_result.gleam +++ b/apps/frontend/src/data/search_result.gleam @@ -1,58 +1,22 @@ -import data/kind.{type Kind} -import data/metadata.{type Metadata} -import data/signature.{type Signature} +import data/type_search.{type TypeSearch} import frontend/view/helpers import gleam/dynamic -import gleam/list -import gleam/option.{None, Some} -import gleam/result - -pub type SearchResult { - SearchResult( - documentation: String, - module_name: String, - name: String, - kind: Kind, - package_name: String, - json_signature: Signature, - metadata: Metadata, - version: String, - ) -} pub type SearchResults { Start InternalServerError SearchResults( - exact_type_matches: List(SearchResult), - exact_name_matches: List(SearchResult), - name_signature_matches: List(SearchResult), - vector_signature_searches: List(SearchResult), - docs_searches: List(SearchResult), - module_searches: List(SearchResult), + exact_type_matches: List(TypeSearch), + exact_name_matches: List(TypeSearch), + name_signature_matches: List(TypeSearch), + vector_signature_searches: List(TypeSearch), + docs_searches: List(TypeSearch), + module_searches: List(TypeSearch), ) } -pub fn decode_search_result(dyn) { - dynamic.decode8( - SearchResult, - dynamic.field("documentation", dynamic.string), - dynamic.field("module_name", dynamic.string), - dynamic.field("name", dynamic.string), - dynamic.field("kind", kind.decode_kind), - dynamic.field("package_name", dynamic.string), - dynamic.field("json_signature", signature.decode_signature), - dynamic.field("metadata", metadata.decode_metadata), - dynamic.field("version", dynamic.string), - )(dyn) - |> result.map(Some) - |> result.try_recover(fn(_) { Ok(None) }) -} - pub fn decode_search_results_list(dyn) { - use data <- result.map(dynamic.list(decode_search_result)(dyn)) - use item <- list.filter_map(data) - option.to_result(item, "") + dynamic.list(type_search.decode)(dyn) } pub fn decode_search_results(dyn) { @@ -72,11 +36,11 @@ pub fn decode_search_results(dyn) { ])(dyn) } -pub fn hexdocs_link(search_result: SearchResult) { +pub fn hexdocs_link(search_result: TypeSearch) { helpers.hexdocs_link( package: search_result.package_name, version: search_result.version, module: search_result.module_name, - name: search_result.name, + name: search_result.type_name, ) } diff --git a/apps/frontend/src/frontend.gleam b/apps/frontend/src/frontend.gleam index fe86bad..2521dc2 100644 --- a/apps/frontend/src/frontend.gleam +++ b/apps/frontend/src/frontend.gleam @@ -1,298 +1,145 @@ -import birl +import bright +import data/analytics import data/model.{type Model} import data/msg.{type Msg} import data/package -import data/search_result import frontend/discuss -import frontend/errors -import frontend/ffi +import frontend/effects/window import frontend/router +import frontend/update import frontend/view import frontend/view/body/search_result as sr -import gleam/bool -import gleam/dict -import gleam/dynamic.{type Dynamic} -import gleam/option.{None, Some} -import gleam/pair +import gleam/dynamic + import gleam/result -import gleam/uri.{type Uri} + import grille_pain -import grille_pain/lustre/toast + import grille_pain/options import lustre import lustre/effect -import lustre/event import lustre/lazy -import lustre/update import modem import sketch import sketch/magic -import toast/error as toast_error -fn focus(on id: String, event event: Dynamic) { - use _ <- effect.from() - use <- bool.guard(when: ffi.is_active(id), return: Nil) - event.prevent_default(event) - ffi.focus(on: id, event: event) -} - -fn blur() { - use _ <- effect.from() - ffi.blur() +pub fn main() { + let assert Ok(_) = setup_sketch() + let assert Ok(_) = setup_components() + let assert Ok(_) = setup_grille_pain() + let assert Ok(_) = start_application() } -fn subscribe_focus() { - use dispatch <- effect.from() - use event <- ffi.subscribe_focus() - case ffi.key(event) { - Ok("Escape") -> dispatch(msg.UserPressedEscape) - _ -> dispatch(msg.UserFocusedSearch(event)) - } +fn setup_sketch() { + use cache <- result.try(sketch.cache(strategy: sketch.Ephemeral)) + use _ <- result.try(magic.setup(cache)) + Ok(Nil) } -fn subscribe_is_mobile() { - use dispatch <- effect.from() - use is_mobile <- ffi.suscribe_is_mobile() - dispatch(msg.BrowserResizedViewport(is_mobile)) +fn setup_components() { + use _ <- result.try(lazy.setup()) + use _ <- result.try(sr.setup()) + Ok(Nil) } -pub fn main() { - let assert Ok(cache) = sketch.cache(strategy: sketch.Ephemeral) - let assert Ok(_) = magic.setup(cache) - let assert Ok(_) = lazy.setup() - let assert Ok(_) = sr.setup() - let assert Ok(_) = - options.default() - |> options.timeout(5000) - |> grille_pain.setup() - - let assert Ok(_) = - lustre.application(init, update, view.view) - |> lustre.start("#app", Nil) +fn setup_grille_pain() { + options.default() + |> options.timeout(5000) + |> grille_pain.setup() } -fn decode_package(dyn) { - dynamic.decode4( - msg.Package, - dynamic.field("name", dynamic.string), - dynamic.field("repository", dynamic.string), - dynamic.field("rank", dynamic.int), - dynamic.field("popularity", dynamic.optional(dynamic.int)), - )(dyn) +fn start_application() { + lustre.application(init, update, view.view) + |> lustre.start("#app", Nil) } -fn init(_) { - let initial = - modem.initial_uri() - |> result.map(router.parse_uri) - |> result.unwrap(router.Home) - |> handle_changed_route(model.init(), _) - handle_submitted_search(initial.0) - |> update.add_effect(modem.init(on_url_change)) - |> update.add_effect(router.update_page_title({ initial.0 }.route)) - |> update.add_effect(subscribe_focus()) - |> update.add_effect(subscribe_is_mobile()) - |> update.add_effect({ - use dispatch <- effect.from - discuss.about(["trendings"]) - |> discuss.expect(dynamic.list(package.decoder)) - |> discuss.on_success(fn(m) { dispatch(msg.ApiReturnedTrendings(m)) }) - |> discuss.on_error(fn(e) { dispatch(msg.AppRequiredDiscussToast(e)) }) - |> discuss.start - Nil - }) - |> update.add_effect({ - use dispatch <- effect.from - discuss.about(["packages"]) - |> discuss.expect(dynamic.list(package.decoder)) - |> discuss.on_success(fn(m) { dispatch(msg.ApiReturnedPackages(m)) }) - |> discuss.on_error(fn(e) { dispatch(msg.AppRequiredDiscussToast(e)) }) - |> discuss.start - Nil - }) - |> update.add_effect({ - use dispatch <- effect.from - discuss.about(["analytics"]) - |> discuss.expect(dynamic.decode6( - msg.Analytics, - dynamic.field("total", dynamic.int), - dynamic.field("signatures", dynamic.int), - dynamic.field("packages", dynamic.int), - dynamic.field("timeseries", { - dynamic.list(dynamic.decode2( - pair.new, - dynamic.field("count", dynamic.int), - dynamic.field("date", fn(dyn) { - dynamic.string(dyn) - |> result.then(fn(t) { - birl.parse(t) - |> result.replace_error([]) - }) - }), - )) - }), - dynamic.field("ranked", dynamic.list(decode_package)), - dynamic.field("popular", dynamic.list(decode_package)), - )) - |> discuss.on_success(fn(m) { dispatch(msg.ApiReturnedAnalytics(m)) }) - |> discuss.on_error(fn(e) { dispatch(msg.AppRequiredDiscussToast(e)) }) - |> discuss.start - Nil - }) +fn get_initial_uri() { + modem.initial_uri() + |> result.map(router.parse_uri) + |> result.unwrap(router.Home) } -fn on_url_change(uri: Uri) -> Msg { +fn init_modem() { + use uri <- modem.init router.parse_uri(uri) |> msg.BrowserChangedRoute } -fn update(model: Model, msg: Msg) { +fn get_trendings() { + use dispatch <- effect.from + discuss.about(["trendings"]) + |> discuss.expect(dynamic.list(package.decode)) + |> discuss.on_success(fn(m) { dispatch(msg.ApiReturnedTrendings(m)) }) + |> discuss.on_error(fn(e) { dispatch(msg.AppRequiredDiscussToast(e)) }) + |> discuss.start + Nil +} + +fn get_packages() { + use dispatch <- effect.from + discuss.about(["packages"]) + |> discuss.expect(dynamic.list(package.decode)) + |> discuss.on_success(fn(m) { dispatch(msg.ApiReturnedPackages(m)) }) + |> discuss.on_error(fn(e) { dispatch(msg.AppRequiredDiscussToast(e)) }) + |> discuss.start + Nil +} + +fn get_analytics() { + use dispatch <- effect.from + discuss.about(["analytics"]) + |> discuss.expect(analytics.decode) + |> discuss.on_success(fn(m) { dispatch(msg.ApiReturnedAnalytics(m)) }) + |> discuss.on_error(fn(e) { dispatch(msg.AppRequiredDiscussToast(e)) }) + |> discuss.start + Nil +} + +fn init(_) -> #(Model, effect.Effect(Msg)) { + let uri = get_initial_uri() + let model = bright.init(model.init_data(), model.Computed) + use model <- bright.start(model) + use model <- bright.update(model, update.handle_changed_route(_, uri)) + use model <- bright.update(model, update.handle_submitted_search) + model + |> bright.run(fn(_, _) { init_modem() }) + |> bright.run(fn(data, _) { router.update_page_title(data.route) }) + |> bright.run(fn(_, _) { window.subscribe_focus() }) + |> bright.run(fn(_, _) { window.subscribe_is_mobile() }) + |> bright.run(fn(_, _) { get_trendings() }) + |> bright.run(fn(_, _) { get_packages() }) + |> bright.run(fn(_, _) { get_analytics() }) +} + +fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) { + use model <- bright.start(model) + use model <- bright.update(model, update_data(_, msg)) + model +} + +fn update_data(model: model.Data, msg: Msg) { case msg { - msg.ApiReturnedAnalytics(analytics:) -> handle_analytics(model, analytics) - msg.ApiReturnedPackages(packages:) -> handle_packages(model, packages) + msg.ApiReturnedAnalytics(analytics:) -> + update.handle_analytics(model, analytics) + msg.ApiReturnedPackages(packages:) -> + update.handle_packages(model, packages) msg.ApiReturnedSearchResults(input:, search_results:) -> - handle_search_results(model, input, search_results) - msg.ApiReturnedTrendings(trendings:) -> handle_trendings(model, trendings) + update.handle_search_results(model, input, search_results) + msg.ApiReturnedTrendings(trendings:) -> + update.handle_trendings(model, trendings) msg.AppRequiredDiscussToast(message:) -> - handle_discuss_toast(model, message) - msg.BrowserChangedRoute(route:) -> handle_changed_route(model, route) + update.handle_discuss_toast(model, message) + msg.BrowserChangedRoute(route:) -> update.handle_changed_route(model, route) msg.BrowserResizedViewport(is_mobile:) -> - handle_resized_viewport(model, is_mobile) - msg.UserClickedSidebarName(id:) -> handle_clicked_sidebar_name(model, id) - msg.UserFocusedSearch(event:) -> handle_focused_search(model, event) - msg.UserInputtedSearch(query:) -> handle_inputted_search(model, query) - msg.UserPressedEscape -> handle_pressed_escape(model) - msg.UserSubmittedSearch -> handle_submitted_search(model) + update.handle_resized_viewport(model, is_mobile) + msg.UserClickedSidebarName(id:) -> + update.handle_clicked_sidebar_name(model, id) + msg.UserFocusedSearch(event:) -> update.handle_focused_search(model, event) + msg.UserInputtedSearch(query:) -> + update.handle_inputted_search(model, query) + msg.UserPressedEscape -> update.handle_pressed_escape(model) + msg.UserSubmittedSearch -> update.handle_submitted_search(model) msg.UserToggledFilter(filter:, value:) -> - handle_toggle_filter(model, filter, value) - } -} - -fn handle_analytics(model: Model, analytics: msg.Analytics) { - model - |> model.update_analytics(analytics) - |> update.none -} - -fn handle_packages(model: Model, packages: List(package.Package)) { - model.Model(..model, packages:) - |> pair.new(effect.none()) -} - -fn handle_search_results( - model: Model, - input: String, - search_results: search_result.SearchResults, -) { - search_results - |> model.update_search_results(model, input, _) - |> model.toggle_loading - |> update.effect(modem.push("/search", Some("q=" <> input), None)) -} - -fn handle_trendings(model: Model, trendings: List(package.Package)) { - trendings - |> model.update_trendings(model, _) - |> update.none -} - -fn handle_discuss_toast(model: Model, message: discuss.DiscussError) { - message - |> toast_error.describe_http_error - |> option.map(errors.capture_message) - |> option.map(toast.error) - |> option.unwrap(effect.none()) - |> pair.new(model, _) -} - -fn handle_changed_route(model: Model, route: router.Route) { - let model = model.update_route(model, route) - case route { - router.Home -> model.update_input(model, "") - router.Packages -> model.update_input(model, "") - router.Trending -> model.update_input(model, "") - router.Analytics -> model.update_input(model, "") - router.Search(q) -> - model.update_input(model, q) - |> model.update_submitted_input - } - |> pair.new(router.update_page_title(route)) -} - -fn handle_resized_viewport(model: Model, is_mobile: Bool) { - model - |> model.update_is_mobile(is_mobile) - |> pair.new(effect.none()) -} - -fn handle_clicked_sidebar_name(model: Model, id: String) { - ffi.scroll_to(element: id) - |> effect.from - |> update.effect(model, _) -} - -fn handle_focused_search(model: Model, event: Dynamic) { - #(model, focus(on: "search-input", event: event)) -} - -fn handle_inputted_search(model: Model, content: String) { - model - |> model.update_input(content) - |> update.none -} - -fn handle_pressed_escape(model: Model) { - #(model, blur()) -} - -fn handle_submitted_search(model: Model) { - use <- bool.guard(when: model.input == "", return: #(model, effect.none())) - use <- bool.guard(when: model.loading, return: #(model, effect.none())) - let new_model = model.update_submitted_input(model) - case dict.get(new_model.search_results, new_model.submitted_input) { - Ok(_) -> { - let new_route = router.Search(new_model.submitted_input) - let is_same_route = new_model.route == new_route - use <- bool.guard(when: is_same_route, return: update.none(new_model)) - new_model - |> update.effect({ - Some("q=" <> new_model.submitted_input) - |> modem.push("search", _, None) - }) - |> update.add_effect(blur()) - } - Error(_) -> { - model.toggle_loading(new_model) - |> pair.new({ - use dispatch <- effect.from - discuss.about(["search"]) - |> discuss.query([#("q", model.input)]) - |> discuss.expect(search_result.decode_search_results) - |> discuss.on_success(fn(search_results) { - model.input - |> msg.ApiReturnedSearchResults(input: _, search_results:) - |> dispatch - }) - |> discuss.on_error(fn(e) { dispatch(msg.AppRequiredDiscussToast(e)) }) - |> discuss.start - Nil - }) - |> update.add_effect(blur()) - } - } -} - -fn handle_toggle_filter(model, filter, value) { - case filter, value { - msg.Functions, value -> model.Model(..model, keep_functions: value) - msg.Types, value -> model.Model(..model, keep_types: value) - msg.Aliases, value -> model.Model(..model, keep_aliases: value) - msg.Documented, value -> model.Model(..model, keep_documented: value) - msg.ShowOldPackages, value -> model.Model(..model, show_old_packages: value) - msg.VectorSearch, value -> model.Model(..model, show_vector_search: value) - msg.DocumentationSearch, value -> - model.Model(..model, show_documentation_search: value) + update.handle_toggle_filter(model, filter, value) } - |> model.update_search_results_filter - |> update.none } diff --git a/apps/frontend/src/frontend/discuss.gleam b/apps/frontend/src/frontend/discuss.gleam index 3a6c62a..35e74f3 100644 --- a/apps/frontend/src/frontend/discuss.gleam +++ b/apps/frontend/src/frontend/discuss.gleam @@ -4,7 +4,6 @@ import gleam/fetch import gleam/http import gleam/http/request import gleam/http/response -import gleam/io import gleam/javascript/promise import gleam/list import gleam/result diff --git a/apps/frontend/src/frontend/effects/window.gleam b/apps/frontend/src/frontend/effects/window.gleam new file mode 100644 index 0000000..a6fc78b --- /dev/null +++ b/apps/frontend/src/frontend/effects/window.gleam @@ -0,0 +1,52 @@ +import data/msg +import gleam/bool +import gleam/dynamic.{type Dynamic} +import lustre/effect +import lustre/event + +pub fn focus(on id: String, event event: Dynamic) { + use _ <- effect.from() + use <- bool.guard(when: do_is_active(id), return: Nil) + event.prevent_default(event) + do_focus(on: id, event: event) +} + +pub fn blur() { + use _ <- effect.from() + do_blur() +} + +pub fn subscribe_focus() { + use dispatch <- effect.from() + use event <- do_subscribe_focus() + case do_key(event) { + Ok("Escape") -> dispatch(msg.UserPressedEscape) + _ -> dispatch(msg.UserFocusedSearch(event)) + } +} + +pub fn subscribe_is_mobile() { + use dispatch <- effect.from() + use is_mobile <- do_suscribe_is_mobile() + dispatch(msg.BrowserResizedViewport(is_mobile)) +} + +// FFI + +@external(javascript, "../../gloogle.ffi.mjs", "subscribeFocus") +fn do_subscribe_focus(callback: fn(Dynamic) -> Nil) -> Nil + +@external(javascript, "../../gloogle.ffi.mjs", "subscribeIsMobile") +fn do_suscribe_is_mobile(callback: fn(Bool) -> Nil) -> Nil + +@external(javascript, "../../gloogle.ffi.mjs", "focus") +fn do_focus(on id: String, event event: a) -> Nil + +@external(javascript, "../../gloogle.ffi.mjs", "isActive") +fn do_is_active(element id: String) -> Bool + +@external(javascript, "../../gloogle.ffi.mjs", "eventKey") +fn do_key(event: Dynamic) -> Result(String, Nil) + +@external(javascript, "../../gloogle.ffi.mjs", "blur") +fn do_blur() -> Nil diff --git a/apps/frontend/src/frontend/ffi.gleam b/apps/frontend/src/frontend/ffi.gleam index 0a567a5..bb68297 100644 --- a/apps/frontend/src/frontend/ffi.gleam +++ b/apps/frontend/src/frontend/ffi.gleam @@ -1,25 +1,5 @@ -import gleam/dynamic.{type Dynamic} - @external(javascript, "../gloogle.ffi.mjs", "scrollTo") pub fn scroll_to(element id: String) -> fn(dispatch) -> Nil -@external(javascript, "../gloogle.ffi.mjs", "subscribeIsMobile") -pub fn suscribe_is_mobile(callback: fn(Bool) -> Nil) -> Nil - -@external(javascript, "../gloogle.ffi.mjs", "subscribeFocus") -pub fn subscribe_focus(callback: fn(Dynamic) -> Nil) -> Nil - -@external(javascript, "../gloogle.ffi.mjs", "focus") -pub fn focus(on id: String, event event: a) -> Nil - -@external(javascript, "../gloogle.ffi.mjs", "isActive") -pub fn is_active(element id: String) -> Bool - -@external(javascript, "../gloogle.ffi.mjs", "blur") -pub fn blur() -> Nil - @external(javascript, "../gloogle.ffi.mjs", "updateTitle") pub fn update_title(title: String) -> Nil - -@external(javascript, "../gloogle.ffi.mjs", "eventKey") -pub fn key(event: Dynamic) -> Result(String, Nil) diff --git a/apps/frontend/src/frontend/update.gleam b/apps/frontend/src/frontend/update.gleam new file mode 100644 index 0000000..400657f --- /dev/null +++ b/apps/frontend/src/frontend/update.gleam @@ -0,0 +1,149 @@ +import data/analytics +import data/model.{type Data} +import data/msg +import data/package +import data/search_result +import frontend/discuss +import frontend/effects/window +import frontend/errors +import frontend/ffi +import frontend/router +import gleam/bool +import gleam/dict +import gleam/dynamic.{type Dynamic} +import gleam/option.{None, Some} +import gleam/pair +import grille_pain/lustre/toast +import lustre/effect +import modem +import toast/error as toast_error + +pub fn handle_analytics(model: Data, analytics: analytics.Analytics) { + model + |> model.update_analytics(analytics) + |> pair.new(effect.none()) +} + +pub fn handle_packages(model: Data, packages: List(package.Package)) { + model.Data(..model, packages:) + |> pair.new(effect.none()) +} + +pub fn handle_search_results( + model: Data, + input: String, + search_results: search_result.SearchResults, +) { + search_results + |> model.update_search_results(model, input, _) + |> model.toggle_loading + |> pair.new(modem.push("/search", Some("q=" <> input), None)) +} + +pub fn handle_trendings(model: Data, trendings: List(package.Package)) { + trendings + |> model.update_trendings(model, _) + |> pair.new(effect.none()) +} + +pub fn handle_discuss_toast(model: Data, message: discuss.DiscussError) { + message + |> toast_error.describe_http_error + |> option.map(errors.capture_message) + |> option.map(toast.error) + |> option.unwrap(effect.none()) + |> pair.new(model, _) +} + +pub fn handle_changed_route(model: Data, route: router.Route) { + let model = model.update_route(model, route) + case route { + router.Home -> model.update_input(model, "") + router.Packages -> model.update_input(model, "") + router.Trending -> model.update_input(model, "") + router.Analytics -> model.update_input(model, "") + router.Search(q) -> + model.update_input(model, q) + |> model.update_submitted_input + } + |> pair.new(router.update_page_title(route)) +} + +pub fn handle_resized_viewport(model: Data, is_mobile: Bool) { + model + |> model.update_is_mobile(is_mobile) + |> pair.new(effect.none()) +} + +pub fn handle_clicked_sidebar_name(model: Data, id: String) { + ffi.scroll_to(element: id) + |> effect.from + |> pair.new(model, _) +} + +pub fn handle_focused_search(model: Data, event: Dynamic) { + #(model, window.focus(on: "search-input", event: event)) +} + +pub fn handle_inputted_search(model: Data, content: String) { + model + |> model.update_input(content) + |> pair.new(effect.none()) +} + +pub fn handle_pressed_escape(model: Data) { + #(model, window.blur()) +} + +pub fn handle_submitted_search(model: Data) { + use <- bool.guard(when: model.input == "", return: #(model, effect.none())) + use <- bool.guard(when: model.loading, return: #(model, effect.none())) + let new_model = model.update_submitted_input(model) + case dict.get(new_model.search_results, new_model.submitted_input) { + Ok(_) -> { + let new_route = router.Search(new_model.submitted_input) + let is_same_route = new_model.route == new_route + use <- bool.guard(when: is_same_route, return: #(new_model, effect.none())) + new_model + |> pair.new( + effect.batch([ + modem.push("search", Some("q=" <> new_model.submitted_input), None), + window.blur(), + ]), + ) + } + Error(_) -> { + model.toggle_loading(new_model) + |> pair.new({ + use dispatch <- effect.from + discuss.about(["search"]) + |> discuss.query([#("q", model.input)]) + |> discuss.expect(search_result.decode_search_results) + |> discuss.on_success(fn(search_results) { + model.input + |> msg.ApiReturnedSearchResults(input: _, search_results:) + |> dispatch + }) + |> discuss.on_error(fn(e) { dispatch(msg.AppRequiredDiscussToast(e)) }) + |> discuss.start + Nil + }) + |> fn(a) { #(a.0, effect.batch([a.1, window.blur()])) } + } + } +} + +pub fn handle_toggle_filter(model, filter, value) { + case filter, value { + msg.Functions, value -> model.Data(..model, keep_functions: value) + msg.Types, value -> model.Data(..model, keep_types: value) + msg.Aliases, value -> model.Data(..model, keep_aliases: value) + msg.Documented, value -> model.Data(..model, keep_documented: value) + msg.ShowOldPackages, value -> model.Data(..model, show_old_packages: value) + msg.VectorSearch, value -> model.Data(..model, show_vector_search: value) + msg.DocumentationSearch, value -> + model.Data(..model, show_documentation_search: value) + } + |> model.update_search_results_filter + |> pair.new(effect.none()) +} diff --git a/apps/frontend/src/frontend/view.gleam b/apps/frontend/src/frontend/view.gleam index 4c01fc3..02dd890 100644 --- a/apps/frontend/src/frontend/view.gleam +++ b/apps/frontend/src/frontend/view.gleam @@ -1,3 +1,4 @@ +import bright import data/model.{type Model} import frontend/colors/palette import frontend/router @@ -29,12 +30,13 @@ fn layout(attributes, children) { pub fn view(model: Model) { use <- magic.render([magic.node()]) + use data, _computed <- bright.view(model) layout([], [ - navbar.navbar(model), - body.body(model), - case model.route { + navbar.navbar(data), + body.body(data), + case data.route { router.Home -> footer.view() - router.Search(_) -> footer.search_bar(model) + router.Search(_) -> footer.search_bar(data) _ -> el.none() }, ]) diff --git a/apps/frontend/src/frontend/view/body/body.gleam b/apps/frontend/src/frontend/view/body/body.gleam index bc8e4c0..2eb8fc3 100644 --- a/apps/frontend/src/frontend/view/body/body.gleam +++ b/apps/frontend/src/frontend/view/body/body.gleam @@ -1,6 +1,6 @@ import birl import chart.{Dataset} -import data/model.{type Model} +import data/model.{type Data} import data/msg import data/search_result import frontend/icons @@ -22,7 +22,7 @@ import lustre/element/html as h import lustre/event as e import lustre/lazy -fn view_search_input(model: Model) { +fn view_search_input(model: Data) { let has_content = { model.input |> string.length() @@ -67,7 +67,7 @@ fn empty_state( ]) } -pub fn view_trending(_model: Model) { +pub fn view_trending(_model: Data) { el.none() // case model.trendings { // None -> @@ -137,7 +137,7 @@ pub fn view_trending(_model: Model) { // } } -fn sidebar(model: Model) { +fn sidebar(model: Data) { use <- bool.guard(when: model.is_mobile, return: el.none()) let disabled = case model.route { router.Search(..) -> a.style([#("opacity", "1")]) @@ -251,7 +251,7 @@ fn analytics_box(title: String, count: Int) { ]) } -fn popularity_chart(model: Model) { +fn popularity_chart(model: Data) { let data = list.filter(model.popular, fn(p) { p.repository != "https://github.com/gleam-lang/gleam" @@ -270,7 +270,7 @@ fn popularity_chart(model: Model) { }) } -fn ranked_chart(model: Model) { +fn ranked_chart(model: Data) { let data = list.filter(model.ranked, fn(p) { p.name != "gleam_stdlib" && p.name != "gleeunit" @@ -284,7 +284,7 @@ fn ranked_chart(model: Model) { }) } -fn analytics_chart(model: Model) { +fn analytics_chart(model: Data) { let data = model.timeseries use <- bool.guard(when: list.is_empty(data), return: el.none()) chart.line_chart({ @@ -322,7 +322,7 @@ fn analytics_title(title: String, border: Bool) { ]) } -fn view_analytics(model: Model) { +fn view_analytics(model: Data) { el.fragment([ sidebar(model), h.main([a.class("main"), a.style([#("padding", "24px 36px")])], [ @@ -374,7 +374,7 @@ fn view_analytics(model: Model) { ]) } -fn view_packages(model: Model) { +fn view_packages(model: Data) { el.fragment([ sidebar(model), h.main( @@ -429,7 +429,7 @@ fn view_packages(model: Model) { ]) } -pub fn body(model: Model) { +pub fn body(model: Data) { case model.route { router.Home -> h.main([a.class("main")], [view_search_input(model)]) router.Packages -> view_packages(model) diff --git a/apps/frontend/src/frontend/view/body/cache.gleam b/apps/frontend/src/frontend/view/body/cache.gleam index 57074b1..8706ca0 100644 --- a/apps/frontend/src/frontend/view/body/cache.gleam +++ b/apps/frontend/src/frontend/view/body/cache.gleam @@ -1,5 +1,5 @@ import data/msg -import data/search_result +import data/type_search import frontend/view/body/search_result as sr import frontend/view/types as t import gleam/list @@ -8,7 +8,7 @@ import lustre/element as el import lustre/element/html as h import lustre/event as e -fn view_search_results(search_results: List(search_result.SearchResult)) { +fn view_search_results(search_results: List(type_search.TypeSearch)) { list.map(search_results, sr.view) |> list.intersperse(h.div([a.class("search-result-separator")], [])) |> el.fragment @@ -76,12 +76,12 @@ fn types_separator() { pub fn cache_search_results( search: String, index: List(#(#(String, String), List(#(String, String)))), - types: List(search_result.SearchResult), - exact: List(search_result.SearchResult), - others: List(search_result.SearchResult), - searches: List(search_result.SearchResult), - docs_searches: List(search_result.SearchResult), - modules_searches: List(search_result.SearchResult), + types: List(type_search.TypeSearch), + exact: List(type_search.TypeSearch), + others: List(type_search.TypeSearch), + searches: List(type_search.TypeSearch), + docs_searches: List(type_search.TypeSearch), + modules_searches: List(type_search.TypeSearch), ) { h.div([a.class("search-results-wrapper")], [ sidebar(search, index), diff --git a/apps/frontend/src/frontend/view/body/search_result.gleam b/apps/frontend/src/frontend/view/body/search_result.gleam index 59ea5b9..750cc09 100644 --- a/apps/frontend/src/frontend/view/body/search_result.gleam +++ b/apps/frontend/src/frontend/view/body/search_result.gleam @@ -1,5 +1,6 @@ import data/implementations -import data/search_result.{type SearchResult} +import data/search_result +import data/type_search.{type TypeSearch} import frontend/colors/palette import frontend/icons import frontend/view/body/signature @@ -11,6 +12,7 @@ import gleam/dict import gleam/dynamic import gleam/list import gleam/option +import gleam/pair import gleam/result import lustre import lustre/attribute as a @@ -18,14 +20,13 @@ import lustre/effect import lustre/element import lustre/element/html as h import lustre/event as e -import lustre/update pub type Model { - Model(item: option.Option(SearchResult), opened: Bool) + Model(item: option.Option(TypeSearch), opened: Bool) } pub type Msg { - Received(option.Option(SearchResult)) + Received(option.Option(TypeSearch)) ToggleOpen } @@ -41,7 +42,7 @@ pub fn setup() { |> lustre.register("search-result") } -pub fn view(item: SearchResult) { +pub fn view(item: TypeSearch) { let attributes = [a.property("item", item)] element.element("search-result", attributes, []) } @@ -51,7 +52,7 @@ fn update(model, msg) { ToggleOpen -> Model(..model, opened: !model.opened) Received(search_result) -> Model(item: search_result, opened: False) } - |> update.none + |> pair.new(effect.none()) } fn implementation_pill(item) { @@ -82,7 +83,7 @@ fn internal_view(model: Model) -> element.Element(Msg) { use <- bool.guard(when: option.is_none(model.item), return: element.none()) let assert option.Some(item) = model.item let package_id = item.package_name <> "@" <> item.version - let id = package_id <> "-" <> item.module_name <> "-" <> item.name + let id = package_id <> "-" <> item.module_name <> "-" <> item.type_name h.div([a.class("search-result"), a.id(id)], [ h.div([a.class("search-details")], [ h.div([a.class("search-details-name")], [ @@ -99,7 +100,7 @@ fn internal_view(model: Model) -> element.Element(Msg) { ]) } -fn view_name(item: SearchResult) { +fn view_name(item: TypeSearch) { let class = a.class("qualified-name") let href = search_result.hexdocs_link(item) h.a([class, a.target("_blank"), a.rel("noreferrer"), a.href(href)], [ @@ -108,11 +109,11 @@ fn view_name(item: SearchResult) { t.dark_white("."), t.keyword(item.module_name), t.dark_white("."), - t.fun(item.name), + t.fun(item.type_name), ]) } -fn view_documentation_arrow(model: Model, item: SearchResult) { +fn view_documentation_arrow(model: Model, item: TypeSearch) { use <- bool.guard(when: item.documentation == "", return: element.none()) let no_implementation = option.is_none(item.metadata.implementations) use <- bool.guard(when: no_implementation, return: element.none()) @@ -132,14 +133,14 @@ fn view_documentation_arrow(model: Model, item: SearchResult) { ]) } -fn view_implementation_pills(model: Model, item: SearchResult) { +fn view_implementation_pills(model: Model, item: TypeSearch) { use <- bool.guard(when: !model.opened, return: element.none()) item.metadata.implementations |> option.map(implementation_pills) |> option.unwrap(element.none()) } -fn view_documentation(model: Model, item: SearchResult) { +fn view_documentation(model: Model, item: TypeSearch) { use <- bool.guard(when: item.documentation == "", return: element.none()) use <- bool.guard(when: !model.opened, return: element.none()) h.div([a.class("documentation")], [documentation.view(item.documentation)]) diff --git a/apps/frontend/src/frontend/view/body/signature.gleam b/apps/frontend/src/frontend/view/body/signature.gleam index 82b36f4..081ee3e 100644 --- a/apps/frontend/src/frontend/view/body/signature.gleam +++ b/apps/frontend/src/frontend/view/body/signature.gleam @@ -1,5 +1,5 @@ -import data/search_result import data/signature.{type Parameter, type Type, Parameter} +import data/type_search import frontend/view/helpers import frontend/view/types as t import gleam/bool @@ -213,11 +213,15 @@ fn view_type_constructor(constructor: signature.TypeConstructor, indent: Int) { ]) } -pub fn view_signature(item: search_result.SearchResult) -> List(el.Element(msg)) { +pub fn view_signature(item: type_search.TypeSearch) -> List(el.Element(msg)) { case item.json_signature { signature.TypeDefinition(parameters, constructors) -> list.flatten([ - [t.keyword("type "), t.fun(item.name), ..render_parameters(parameters)], + [ + t.keyword("type "), + t.fun(item.type_name), + ..render_parameters(parameters) + ], case constructors { [] -> [] _ -> [h.text(" {"), helpers.newline()] @@ -237,7 +241,7 @@ pub fn view_signature(item: search_result.SearchResult) -> List(el.Element(msg)) ]) signature.Constant(width, type_) -> list.flatten([ - [t.keyword("const "), t.fun(item.name), h.text(" = ")], + [t.keyword("const "), t.fun(item.type_name), h.text(" = ")], case width > 80 { True -> [helpers.newline(), ..view_type(type_, 2)] False -> view_type(type_, 0) @@ -247,7 +251,7 @@ pub fn view_signature(item: search_result.SearchResult) -> List(el.Element(msg)) list.flatten([ [ t.keyword("type "), - t.type_(item.name), + t.type_(item.type_name), ..render_parameters(parameters) ], [h.text(" = ")], diff --git a/apps/frontend/src/frontend/view/footer/footer.gleam b/apps/frontend/src/frontend/view/footer/footer.gleam index 9c3994e..dff4a6c 100644 --- a/apps/frontend/src/frontend/view/footer/footer.gleam +++ b/apps/frontend/src/frontend/view/footer/footer.gleam @@ -1,4 +1,4 @@ -import data/model.{type Model} +import data/model.{type Data} import data/msg import frontend/view/footer/links.{links} import frontend/view/footer/styles as s @@ -39,7 +39,7 @@ pub fn view() { ]) } -pub fn search_bar(model: Model) { +pub fn search_bar(model: Data) { use <- bool.guard(when: !model.is_mobile, return: el.none()) h.div([a.class("footer-search")], [ h.form([e.on_submit(msg.UserSubmittedSearch)], [ diff --git a/apps/frontend/src/frontend/view/navbar/navbar.gleam b/apps/frontend/src/frontend/view/navbar/navbar.gleam index dce12a2..243b4f7 100644 --- a/apps/frontend/src/frontend/view/navbar/navbar.gleam +++ b/apps/frontend/src/frontend/view/navbar/navbar.gleam @@ -1,4 +1,4 @@ -import data/model.{type Model} +import data/model.{type Data} import frontend/router import frontend/view/navbar/styles as s import lustre/attribute as a @@ -11,7 +11,7 @@ fn navbar_links() { ]) } -pub fn navbar(model: Model) { +pub fn navbar(model: Data) { let transparent = model.route == router.Home s.navbar(transparent, [a.class("navbar")], [ case model.route { diff --git a/apps/frontend/src/gleam/coerce.gleam b/apps/frontend/src/gleam/coerce.gleam deleted file mode 100644 index 3f1da9e..0000000 --- a/apps/frontend/src/gleam/coerce.gleam +++ /dev/null @@ -1,2 +0,0 @@ -@external(javascript, "../gloogle.ffi.mjs", "coerce") -pub fn coerce(value: a) -> b diff --git a/apps/frontend/src/gleam/decoder_extra.gleam b/apps/frontend/src/gleam/decoder_extra.gleam deleted file mode 100644 index b2905f9..0000000 --- a/apps/frontend/src/gleam/decoder_extra.gleam +++ /dev/null @@ -1,10 +0,0 @@ -import gleam/dynamic.{type Dynamic} -import gleam/option -import gleam/result - -pub fn completely_option(field: String) { - fn(dyn: Dynamic) { - dynamic.optional_field(field, dynamic.optional(dynamic.string))(dyn) - |> result.map(fn(res) { option.flatten(res) }) - } -} diff --git a/apps/frontend/src/lustre/update.gleam b/apps/frontend/src/lustre/update.gleam deleted file mode 100644 index 27ecdb2..0000000 --- a/apps/frontend/src/lustre/update.gleam +++ /dev/null @@ -1,18 +0,0 @@ -import lustre/effect.{type Effect} - -pub fn none(model: model) { - #(model, effect.none()) -} - -pub fn effect(model: model, effect: Effect(msg)) { - #(model, effect) -} - -pub fn effects(model: model, effects: List(Effect(msg))) { - #(model, effect.batch(effects)) -} - -pub fn add_effect(tuple: #(model, Effect(msg)), effect: Effect(msg)) { - let #(model, fst_effect) = tuple - #(model, effect.batch([fst_effect, effect])) -} diff --git a/apps/frontend/src/toast/error.gleam b/apps/frontend/src/toast/error.gleam index 55030d5..dc5bb74 100644 --- a/apps/frontend/src/toast/error.gleam +++ b/apps/frontend/src/toast/error.gleam @@ -1,14 +1,18 @@ import frontend/discuss +import gleam/io import gleam/option.{Some} pub fn describe_http_error(error: discuss.DiscussError) { case error { discuss.InternalServerError -> Some("Internal server error. Please try again later.") - discuss.DecodeError(_) -> Some("Format error. Please try again later.") discuss.NetworkError -> Some("Network error. Please try again later.") discuss.NotFound -> Some("Resource not found. Make sure you have the correct URL.") discuss.InvalidJsonBody -> Some("Invalid JSON body. Please, retry later.") + discuss.DecodeError(error) -> { + io.debug(error) + Some("Format error. Please try again later.") + } } } diff --git a/packages/bright/.github/workflows/demo.yml b/packages/bright/.github/workflows/demo.yml new file mode 100644 index 0000000..af1a259 --- /dev/null +++ b/packages/bright/.github/workflows/demo.yml @@ -0,0 +1,54 @@ +name: Deploy demo + +on: + push: + branches: [main] + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup BEAM + uses: erlef/setup-beam@v1 + with: + otp-version: "27.0.0" + gleam-version: "1.6.1" + rebar3-version: "3" + # elixir-version: "1.15.4" + - name: Setup Pages + uses: actions/configure-pages@v5 + - name: Downloading dependencies + run: gleam deps download + working-directory: e2e/sample + - name: Build demo + run: gleam run -m lustre/dev build + working-directory: e2e/sample + - name: Copy files + run: | + mkdir _site + cp -r e2e/sample/priv _site + cp e2e/sample/index.html _site + - name: Upload artifacts + uses: actions/upload-pages-artifact@v3 + + deploy: + runs-on: ubuntu-latest + needs: build + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/packages/bright/.github/workflows/test.yml b/packages/bright/.github/workflows/test.yml new file mode 100644 index 0000000..6026a40 --- /dev/null +++ b/packages/bright/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: test + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: + otp-version: "27.1.2" + gleam-version: "1.6.1" + rebar3-version: "3" + # elixir-version: "1.15.4" + - run: gleam deps download + - run: gleam test + - run: gleam format --check src test diff --git a/packages/bright/.gitignore b/packages/bright/.gitignore new file mode 100644 index 0000000..93d449b --- /dev/null +++ b/packages/bright/.gitignore @@ -0,0 +1,7 @@ +*.beam +*.ez +/build +erl_crash.dump +sample.mjs +/index.html +.DS_Store diff --git a/packages/bright/.prettierrc.json b/packages/bright/.prettierrc.json new file mode 100644 index 0000000..cce9d3c --- /dev/null +++ b/packages/bright/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "semi": false +} diff --git a/packages/bright/LICENCE b/packages/bright/LICENCE new file mode 100644 index 0000000..62fede3 --- /dev/null +++ b/packages/bright/LICENCE @@ -0,0 +1,7 @@ +Copyright 2024 Guillaume Hivert + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/bright/README.md b/packages/bright/README.md new file mode 100644 index 0000000..b12db53 --- /dev/null +++ b/packages/bright/README.md @@ -0,0 +1,272 @@ +# Bright + +Bright is a library to help you manage your `model` and your `update` function in +a [Lustre](https://lustre.build) application. As you probably know, in Lustre, +your model is the only mutable place of the application, and centralize every +data your application uses. If you're coming from the JS world, it can seems +weird at first, because it's usual to have multiple storage places in an +application, whether they're contexts, stores, observables, or anything else. + +In such a centralized model, everything is simpler, and just works. However, +managing your model and your updates can quickly become a mess, with dependent +data, data splitting, normalization, etc. Bright comes in to help you avoid such +states, by both helping your to maintain a properly defined model, but also +to define dependant data in an easy way. Bright also provides a powerful caching +system to guarantee to not compute the same information twice! + +> As usual, a demo is worth a thousand words, so take a look at +> [https://bright.chouquette.dev](https://bright.chouquette.dev)! + +If you're used to stores & data management, you can skip the next section, see +you in the [getting started](#getting-started)! + +## Installation + +```sh +gleam add bright@1 +``` + +## Dependent data? Caching system? + +To sum up simply, a data is dependent on another data when the latter data is +required to compute the former. If you have worked with relational data, you +already encountered it. Let's take an example. + +Imagine you have a list of users on one side, with their info (name, age for example) +and ID, and you have a list of addresses referring to a user by their ID. You want to +create a page displaying the user info and all of their addresses. +Intuitively, you would display the user info, and then you would find all the user +addresses in the address list to display them. + +```gleam +pub type User { + User( + name: String, + age: Int, + id: String + ) +} + +pub type Address { + Address( + street: String, + city: String, + country: String, + user_id: String + ) +} + +pub fn display_user_page(model, user_id) { + // First find the user. + use user <- result.try(list.find(model.users, fn (user) { user.id == user_id })) + // After the user has been found, filter the addresses to find them. + use addresses <- list.filter(model.addresses, fn (address) { address.user_id == user.id }) + // Do the display here. +} +``` + +While it works perfectly in this example, what if every time you need to access +those data? Recompute the same data over and over again, in every part of the +application? We can do better: define the user and its address in one record, +and use it everywhere! + +```gleam +pub type UserAndAddress { + UserAndAddress( + user: User, + addresses: List(Address) + ) +} + +// Compute the data, and store it in your model. +pub fn compute_user_address(model) { + list.map(model.users, fn (user) { + let addresses = list.filter(model.addresses, fn (address) { address.user_id == user.id }) + UserAndAddresses(user:, addresses:) + }) +} + +// The data is in your model. +pub fn display_user_page(model, user_id) { + // Find directly everything! + use UserAndAddress(user:, addresses:) <- result.try({ + list.find(model.users_and_addresses, fn (user) { user.id == user_id }) + }) + // Do the display here. +} +``` + +You have defined here a derived, computed data that depends on two previous data +you own. Now, every time you need to access the user, you have the address bundled! +But in a classic application, you would have to define that computation by yourself, +and make sure to keep it in sync after each update. That's where Bright comes in, +and do the hard work for you! Instead of having to think to synchronize the data +when a new data comes in, let Bright does it for you. And with built-in caching +on-demand, Bright will never recompute the same data twice for intense computations. + +## Getting Started + +Bright handles the hard task of computing the derived data when needed, and as +such, you have to initialize it at first. Bright accepts two types of data: +your main model, holding the raw data, and your derived, computed data. Because +Gleam is strongly-typed language, you'll have to define those data by hand. +A counter will be used to illustrate how to use it. + +```gleam +/// Define your raw data. No derived data will reside here. +pub type Data { + Data(counter: Int) +} + +/// Define your derived data. No raw data will reside here. +pub type Computed { + Computed(double: Int) +} + +/// Define an alias, to simplify reference to the model. +pub type Model = + Bright(Data, Computed) + +// In your init function, you'll initialize Bright. You can use it as-is as a +// replacement for your model. +pub fn init() { + let data = Data(counter: 0) + let computed = Computed(double: 0) + let model = bright.init(data, computed) + // bright.return is a helper to write nice little DSL. + bright.return(model) +} +``` + +Once Bright is initialized, you have to modify a bit your update function. Now, +instead of simply receiving the message and modifying your model, you'll have to +run that modification through Bright. You can then chain your computation calls +to the Bright object. And of course, derived data can be computed from pre-computed +derived data! + +```gleam +pub type Msg { + Increment + Decrement +} + +pub fn update(model: Model, msg: Msg) { + // By using function capture, we can easily use our update function here. + // bright.update will automatically run your update against data, here our + // Data record. Like every update function, that function have to return + // a #(Data, Effect(Msg)). The message will automatically be batched with + // next messages. + // Finally, Bright(Data, Computed) is returned, with Data updated. To let you + // continue the chain. + use model <- bright.update(model, update_data(_, msg)) + model + // bright.compute will compute the new derived data, and let you set it in + // the computed. You can also simply return the original computed, in which + // case the data is not updated. + |> bright.compute(fn(data, computed) { Computed(..computed, double: d.counter * 2) }) + // bright.lazy_compute will compute the new derived data, if and only if the + // selector you pass as the first argument changed between two renders. + // In case the selector did not change, the old data is kept in memory for the + // next render. + |> bright.lazy_compute( + // That selector value is compared at every render. + fn (data) { data.counter / 10 }, + fn(data, computed) { Computed(..computed, double: d.counter * 2) } + ) +} + +pub fn update_data(data: Data, msg: Msg) { + case msg { + Increment -> #(Data(..data, counter: data.counter + 1), effect.none()) + Decrement -> #(Data(..data, counter: data.counter - 1), effect.none()) + } +} +``` + +And once your data is computed, all you have to do is to run through your view +function! + +```gleam +pub fn view(model: Model) { + use data, computed <- bright.view(model) + // You can use data & computed with correct, up to date data. + html.div([], []) +} +``` + +And you're good to go! Now, you don't have to think anymore to update your +derived data, everything is kept in-sync directly for you! + +## Using guards + +Sometimes, you also have to define side-effects that run after your computations +have run. Because you figure out the data is finally incorrect. Or because your +user have written a false URL in the address bar. Bright got you covered too! +Just use `bright.guard`, and let the side-effects flow automatically in your app, +only when you need it! + +```gleam +pub fn update(model: Model, msg: Msg) { + use model <- bright.update(model, update_data(_, msg)) + model + |> bright.compute(fn(data, computed) { Computed(..computed, double: d.counter * 2) }) + |> bright.lazy_compute( + fn (data) { data.counter / 10 }, + fn(data, computed) { Computed(..computed, double: d.counter * 2) } + ) + // bright.guard will run at every render, and let you the possibility to issue + // a side-effect. Bright will take care to gather them, and provide them to the + // runtime! + |> bright.guard(fn (data, computed) { + effect.from(fn (dispatch) { + io.println("That side-effect will run at every render!") + }) + }) + // bright.lazy_guard will issue the side-effect, if and only if the + // selector you pass as the first argument changed between two renders. + // In case the selector did not change, the old data is kept in memory for the + // next render. + |> bright.lazy_guard( + fn (data) { data.counter / 10 }, + fn (data, computed) { + effect.from(fn (dispatch) { + io.println("That side-effect will only run when the selector changes!") + }) + } + ) +} +``` + +## Combining multiple Bright + +Sometimes, you also need to combine multiple Bright in the same model. While you +can keep a `Bright` as model, you could want to combine them, to handle one `Bright` +by page for example. `bright.step` helps you to do this. + +```gleam +pub type Model { + Model( + counter_1: Bright(Data, Computed), + counter_2: Bright(Data, Computed), + ) +} + +pub type Msg { + First(counter: Counter) + Second(counter: Counter) +} + +pub type Counter { + Decrement + Increment +} + +/// Here, we define a new update function, that calls our previously defined +/// update function. It keeps the two Bright synchronized by running the full +/// updated cycle on each of them. +fn update_both_counters(model: Model, msg: Msg) { + use counter_1 <- bright.step(update(model.counter_1, msg.counter)) + use counter_2 <- bright.step(update(model.counter_2, msg.counter)) + bright.return(Model(..model, counter_1:, counter_2:)) +} +``` diff --git a/packages/bright/e2e/sample/.gitignore b/packages/bright/e2e/sample/.gitignore new file mode 100644 index 0000000..599be4e --- /dev/null +++ b/packages/bright/e2e/sample/.gitignore @@ -0,0 +1,4 @@ +*.beam +*.ez +/build +erl_crash.dump diff --git a/packages/bright/e2e/sample/README.md b/packages/bright/e2e/sample/README.md new file mode 100644 index 0000000..27a5d5c --- /dev/null +++ b/packages/bright/e2e/sample/README.md @@ -0,0 +1,24 @@ +# sample + +[![Package Version](https://img.shields.io/hexpm/v/sample)](https://hex.pm/packages/sample) +[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/sample/) + +```sh +gleam add sample@1 +``` +```gleam +import sample + +pub fn main() { + // TODO: An example of the project in use +} +``` + +Further documentation can be found at . + +## Development + +```sh +gleam run # Run the project +gleam test # Run the tests +``` diff --git a/packages/bright/e2e/sample/gleam.toml b/packages/bright/e2e/sample/gleam.toml new file mode 100644 index 0000000..0419f3b --- /dev/null +++ b/packages/bright/e2e/sample/gleam.toml @@ -0,0 +1,24 @@ +name = "sample" +version = "1.0.0" + +# Fill out these fields if you intend to generate HTML documentation or publish +# your project to the Hex package manager. +# +# description = "" +# licences = ["Apache-2.0"] +# repository = { type = "github", user = "", repo = "" } +# links = [{ title = "Website", href = "" }] +# +# For a full reference of all the available options, you can have a look at +# https://gleam.run/writing-gleam/gleam-toml/. + +[dependencies] +gleam_stdlib = ">= 0.34.0 and < 2.0.0" +lustre = ">= 4.6.1 and < 5.0.0" +bright = {path = "../.."} +sketch = ">= 3.1.1 and < 4.0.0" +sketch_lustre = ">= 1.0.3 and < 2.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" +lustre_dev_tools = ">= 1.6.0 and < 2.0.0" diff --git a/packages/bright/e2e/sample/index.html b/packages/bright/e2e/sample/index.html new file mode 100644 index 0000000..7d7baf8 --- /dev/null +++ b/packages/bright/e2e/sample/index.html @@ -0,0 +1,18 @@ + + + + + + + Bright 💡 + + + + + + + +
+
+ + diff --git a/packages/bright/e2e/sample/manifest.toml b/packages/bright/e2e/sample/manifest.toml new file mode 100644 index 0000000..37b2c4f --- /dev/null +++ b/packages/bright/e2e/sample/manifest.toml @@ -0,0 +1,58 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, + { name = "birl", version = "1.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" }, + { name = "bright", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre", "lustre_dev_tools"], source = "local", path = "../.." }, + { name = "conversation", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "conversation", source = "hex", outer_checksum = "908B46F60444442785A495197D482558AD8B849C3714A38FAA1940358CC8CCCD" }, + { name = "directories", version = "1.1.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "BDA521A4EB9EE3A7894F0DC863797878E91FF5C7826F7084B2E731E208BDB076" }, + { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, + { name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" }, + { name = "filepath", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "67A6D15FB39EEB69DD31F8C145BB5A421790581BD6AA14B33D64D5A55DBD6587" }, + { name = "fs", version = "8.6.1", build_tools = ["rebar3"], requirements = [], otp_app = "fs", source = "hex", outer_checksum = "61EA2BDAEDAE4E2024D0D25C63E44DCCF65622D4402DB4A2DF12868D1546503F" }, + { name = "gleam_community_ansi", version = "1.4.1", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "4CD513FC62523053E62ED7BAC2F36136EC17D6A8942728250A9A00A15E340E4B" }, + { name = "gleam_community_colour", version = "1.4.1", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "386CB9B01B33371538672EEA8A6375A0A0ADEF41F17C86DDCB81C92AD00DA610" }, + { name = "gleam_crypto", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "8AE56026B3E05EBB1F076778478A762E9EB62B31AEEB4285755452F397029D22" }, + { name = "gleam_erlang", version = "0.30.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "760618870AE4A497B10C73548E6E44F43B76292A54F0207B3771CBB599C675B4" }, + { name = "gleam_http", version = "3.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "A9EE0722106FCCAB8AD3BF9D0A3EFF92BFE8561D59B83BAE96EB0BE1938D4E0F" }, + { name = "gleam_httpc", version = "3.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "091CDD2BEC8092E82707BEA03FB5205A2BBBDE4A2F551E3C069E13B8BC0C428E" }, + { name = "gleam_javascript", version = "0.13.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "F98328FCF573DA6F3A35D7F6CB3F9FF19FD5224CCBA9151FCBEAA0B983AF2F58" }, + { name = "gleam_json", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "0A57FB5666E695FD2BEE74C0428A98B0FC11A395D2C7B4CDF5E22C5DD32C74C6" }, + { name = "gleam_otp", version = "0.14.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "5A8CE8DBD01C29403390A7BD5C0A63D26F865C83173CF9708E6E827E53159C65" }, + { name = "gleam_package_interface", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_package_interface", source = "hex", outer_checksum = "CF3BFC5D0997750D9550D8D73A90F4B8D71C6C081B20ED4E70FFBE1E99AFC3C2" }, + { name = "gleam_stdlib", version = "0.44.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "A6E55E309A6778206AAD4038D9C49E15DF71027A1DB13C6ADA06BFDB6CF1260E" }, + { name = "glearray", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "B99767A9BC63EF9CC8809F66C7276042E5EFEACAA5B25188B552D3691B91AC6D" }, + { name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" }, + { name = "glint", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "5F6720081150AED8023131B0F3A35F9B0D6426A96CE02BEC52AD7018DF70566A" }, + { name = "glisten", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "48EF7F6D1DCA877C2F49AF35CC33946C7129EEB05A114758A2CC569C708BFAF8" }, + { name = "gramps", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "3CCAA6E081225180D95C79679D383BBF51C8D1FDC1B84DA1DA444F628C373793" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, + { name = "lustre", version = "4.6.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "BDF833368F6C8F152F948D5B6A79866E9881CB80CB66C0685B3327E7DCBFB12F" }, + { name = "lustre_dev_tools", version = "1.6.0", build_tools = ["gleam"], requirements = ["argv", "filepath", "fs", "gleam_community_ansi", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_package_interface", "gleam_stdlib", "glint", "glisten", "mist", "simplifile", "spinner", "term_size", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "5A1C7D20FA2C0D77D59F259EAE0E14BB3F5359CC1DE7C5ED6922B65FFCBD4C31" }, + { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, + { name = "mist", version = "2.0.0", build_tools = ["gleam"], requirements = ["birl", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "981F12FC8BA0656B40099EC876D6F2BEE7B95593610F342E9AB0DC4E663A932F" }, + { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, + { name = "plinth", version = "0.5.2", build_tools = ["gleam"], requirements = ["conversation", "gleam_javascript", "gleam_json", "gleam_stdlib"], otp_app = "plinth", source = "hex", outer_checksum = "6F346577D02879D5D516C61EC7C41CD09642528072BA04E1DBD36B9415A82517" }, + { name = "ranger", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "B8F3AFF23A3A5B5D9526B8D18E7C43A7DFD3902B151B97EC65397FE29192B695" }, + { name = "repeatedly", version = "2.1.2", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "93AE1938DDE0DC0F7034F32C1BF0D4E89ACEBA82198A1FE21F604E849DA5F589" }, + { name = "simplifile", version = "2.2.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0DFABEF7DC7A9E2FF4BB27B108034E60C81BEBFCB7AB816B9E7E18ED4503ACD8" }, + { name = "sketch", version = "3.1.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "sketch", source = "hex", outer_checksum = "6CBFAAA92C37F1F44FC552FD9E9DAC34598BDEB5F873B6191C696DC67D85AD00" }, + { name = "sketch_lustre", version = "1.0.3", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre", "plinth", "sketch"], otp_app = "sketch_lustre", source = "hex", outer_checksum = "DD5437B10D4BB8AB45A19820B17883188B8568B6ED7885D7D073A983F4984E79" }, + { name = "snag", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "08E9EB87C413457DB1DD66CD704C6878DACC9C93B418600F63873D0CD224E756" }, + { name = "spinner", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_stdlib", "glearray", "repeatedly"], otp_app = "spinner", source = "hex", outer_checksum = "9EE43AA33BE2DA5731B8F3F170AAB59AF1A815AFA5BF615F12C1B91F3B03F157" }, + { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, + { name = "tom", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "228E667239504B57AD05EC3C332C930391592F6C974D0EFECF32FFD0F3629A27" }, + { name = "wisp", version = "1.2.0", build_tools = ["gleam"], requirements = ["directories", "exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "F71265D2F1DE11426535A2FA1DA3B11D2FFB783B116DF9496BC8C41983EBADB4" }, +] + +[requirements] +bright = { path = "../.." } +gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +lustre = { version = ">= 4.6.1 and < 5.0.0" } +lustre_dev_tools = { version = ">= 1.6.0 and < 2.0.0" } +sketch = { version = ">= 3.1.1 and < 4.0.0" } +sketch_lustre = { version = ">= 1.0.3 and < 2.0.0" } diff --git a/packages/bright/e2e/sample/src/icons.gleam b/packages/bright/e2e/sample/src/icons.gleam new file mode 100644 index 0000000..597150e --- /dev/null +++ b/packages/bright/e2e/sample/src/icons.gleam @@ -0,0 +1,28 @@ +import icons/book_open +import icons/check +import icons/copy +import icons/github +import icons/home +import sketch as s +import sketch/lustre/element/html as h +import sketch/size.{px} + +pub fn small(icon) { + s.class([s.width(px(24)), s.height(px(24))]) + |> h.div([], [icon]) +} + +pub fn tiny(icon) { + s.class([s.width(px(12)), s.height(px(12))]) + |> h.div([], [icon]) +} + +pub const book_open = book_open.icon + +pub const check = check.icon + +pub const copy = copy.icon + +pub const github = github.icon + +pub const home = home.icon diff --git a/packages/bright/e2e/sample/src/icons/book_open.gleam b/packages/bright/e2e/sample/src/icons/book_open.gleam new file mode 100644 index 0000000..a518c4d --- /dev/null +++ b/packages/bright/e2e/sample/src/icons/book_open.gleam @@ -0,0 +1,17 @@ +import lustre/attribute as a +import sketch/lustre/element/html + +const content = "" + +pub fn icon() { + html.svg_( + [ + a.style([#("max-width", "100%"), #("max-height", "100%")]), + a.attribute("xmlns", "http://www.w3.org/2000/svg"), + a.attribute("viewBox", "0 0 256 256"), + a.attribute("fill", "currentColor"), + a.attribute("dangerous-unescaped-html", content), + ], + [], + ) +} diff --git a/packages/bright/e2e/sample/src/icons/check.gleam b/packages/bright/e2e/sample/src/icons/check.gleam new file mode 100644 index 0000000..d8aadbf --- /dev/null +++ b/packages/bright/e2e/sample/src/icons/check.gleam @@ -0,0 +1,17 @@ +import lustre/attribute as a +import sketch/lustre/element/html + +const content = "" + +pub fn icon() { + html.svg_( + [ + a.style([#("max-width", "100%"), #("max-height", "100%")]), + a.attribute("xmlns", "http://www.w3.org/2000/svg"), + a.attribute("viewBox", "0 0 256 256"), + a.attribute("fill", "currentColor"), + a.attribute("dangerous-unescaped-html", content), + ], + [], + ) +} diff --git a/packages/bright/e2e/sample/src/icons/copy.gleam b/packages/bright/e2e/sample/src/icons/copy.gleam new file mode 100644 index 0000000..cfc9ffc --- /dev/null +++ b/packages/bright/e2e/sample/src/icons/copy.gleam @@ -0,0 +1,17 @@ +import lustre/attribute as a +import sketch/lustre/element/html + +const content = "" + +pub fn icon() { + html.svg_( + [ + a.style([#("max-width", "100%"), #("max-height", "100%")]), + a.attribute("xmlns", "http://www.w3.org/2000/svg"), + a.attribute("viewBox", "0 0 256 256"), + a.attribute("fill", "currentColor"), + a.attribute("dangerous-unescaped-html", content), + ], + [], + ) +} diff --git a/packages/bright/e2e/sample/src/icons/github.gleam b/packages/bright/e2e/sample/src/icons/github.gleam new file mode 100644 index 0000000..db6b326 --- /dev/null +++ b/packages/bright/e2e/sample/src/icons/github.gleam @@ -0,0 +1,17 @@ +import lustre/attribute as a +import sketch/lustre/element/html + +const content = "" + +pub fn icon() { + html.svg_( + [ + a.style([#("max-width", "100%"), #("max-height", "100%")]), + a.attribute("xmlns", "http://www.w3.org/2000/svg"), + a.attribute("viewBox", "0 0 256 256"), + a.attribute("fill", "currentColor"), + a.attribute("dangerous-unescaped-html", content), + ], + [], + ) +} diff --git a/packages/bright/e2e/sample/src/icons/home.gleam b/packages/bright/e2e/sample/src/icons/home.gleam new file mode 100644 index 0000000..2869405 --- /dev/null +++ b/packages/bright/e2e/sample/src/icons/home.gleam @@ -0,0 +1,17 @@ +import lustre/attribute as a +import sketch/lustre/element/html + +const content = "" + +pub fn icon() { + html.svg_( + [ + a.style([#("max-width", "100%"), #("max-height", "100%")]), + a.attribute("xmlns", "http://www.w3.org/2000/svg"), + a.attribute("viewBox", "0 0 256 256"), + a.attribute("fill", "currentColor"), + a.attribute("dangerous-unescaped-html", content), + ], + [], + ) +} diff --git a/packages/bright/e2e/sample/src/sample.ffi.mjs b/packages/bright/e2e/sample/src/sample.ffi.mjs new file mode 100644 index 0000000..27bfa91 --- /dev/null +++ b/packages/bright/e2e/sample/src/sample.ffi.mjs @@ -0,0 +1,3 @@ +export function dateNow() { + return Date.now() +} diff --git a/packages/bright/e2e/sample/src/sample.gleam b/packages/bright/e2e/sample/src/sample.gleam new file mode 100644 index 0000000..915c290 --- /dev/null +++ b/packages/bright/e2e/sample/src/sample.gleam @@ -0,0 +1,235 @@ +import bright.{type Bright} +import gleam/bool +import gleam/int +import gleam/io +import gleam/pair +import gleam/result +import gleam/string +import lustre +import lustre/effect +import lustre/event as e +import sketch +import sketch/lustre as sketch_ +import sketch/lustre/element +import sketch/lustre/element/html as h +import sketch/size.{px} +import styles + +@external(javascript, "./sample.ffi.mjs", "dateNow") +fn now() -> Int { + 0 +} + +pub type Data { + Data(counter: Int) +} + +pub type Computed { + Computed(double: Int, triple: Int, memoized: Int, last_lazy: Int) +} + +pub type Model { + Model( + node: String, + counter_1: Bright(Data, Computed), + counter_2: Bright(Data, Computed), + ) +} + +pub type Msg { + First(counter: Counter) + Second(counter: Counter) +} + +pub type Counter { + Decrement + Increment +} + +/// It's possible to switch between `update_both` and `update_one` +/// to see how it works actually. +pub fn main() { + let assert Ok(cache) = sketch.cache(strategy: sketch.Ephemeral) + use _ <- result.try(start(cache, update_one, "#single")) + use _ <- result.try(start(cache, update_both, "#double")) + Ok(Nil) +} + +fn start(cache, update, node) { + let view = sketch_.compose(sketch_.node(), view, cache) + lustre.application(init, update, view) + |> lustre.start(node, node) +} + +fn init(node: String) { + let data = Data(counter: 0) + let computed = Computed(double: 0, triple: 0, memoized: 0, last_lazy: 0) + let counter = bright.init(data, computed) + bright.return(Model(node:, counter_1: counter, counter_2: counter)) +} + +/// Here, update both fields in `Model` with the Counter message. +/// Both counters are synchronized, both exebright the full lifecycle +/// and both side-effects run as desired. +fn update_both(model: Model, msg: Msg) { + use counter_1 <- bright.step(update(model.counter_1, msg.counter)) + use counter_2 <- bright.step(update(model.counter_2, msg.counter)) + bright.return(Model(..model, counter_1:, counter_2:)) +} + +/// Here, update only one field, according to the main message. +/// The other message is not updated. +fn update_one(model: Model, msg: Msg) { + let #(data, msg_) = select_data_structure(model, msg) + use counter <- bright.step(update(data, msg_)) + case msg { + First(..) -> bright.return(Model(..model, counter_1: counter)) + Second(..) -> bright.return(Model(..model, counter_2: counter)) + } +} + +fn select_data_structure(model: Model, msg: Msg) { + case msg { + First(counter) -> #(model.counter_1, counter) + Second(counter) -> #(model.counter_2, counter) + } +} + +/// Execute the full lifecycle, with derived data, and lazy computations. +fn update(model: Bright(Data, Computed), msg: Counter) { + use model <- bright.update(model, update_data(_, msg)) + model + |> bright.compute(fn(d, c) { Computed(..c, double: d.counter * 2) }) + |> bright.compute(fn(d, c) { Computed(..c, triple: d.counter * 3) }) + |> bright.lazy_compute(fn(d) { d.counter / 10 }, compute_memoized) + |> bright.guard(warn_on_three) + |> bright.guard(warn_on_three_multiple) + |> bright.lazy_guard(fn(d) { d.counter / 10 }, warn) +} + +/// Raw update. +fn update_data(model: Data, msg: Counter) { + case msg { + Decrement -> Data(counter: model.counter - 1) + Increment -> Data(counter: model.counter + 1) + } + |> pair.new(effect.none()) +} + +fn view(model: Model) { + element.fragment([ + navbar(model), + styles.body([], [ + introduction(model.node), + explanations(model.node), + styles.container([], [ + counter(model.counter_1) |> element.map(First), + counter(model.counter_2) |> element.map(Second), + ]), + ]), + case model.node { + "#double" -> element.none() + _ -> styles.footer([], [h.text("Made with 💜 at Chou Corp.")]) + }, + ]) +} + +fn introduction(node) { + case node { + "#single" -> element.none() + _ -> + h.div(styles.intro(), [], [ + h.text("Bright is a Lustre's model & update management "), + h.text("package. While your model is the only mutable "), + h.text("place in your application, you can store almost "), + h.text("everything you want inside. Bright provides an "), + h.text("abstraction layer on top of Lustre's model, "), + h.text("and add the ability to derive some data from "), + h.text("your raw data, add some caching for extensive "), + h.text("computations, and protects you from some "), + h.text("invalid state that could come in sometimes."), + ]) + } +} + +fn explanations(node) { + case node { + "#single" -> + sketch.class([sketch.compose(styles.intro()), sketch.margin_top(px(60))]) + |> h.div([], [ + styles.title("Dissociated counters"), + h.text("That second example illustrates the ability to run two "), + h.text("Bright counters in the same application, dissociated with "), + h.text("each other. They both contains two computed, derived "), + h.text("data, and one lazy data, computed every time the result "), + h.text("of counter / 10 changes. But that time, when you change "), + h.text("one, the other will stay the same. You can see the data "), + h.text("and computations will not happen again. Open your "), + h.text("console, and watch the side-effects running!"), + ]) + _ -> + h.div(styles.intro(), [], [ + styles.title("Synchronized counters"), + h.text("That first example illustrates the ability to run two "), + h.text("Bright counters in the same application, synchronized "), + h.text("with each other. They both contains two computed, derived "), + h.text("data, and one lazy data, computed every time the result"), + h.text("of counter / 10 changes. Open your console, and watch "), + h.text("the side-effects running!"), + ]) + } +} + +fn navbar(model: Model) { + case model.node { + "#single" -> element.none() + _ -> styles.nav() + } +} + +fn counter(counter: Bright(Data, Computed)) { + use data, computed <- bright.view(counter) + styles.counter_wrapper([], [ + styles.counter([], [ + styles.counter_number(data.counter), + styles.buttons_wrapper([], [ + styles.button([e.on_click(Increment)], [h.text("Increase")]), + styles.button([e.on_click(Decrement)], [h.text("Decrease")]), + ]), + ]), + styles.counter_infos([], [ + styles.computed("computed.last_lazy: ", computed.last_lazy), + h.hr_([]), + styles.computed("computed.double: ", computed.double), + styles.computed("computed.triple: ", computed.triple), + ]), + ]) +} + +fn compute_memoized(data: Data, computed: Computed) { + let memoized = data.counter * 1000 + let last_lazy = now() + Computed(..computed, memoized:, last_lazy:) +} + +fn warn_on_three(data: Data, _: Computed) { + use <- bool.guard(when: data.counter != 3, return: effect.none()) + use _ <- effect.from + io.println("This message happened because the counter equals 3!") +} + +fn warn_on_three_multiple(data: Data, _: Computed) { + use <- bool.guard(when: data.counter % 3 != 0, return: effect.none()) + use _ <- effect.from + let counter = int.to_string(data.counter) + let msg = "This message happened because the counter is a multiple of 3!" + [msg, "(" <> counter <> ")"] + |> string.join(" ") + |> io.println +} + +fn warn(_, _) { + use _ <- effect.from + "This lazy message happened because the result of counter / 10 changed value!" + |> io.println +} diff --git a/packages/bright/e2e/sample/src/styles.gleam b/packages/bright/e2e/sample/src/styles.gleam new file mode 100644 index 0000000..95fa149 --- /dev/null +++ b/packages/bright/e2e/sample/src/styles.gleam @@ -0,0 +1,192 @@ +import gleam/int +import icons +import lustre/attribute as a +import sketch +import sketch/lustre/element/html as h +import sketch/media +import sketch/size.{px} + +pub fn intro() { + sketch.class([ + sketch.padding(px(40)), + sketch.padding_top(px(0)), + sketch.margin_bottom(px(20)), + sketch.first_of_type([sketch.padding_top(px(40))]), + ]) +} + +pub fn title(title) { + sketch.class([sketch.font_weight("bold")]) + |> h.h2([], [h.text(title)]) +} + +pub fn nav() { + sketch.class([ + sketch.font_size(size.rem(1.3)), + sketch.font_weight("bold"), + sketch.display("flex"), + sketch.justify_content("space-between"), + sketch.margin(px(18)), + sketch.gap(px(36)), + sketch.background("var(--navbar-background)"), + sketch.position("sticky"), + sketch.border_radius(px(10)), + sketch.top(px(18)), + sketch.border("1px solid var(--dark-background)"), + sketch.backdrop_filter("blur(8px)"), + ]) + |> h.nav([a.id("navbar")], [ + sketch.class([ + sketch.display("flex"), + sketch.align_items("center"), + sketch.padding_left(px(18)), + ]) + |> h.div([], [h.text("Bright")]), + h.div_([], []), + h.div( + sketch.class([ + sketch.display("flex"), + sketch.gap(px(24)), + sketch.padding(px(18)), + ]), + [], + [ + external_icon("https://hexdocs.pm/bright", icons.book_open()), + external_icon("https://github.com/ghivert/bright", icons.github()), + ], + ), + ]) +} + +fn external_icon(url, icon) { + sketch.class([ + sketch.color("#aaa"), + sketch.transition("all .3s"), + sketch.hover([sketch.color("var(--text-color)")]), + ]) + |> h.a([a.href(url)], [icons.small(icon)]) +} + +pub fn counter(attrs, children) { + sketch.class([ + sketch.display("flex"), + sketch.flex_direction("column"), + sketch.align_items("center"), + sketch.background("var(--darker-background)"), + sketch.color("var(--text-color)"), + sketch.height(px(220)), + sketch.width(px(220)), + sketch.border_radius(px(2)), + sketch.position("relative"), + sketch.z_index(100), + sketch.border_radius(px(10)), + ]) + |> h.div(attrs, children) +} + +pub fn button(attrs, children) { + sketch.class([ + sketch.appearance("none"), + sketch.border_radius(px(5)), + sketch.background("var(--dark-background)"), + sketch.display("flex"), + sketch.border("1px solid var(--border-color)"), + sketch.align_items("center"), + sketch.justify_content("center"), + sketch.padding(px(10)), + sketch.cursor("pointer"), + sketch.font_family("inherit"), + sketch.color("var(--text-color)"), + sketch.font_size_("inherit"), + sketch.text_transform("uppercase"), + sketch.font_weight("bold"), + sketch.hover([sketch.background("var(--button-hover)")]), + ]) + |> h.button(attrs, children) +} + +pub fn counter_number(counter) { + sketch.class([ + sketch.flex("1"), + sketch.display("flex"), + sketch.align_items("center"), + sketch.padding_top(px(20)), + sketch.font_weight("bold"), + sketch.font_size(size.rem(1.4)), + ]) + |> h.div([], [h.text(int.to_string(counter))]) +} + +pub fn buttons_wrapper(attrs, children) { + sketch.class([ + sketch.display("flex"), + sketch.flex_direction("column"), + sketch.padding(px(10)), + sketch.justify_content("space-evenly"), + sketch.width(size.percent(100)), + sketch.gap(px(10)), + ]) + |> h.div(attrs, children) +} + +pub fn counter_infos(attrs, children) { + sketch.class([ + sketch.font_family("Fira Code"), + sketch.background("var(--dark-background)"), + sketch.position("absolute"), + sketch.top(px(110)), + sketch.left(px(50)), + sketch.width(px(250)), + sketch.height(px(250)), + sketch.z_index(10), + sketch.display("flex"), + sketch.flex_direction("column"), + sketch.justify_content("end"), + sketch.padding(px(10)), + sketch.border_radius(px(10)), + sketch.media(media.max_width(px(400)), [sketch.width(px(200))]), + ]) + |> h.div(attrs, children) +} + +pub fn computed(title, content) { + h.div_([], [h.text(title), h.text(int.to_string(content))]) +} + +pub fn container(attrs, children) { + sketch.class([ + sketch.display("flex"), + sketch.gap(px(10)), + sketch.justify_content("center"), + sketch.media(media.max_width(px(700)), [ + sketch.flex_direction("column"), + sketch.align_items("center"), + ]), + ]) + |> h.div(attrs, children) +} + +pub fn counter_wrapper(attrs, children) { + sketch.class([ + sketch.position("relative"), + sketch.width(px(350)), + sketch.height(px(400)), + sketch.media(media.max_width(px(400)), [sketch.width(px(250))]), + ]) + |> h.div(attrs, children) +} + +pub fn footer(attrs, children) { + sketch.class([ + sketch.text_align("center"), + sketch.margin_top(px(60)), + sketch.margin_bottom(px(30)), + sketch.color("var(--text-grey)"), + ]) + |> h.div(attrs, children) +} + +pub fn body(attrs, children) { + sketch.class([sketch.max_width(px(1000)), sketch.margin_("auto")]) + |> h.div(attrs, children) +} diff --git a/packages/bright/e2e/sample/test/sample_test.gleam b/packages/bright/e2e/sample/test/sample_test.gleam new file mode 100644 index 0000000..3831e7a --- /dev/null +++ b/packages/bright/e2e/sample/test/sample_test.gleam @@ -0,0 +1,12 @@ +import gleeunit +import gleeunit/should + +pub fn main() { + gleeunit.main() +} + +// gleeunit test functions end in `_test` +pub fn hello_world_test() { + 1 + |> should.equal(1) +} diff --git a/packages/bright/gleam.toml b/packages/bright/gleam.toml new file mode 100644 index 0000000..d2072ff --- /dev/null +++ b/packages/bright/gleam.toml @@ -0,0 +1,22 @@ +name = "bright" +version = "0.1.0" + +description = "Be bright. Derive data in your Lustre model." +licences = ["MIT"] + +[[links]] +title = "Sponsor" +href = "https://github.com/sponsors/ghivert" + +[repository] +type = "github" +user = "ghivert" +repo = "bright" + +[dependencies] +gleam_stdlib = ">= 0.34.0 and < 2.0.0" +lustre = ">= 4.6.1 and < 5.0.0" +lustre_dev_tools = ">= 1.6.0 and < 2.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/packages/bright/manifest.toml b/packages/bright/manifest.toml new file mode 100644 index 0000000..d0bc494 --- /dev/null +++ b/packages/bright/manifest.toml @@ -0,0 +1,49 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, + { name = "birl", version = "1.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" }, + { name = "directories", version = "1.1.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "BDA521A4EB9EE3A7894F0DC863797878E91FF5C7826F7084B2E731E208BDB076" }, + { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, + { name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" }, + { name = "filepath", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "67A6D15FB39EEB69DD31F8C145BB5A421790581BD6AA14B33D64D5A55DBD6587" }, + { name = "fs", version = "8.6.1", build_tools = ["rebar3"], requirements = [], otp_app = "fs", source = "hex", outer_checksum = "61EA2BDAEDAE4E2024D0D25C63E44DCCF65622D4402DB4A2DF12868D1546503F" }, + { name = "gleam_community_ansi", version = "1.4.1", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "4CD513FC62523053E62ED7BAC2F36136EC17D6A8942728250A9A00A15E340E4B" }, + { name = "gleam_community_colour", version = "1.4.1", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "386CB9B01B33371538672EEA8A6375A0A0ADEF41F17C86DDCB81C92AD00DA610" }, + { name = "gleam_crypto", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "8AE56026B3E05EBB1F076778478A762E9EB62B31AEEB4285755452F397029D22" }, + { name = "gleam_erlang", version = "0.30.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "760618870AE4A497B10C73548E6E44F43B76292A54F0207B3771CBB599C675B4" }, + { name = "gleam_http", version = "3.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "A9EE0722106FCCAB8AD3BF9D0A3EFF92BFE8561D59B83BAE96EB0BE1938D4E0F" }, + { name = "gleam_httpc", version = "3.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "091CDD2BEC8092E82707BEA03FB5205A2BBBDE4A2F551E3C069E13B8BC0C428E" }, + { name = "gleam_json", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "0A57FB5666E695FD2BEE74C0428A98B0FC11A395D2C7B4CDF5E22C5DD32C74C6" }, + { name = "gleam_otp", version = "0.14.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "5A8CE8DBD01C29403390A7BD5C0A63D26F865C83173CF9708E6E827E53159C65" }, + { name = "gleam_package_interface", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_package_interface", source = "hex", outer_checksum = "CF3BFC5D0997750D9550D8D73A90F4B8D71C6C081B20ED4E70FFBE1E99AFC3C2" }, + { name = "gleam_stdlib", version = "0.43.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "69EF22E78FDCA9097CBE7DF91C05B2A8B5436826D9F66680D879182C0860A747" }, + { name = "glearray", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "B99767A9BC63EF9CC8809F66C7276042E5EFEACAA5B25188B552D3691B91AC6D" }, + { name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" }, + { name = "glint", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "5F6720081150AED8023131B0F3A35F9B0D6426A96CE02BEC52AD7018DF70566A" }, + { name = "glisten", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "48EF7F6D1DCA877C2F49AF35CC33946C7129EEB05A114758A2CC569C708BFAF8" }, + { name = "gramps", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "3CCAA6E081225180D95C79679D383BBF51C8D1FDC1B84DA1DA444F628C373793" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, + { name = "lustre", version = "4.6.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "486C3CFBD126939CAD2CA8B92A979A2DAADA5BABAA62BF2B163CD21E257BD4A1" }, + { name = "lustre_dev_tools", version = "1.6.0", build_tools = ["gleam"], requirements = ["argv", "filepath", "fs", "gleam_community_ansi", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_package_interface", "gleam_stdlib", "glint", "glisten", "mist", "simplifile", "spinner", "term_size", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "5A1C7D20FA2C0D77D59F259EAE0E14BB3F5359CC1DE7C5ED6922B65FFCBD4C31" }, + { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, + { name = "mist", version = "2.0.0", build_tools = ["gleam"], requirements = ["birl", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "981F12FC8BA0656B40099EC876D6F2BEE7B95593610F342E9AB0DC4E663A932F" }, + { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, + { name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" }, + { name = "repeatedly", version = "2.1.2", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "93AE1938DDE0DC0F7034F32C1BF0D4E89ACEBA82198A1FE21F604E849DA5F589" }, + { name = "simplifile", version = "2.2.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0DFABEF7DC7A9E2FF4BB27B108034E60C81BEBFCB7AB816B9E7E18ED4503ACD8" }, + { name = "snag", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "08E9EB87C413457DB1DD66CD704C6878DACC9C93B418600F63873D0CD224E756" }, + { name = "spinner", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_stdlib", "glearray", "repeatedly"], otp_app = "spinner", source = "hex", outer_checksum = "9EE43AA33BE2DA5731B8F3F170AAB59AF1A815AFA5BF615F12C1B91F3B03F157" }, + { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, + { name = "tom", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "228E667239504B57AD05EC3C332C930391592F6C974D0EFECF32FFD0F3629A27" }, + { name = "wisp", version = "1.2.0", build_tools = ["gleam"], requirements = ["directories", "exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "F71265D2F1DE11426535A2FA1DA3B11D2FFB783B116DF9496BC8C41983EBADB4" }, +] + +[requirements] +gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +lustre = { version = ">= 4.6.1 and < 5.0.0" } +lustre_dev_tools = { version = ">= 1.6.0 and < 2.0.0" } diff --git a/packages/bright/src/bright.ffi.mjs b/packages/bright/src/bright.ffi.mjs new file mode 100644 index 0000000..599e83f --- /dev/null +++ b/packages/bright/src/bright.ffi.mjs @@ -0,0 +1,9 @@ +import * as gleam from "./gleam.mjs" + +export function coerce(a) { + return a +} + +export function areReferentiallyEqual(a, b) { + return a === b && gleam.isEqual(a, b) +} diff --git a/packages/bright/src/bright.gleam b/packages/bright/src/bright.gleam new file mode 100644 index 0000000..7cfd93c --- /dev/null +++ b/packages/bright/src/bright.gleam @@ -0,0 +1,254 @@ +import gleam/bool +import gleam/dynamic.{type Dynamic} +import gleam/function +import gleam/list +import gleam/pair +import lustre/effect.{type Effect} + +@external(erlang, "bright_ffi", "coerce") +@external(javascript, "./bright.ffi.mjs", "coerce") +fn coerce(a: a) -> b + +/// Optimization on JS, to ensure two data sharing the referential equality +/// will shortcut the comparison. Useful when performance are a thing in client +/// browser. +@external(javascript, "./bright.ffi.mjs", "areReferentiallyEqual") +fn are_referentially_equal(a: a, b: b) -> Bool { + dynamic.from(a) == dynamic.from(b) +} + +/// `Bright` holds raw data and computed data, and is used to compute caching. +/// `Bright` is instanciated using `init`, with initial data and computed data. +pub opaque type Bright(data, computed) { + Bright( + data: data, + computed: computed, + selections: List(Dynamic), + past_selections: List(Dynamic), + effects: List(Dynamic), + ) +} + +/// Creates the initial `Bright`. `data` & `computed` should be initialised with +/// their correct empty initial state. +pub fn init(data data: data, computed computed: computed) { + Bright(data:, computed:, selections: [], past_selections: [], effects: []) +} + +pub fn start( + bright: Bright(data, computed), + next: fn(Bright(data, computed)) -> Bright(data, computed), +) -> #(Bright(data, computed), Effect(msg)) { + let old_computations = bright.past_selections + let new_data = next(bright) + let all_effects = dynamic.from(new_data.effects) |> coerce |> list.reverse + panic_if_different_computations_count(old_computations, new_data.selections) + let past_selections = list.reverse(new_data.selections) + Bright(..new_data, past_selections:, selections: [], effects: []) + |> pair.new(effect.batch(all_effects)) +} + +/// Entrypoint for the update cycle. Use it a way to trigger the start of `Bright` +/// computations, and chain them with other `bright` calls. +/// +/// ```gleam +/// pub fn update(model: Bright(data, computed), msg: Msg) { +/// // Starts the update cycle, and returns #(Bright(data, computed), Effect(msg)). +/// use model <- bright.update(model, update_data(_, msg)) +/// bright.return(model) +/// } +/// ``` +pub fn update( + bright: Bright(data, computed), + update_: fn(data) -> #(data, Effect(msg)), + next: fn(Bright(data, computed)) -> Bright(data, computed), +) -> Bright(data, computed) { + let #(data, effects) = update_(bright.data) + let effects = [dynamic.from(effects), ..bright.effects] + Bright(..bright, data:, effects:) + |> next +} + +/// Derives data from the `data` state, and potentially the current `computed` +/// state. `compute` will run **at every render**, so be careful with computations +/// as they can block paint or actors. +/// +/// ```gleam +/// pub fn update(model: Bright(data, computed), msg: Msg) { +/// use model <- bright.update(model, update_data(_, msg)) +/// model +/// |> bright.compute(fn (d, c) { Computed(..c, field1: computation1(d)) }) +/// |> bright.compute(fn (d, c) { Computed(..c, field2: computation2(d)) }) +/// |> bright.compute(fn (d, c) { Computed(..c, field3: computation3(d)) }) +/// } +/// ``` +pub fn compute( + bright: Bright(data, computed), + compute_: fn(data, computed) -> computed, +) -> Bright(data, computed) { + compute_(bright.data, bright.computed) + |> fn(computed) { Bright(..bright, computed:) } +} + +/// Plugs in existing `data` and `computed` state, to issue some side-effects, +/// when your application needs to run side-effects depending on the current state. +/// +/// ```gleam +/// pub fn update(model: Bright(data, computed), msg: Msg) { +/// use model <- bright.update(model, update_data(_, msg)) +/// use d, c <- bright.guard(model) +/// use dispatch <- effect.from +/// case d.field == 10 { +/// True -> dispatch(my_msg) +/// False -> Nil +/// } +/// } +/// ``` +pub fn run( + bright: Bright(data, computed), + guard_: fn(data, computed) -> Effect(msg), +) -> Bright(data, computed) { + guard_(bright.data, bright.computed) + |> dynamic.from + |> list.prepend(bright.effects, _) + |> fn(effects) { Bright(..bright, effects:) } +} + +/// Derives data like [`compute`](#compute) lazily. `lazy_compute` accepts a +/// selector as second argument. Each time the selector returns a different data +/// than previous run, the computation will run. Otherwise, nothing happens. +/// +/// ```gleam +/// pub fn update(model: Bright(data, computed), msg: Msg) { +/// use model <- bright.update(model, update_data(_, msg)) +/// model +/// |> bright.lazy_compute(selector, fn (d, c) { Computed(..c, field1: computation1(d)) }) +/// |> bright.lazy_compute(selector, fn (d, c) { Computed(..c, field2: computation2(d)) }) +/// |> bright.lazy_compute(selector, fn (d, c) { Computed(..c, field3: computation3(d)) }) +/// } +/// +/// /// Use it with lazy_compute to recompute only when the field when +/// /// { old_data.field / 10 } != { data.field / 10 } +/// fn selector(d, _) { +/// d.field / 10 +/// } +/// ``` +pub fn lazy_compute( + bright: Bright(data, computed), + selector: fn(data) -> a, + compute_: fn(data, computed) -> computed, +) -> Bright(data, computed) { + lazy_wrap(bright, selector, compute, compute_) +} + +/// Plugs in existing `data` like [`guard`](#guard) lazily. `lazy_guard` accepts +/// a selector as second argument. Each time the selector returns a different data +/// than previous run, the computation will run. Otherwise, nothing happens. +/// +/// ```gleam +/// pub fn update(model: Bright(data, computed), msg: Msg) { +/// use model <- bright.update(model, update_data(_, msg)) +/// use d, c <- bright.lazy_guard(model, selector) +/// use dispatch <- effect.from +/// case d.field == 10 { +/// True -> dispatch(my_msg) +/// False -> Nil +/// } +/// } +/// +/// /// Use it with lazy_guard to recompute only when the field when +/// /// { old_data.field / 10 } != { data.field / 10 } +/// fn selector(d, _) { +/// d.field / 10 +/// } +/// ``` +pub fn lazy_run( + bright: Bright(data, computed), + selector: fn(data) -> a, + guard_: fn(data, computed) -> Effect(msg), +) -> Bright(data, computed) { + lazy_wrap(bright, selector, run, guard_) +} + +/// Injects `Bright(data, computed)` in the `view` function, like a middleware. +/// Used to extract `data` & `computed` states from `Bright`. +/// +/// ```gleam +/// pub fn view(model: Bright(data, computed)) { +/// use data, computed <- bright.view(model) +/// html.div([], [ +/// // Use data or computed here. +/// ]) +/// } +/// ``` +pub fn view( + bright: Bright(data, computed), + viewer: fn(data, computed) -> a, +) -> a { + viewer(bright.data, bright.computed) +} + +/// Allows to run multiple `update` on multiple `Bright` in the same update cycle. +/// Every call to step with compute a new `Bright`, and will let you chain the +/// steps. +/// +/// ```gleam +/// pub type Model { +/// Model( +/// fst_bright: Bright(data, computed), +/// snd_bright: Bright(data, computed), +/// ) +/// } +/// +/// fn update(model: Model, msg: Msg) { +/// use fst_bright <- bright.step(update_fst(model.fst_bright, msg)) +/// use snd_bright <- bright.step(update_snd(model.snd_bright, msg)) +/// bright.return(Model(fst_bright:, snd_bright:)) +/// } +/// ``` +pub fn step( + bright: #(Bright(data, computed), Effect(msg)), + next: fn(Bright(data, computed)) -> #(model, Effect(msg)), +) { + let #(bright, effs) = bright + let #(model, effs_) = next(bright) + #(model, effect.batch([effs, effs_])) +} + +/// Helper to write `bright` update cycle. Equivalent to `#(a, effect.none())`. +pub fn return(a) { + #(a, effect.none()) +} + +fn lazy_wrap( + bright: Bright(data, computed), + selector: fn(data) -> a, + setter: fn(Bright(data, computed), fn(data, computed) -> c) -> + Bright(data, computed), + compute_: fn(data, computed) -> c, +) -> Bright(data, computed) { + let selected_data = selector(bright.data) + let selections = [dynamic.from(selected_data), ..bright.selections] + let bright = Bright(..bright, selections:) + case bright.past_selections { + [] -> setter(bright, compute_) + [value, ..past_selections] -> { + Bright(..bright, past_selections:) + |> case are_referentially_equal(value, selected_data) { + True -> function.identity + False -> setter(_, compute_) + } + } + } +} + +fn panic_if_different_computations_count( + old_computations: List(c), + computations: List(d), +) -> Nil { + let count = list.length(old_computations) + use <- bool.guard(when: count == 0, return: Nil) + let is_same_count = count == list.length(computations) + use <- bool.guard(when: is_same_count, return: Nil) + panic as "Memoized computed should be consistent over time, otherwise memo can not work." +} diff --git a/packages/bright/src/bright_ffi.erl b/packages/bright/src/bright_ffi.erl new file mode 100644 index 0000000..4cc764f --- /dev/null +++ b/packages/bright/src/bright_ffi.erl @@ -0,0 +1,6 @@ +-module(bright_ffi). + +-export([coerce/1]). + +coerce(A) -> + A. diff --git a/packages/bright/test/bright_test.gleam b/packages/bright/test/bright_test.gleam new file mode 100644 index 0000000..3831e7a --- /dev/null +++ b/packages/bright/test/bright_test.gleam @@ -0,0 +1,12 @@ +import gleeunit +import gleeunit/should + +pub fn main() { + gleeunit.main() +} + +// gleeunit test functions end in `_test` +pub fn hello_world_test() { + 1 + |> should.equal(1) +} diff --git a/packages/interfaces/.github/workflows/test.yml b/packages/interfaces/.github/workflows/test.yml new file mode 100644 index 0000000..c3d0e65 --- /dev/null +++ b/packages/interfaces/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: test + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: + otp-version: "27.1.2" + gleam-version: "1.6.2" + rebar3-version: "3" + # elixir-version: "1.15.4" + - run: gleam deps download + - run: gleam test + - run: gleam format --check src test diff --git a/packages/interfaces/.gitignore b/packages/interfaces/.gitignore new file mode 100644 index 0000000..599be4e --- /dev/null +++ b/packages/interfaces/.gitignore @@ -0,0 +1,4 @@ +*.beam +*.ez +/build +erl_crash.dump diff --git a/packages/interfaces/README.md b/packages/interfaces/README.md new file mode 100644 index 0000000..446cba5 --- /dev/null +++ b/packages/interfaces/README.md @@ -0,0 +1,24 @@ +# interfaces + +[![Package Version](https://img.shields.io/hexpm/v/interfaces)](https://hex.pm/packages/interfaces) +[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/interfaces/) + +```sh +gleam add interfaces@1 +``` +```gleam +import interfaces + +pub fn main() { + // TODO: An example of the project in use +} +``` + +Further documentation can be found at . + +## Development + +```sh +gleam run # Run the project +gleam test # Run the tests +``` diff --git a/packages/interfaces/gleam.toml b/packages/interfaces/gleam.toml new file mode 100644 index 0000000..663d43a --- /dev/null +++ b/packages/interfaces/gleam.toml @@ -0,0 +1,21 @@ +name = "interfaces" +version = "1.0.0" + +# Fill out these fields if you intend to generate HTML documentation or publish +# your project to the Hex package manager. +# +# description = "" +# licences = ["Apache-2.0"] +# repository = { type = "github", user = "", repo = "" } +# links = [{ title = "Website", href = "" }] +# +# For a full reference of all the available options, you can have a look at +# https://gleam.run/writing-gleam/gleam-toml/. + +[dependencies] +gleam_stdlib = ">= 0.34.0 and < 2.0.0" +birl = ">= 1.7.1 and < 2.0.0" +gleam_json = ">= 2.1.0 and < 3.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/packages/interfaces/manifest.toml b/packages/interfaces/manifest.toml new file mode 100644 index 0000000..fbe77a1 --- /dev/null +++ b/packages/interfaces/manifest.toml @@ -0,0 +1,16 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "birl", version = "1.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" }, + { name = "gleam_json", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "0A57FB5666E695FD2BEE74C0428A98B0FC11A395D2C7B4CDF5E22C5DD32C74C6" }, + { name = "gleam_stdlib", version = "0.45.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "206FCE1A76974AECFC55AEBCD0217D59EDE4E408C016E2CFCCC8FF51278F186E" }, + { name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" }, + { name = "ranger", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "B8F3AFF23A3A5B5D9526B8D18E7C43A7DFD3902B151B97EC65397FE29192B695" }, +] + +[requirements] +birl = { version = ">= 1.7.1 and < 2.0.0" } +gleam_json = { version = ">= 2.1.0 and < 3.0.0" } +gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/packages/interfaces/src/data/analytics.gleam b/packages/interfaces/src/data/analytics.gleam new file mode 100644 index 0000000..65a22ab --- /dev/null +++ b/packages/interfaces/src/data/analytics.gleam @@ -0,0 +1,88 @@ +import birl +import gleam/dynamic +import gleam/json +import gleam/option +import gleam/pair +import gleam/result + +pub type Analytics { + Analytics( + total_searches: Int, + total_signatures: Int, + total_indexed: Int, + timeseries: List(#(Int, birl.Time)), + ranked: List(Package), + popular: List(Package), + ) +} + +pub type Package { + Package( + name: String, + repository: String, + rank: Int, + popularity: option.Option(Int), + ) +} + +pub fn encode(analytics: Analytics) { + json.object([ + #("total_searches", json.int(analytics.total_searches)), + #("total_signatures", json.int(analytics.total_signatures)), + #("total_indexed", json.int(analytics.total_indexed)), + #("ranked", json.array(analytics.ranked, encode_package)), + #("popular", json.array(analytics.popular, encode_package)), + #("timeseries", { + json.array(analytics.timeseries, fn(row) { + json.object([ + #("count", json.int(row.0)), + #("date", json.string(birl.to_iso8601(row.1))), + ]) + }) + }), + ]) +} + +pub fn decode(dyn) { + dynamic.decode6( + Analytics, + dynamic.field("total_searches", dynamic.int), + dynamic.field("total_signatures", dynamic.int), + dynamic.field("total_indexed", dynamic.int), + dynamic.field("timeseries", { + dynamic.list(dynamic.decode2( + pair.new, + dynamic.field("count", dynamic.int), + dynamic.field("date", fn(dyn) { + dynamic.string(dyn) + |> result.then(fn(t) { + birl.parse(t) + |> result.replace_error([]) + }) + }), + )) + }), + dynamic.field("ranked", dynamic.list(decode_package)), + dynamic.field("popular", dynamic.list(decode_package)), + )(dyn) +} + +pub fn encode_package(package: Package) { + let Package(name:, repository:, rank:, popularity:) = package + json.object([ + #("name", json.string(name)), + #("repository", json.string(repository)), + #("rank", json.int(rank)), + #("popularity", json.nullable(popularity, json.int)), + ]) +} + +pub fn decode_package(dyn) { + dynamic.decode4( + Package, + dynamic.field("name", dynamic.string), + dynamic.field("repository", dynamic.string), + dynamic.field("rank", dynamic.int), + dynamic.field("popularity", dynamic.optional(dynamic.int)), + )(dyn) +} diff --git a/apps/frontend/src/data/implementations.gleam b/packages/interfaces/src/data/implementations.gleam similarity index 100% rename from apps/frontend/src/data/implementations.gleam rename to packages/interfaces/src/data/implementations.gleam diff --git a/apps/frontend/src/data/kind.gleam b/packages/interfaces/src/data/kind.gleam similarity index 67% rename from apps/frontend/src/data/kind.gleam rename to packages/interfaces/src/data/kind.gleam index 0c31fd2..4ddf899 100644 --- a/apps/frontend/src/data/kind.gleam +++ b/packages/interfaces/src/data/kind.gleam @@ -1,4 +1,5 @@ import gleam/dynamic +import gleam/json import gleam/result pub type Kind { @@ -8,7 +9,7 @@ pub type Kind { Constant } -pub fn decode_kind(dyn) { +pub fn decode(dyn) { use str <- result.try(dynamic.string(dyn)) case str { "function" -> Ok(Function) @@ -19,7 +20,17 @@ pub fn decode_kind(dyn) { } } -pub fn display_kind(kind) { +pub fn encode(kind) { + case kind { + Function -> "function" + TypeDefinition -> "type_definition" + TypeAlias -> "type_alias" + Constant -> "constant" + } + |> json.string +} + +pub fn display(kind) { case kind { Function -> "Function" TypeDefinition -> "Type" diff --git a/packages/interfaces/src/data/metadata.gleam b/packages/interfaces/src/data/metadata.gleam new file mode 100644 index 0000000..795bb27 --- /dev/null +++ b/packages/interfaces/src/data/metadata.gleam @@ -0,0 +1,60 @@ +import data/implementations.{type Implementations, Implementations} +import gleam/decoder_extra +import gleam/dynamic +import gleam/json +import gleam/option.{type Option} + +pub type Metadata { + Metadata( + deprecation: Option(String), + implementations: Option(Implementations), + ) +} + +pub fn decode(dyn) { + dynamic.decode2( + Metadata, + decoder_extra.completely_option("deprecation"), + dynamic.optional_field( + "implementations", + dynamic.decode3( + Implementations, + dynamic.field("gleam", dynamic.bool), + dynamic.field("uses_erlang_externals", dynamic.bool), + dynamic.field("uses_javascript_externals", dynamic.bool), + ), + ), + )(dyn) +} + +pub fn encode(metadata: Metadata) { + [] + |> fn(elems) { + case metadata.deprecation { + option.None -> elems + option.Some(deprecation) -> [#("deprecation", json.string(deprecation))] + } + } + |> fn(elems) { + case metadata.implementations { + option.None -> elems + option.Some(implementations) -> [ + #( + "implementations", + json.object([ + #("gleam", json.bool(implementations.gleam)), + #( + "uses_erlang_externals", + json.bool(implementations.uses_erlang_externals), + ), + #( + "uses_javascript_externals", + json.bool(implementations.uses_javascript_externals), + ), + ]), + ), + ] + } + } + |> json.object +} diff --git a/apps/frontend/src/data/package.gleam b/packages/interfaces/src/data/package.gleam similarity index 50% rename from apps/frontend/src/data/package.gleam rename to packages/interfaces/src/data/package.gleam index cc14763..b229b82 100644 --- a/apps/frontend/src/data/package.gleam +++ b/packages/interfaces/src/data/package.gleam @@ -16,18 +16,23 @@ pub type Package { ) } -pub fn decoder(dyn) { +pub fn decode(dyn) { dynamic.decode8( Package, dynamic.field("name", dynamic.string), dynamic.field("repository", dynamic.optional(dynamic.string)), dynamic.field("documentation", dynamic.optional(dynamic.string)), - dynamic.field("hex-url", dynamic.optional(dynamic.string)), + dynamic.field("hex_url", dynamic.optional(dynamic.string)), dynamic.field("licenses", fn(dyn) { - use data <- result.try(dynamic.optional(dynamic.string)(dyn)) - option.unwrap(data, "[]") - |> json.decode(using: dynamic.list(dynamic.string)) - |> result.replace_error([dynamic.DecodeError("", "", [])]) + dynamic.any([ + dynamic.list(dynamic.string), + fn(dyn) { + use data <- result.try(dynamic.optional(dynamic.string)(dyn)) + option.unwrap(data, "[]") + |> json.decode(using: dynamic.list(dynamic.string)) + |> result.replace_error([dynamic.DecodeError("", "", [])]) + }, + ])(dyn) }), dynamic.field("description", dynamic.optional(dynamic.string)), dynamic.field("rank", dynamic.optional(dynamic.int)), @@ -40,3 +45,20 @@ pub fn decoder(dyn) { }), )(dyn) } + +pub fn encode(package: Package) { + json.object([ + #("name", json.string(package.name)), + #("repository", json.nullable(package.repository, json.string)), + #("documentation", json.nullable(package.documentation, json.string)), + #("hex_url", json.nullable(package.hex_url, json.string)), + #("licenses", json.array(package.licenses, json.string)), + #("description", json.nullable(package.description, json.string)), + #("rank", json.nullable(package.rank, json.int)), + #("popularity", { + json.object([#("github", json.int(package.popularity))]) + |> json.to_string + |> json.string + }), + ]) +} diff --git a/apps/frontend/src/data/signature.gleam b/packages/interfaces/src/data/signature.gleam similarity index 68% rename from apps/frontend/src/data/signature.gleam rename to packages/interfaces/src/data/signature.gleam index 7c8adda..10242c1 100644 --- a/apps/frontend/src/data/signature.gleam +++ b/packages/interfaces/src/data/signature.gleam @@ -1,5 +1,6 @@ import gleam/dynamic import gleam/int +import gleam/json import gleam/list import gleam/option.{type Option} import gleam/result @@ -46,7 +47,7 @@ pub type Signature { TypeDefinition(parameters: Int, constructors: List(TypeConstructor)) } -pub fn decode_signature(dyn) { +pub fn decode(dyn) { use res <- result.try(dynamic.field("kind", dynamic.string)(dyn)) case res { "constant" -> decode_constant(dyn) @@ -57,6 +58,35 @@ pub fn decode_signature(dyn) { } } +pub fn encode(signature: Signature) { + case signature { + Constant(type_:, ..) -> + json.object([ + #("kind", json.string("constant")), + #("type", encode_type(type_)), + ]) + TypeAlias(parameters:, alias:, ..) -> + json.object([ + #("kind", json.string("type-alias")), + #("parameters", json.int(parameters)), + #("alias", encode_type(alias)), + ]) + TypeDefinition(parameters:, constructors:) -> + json.object([ + #("kind", json.string("type-definition")), + #("parameters", json.int(parameters)), + #("constructors", json.array(constructors, encode_constructors)), + ]) + Function(name:, return:, parameters:, ..) -> + json.object([ + #("kind", json.string("function")), + #("name", json.string(name)), + #("return", encode_type(return)), + #("parameters", json.array(parameters, encode_parameter)), + ]) + } +} + fn decode_type(dyn) { use res <- result.try(dynamic.field("kind", dynamic.string)(dyn)) case res { @@ -68,6 +98,33 @@ fn decode_type(dyn) { } } +fn encode_type(type_: Type) { + case type_ { + Variable(id:, ..) -> + json.object([#("kind", json.string("variable")), #("id", json.int(id))]) + Fn(parameters:, return:, ..) -> + json.object([ + #("kind", json.string("fn")), + #("params", json.array(parameters, encode_type)), + #("return", encode_type(return)), + ]) + Named(name:, package:, module:, parameters:, ref:, ..) -> + json.object([ + #("kind", json.string("named")), + #("name", json.string(name)), + #("package", json.string(package)), + #("module", json.string(module)), + #("parameters", json.array(parameters, encode_type)), + #("ref", json.nullable(ref, json.string)), + ]) + Tuple(elements:, ..) -> + json.object([ + #("kind", json.string("tuple")), + #("elements", json.array(elements, encode_type)), + ]) + } +} + fn decode_variable(dyn) { dynamic.decode1(fn(a) { Variable(1, a) }, dynamic.field("id", dynamic.int))( dyn, @@ -137,6 +194,13 @@ fn decode_parameter(dyn) { )(dyn) } +pub fn encode_parameter(parameter: Parameter) { + json.object([ + #("label", json.nullable(parameter.label, json.string)), + #("type", encode_type(parameter.type_)), + ]) +} + fn decode_constant(dyn) { dynamic.decode1( fn(a: Type) { @@ -201,3 +265,11 @@ fn decode_constructors(dyn) { dynamic.field("parameters", dynamic.list(decode_parameter)), )(dyn) } + +fn encode_constructors(constructor: TypeConstructor) { + json.object([ + #("documentation", json.nullable(constructor.documentation, json.string)), + #("name", json.string(constructor.name)), + #("parameters", json.array(constructor.parameters, encode_parameter)), + ]) +} diff --git a/packages/interfaces/src/data/type_search.gleam b/packages/interfaces/src/data/type_search.gleam new file mode 100644 index 0000000..a30435d --- /dev/null +++ b/packages/interfaces/src/data/type_search.gleam @@ -0,0 +1,46 @@ +import data/kind.{type Kind} +import data/metadata.{type Metadata} +import data/signature.{type Signature} +import gleam/decoder_extra as decode_ +import gleam/dynamic +import gleam/json + +pub type TypeSearch { + TypeSearch( + type_name: String, + documentation: String, + signature_kind: Kind, + metadata: Metadata, + json_signature: Signature, + module_name: String, + package_name: String, + version: String, + ) +} + +pub fn decode(dyn) { + dynamic.decode8( + TypeSearch, + dynamic.field("type_name", dynamic.string), + dynamic.field("documentation", dynamic.string), + dynamic.field("signature_kind", kind.decode), + dynamic.field("metadata", decode_.json(metadata.decode)), + dynamic.field("json_signature", decode_.json(signature.decode)), + dynamic.field("module_name", dynamic.string), + dynamic.field("package_name", dynamic.string), + dynamic.field("version", dynamic.string), + )(dyn) +} + +pub fn encode(item: TypeSearch) { + json.object([ + #("type_name", json.string(item.type_name)), + #("documentation", json.string(item.documentation)), + #("signature_kind", kind.encode(item.signature_kind)), + #("metadata", metadata.encode(item.metadata)), + #("json_signature", signature.encode(item.json_signature)), + #("module_name", json.string(item.module_name)), + #("package_name", json.string(item.package_name)), + #("version", json.string(item.version)), + ]) +} diff --git a/packages/interfaces/src/gleam/coerce.gleam b/packages/interfaces/src/gleam/coerce.gleam new file mode 100644 index 0000000..c379fff --- /dev/null +++ b/packages/interfaces/src/gleam/coerce.gleam @@ -0,0 +1,3 @@ +@external(erlang, "interfaces_ffi", "coerce") +@external(javascript, "../interfaces.ffi.mjs", "coerce") +pub fn coerce(value: a) -> b diff --git a/packages/interfaces/src/gleam/decoder_extra.gleam b/packages/interfaces/src/gleam/decoder_extra.gleam new file mode 100644 index 0000000..6222325 --- /dev/null +++ b/packages/interfaces/src/gleam/decoder_extra.gleam @@ -0,0 +1,25 @@ +import gleam/dynamic.{type Dynamic} +import gleam/json +import gleam/option +import gleam/result + +pub fn completely_option(field: String) { + fn(dyn: Dynamic) { + dynamic.optional_field(field, dynamic.optional(dynamic.string))(dyn) + |> result.map(fn(res) { option.flatten(res) }) + } +} + +pub fn json(decoder: dynamic.Decoder(a)) { + dynamic.any([ + decoder, + fn(dyn) { + case dynamic.string(dyn) { + Error(e) -> Error(e) + Ok(content) -> + json.decode(content, decoder) + |> result.replace_error([]) + } + }, + ]) +} diff --git a/packages/interfaces/src/interfaces.ffi.mjs b/packages/interfaces/src/interfaces.ffi.mjs new file mode 100644 index 0000000..b6eec50 --- /dev/null +++ b/packages/interfaces/src/interfaces.ffi.mjs @@ -0,0 +1,3 @@ +export function coerce(a) { + return a +} diff --git a/packages/interfaces/src/interfaces.gleam b/packages/interfaces/src/interfaces.gleam new file mode 100644 index 0000000..942143f --- /dev/null +++ b/packages/interfaces/src/interfaces.gleam @@ -0,0 +1,5 @@ +import gleam/io + +pub fn main() { + io.println("Hello from interfaces!") +} diff --git a/packages/interfaces/src/interfaces_ffi.erl b/packages/interfaces/src/interfaces_ffi.erl new file mode 100644 index 0000000..e8ab1dc --- /dev/null +++ b/packages/interfaces/src/interfaces_ffi.erl @@ -0,0 +1,6 @@ +-module(interfaces_ffi). + +-export([coerce/1]). + +coerce(A) -> + A. diff --git a/packages/interfaces/test/interfaces_test.gleam b/packages/interfaces/test/interfaces_test.gleam new file mode 100644 index 0000000..3831e7a --- /dev/null +++ b/packages/interfaces/test/interfaces_test.gleam @@ -0,0 +1,12 @@ +import gleeunit +import gleeunit/should + +pub fn main() { + gleeunit.main() +} + +// gleeunit test functions end in `_test` +pub fn hello_world_test() { + 1 + |> should.equal(1) +}