🧚 A practical web framework for Gleam

Hello, Wisp!

+7 -1523
+1 -42
.github/workflows/ci.yml
··· 1 - name: ci 1 + name: test 2 2 3 3 on: 4 4 push: ··· 9 9 jobs: 10 10 test-action: 11 11 runs-on: ubuntu-latest 12 - defaults: 13 - run: 14 - working-directory: ./action 15 12 steps: 16 13 - uses: actions/checkout@v3.5.1 17 14 - uses: erlef/setup-beam@v1.15.4 ··· 23 20 - run: gleam format --check src test 24 21 - run: gleam deps download 25 22 - run: gleam test 26 - 27 - container-image: 28 - runs-on: ubuntu-latest 29 - needs: test-action 30 - # if: github.ref == 'refs/heads/main' 31 - env: 32 - REGISTRY: ghcr.io 33 - IMAGE_NAME: ${{ github.repository }} 34 - 35 - # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. 36 - permissions: 37 - contents: read 38 - packages: write 39 - 40 - steps: 41 - - name: Checkout repository 42 - uses: actions/checkout@v3 43 - - name: Log in to the Container registry 44 - uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 45 - with: 46 - registry: ${{ env.REGISTRY }} 47 - username: ${{ github.actor }} 48 - password: ${{ secrets.GITHUB_TOKEN }} 49 - - name: Extract metadata (tags, labels) for Docker 50 - id: meta 51 - uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 52 - with: 53 - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 54 - tags: | 55 - type=sha,prefix=,format=short 56 - type=ref,event=branch 57 - - name: Build and push Docker image 58 - uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 59 - with: 60 - context: . 61 - push: true 62 - tags: ${{ steps.meta.outputs.tags }} 63 - labels: ${{ steps.meta.outputs.labels }}
-19
Dockerfile
··· 1 - FROM ghcr.io/gleam-lang/gleam:v0.30.3-erlang-alpine 2 - 3 - # Add project code 4 - COPY . /build/ 5 - 6 - # Compile the application 7 - RUN apk add --no-cache sqlite gcc make libc-dev bsd-compat-headers \ 8 - && cd /build/action \ 9 - && gleam export erlang-shipment \ 10 - && mv build/erlang-shipment /app \ 11 - && rm -r /build \ 12 - && addgroup -S action \ 13 - && adduser -S action -G action \ 14 - && chown -R action /app 15 - 16 - USER action 17 - WORKDIR /app 18 - ENTRYPOINT ["/app/entrypoint.sh"] 19 - CMD ["run", "server"]
+4 -11
README.md
··· 1 - # Action! 2 - 3 - A Gleam web application. 1 + # Wisp 4 2 5 - ## TODO 3 + [![Package Version](https://img.shields.io/hexpm/v/wisp)](https://hex.pm/packages/wisp) 4 + [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/wisp/) 6 5 7 - - Read database file location from environment. 8 - - Store answer from form in database. 9 - - TODO: And also write tests! 10 - - Send you to the next question. 11 - - Eventual "thank you!" page. 12 - - HTML layouts. 13 - - Basic styling. 6 + A Gleam project 🧚
-5
action/.gitignore
··· 1 - *.beam 2 - *.ez 3 - *.sqlite3 4 - build 5 - erl_crash.dump
-24
action/README.md
··· 1 - # action 2 - 3 - [![Package Version](https://img.shields.io/hexpm/v/action)](https://hex.pm/packages/action) 4 - [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/action/) 5 - 6 - A Gleam project 7 - 8 - ## Quick start 9 - 10 - ```sh 11 - gleam run # Run the project 12 - gleam test # Run the tests 13 - gleam shell # Run an Erlang shell 14 - ``` 15 - 16 - ## Installation 17 - 18 - If available on Hex this package can be added to your Gleam project: 19 - 20 - ```sh 21 - gleam add action 22 - ``` 23 - 24 - and its documentation can be found at <https://hexdocs.pm/action>.
-14
action/gleam.toml
··· 1 - name = "action" 2 - version = "1.0.0" 3 - description = "A Gleam web application" 4 - 5 - [dependencies] 6 - gleam_stdlib = "~> 0.29" 7 - mist = "~> 0.13" 8 - sqlight = "~> 0.6" 9 - ids = "~> 0.7" 10 - framework = { path = "../framework" } 11 - htmb = { path = "../htmb" } 12 - 13 - [dev-dependencies] 14 - gleeunit = "~> 0.10"
-27
action/manifest.toml
··· 1 - # This file was generated by Gleam 2 - # You typically do not need to edit this file 3 - 4 - packages = [ 5 - { name = "esqlite", version = "0.8.6", build_tools = ["rebar3"], requirements = [], otp_app = "esqlite", source = "hex", outer_checksum = "607E45F4DA42601D8F530979417F57A4CD629AB49085891849302057E68EA188" }, 6 - { name = "framework", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_stdlib", "mist", "simplifile"], source = "local", path = "../framework" }, 7 - { name = "gleam_erlang", version = "0.20.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "F216A80C8FDFF774447B494D5E08AE4E9A911E971727B9A78FEBF5F300914584" }, 8 - { name = "gleam_http", version = "3.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "B6EB76D304E0E66267485983E6B7BC28F3BFA6795BB2BF90FC411F6903AF6A1A" }, 9 - { name = "gleam_otp", version = "0.6.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "E31B158857E3D2AF946FE6E90E0CB21699AF226F4630E93FBEAC5DB4515F8920" }, 10 - { name = "gleam_stdlib", version = "0.30.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "03710B3DA047A3683117591707FCA19D32B980229DD8CE8B0603EB5B5144F6C3" }, 11 - { name = "gleeunit", version = "0.10.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "ECEA2DE4BE6528D36AFE74F42A21CDF99966EC36D7F25DEB34D47DD0F7977BAF" }, 12 - { name = "glisten", version = "0.8.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib", "gleam_otp"], otp_app = "glisten", source = "hex", outer_checksum = "6DDE276F8A2E3C79E5A580DEA05C7D87FCDE3A37FF69F607770D92686E193531" }, 13 - { name = "htmb", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../htmb" }, 14 - { name = "ids", version = "0.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_otp", "gleam_erlang"], otp_app = "ids", source = "hex", outer_checksum = "7A378014D40E848326FBEE8AC0C9B35EB9C8094DC4414D89F9A5AA99397A6042" }, 15 - { name = "mist", version = "0.13.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_stdlib", "gleam_erlang", "glisten"], otp_app = "mist", source = "hex", outer_checksum = "9A374CA245D682E2C08A5224B4420DDA252EF553AE5FD0ED7BAD33F86ACF7C98" }, 16 - { name = "simplifile", version = "0.1.8", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "9CED66E65AF32C98AA336A65365A498DCF018D2A3D96A05D861C4005DCDE4D2D" }, 17 - { name = "sqlight", version = "0.7.0", build_tools = ["gleam"], requirements = ["esqlite", "gleam_stdlib"], otp_app = "sqlight", source = "hex", outer_checksum = "BDBAA35B58E11B6DE20DC869EAA247F447268B8F398E0677F25444C9F7AE54EA" }, 18 - ] 19 - 20 - [requirements] 21 - framework = { path = "../framework" } 22 - gleam_stdlib = { version = "~> 0.29" } 23 - gleeunit = { version = "~> 0.10" } 24 - htmb = { path = "../htmb" } 25 - ids = { version = "~> 0.7" } 26 - mist = { version = "~> 0.13" } 27 - sqlight = { version = "~> 0.6" }
-10
action/priv/static/styles.css
··· 1 - * { 2 - box-sizing: border-box; 3 - } 4 - 5 - body { 6 - margin: 0; 7 - padding: 0; 8 - font-family: sans-serif; 9 - background-color: hotpink; 10 - }
-25
action/src/action.gleam
··· 1 - import gleam/erlang/process 2 - import gleam/io 3 - import framework 4 - import mist 5 - import action/database 6 - import action/web.{Context} 7 - import action/router 8 - 9 - pub fn main() { 10 - let assert Ok(_) = 11 - handle_request 12 - |> framework.mist_service 13 - |> mist.new 14 - |> mist.port(8000) 15 - |> mist.start_http 16 - io.println("Started listening on http://localhost:8000 ✨") 17 - process.sleep_forever() 18 - } 19 - 20 - pub fn handle_request(request: framework.Request) { 21 - use db <- database.with_connection("db.sqlite3") 22 - 23 - let context = Context(db: db) 24 - router.handle_request(request, context) 25 - }
-168
action/src/action/applications.gleam
··· 1 - // TODO: Sign the application id so that bad actors can't generate them. 2 - 3 - import action/database 4 - import action/applications/form.{ 5 - BoolSubmitButtons, Checkboxes, Email, Phone, Question, Radio, Step, Text, 6 - } 7 - import action/html 8 - import action/web.{Context} 9 - import framework.{Request, Response} 10 - import gleam/list 11 - import gleam/string 12 - import gleam/result 13 - // TODO: import from framework once we have constructor re-exports 14 - import gleam/http.{Get, Patch} 15 - 16 - pub type Next { 17 - NextStep(Step) 18 - ThankYou 19 - } 20 - 21 - pub const region_options = [#("london", "London"), #("etc", "Etc.")] 22 - 23 - pub const step_initial = Step( 24 - "intro", 25 - [ 26 - Question( 27 - text: "Are you ready to take action?", 28 - name: "ready", 29 - input: BoolSubmitButtons(true: "I'm ready", false: "Not yet"), 30 - ), 31 - ], 32 - ) 33 - 34 - pub const step_not_ready = Step( 35 - "not_ready", 36 - [ 37 - Question( 38 - text: "What do you need to take part?", 39 - name: "help_required", 40 - input: Checkboxes([#("logistics", "I need help getting to London")]), 41 - ), 42 - ], 43 - ) 44 - 45 - pub const step_ready = Step( 46 - "ready", 47 - [ 48 - Question( 49 - text: "Have you been trained in non-violent resistance by Just Stop Oil?", 50 - name: "non-violence-trained", 51 - input: BoolSubmitButtons(true: "Yes", false: "Not yet"), 52 - ), 53 - ], 54 - ) 55 - 56 - pub const step_contact_details = Step( 57 - "contact_details", 58 - [ 59 - Question(text: "What's your name?", name: "name", input: Text(True)), 60 - Question( 61 - text: "What's your email address?", 62 - name: "email", 63 - input: Email(True), 64 - ), 65 - Question( 66 - text: "What's your phone number?", 67 - name: "phone", 68 - input: Phone(True), 69 - ), 70 - Question( 71 - text: "What's your region?", 72 - name: "region", 73 - input: Radio(True, region_options), 74 - ), 75 - ], 76 - ) 77 - 78 - pub const steps = [ 79 - step_initial, 80 - step_not_ready, 81 - step_ready, 82 - step_contact_details, 83 - ] 84 - 85 - pub fn resource(req: Request, ctx: Context) -> Response { 86 - case req.method { 87 - Get -> new_application(req, ctx) 88 - Patch -> update_application(req, ctx) 89 - _ -> framework.method_not_allowed([Get, Patch]) 90 - } 91 - } 92 - 93 - // TODO: test 94 - fn new_application(_req: Request, ctx: Context) -> Response { 95 - let id = database.create_application(ctx.db) 96 - 97 - form.step_html(id, step_initial) 98 - |> html.page 99 - |> framework.html_response(200) 100 - } 101 - 102 - // TODO: test 103 - fn update_application(req: Request, ctx: Context) -> Response { 104 - use formdata <- framework.require_form_urlencoded_body(req) 105 - use step <- framework.require(find_step(formdata)) 106 - use app <- framework.require(list.key_find(formdata, "application_id")) 107 - 108 - // TODO: report error to the user 109 - let assert Ok(answers) = 110 - list.try_map(step.questions, form.answer(_, formdata)) 111 - // TODO: report error to the user 112 - let assert Ok(_) = save_answers(ctx.db, app, step, answers) 113 - 114 - next(step, answers) 115 - |> html_page(app) 116 - |> framework.html_response(200) 117 - } 118 - 119 - pub fn next(step: Step, answers: List(form.Answer)) -> Next { 120 - case step.id { 121 - "intro" -> 122 - case find_answer(answers, "ready") { 123 - form.BoolAnswer(True) -> NextStep(step_ready) 124 - _ -> NextStep(step_not_ready) 125 - } 126 - 127 - _ -> NextStep(step_initial) 128 - } 129 - } 130 - 131 - fn find_answer(answers: List(form.Answer), name: String) -> form.AnswerValue { 132 - list.find(answers, fn(a) { a.name == name }) 133 - |> result.map(fn(a) { a.value }) 134 - |> result.unwrap(form.NoAnswer) 135 - } 136 - 137 - fn html_page(next: Next, application_id: String) -> html.StringBuilder { 138 - case next { 139 - NextStep(step) -> form.step_html(application_id, step) 140 - ThankYou -> html.text("Thank you!") 141 - } 142 - |> html.page 143 - } 144 - 145 - fn find_step(formdata: List(#(String, String))) -> Result(Step, Nil) { 146 - use step_id <- result.try(list.key_find(formdata, "step_id")) 147 - list.find(steps, fn(s) { s.id == step_id }) 148 - } 149 - 150 - fn save_answers( 151 - db: database.Connection, 152 - application_id: String, 153 - step: Step, 154 - answers: List(form.Answer), 155 - ) -> Result(Nil, database.Error) { 156 - let submission_id = database.create_submission(db, application_id, step.id) 157 - let insert = fn(_, answer: form.Answer) { 158 - let value = case answer.value { 159 - form.BoolAnswer(True) -> "true" 160 - form.BoolAnswer(False) -> "false" 161 - form.TextAnswer(text) -> text 162 - form.MultipleChoiceAnswer(choices) -> string.join(choices, ",") 163 - form.NoAnswer -> "" 164 - } 165 - database.record_answer(db, submission_id, answer.name, value) 166 - } 167 - list.try_fold(answers, Nil, insert) 168 - }
-262
action/src/action/applications/form.gleam
··· 1 - import action/html.{Html, h, text} 2 - import gleam/regex 3 - import gleam/result 4 - import gleam/string 5 - import gleam/list 6 - 7 - pub type Step { 8 - Step(id: String, questions: List(Question)) 9 - } 10 - 11 - pub type Question { 12 - Question(text: String, name: String, input: Input) 13 - } 14 - 15 - pub type Input { 16 - Checkboxes(options: List(#(String, String))) 17 - BoolSubmitButtons(true: String, false: String) 18 - Text(required: Bool) 19 - Email(required: Bool) 20 - Phone(required: Bool) 21 - Radio(required: Bool, options: List(#(String, String))) 22 - } 23 - 24 - pub type Answer { 25 - Answer(name: String, value: AnswerValue) 26 - } 27 - 28 - pub type AnswerValue { 29 - TextAnswer(String) 30 - MultipleChoiceAnswer(List(String)) 31 - BoolAnswer(Bool) 32 - NoAnswer 33 - } 34 - 35 - fn is_submit(input: Input) -> Bool { 36 - case input { 37 - BoolSubmitButtons(_, _) -> True 38 - _ -> False 39 - } 40 - } 41 - 42 - pub fn input_html(input: Input, name: String) -> List(Html) { 43 - let input_element = fn(type_, name, required) { 44 - let attrs = [#("type", type_), #("name", name), #("id", name)] 45 - let attrs = case required { 46 - True -> [#("required", "required"), ..attrs] 47 - False -> attrs 48 - } 49 - [h("input", attrs, [])] 50 - } 51 - 52 - case input { 53 - Checkboxes(options) -> { 54 - let make = fn(option: #(String, String)) { 55 - let name = name <> ":" <> option.0 56 - list.append( 57 - input_element("checkbox", name, False), 58 - [h("label", [#("for", name)], [html.text(option.1)])], 59 - ) 60 - } 61 - list.flat_map(options, make) 62 - } 63 - 64 - Text(required) -> input_element("text", name, required) 65 - 66 - Email(required) -> input_element("email", name, required) 67 - 68 - Phone(required) -> input_element("tel", name, required) 69 - 70 - BoolSubmitButtons(yes, no) -> { 71 - let attrs = fn(text, value) { 72 - [#("type", "submit"), #("value", text), #("name", name <> ":" <> value)] 73 - } 74 - [h("input", attrs(yes, "1"), []), h("input", attrs(no, "0"), [])] 75 - } 76 - 77 - Radio(required, options) -> { 78 - let option_html = fn(pair) { 79 - let #(option, text) = pair 80 - let attrs = [ 81 - #("type", "radio"), 82 - #("name", name), 83 - #("value", option), 84 - #("id", name <> ":" <> option), 85 - ] 86 - let attrs = case required { 87 - True -> [#("required", "required"), ..attrs] 88 - False -> attrs 89 - } 90 - let label_attrs = [#("for", name <> ":" <> option)] 91 - h("label", label_attrs, [h("input", attrs, []), html.text(text)]) 92 - } 93 - list.map(options, option_html) 94 - } 95 - } 96 - } 97 - 98 - pub fn step_html(application_id: String, step: Step) -> Html { 99 - let hidden = fn(name, value) { 100 - let attrs = [#("type", "hidden"), #("name", name), #("value", value)] 101 - h("input", attrs, []) 102 - } 103 - let any_submit = list.any(step.questions, fn(q) { is_submit(q.input) }) 104 - 105 - let elements = [ 106 - hidden("application_id", application_id), 107 - hidden("step_id", step.id), 108 - ..list.map(step.questions, question_html) 109 - ] 110 - let elements = case any_submit { 111 - False -> 112 - elements 113 - |> list.append([ 114 - h("input", [#("type", "submit"), #("value", "Submit")], []), 115 - ]) 116 - True -> elements 117 - } 118 - h("form", [#("method", "POST"), #("action", "?_method=PATCH")], elements) 119 - } 120 - 121 - fn question_html(question: Question) -> Html { 122 - h( 123 - "fieldset", 124 - [], 125 - [ 126 - h("legend", [], [text(question.text)]), 127 - ..input_html(question.input, question.name) 128 - ], 129 - ) 130 - } 131 - 132 - pub fn answer( 133 - question: Question, 134 - formdata: List(#(String, String)), 135 - ) -> Result(Answer, String) { 136 - case question.input { 137 - Text(required) -> { 138 - text_answer(question, required, formdata) 139 - } 140 - Phone(required) -> { 141 - phone_answer(question, required, formdata) 142 - } 143 - Email(required) -> { 144 - email_answer(question, required, formdata) 145 - } 146 - Checkboxes(options) -> { 147 - checkboxes_answer(question, options, formdata) 148 - } 149 - BoolSubmitButtons(_, _) -> { 150 - bool_submit_buttons_answer(question, formdata) 151 - } 152 - Radio(required, options) -> { 153 - radio_answer(question, required, options, formdata) 154 - } 155 - } 156 - |> result.map(Answer(question.name, _)) 157 - } 158 - 159 - fn text_answer( 160 - question: Question, 161 - required: Bool, 162 - formdata: List(#(String, String)), 163 - ) -> Result(AnswerValue, String) { 164 - case list.key_find(formdata, question.name) { 165 - Ok(value) -> Ok(TextAnswer(value)) 166 - _ if required -> required_error(question) 167 - _ -> Ok(NoAnswer) 168 - } 169 - } 170 - 171 - fn try_map_text_answer( 172 - value: AnswerValue, 173 - mapper: fn(String) -> Result(String, String), 174 - ) -> Result(AnswerValue, String) { 175 - case value { 176 - TextAnswer(value) -> result.map(mapper(value), TextAnswer) 177 - _ -> Ok(value) 178 - } 179 - } 180 - 181 - fn email_answer( 182 - question: Question, 183 - required: Bool, 184 - formdata: List(#(String, String)), 185 - ) -> Result(AnswerValue, String) { 186 - use answer <- result.try(text_answer(question, required, formdata)) 187 - use email <- try_map_text_answer(answer) 188 - case string.contains(email, "@") { 189 - True -> Ok(string.trim(email)) 190 - False -> Error("\"" <> email <> "\" is not a valid email address") 191 - } 192 - } 193 - 194 - fn phone_answer( 195 - question: Question, 196 - required: Bool, 197 - formdata: List(#(String, String)), 198 - ) -> Result(AnswerValue, String) { 199 - use answer <- result.try(text_answer(question, required, formdata)) 200 - use number <- try_map_text_answer(answer) 201 - let number = string.replace(in: number, each: " ", with: "") 202 - let assert Ok(regex) = regex.from_string("^\\+?[0-9]{8,16}$") 203 - case regex.check(regex, number) { 204 - True -> Ok(number) 205 - False -> Error("\"" <> number <> "\" is not a valid phone number") 206 - } 207 - } 208 - 209 - fn checkboxes_answer( 210 - question: Question, 211 - options: List(#(String, String)), 212 - formdata: List(#(String, String)), 213 - ) -> Result(AnswerValue, String) { 214 - let make = fn(option: #(String, String)) { 215 - let name = question.name <> ":" <> option.0 216 - list.key_find(formdata, name) 217 - |> result.replace(option.0) 218 - } 219 - let answers = list.filter_map(options, make) 220 - case answers { 221 - [] -> Ok(NoAnswer) 222 - _ -> Ok(MultipleChoiceAnswer(answers)) 223 - } 224 - } 225 - 226 - pub fn bool_submit_buttons_answer( 227 - question: Question, 228 - formdata: List(#(String, String)), 229 - ) -> Result(AnswerValue, String) { 230 - let yes_name = question.name <> ":1" 231 - let no_name = question.name <> ":0" 232 - let yes = list.key_find(formdata, yes_name) 233 - let no = list.key_find(formdata, no_name) 234 - case yes, no { 235 - Ok(_), _ -> Ok(BoolAnswer(True)) 236 - _, Ok(_) -> Ok(BoolAnswer(False)) 237 - _, _ -> required_error(question) 238 - } 239 - } 240 - 241 - fn radio_answer( 242 - question: Question, 243 - required: Bool, 244 - options: List(#(String, String)), 245 - formdata: List(#(String, String)), 246 - ) -> Result(AnswerValue, String) { 247 - let value = 248 - formdata 249 - |> list.key_find(question.name) 250 - |> result.unwrap("") 251 - 252 - case list.key_find(options, value) { 253 - Ok(_) -> Ok(TextAnswer(value)) 254 - _ if value == "" && required -> required_error(question) 255 - _ if value == "" -> Ok(NoAnswer) 256 - _ -> Error("\"" <> question.text <> "\" was not valid") 257 - } 258 - } 259 - 260 - fn required_error(question: Question) -> Result(t, String) { 261 - Error("\"" <> question.text <> "\" is required") 262 - }
-174
action/src/action/database.gleam
··· 1 - import gleam/dynamic 2 - import ids/nanoid 3 - import sqlight.{SqlightError} 4 - 5 - pub type Connection = 6 - sqlight.Connection 7 - 8 - pub type Error { 9 - ApplicationNotFound 10 - DatabaseError(sqlight.Error) 11 - RecordNotFound 12 - TooManyRecords 13 - AlreadyAnswered 14 - } 15 - 16 - const database_schema = " 17 - create table if not exists applications ( 18 - id text primary key not null 19 - constraint valid_id check (length(id) > 0) 20 - , created_at text 21 - default current_timestamp 22 - constraint valid_created_at check (datetime(created_at) not null) 23 - ) strict; 24 - 25 - create table if not exists submissions ( 26 - id integer primary key autoincrement not null 27 - , application_id text not null 28 - , step text not null 29 - constraint valid_step check (length(step) > 0) 30 - , created_at text 31 - default current_timestamp 32 - constraint valid_created_at check (datetime(created_at) not null) 33 - , foreign key (application_id) references applications (id) 34 - ) strict; 35 - 36 - create table if not exists answers ( 37 - id integer primary key autoincrement not null 38 - , submission_id text not null 39 - , created_at text 40 - default current_timestamp 41 - constraint valid_created_at check (datetime(created_at) not null) 42 - , question text not null 43 - constraint valid_question check (length(question) > 0) 44 - , value text not null 45 - constraint valid_value check (length(value) > 0) 46 - , foreign key (submission_id) references submissions (id) 47 - , unique (submission_id, question) 48 - ) strict; 49 - " 50 - 51 - pub fn with_connection(path: String, next: fn(sqlight.Connection) -> a) -> a { 52 - use db <- sqlight.with_connection(path) 53 - 54 - let preamble = 55 - " 56 - pragma foreign_keys = on; 57 - pragma journal_mode = wal; 58 - " 59 - 60 - // Enable configuration we want for all connections 61 - let assert Ok(_) = sqlight.exec(preamble, db) 62 - 63 - // Migrate the database 64 - let assert Ok(_) = sqlight.exec(database_schema, db) 65 - 66 - next(db) 67 - } 68 - 69 - fn one( 70 - database: sqlight.Connection, 71 - sql: String, 72 - arguments: List(sqlight.Value), 73 - decoder: dynamic.Decoder(a), 74 - ) -> Result(a, Error) { 75 - case sqlight.query(sql, database, arguments, decoder) { 76 - Ok([]) -> Error(RecordNotFound) 77 - Ok([x]) -> Ok(x) 78 - Ok(_) -> Error(TooManyRecords) 79 - Error(e) -> Error(DatabaseError(e)) 80 - } 81 - } 82 - 83 - pub fn create_application(database: sqlight.Connection) -> String { 84 - let id = nanoid.generate() 85 - let sql = 86 - " 87 - insert into applications 88 - (id) 89 - values 90 - (?) 91 - " 92 - let arguments = [sqlight.text(id)] 93 - let assert Ok(_) = sqlight.query(sql, database, arguments, Ok) 94 - id 95 - } 96 - 97 - pub fn create_submission( 98 - database: sqlight.Connection, 99 - application_id application_id: String, 100 - step_name step_name: String, 101 - ) -> Int { 102 - let sql = 103 - " 104 - insert into submissions 105 - (application_id, step) 106 - values 107 - (?, ?) 108 - returning 109 - id 110 - " 111 - let arguments = [sqlight.text(application_id), sqlight.text(step_name)] 112 - let decoder = dynamic.element(0, dynamic.int) 113 - let assert Ok(id) = one(database, sql, arguments, decoder) 114 - id 115 - } 116 - 117 - pub fn record_answer( 118 - database database: sqlight.Connection, 119 - submission_id submission_id: Int, 120 - question question: String, 121 - answer answer: String, 122 - ) -> Result(Nil, Error) { 123 - let sql = 124 - " 125 - insert into answers 126 - (submission_id, question, value) 127 - values 128 - (?, ?, ?) 129 - " 130 - let arguments = [ 131 - sqlight.int(submission_id), 132 - sqlight.text(question), 133 - sqlight.text(answer), 134 - ] 135 - case sqlight.query(sql, database, arguments, Ok) { 136 - Ok(_) -> Ok(Nil) 137 - Error(SqlightError(sqlight.ConstraintUnique, _, _)) -> 138 - Error(AlreadyAnswered) 139 - Error(e) -> Error(DatabaseError(e)) 140 - } 141 - } 142 - 143 - pub type Answer { 144 - Answer(created_at: String, question: String, value: String) 145 - } 146 - 147 - pub fn list_answers( 148 - database database: sqlight.Connection, 149 - application_id application_id: String, 150 - ) -> List(Answer) { 151 - let sql = 152 - " 153 - select 154 - answers.created_at, question, value 155 - from 156 - answers 157 - join 158 - submissions on answers.submission_id = submissions.id 159 - where 160 - application_id = ? 161 - order by 162 - answers.id asc 163 - " 164 - let arguments = [sqlight.text(application_id)] 165 - let decoder = 166 - dynamic.decode3( 167 - Answer, 168 - dynamic.element(0, dynamic.string), 169 - dynamic.element(1, dynamic.string), 170 - dynamic.element(2, dynamic.string), 171 - ) 172 - let assert Ok(rows) = sqlight.query(sql, database, arguments, decoder) 173 - rows 174 - }
-33
action/src/action/html.gleam
··· 1 - import htmb 2 - import gleam/string_builder 3 - 4 - pub type Html = 5 - htmb.Html 6 - 7 - pub type StringBuilder = 8 - string_builder.StringBuilder 9 - 10 - pub const h = htmb.h 11 - 12 - pub const text = htmb.text 13 - 14 - pub fn page(html: Html) -> StringBuilder { 15 - let viewport = [ 16 - #("name", "viewport"), 17 - #("content", "width=device-width, initial-scale=1"), 18 - ] 19 - 20 - h( 21 - "html", 22 - [#("lang", "en")], 23 - [ 24 - h( 25 - "head", 26 - [], 27 - [h("meta", [#("charset", "utf-8")], []), h("meta", viewport, [])], 28 - ), 29 - h("body", [], [html]), 30 - ], 31 - ) 32 - |> htmb.render_page(doctype: "html") 33 - }
-34
action/src/action/router.gleam
··· 1 - import action/applications 2 - import action/html.{h, text} 3 - import action/web.{Context} 4 - import framework.{Request, Response} 5 - // TODO: import from framework once we have constructor re-exports 6 - import gleam/http.{Get} 7 - 8 - pub fn handle_request(req: Request, ctx: Context) -> Response { 9 - use req <- web.middleware(req) 10 - 11 - case framework.path_segments(req) { 12 - ["take-action"] -> applications.resource(req, ctx) 13 - [] -> home_page(req) 14 - _ -> framework.not_found() 15 - } 16 - } 17 - 18 - pub fn home_page(request: Request) -> Response { 19 - use <- framework.require_method(request, Get) 20 - home_html() 21 - |> framework.html_response(200) 22 - } 23 - 24 - fn home_html() { 25 - h( 26 - "div", 27 - [], 28 - [ 29 - h("h1", [], [text("Hello, Joe!")]), 30 - h("p", [], [text("This is a Gleam app!")]), 31 - ], 32 - ) 33 - |> html.page 34 - }
-51
action/src/action/web.gleam
··· 1 - import sqlight 2 - import framework.{Request, Response} 3 - import htmb.{h, text} 4 - import gleam/bool 5 - 6 - pub type Context { 7 - Context(db: sqlight.Connection) 8 - } 9 - 10 - pub fn middleware(req: Request, service: fn(Request) -> Response) -> Response { 11 - let req = framework.method_override(req) 12 - use <- framework.serve_static(req, under: "/static", from: "priv/static") 13 - use <- framework.log_requests(req) 14 - use <- serve_default_responses 15 - use <- framework.rescue_crashes 16 - service(req) 17 - } 18 - 19 - fn serve_default_responses(service: fn() -> Response) -> Response { 20 - let response = service() 21 - use <- bool.guard(response.body != framework.Empty, return: response) 22 - 23 - case response.status { 24 - 404 -> 25 - h("h1", [], [text("There's nothing here")]) 26 - |> htmb.render_page(doctype: "html") 27 - |> framework.html_body(response, _) 28 - 29 - 405 -> 30 - h("h1", [], [text("There's nothing here")]) 31 - |> htmb.render_page(doctype: "html") 32 - |> framework.html_body(response, _) 33 - 34 - 400 -> 35 - h("h1", [], [text("Invalid request")]) 36 - |> htmb.render_page(doctype: "html") 37 - |> framework.html_body(response, _) 38 - 39 - 413 -> 40 - h("h1", [], [text("Request entity too large")]) 41 - |> htmb.render_page(doctype: "html") 42 - |> framework.html_body(response, _) 43 - 44 - 500 -> 45 - h("h1", [], [text("Internal server error")]) 46 - |> htmb.render_page(doctype: "html") 47 - |> framework.html_body(response, _) 48 - 49 - _ -> response 50 - } 51 - }
-259
action/test/action/applications/form_test.gleam
··· 1 - import action/applications/form.{ 2 - Answer, BoolAnswer, BoolSubmitButtons, Checkboxes, Email, MultipleChoiceAnswer, 3 - NoAnswer, Phone, Question, Radio, Text, TextAnswer, 4 - } 5 - import gleeunit/should 6 - 7 - pub fn text_test() { 8 - Question( 9 - name: "wibble", 10 - text: "Wibble, wobble?", 11 - input: Text(required: False), 12 - ) 13 - |> form.answer([#("doo", "bah"), #("wibble", "wobble")]) 14 - |> should.equal(Ok(Answer(name: "wibble", value: TextAnswer("wobble")))) 15 - } 16 - 17 - pub fn text_optional_test() { 18 - Question( 19 - name: "wibble", 20 - text: "Wibble, wobble?", 21 - input: Text(required: False), 22 - ) 23 - |> form.answer([#("doo", "bah")]) 24 - |> should.equal(Ok(Answer(name: "wibble", value: NoAnswer))) 25 - } 26 - 27 - pub fn text_required_test() { 28 - Question(name: "wibble", text: "Wibble, wobble?", input: Text(required: True)) 29 - |> form.answer([#("doo", "bah")]) 30 - |> should.equal(Error("\"Wibble, wobble?\" is required")) 31 - } 32 - 33 - pub fn email_test() { 34 - Question( 35 - name: "wibble", 36 - text: "Wibble, wobble?", 37 - input: Email(required: False), 38 - ) 39 - |> form.answer([#("doo", "bah"), #("wibble", "wobble@example.com")]) 40 - |> should.equal(Ok(Answer( 41 - name: "wibble", 42 - value: TextAnswer("wobble@example.com"), 43 - ))) 44 - } 45 - 46 - pub fn email_trimming_test() { 47 - Question( 48 - name: "wibble", 49 - text: "Wibble, wobble?", 50 - input: Email(required: False), 51 - ) 52 - |> form.answer([#("doo", "bah"), #("wibble", " wobble@example.com ")]) 53 - |> should.equal(Ok(Answer( 54 - name: "wibble", 55 - value: TextAnswer("wobble@example.com"), 56 - ))) 57 - } 58 - 59 - pub fn email_invalid_test() { 60 - Question( 61 - name: "wibble", 62 - text: "Wibble, wobble?", 63 - input: Email(required: False), 64 - ) 65 - |> form.answer([#("doo", "bah"), #("wibble", "wobble")]) 66 - |> should.equal(Error("\"wobble\" is not a valid email address")) 67 - } 68 - 69 - pub fn email_optional_test() { 70 - Question( 71 - name: "wibble", 72 - text: "Wibble, wobble?", 73 - input: Email(required: False), 74 - ) 75 - |> form.answer([#("doo", "bah")]) 76 - |> should.equal(Ok(Answer(name: "wibble", value: NoAnswer))) 77 - } 78 - 79 - pub fn email_required_test() { 80 - Question( 81 - name: "wibble", 82 - text: "Wibble, wobble?", 83 - input: Email(required: True), 84 - ) 85 - |> form.answer([#("doo", "bah")]) 86 - |> should.equal(Error("\"Wibble, wobble?\" is required")) 87 - } 88 - 89 - pub fn phone_test() { 90 - Question( 91 - name: "wibble", 92 - text: "Wibble, wobble?", 93 - input: Phone(required: False), 94 - ) 95 - |> form.answer([#("doo", "bah"), #("wibble", "071234567890")]) 96 - |> should.equal(Ok(Answer(name: "wibble", value: TextAnswer("071234567890")))) 97 - } 98 - 99 - pub fn phone_trim_test() { 100 - Question( 101 - name: "wibble", 102 - text: "Wibble, wobble?", 103 - input: Phone(required: False), 104 - ) 105 - |> form.answer([#("wibble", " +447123 4567890 ")]) 106 - |> should.equal(Ok(Answer(name: "wibble", value: TextAnswer("+4471234567890")))) 107 - } 108 - 109 - pub fn phone_too_short_test() { 110 - Question( 111 - name: "wibble", 112 - text: "Wibble, wobble?", 113 - input: Phone(required: False), 114 - ) 115 - |> form.answer([#("wibble", "4567890 ")]) 116 - |> should.equal(Error("\"4567890\" is not a valid phone number")) 117 - } 118 - 119 - pub fn phone_not_numbers_test() { 120 - Question( 121 - name: "wibble", 122 - text: "Wibble, wobble?", 123 - input: Phone(required: False), 124 - ) 125 - |> form.answer([#("wibble", "wibblewobblewoo")]) 126 - |> should.equal(Error("\"wibblewobblewoo\" is not a valid phone number")) 127 - } 128 - 129 - pub fn phone_optional_test() { 130 - Question( 131 - name: "wibble", 132 - text: "Wibble, wobble?", 133 - input: Phone(required: False), 134 - ) 135 - |> form.answer([#("doo", "bah")]) 136 - |> should.equal(Ok(Answer(name: "wibble", value: NoAnswer))) 137 - } 138 - 139 - pub fn phone_required_test() { 140 - Question( 141 - name: "wibble", 142 - text: "Wibble, wobble?", 143 - input: Phone(required: True), 144 - ) 145 - |> form.answer([#("doo", "bah")]) 146 - |> should.equal(Error("\"Wibble, wobble?\" is required")) 147 - } 148 - 149 - pub fn bool_submit_buttons_true_test() { 150 - Question( 151 - name: "wibble", 152 - text: "Wibble, wobble?", 153 - input: BoolSubmitButtons("yee", "nah"), 154 - ) 155 - |> form.answer([#("doo", "bah"), #("wibble:1", "yee")]) 156 - |> should.equal(Ok(Answer(name: "wibble", value: BoolAnswer(True)))) 157 - } 158 - 159 - pub fn bool_submit_buttons_false_test() { 160 - Question( 161 - name: "wibble", 162 - text: "Wibble, wobble?", 163 - input: BoolSubmitButtons("yee", "nah"), 164 - ) 165 - |> form.answer([#("doo", "bah"), #("wibble:0", "nah")]) 166 - |> should.equal(Ok(Answer(name: "wibble", value: BoolAnswer(False)))) 167 - } 168 - 169 - pub fn bool_submit_buttons_required_test() { 170 - Question( 171 - name: "wibble", 172 - text: "Wibble, wobble?", 173 - input: BoolSubmitButtons("yee", "nah"), 174 - ) 175 - |> form.answer([#("doo", "bah"), #("wibble:2", "?")]) 176 - |> should.equal(Error("\"Wibble, wobble?\" is required")) 177 - } 178 - 179 - pub fn checkboxes_test() { 180 - Question( 181 - name: "wibble", 182 - text: "Wibble, wobble?", 183 - input: Checkboxes([#("one", "One"), #("two", "Two"), #("three", "Three")]), 184 - ) 185 - |> form.answer([ 186 - #("doo", "bah"), 187 - #("wibble:two", "on"), 188 - #("wibble:three", "on"), 189 - #("wibble:unknown-other", "on"), 190 - ]) 191 - |> should.equal(Ok(Answer( 192 - name: "wibble", 193 - value: MultipleChoiceAnswer(["two", "three"]), 194 - ))) 195 - } 196 - 197 - pub fn checkboxes_none_selected_test() { 198 - Question( 199 - name: "wibble", 200 - text: "Wibble, wobble?", 201 - input: Checkboxes([#("one", "One"), #("two", "Two"), #("three", "Three")]), 202 - ) 203 - |> form.answer([#("doo", "bah"), #("wibble:unknown-other", "on")]) 204 - |> should.equal(Ok(Answer(name: "wibble", value: NoAnswer))) 205 - } 206 - 207 - // Radio, 208 - 209 - pub fn radio_test() { 210 - Question( 211 - name: "wibble", 212 - text: "Wibble, wobble?", 213 - input: Radio( 214 - required: False, 215 - options: [#("one", "One"), #("two", "Two"), #("three", "Three")], 216 - ), 217 - ) 218 - |> form.answer([#("doo", "bah"), #("wibble", "two")]) 219 - |> should.equal(Ok(Answer(name: "wibble", value: TextAnswer("two")))) 220 - } 221 - 222 - pub fn radio_optional_test() { 223 - Question( 224 - name: "wibble", 225 - text: "Wibble, wobble?", 226 - input: Radio( 227 - required: False, 228 - options: [#("one", "One"), #("two", "Two"), #("three", "Three")], 229 - ), 230 - ) 231 - |> form.answer([#("doo", "bah")]) 232 - |> should.equal(Ok(Answer(name: "wibble", value: NoAnswer))) 233 - } 234 - 235 - pub fn radio_required_test() { 236 - Question( 237 - name: "wibble", 238 - text: "Wibble, wobble?", 239 - input: Radio( 240 - required: True, 241 - options: [#("one", "One"), #("two", "Two"), #("three", "Three")], 242 - ), 243 - ) 244 - |> form.answer([#("doo", "bah")]) 245 - |> should.equal(Error("\"Wibble, wobble?\" is required")) 246 - } 247 - 248 - pub fn radio_invalid_test() { 249 - Question( 250 - name: "wibble", 251 - text: "Wibble, wobble?", 252 - input: Radio( 253 - required: True, 254 - options: [#("one", "One"), #("two", "Two"), #("three", "Three")], 255 - ), 256 - ) 257 - |> form.answer([#("doo", "bah"), #("wibble", "unknown")]) 258 - |> should.equal(Error("\"Wibble, wobble?\" was not valid")) 259 - }
-67
action/test/action/database_test.gleam
··· 1 - import gleeunit/should 2 - import gleam/string 3 - import gleam/order 4 - import action/database 5 - 6 - pub fn application_creation_test() { 7 - use db <- database.with_connection(":memory:") 8 - let id1 = database.create_application(db) 9 - let id2 = database.create_application(db) 10 - 11 - id1 12 - |> should.not_equal(id2) 13 - 14 - let assert 21 = string.length(id1) 15 - let assert 21 = string.length(id2) 16 - } 17 - 18 - pub fn recording_answers_test() { 19 - use db <- database.with_connection(":memory:") 20 - let application_id = database.create_application(db) 21 - let id = database.create_submission(db, application_id, "steppy") 22 - let assert Ok(_) = database.record_answer(db, id, "Who? What?", "Slim Shadey") 23 - let assert Ok(_) = 24 - database.record_answer(db, id, "System still working?", "Seems to be") 25 - 26 - let assert [] = database.list_answers(db, "wibble") 27 - 28 - let assert [one, two] = database.list_answers(db, application_id) 29 - 30 - one.question 31 - |> should.equal("Who? What?") 32 - one.value 33 - |> should.equal("Slim Shadey") 34 - 35 - two.question 36 - |> should.equal("System still working?") 37 - two.value 38 - |> should.equal("Seems to be") 39 - 40 - one.created_at 41 - |> string.compare(two.created_at) 42 - |> should.not_equal(order.Gt) 43 - } 44 - 45 - pub fn duplicate_answer_test() { 46 - use db <- database.with_connection(":memory:") 47 - let application_id = database.create_application(db) 48 - let id = database.create_submission(db, application_id, "steppy") 49 - let question = "Who? What?" 50 - 51 - let assert Ok(_) = database.record_answer(db, id, question, "Slim Shadey") 52 - let assert Error(database.AlreadyAnswered) = 53 - database.record_answer(db, id, question, "Dave") 54 - 55 - let assert [one] = database.list_answers(db, application_id) 56 - one.question 57 - |> should.equal("Who? What?") 58 - one.value 59 - |> should.equal("Slim Shadey") 60 - 61 - // The same answer can be recorded for different applications 62 - let application_id2 = database.create_application(db) 63 - let id2 = database.create_submission(db, application_id2, "steppy") 64 - let assert Ok(_) = database.record_answer(db, id2, question, "Another") 65 - let assert [_] = database.list_answers(db, application_id) 66 - let assert [_] = database.list_answers(db, application_id2) 67 - }
-15
action/test/action/feature/applications_test.gleam
··· 1 - import gleeunit/should 2 - import action/applications.{NextStep} 3 - import action/applications/form 4 - 5 - pub fn next_ready_test() { 6 - applications.step_initial 7 - |> applications.next([form.Answer("ready", form.BoolAnswer(True))]) 8 - |> should.equal(NextStep(applications.step_ready)) 9 - } 10 - 11 - pub fn next_not_ready_test() { 12 - applications.step_initial 13 - |> applications.next([form.Answer("ready", form.BoolAnswer(False))]) 14 - |> should.equal(NextStep(applications.step_not_ready)) 15 - }
-66
action/test/action_test.gleam
··· 1 - import gleeunit 2 - import gleeunit/should 3 - import action/router 4 - import action/web.{Context} 5 - import action/database 6 - import gleam/string 7 - import gleam/http/request 8 - import gleam/http.{Get, Method, Post} 9 - import gleam/string_builder 10 - import framework 11 - 12 - pub fn main() { 13 - gleeunit.main() 14 - } 15 - 16 - // TODO: move this to a helper module 17 - pub fn test_context(next: fn(Context) -> t) -> t { 18 - use db <- database.with_connection(":memory:") 19 - next(Context(db: db)) 20 - } 21 - 22 - // TODO: move this to a helper module 23 - pub fn request(method: Method, path: String) -> framework.Request { 24 - request.new() 25 - |> request.set_method(method) 26 - |> request.set_path(path) 27 - |> request.set_body(framework.test_connection(<<>>)) 28 - } 29 - 30 - // TODO: move this to a helper module 31 - pub fn content(response: framework.Response) -> String { 32 - response.body 33 - |> framework.body_to_string_builder 34 - |> string_builder.to_string 35 - } 36 - 37 - pub fn page_not_found_test() { 38 - use context <- test_context() 39 - let request = request(Get, "/not-found") 40 - let response = router.handle_request(request, context) 41 - 42 - response.status 43 - |> should.equal(404) 44 - } 45 - 46 - pub fn home_page_test() { 47 - use context <- test_context() 48 - let request = request(Get, "/") 49 - let response = router.handle_request(request, context) 50 - 51 - response.status 52 - |> should.equal(200) 53 - 54 - content(response) 55 - |> string.contains("<h1>Hello, Joe!</h1>") 56 - |> should.be_true 57 - } 58 - 59 - pub fn home_page_post_test() { 60 - use context <- test_context() 61 - let request = request(Post, "/") 62 - let response = router.handle_request(request, context) 63 - 64 - response.status 65 - |> should.equal(405) 66 - }
-4
framework/.gitignore
··· 1 - *.beam 2 - *.ez 3 - build 4 - erl_crash.dump
-24
framework/README.md
··· 1 - # framework 2 - 3 - [![Package Version](https://img.shields.io/hexpm/v/framework)](https://hex.pm/packages/framework) 4 - [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/framework/) 5 - 6 - A Gleam project 7 - 8 - ## Quick start 9 - 10 - ```sh 11 - gleam run # Run the project 12 - gleam test # Run the tests 13 - gleam shell # Run an Erlang shell 14 - ``` 15 - 16 - ## Installation 17 - 18 - If available on Hex this package can be added to your Gleam project: 19 - 20 - ```sh 21 - gleam add framework 22 - ``` 23 - 24 - and its documentation can be found at <https://hexdocs.pm/framework>.
+1 -1
framework/gleam.toml gleam.toml
··· 1 - name = "framework" 1 + name = "wisp" 2 2 version = "0.1.0" 3 3 description = "A Gleam project" 4 4
framework/manifest.toml manifest.toml
framework/src/framework.gleam src/wisp.gleam
framework/test/framework_test.gleam test/wisp_test.gleam
+1
htmb/.gitignore .gitignore
··· 2 2 *.ez 3 3 build 4 4 erl_crash.dump 5 + tmp
-24
htmb/README.md
··· 1 - # htmb 2 - 3 - [![Package Version](https://img.shields.io/hexpm/v/htmb)](https://hex.pm/packages/htmb) 4 - [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/htmb/) 5 - 6 - A Gleam project 7 - 8 - ## Quick start 9 - 10 - ```sh 11 - gleam run # Run the project 12 - gleam test # Run the tests 13 - gleam shell # Run an Erlang shell 14 - ``` 15 - 16 - ## Installation 17 - 18 - If available on Hex this package can be added to your Gleam project: 19 - 20 - ```sh 21 - gleam add htmb 22 - ``` 23 - 24 - and its documentation can be found at <https://hexdocs.pm/htmb>.
-16
htmb/gleam.toml
··· 1 - name = "htmb" 2 - version = "0.1.0" 3 - description = "A tiny HTML builder for Gleam" 4 - 5 - # Fill out these fields if you intend to generate HTML documentation or publish 6 - # your project to the Hex package manager. 7 - # 8 - # licences = ["Apache-2.0"] 9 - # repository = { type = "github", user = "username", repo = "project" } 10 - # links = [{ title = "Website", href = "https://gleam.run" }] 11 - 12 - [dependencies] 13 - gleam_stdlib = "~> 0.29" 14 - 15 - [dev-dependencies] 16 - gleeunit = "~> 0.10"
-11
htmb/manifest.toml
··· 1 - # This file was generated by Gleam 2 - # You typically do not need to edit this file 3 - 4 - packages = [ 5 - { name = "gleam_stdlib", version = "0.29.2", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "B296BF9B8AA384A6B64CD49F333016A9DCA6AC73A95400D17F2271E072EFF986" }, 6 - { name = "gleeunit", version = "0.10.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "ECEA2DE4BE6528D36AFE74F42A21CDF99966EC36D7F25DEB34D47DD0F7977BAF" }, 7 - ] 8 - 9 - [requirements] 10 - gleam_stdlib = { version = "~> 0.29" } 11 - gleeunit = { version = "~> 0.10" }
-79
htmb/src/htmb.gleam
··· 1 - //// # HyperText Markup Builder 2 - //// 3 - //// A tiny HTML builder for Gleam. 4 - //// 5 - //// ```gleam 6 - //// let html = 7 - //// h("h1", [], [text("Hello, Joe!")]) 8 - //// |> render 9 - //// |> string_builder.to_string 10 - //// assert html == "<h1>Hello, Joe!</h1>" 11 - //// ``` 12 - //// 13 - //// This package doesn't do much. If you'd like more features, check out these 14 - //// alternatives: 15 - //// 16 - //// - [Glemplate](https://hex.pm/packages/glemplate) 17 - //// - [Lustre](https://hex.pm/packages/lustre) 18 - //// - [Nakai](https://hex.pm/packages/nakai) 19 - //// - [React Gleam](https://hex.pm/packages/react_gleam) 20 - //// 21 - 22 - import gleam/string_builder.{StringBuilder} 23 - import gleam/string 24 - import gleam/list 25 - 26 - pub type Html 27 - 28 - pub fn h( 29 - tag: String, 30 - attributes: List(#(String, String)), 31 - children: List(Html), 32 - ) -> Html { 33 - "<" 34 - |> string.append(tag) 35 - |> list.fold(attributes, _, attribute) 36 - |> string.append(">") 37 - |> string_builder.from_string 38 - |> list.fold(children, _, child) 39 - |> string_builder.append("</" <> tag <> ">") 40 - |> dangerous_unescaped_fragment 41 - } 42 - 43 - pub fn text(content: String) -> Html { 44 - content 45 - |> escape("", _) 46 - |> string_builder.from_string 47 - |> dangerous_unescaped_fragment 48 - } 49 - 50 - pub fn escape(escaped: String, content: String) -> String { 51 - case string.pop_grapheme(content) { 52 - Ok(#("<", xs)) -> escape(escaped <> "&lt;", xs) 53 - Ok(#(">", xs)) -> escape(escaped <> "&gt;", xs) 54 - Ok(#("&", xs)) -> escape(escaped <> "&amp;", xs) 55 - Ok(#(x, xs)) -> escape(escaped <> x, xs) 56 - Error(_) -> escaped <> content 57 - } 58 - } 59 - 60 - pub fn render_page(html: Html, doctype doctype: String) -> StringBuilder { 61 - render(html) 62 - |> string_builder.prepend("<!DOCTYPE " <> doctype <> ">") 63 - } 64 - 65 - fn attribute(content: String, attribute: #(String, String)) -> String { 66 - content <> " " <> attribute.0 <> "=\"" <> attribute.1 <> "\"" 67 - } 68 - 69 - fn child(siblings: StringBuilder, child: Html) -> StringBuilder { 70 - string_builder.append_builder(siblings, render(child)) 71 - } 72 - 73 - @external(erlang, "htmb_ffi", "identity") 74 - @external(javascript, "./htmb_ffi.mjs", "identity") 75 - pub fn dangerous_unescaped_fragment(s: StringBuilder) -> Html 76 - 77 - @external(erlang, "htmb_ffi", "identity") 78 - @external(javascript, "./htmb_ffi.mjs", "identity") 79 - pub fn render(element: Html) -> StringBuilder
-3
htmb/src/htmb_ffi.erl
··· 1 - -module(htmb_ffi). 2 - -export([identity/1]). 3 - identity(X) -> X.
-3
htmb/src/htmb_ffi.mjs
··· 1 - export function identity(x) { 2 - return x; 3 - }
-52
htmb/test/htmb_test.gleam
··· 1 - import gleeunit 2 - import gleeunit/should 3 - import htmb.{h, text} 4 - import gleam/string_builder 5 - 6 - pub fn main() { 7 - gleeunit.main() 8 - } 9 - 10 - pub fn hello_joe_test() { 11 - h("h1", [], [text("Hello Joe!")]) 12 - |> htmb.render 13 - |> string_builder.to_string 14 - |> should.equal("<h1>Hello Joe!</h1>") 15 - } 16 - 17 - pub fn page_test() { 18 - h( 19 - "html", 20 - [#("lang", "en")], 21 - [ 22 - h( 23 - "head", 24 - [], 25 - [ 26 - h("title", [], [text("htmb test")]), 27 - h("meta", [#("charset", "utf-8")], []), 28 - ], 29 - ), 30 - h( 31 - "body", 32 - [], 33 - [ 34 - h("h1", [], [text("Hello, Joe!")]), 35 - h("script", [], [text("console.log('Hello, Joe!');")]), 36 - ], 37 - ), 38 - ], 39 - ) 40 - |> htmb.render_page(doctype: "html") 41 - |> string_builder.to_string 42 - |> should.equal( 43 - "<!DOCTYPE html><html lang=\"en\"><head><title>htmb test</title><meta charset=\"utf-8\"></meta></head><body><h1>Hello, Joe!</h1><script>console.log('Hello, Joe!');</script></body></html>", 44 - ) 45 - } 46 - 47 - pub fn escaping_test() { 48 - h("h1", [], [text("<script>alert('&');</script>")]) 49 - |> htmb.render 50 - |> string_builder.to_string 51 - |> should.equal("<h1>&lt;script&gt;alert('&amp;');&lt;/script&gt;</h1>") 52 - }
tmp/.keep

This is a binary file and will not be displayed.