aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorFuwn <[email protected]>2024-06-23 06:57:07 +0000
committerFuwn <[email protected]>2024-06-23 06:57:07 +0000
commit74f1bfd0c1971ede4a8db3276f918dcbb0543acf (patch)
treeccf713e5d698d612a1e26e0966bcf576f82f9a03 /src
downloadmomoka-74f1bfd0c1971ede4a8db3276f918dcbb0543acf.tar.xz
momoka-74f1bfd0c1971ede4a8db3276f918dcbb0543acf.zip
feat: initial releasev1.0.0
Diffstat (limited to 'src')
-rw-r--r--src/gemini.gleam49
-rw-r--r--src/gemtext/blockquote.gleam20
-rw-r--r--src/gemtext/gemtext.gleam14
-rw-r--r--src/gemtext/heading.gleam17
-rw-r--r--src/gemtext/list.gleam16
-rw-r--r--src/gemtext/parse.gleam49
-rw-r--r--src/gemtext/preformatted_block.gleam34
-rw-r--r--src/gopher.gleam62
-rw-r--r--src/momoka.gleam34
-rw-r--r--src/tcp.gleam16
10 files changed, 311 insertions, 0 deletions
diff --git a/src/gemini.gleam b/src/gemini.gleam
new file mode 100644
index 0000000..9cd0884
--- /dev/null
+++ b/src/gemini.gleam
@@ -0,0 +1,49 @@
+import envoy
+import gemtext/parse
+import gleam/bit_array
+import gleam/bytes_builder
+import gleam/http/request
+import gleam/httpc
+import gleam/string
+import gopher
+
+pub fn get_gemtext_from_capsule(message) {
+ let root_capsule = case envoy.get("ROOT") {
+ Ok(capsule) -> capsule
+ _ -> "fuwn.me"
+ }
+ // Gleam isn't mature enough to even have an SSL/TLS libraries that I could
+ // find, and Erlang FFI is unclear, so Momoka uses the
+ // [September](https://github.com/gemrest/september) proxy deployed at
+ // <https://fuwn.me> to fetch the raw Gemini content.
+ //
+ // I'm sure as the language grows, this will be replaced with a more direct
+ // approach.
+ let gemini_proxy = case envoy.get("GEMINI_PROXY") {
+ Ok(proxy) -> proxy
+ _ -> "https://fuwn.me/raw/"
+ }
+ let assert Ok(request) = case bit_array.to_string(message) {
+ Ok(path) -> {
+ case path {
+ "/\r\n" | "\r\n" -> request.to(gemini_proxy <> root_capsule)
+ "/proxy/" <> route ->
+ request.to(gemini_proxy <> string.replace(route, "\r\n", ""))
+ "/" <> path ->
+ request.to(
+ gemini_proxy
+ <> root_capsule
+ <> "/"
+ <> string.replace(path, "\r\n", ""),
+ )
+ _ -> request.to(root_capsule <> string.replace(path, "\r\n", ""))
+ }
+ }
+ _ -> request.to(root_capsule)
+ }
+ let assert Ok(response) = httpc.send(request)
+
+ bytes_builder.from_string(
+ gopher.gemtext_to_gopher(parse.parse_gemtext_raw(response.body)) <> "\r\n",
+ )
+}
diff --git a/src/gemtext/blockquote.gleam b/src/gemtext/blockquote.gleam
new file mode 100644
index 0000000..94aa937
--- /dev/null
+++ b/src/gemtext/blockquote.gleam
@@ -0,0 +1,20 @@
+import gemtext/gemtext.{type Gemtext}
+import gleam/list
+import gleam/string
+
+pub fn combine_adjacent_blockquote_lines(lines: List(Gemtext)) -> List(Gemtext) {
+ case lines {
+ [gemtext.BlockquoteLine(a), gemtext.BlockquoteLine(b), ..rest] ->
+ combine_adjacent_blockquote_lines([
+ gemtext.Blockquote(string.join([a, b], "\n")),
+ ..rest
+ ])
+ [gemtext.Blockquote(a), gemtext.BlockquoteLine(b), ..rest] ->
+ combine_adjacent_blockquote_lines([
+ gemtext.Blockquote(string.join([a, b], "\n")),
+ ..rest
+ ])
+ [g, ..rest] -> list.append([g], combine_adjacent_blockquote_lines(rest))
+ [] -> []
+ }
+}
diff --git a/src/gemtext/gemtext.gleam b/src/gemtext/gemtext.gleam
new file mode 100644
index 0000000..a1dd3a5
--- /dev/null
+++ b/src/gemtext/gemtext.gleam
@@ -0,0 +1,14 @@
+import gleam/option.{type Option}
+
+pub type Gemtext {
+ Text(String)
+ Link(to: String, Option(String))
+ Heading(String, depth: Int)
+ ListLine(String)
+ List(List(List(String)))
+ BlockquoteLine(String)
+ Blockquote(String)
+ Preformatted(description: Option(String), body: String)
+ PreformattedLine(String)
+ Whitespace
+}
diff --git a/src/gemtext/heading.gleam b/src/gemtext/heading.gleam
new file mode 100644
index 0000000..153153e
--- /dev/null
+++ b/src/gemtext/heading.gleam
@@ -0,0 +1,17 @@
+import gleam/string
+
+pub fn count_leading_hashes(line: String) -> Int {
+ do_count_leading_hashes(string.to_graphemes(line), 0)
+}
+
+fn do_count_leading_hashes(characters: List(String), accumulator: Int) -> Int {
+ case characters {
+ [c, ..rest] -> {
+ case c {
+ "#" -> do_count_leading_hashes(rest, accumulator + 1)
+ _ -> accumulator
+ }
+ }
+ _ -> accumulator
+ }
+}
diff --git a/src/gemtext/list.gleam b/src/gemtext/list.gleam
new file mode 100644
index 0000000..23a73c7
--- /dev/null
+++ b/src/gemtext/list.gleam
@@ -0,0 +1,16 @@
+import gemtext/gemtext.{type Gemtext}
+import gleam/list
+
+pub fn group_adjacent_list_items(lines: List(Gemtext)) -> List(Gemtext) {
+ case lines {
+ [gemtext.ListLine(a), gemtext.ListLine(b), ..rest] ->
+ group_adjacent_list_items([gemtext.List([[a], [b]]), ..rest])
+ [gemtext.List(lists), gemtext.ListLine(item), ..rest] ->
+ group_adjacent_list_items([
+ gemtext.List(list.append(lists, [[item]])),
+ ..rest
+ ])
+ [g, ..rest] -> list.append([g], group_adjacent_list_items(rest))
+ [] -> []
+ }
+}
diff --git a/src/gemtext/parse.gleam b/src/gemtext/parse.gleam
new file mode 100644
index 0000000..f26cf3d
--- /dev/null
+++ b/src/gemtext/parse.gleam
@@ -0,0 +1,49 @@
+import gemtext/blockquote
+import gemtext/gemtext.{type Gemtext}
+import gemtext/heading
+import gemtext/list as gemtext_list
+import gemtext/preformatted_block
+import gleam/list
+import gleam/option.{None, Some}
+import gleam/string
+
+pub fn parse_gemtext_line(line) -> Gemtext {
+ let trimmed_line = string.trim(line)
+
+ case string.split(trimmed_line, " ") {
+ ["=>", to] -> gemtext.Link(to, None)
+ ["=>", to, ..title] -> gemtext.Link(to, Some(string.join(title, " ")))
+ [">", ..rest] ->
+ gemtext.BlockquoteLine(string.trim_left(string.join(rest, " ")))
+ ["*", ..rest] -> gemtext.ListLine(string.trim_left(string.join(rest, " ")))
+ [""] -> gemtext.Whitespace
+ _ -> {
+ case string.to_graphemes(trimmed_line) {
+ ["#", ..rest] ->
+ gemtext.Heading(
+ string.trim_left(string.replace(string.join(rest, ""), "#", "")),
+ heading.count_leading_hashes(line),
+ )
+ ["`", "`", "`", ..rest] ->
+ gemtext.PreformattedLine(string.trim_left(
+ "```\n" <> string.join(rest, ""),
+ ))
+ _ -> gemtext.Text(line)
+ }
+ }
+ }
+}
+
+pub fn parse_gemtext(text: String) -> List(Gemtext) {
+ string.split(text, "\n")
+ |> preformatted_block.group_adjacent_preformatted_block_lines
+ |> list.map(parse_gemtext_line)
+ |> gemtext_list.group_adjacent_list_items
+ |> blockquote.combine_adjacent_blockquote_lines
+}
+
+pub fn parse_gemtext_raw(text: String) -> List(Gemtext) {
+ string.split(text, "\n")
+ |> preformatted_block.group_adjacent_preformatted_block_lines
+ |> list.map(parse_gemtext_line)
+}
diff --git a/src/gemtext/preformatted_block.gleam b/src/gemtext/preformatted_block.gleam
new file mode 100644
index 0000000..5e94fef
--- /dev/null
+++ b/src/gemtext/preformatted_block.gleam
@@ -0,0 +1,34 @@
+import gleam/list
+import gleam/string
+
+pub fn group_adjacent_preformatted_block_lines(
+ lines: List(String),
+) -> List(String) {
+ case lines {
+ ["```", desc, ..rest] -> {
+ let #(body, rest) = do_group_adjacent_preformatted_block_lines(rest, [])
+ list.append(
+ ["```" <> desc <> "\n" <> body <> "\n```"],
+ group_adjacent_preformatted_block_lines(rest),
+ )
+ }
+ [line, ..rest] ->
+ list.append([line], group_adjacent_preformatted_block_lines(rest))
+ [] -> []
+ }
+}
+
+fn do_group_adjacent_preformatted_block_lines(
+ lines: List(String),
+ accumulator: List(String),
+) {
+ case lines {
+ ["```", ..rest] -> #(string.join(accumulator, "\n"), rest)
+ [line, ..rest] ->
+ do_group_adjacent_preformatted_block_lines(
+ rest,
+ list.append(accumulator, [line]),
+ )
+ [] -> #(string.join(accumulator, "\n"), [])
+ }
+}
diff --git a/src/gopher.gleam b/src/gopher.gleam
new file mode 100644
index 0000000..044ee40
--- /dev/null
+++ b/src/gopher.gleam
@@ -0,0 +1,62 @@
+import gemtext/gemtext.{type Gemtext}
+import gleam/list
+import gleam/option.{None, Some}
+import gleam/string
+
+pub fn gemtext_to_gopher(gemtext: List(Gemtext)) -> String {
+ gemtext
+ |> list.map(fn(line) {
+ case line {
+ gemtext.PreformattedLine(content) ->
+ content
+ |> string.split("\n")
+ |> list.map(fn(line) {
+ case line {
+ "```" -> ""
+ _ -> "i`` " <> line
+ }
+ })
+ |> string.join("\n")
+ _ -> gemtext_line_to_gopher_line(line)
+ }
+ })
+ |> string.join("\n")
+}
+
+fn extract_domain_from_url(url: String) -> String {
+ case string.split(url, "://") {
+ [_, rest] -> {
+ case string.split(rest, "/") {
+ [domain, _] -> domain
+ _ -> rest
+ }
+ }
+ _ -> "null.host"
+ }
+}
+
+fn gopher_link_line(to, description) {
+ "h"
+ <> description
+ <> "\tURL:"
+ <> to
+ <> "\t"
+ <> extract_domain_from_url(to)
+ <> "\t70"
+}
+
+pub fn gemtext_line_to_gopher_line(line: Gemtext) -> String {
+ case line {
+ gemtext.Text(text) -> "i" <> text
+ gemtext.Link(to, description) ->
+ case description {
+ Some(description) -> gopher_link_line(to, description)
+ None -> gopher_link_line(to, to)
+ }
+ gemtext.Heading(text, depth) ->
+ "i" <> list.repeat("#", depth) |> string.join("") <> " " <> text
+ gemtext.ListLine(text) -> "i* " <> text
+ gemtext.BlockquoteLine(text) -> "i> " <> text
+ _ -> ""
+ }
+}
diff --git a/src/momoka.gleam b/src/momoka.gleam
new file mode 100644
index 0000000..6b49c87
--- /dev/null
+++ b/src/momoka.gleam
@@ -0,0 +1,34 @@
+import envoy
+import gemini
+import gleam/erlang/process
+import gleam/int
+import gleam/option.{None}
+import gleam/otp/actor
+import glisten.{Packet}
+import tcp
+
+pub fn main() {
+ let assert Ok(_) =
+ glisten.handler(
+ fn(_connection) { #(Nil, None) },
+ fn(message, state, connection) {
+ let assert Packet(message) = message
+ let assert Ok(_) =
+ glisten.send(connection, gemini.get_gemtext_from_capsule(message))
+ let assert Ok(_) = tcp.close_connection(connection)
+
+ actor.continue(state)
+ },
+ )
+ |> glisten.serve(case envoy.get("PORT") {
+ Ok(port) -> {
+ case int.base_parse(port, 10) {
+ Ok(port) -> port
+ _ -> 70
+ }
+ }
+ _ -> 70
+ })
+
+ process.sleep_forever()
+}
diff --git a/src/tcp.gleam b/src/tcp.gleam
new file mode 100644
index 0000000..30e443e
--- /dev/null
+++ b/src/tcp.gleam
@@ -0,0 +1,16 @@
+import gleam/erlang/atom.{type Atom}
+import glisten
+import glisten/socket.{type Socket, type SocketReason}
+
+@external(erlang, "glisten_tcp_ffi", "shutdown")
+fn do_shutdown(socket: Socket, write: Atom) -> Result(Nil, SocketReason)
+
+pub fn shutdown(socket: Socket) -> Result(Nil, SocketReason) {
+ let assert Ok(write) = atom.from_string("write")
+
+ do_shutdown(socket, write)
+}
+
+pub fn close_connection(connection: glisten.Connection(a)) {
+ shutdown(connection.socket)
+}