├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── gleam.toml ├── manifest.toml ├── src └── webls │ ├── atom.gleam │ ├── robots.gleam │ ├── rss.gleam │ └── sitemap.gleam └── test ├── robots.txt ├── rss.xml ├── sitemap.xml └── webls_test.gleam /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | pull_request: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: erlef/setup-beam@v1 16 | with: 17 | otp-version: "26.0.2" 18 | gleam-version: "1.4.1" 19 | rebar3-version: "3" 20 | - run: gleam deps download 21 | - run: gleam test 22 | - run: gleam format --check src test 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | /build 4 | erl_crash.dump 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebLS 2 | 3 | A Gleam library for generating sitemaps and RSS feeds and more. to meet all 4 | your common web listing needs. 5 | 6 | [![Package Version](https://img.shields.io/hexpm/v/webls)](https://hex.pm/packages/webls) 7 | [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/webls/) 8 | 9 | ```sh 10 | gleam add webls 11 | ``` 12 | 13 | ```gleam 14 | import webls/sitemap 15 | import webls/rss 16 | import webls/robots 17 | 18 | pub fn sitemap() -> String { 19 | sitemap.sitemap("https://gleam.run/sitemap.xml") 20 | |> sitemap.with_sitemap_last_modified(birl.now()) 21 | |> sitemap.with_sitemap_items([ 22 | sitemap.item("https://gleam.run") 23 | |> sitemap.with_item_frequency(sitemap.Monthly) 24 | |> sitemap.with_item_priority(1.0), 25 | sitemap.item("https://gleam.run/blog") 26 | |> sitemap.with_item_frequency(sitemap.Weekly), 27 | sitemap.item("https://gleam.run/blog/gleam-1.0"), 28 | sitemap.item("https://gleam.run/blog/gleam-1.1"), 29 | ]) |> sitemap.to_string() 30 | } 31 | 32 | pub fn rss() -> String { 33 | [ 34 | rss.channel("Gleam RSS", "A test RSS feed", "https://gleam.run") 35 | |> rss.with_channel_category("Releases") 36 | |> rss.with_channel_language("en") 37 | |> rss.with_channel_items([ 38 | rss.item("Gleam 1.0", "Gleam 1.0 is here!") 39 | |> rss.with_item_link("https://gleam.run/blog/gleam-1.0") 40 | |> rss.with_item_pub_date(birl.now()) 41 | |> rss.with_item_guid(#("gleam 1.0", Some(False))), 42 | rss.item("Gleam 0.10", "Gleam 0.10 is here!") 43 | |> rss.with_item_link("https://gleam.run/blog/gleam-0.10") 44 | |> rss.with_item_author("user@example.com") 45 | |> rss.with_item_guid(#("gleam 0.10", Some(True))), 46 | ]), 47 | ] |> rss.to_string() 48 | } 49 | 50 | pub fn robots() -> String { 51 | robots.config("https://example.com/sitemap.xml") 52 | |> robots.with_config_robots([ 53 | robots.robot("googlebot") 54 | |> robots.with_robot_allowed_routes(["/posts/", "/contact/"]) 55 | |> robots.with_robot_disallowed_routes(["/admin/", "/private/"]), 56 | robots.robot("bingbot") 57 | |> robots.with_robot_allowed_routes(["/posts/", "/contact/", "/private/"]) 58 | |> robots.with_robot_disallowed_routes(["/"]), 59 | ]) 60 | |> robots.to_string() 61 | } 62 | ``` 63 | 64 | Further documentation can be found at . 65 | 66 | ## Current Standards Compliance 67 | 68 | | Protocol | Version | Status | 69 | | ---------- | -------- | -------- | 70 | | Sitemaps | 0.9 | Complete | 71 | | RSS | 2.0.1 | Complete | 72 | | Robots.txt | 1997 IDS | Complete | 73 | | Atom | 1.0 | Complete | 74 | 75 | > A Note on the RSS 2.0 spec, the PICS field for content ratings is not going 76 | > to be supported as the PICS standard was discontinued more than a decade ago. 77 | 78 | ## Utility Support 79 | 80 | | Type | to_string | Builder Functions | Validators | 81 | | ---------- | --------- | ----------------- | ---------- | 82 | | Sitemap | Complete | Complete | None | 83 | | RSS v2.0 | Complete | Complete | None | 84 | | Robots.txt | Complete | Complete | None | 85 | | Atom | Complete | Complete | None | 86 | 87 | ## Development 88 | 89 | ```sh 90 | gleam run # Run the project 91 | gleam test # Run the tests 92 | ``` 93 | 94 | > Yes the name is a reference to the `ls` command in unix to list files 95 | -------------------------------------------------------------------------------- /gleam.toml: -------------------------------------------------------------------------------- 1 | name = "webls" 2 | version = "1.5.1" 3 | 4 | description = "A simple web utility library for RSS feeds, Sitemaps, Robots.txt, etc." 5 | licences = ["Apache-2.0"] 6 | repository = { type = "github", user = "versecafe", repo = "webls" } 7 | 8 | [dependencies] 9 | gleam_stdlib = ">= 0.34.0 and < 2.0.0" 10 | birl = ">= 1.7.1 and < 2.0.0" 11 | 12 | [dev-dependencies] 13 | gleeunit = ">= 1.0.0 and < 2.0.0" 14 | -------------------------------------------------------------------------------- /manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "birl", version = "1.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" }, 6 | { name = "gleam_stdlib", version = "0.39.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "2D7DE885A6EA7F1D5015D1698920C9BAF7241102836CE0C3837A4F160128A9C4" }, 7 | { name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" }, 8 | { name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" }, 9 | ] 10 | 11 | [requirements] 12 | birl = { version = ">= 1.7.1 and < 2.0.0" } 13 | gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } 14 | gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 15 | -------------------------------------------------------------------------------- /src/webls/atom.gleam: -------------------------------------------------------------------------------- 1 | import birl.{type Time} 2 | import gleam/int 3 | import gleam/list 4 | import gleam/option.{type Option, None, Some} 5 | import gleam/result 6 | 7 | // Stringify ------------------------------------------------------------------ 8 | 9 | /// Converts an Atom feed to a string of a valid Atom 1.0 feed 10 | pub fn to_string(feed: AtomFeed) -> String { 11 | "\n" 12 | <> atom_feed_to_string(feed) 13 | <> "" 14 | } 15 | 16 | fn atom_feed_to_string(feed: AtomFeed) -> String { 17 | "\n" 18 | <> feed.id 19 | <> "\n" 20 | <> "" 21 | <> text_to_string(feed.title) 22 | <> "\n" 23 | <> "" 24 | <> birl.to_iso8601(feed.updated) 25 | <> "\n" 26 | <> list.map(feed.authors, person_to_string) 27 | |> list.reduce(fn(acc, author) { acc <> author }) 28 | |> result.unwrap("") 29 | <> case feed.link { 30 | Some(link) -> link_to_string(link) 31 | None -> "" 32 | } 33 | <> list.map(feed.categories, category_to_string) 34 | |> list.reduce(fn(acc, category) { acc <> category }) 35 | |> result.unwrap("") 36 | <> list.map(feed.contributors, person_to_string) 37 | |> list.reduce(fn(acc, contributor) { acc <> contributor }) 38 | |> result.unwrap("") 39 | <> case feed.generator { 40 | Some(generator) -> generator_to_string(generator) 41 | None -> "" 42 | } 43 | <> case feed.icon { 44 | Some(icon) -> "" <> icon <> "\n" 45 | None -> "" 46 | } 47 | <> case feed.logo { 48 | Some(logo) -> "" <> logo <> "\n" 49 | None -> "" 50 | } 51 | <> case feed.rights { 52 | Some(rights) -> "" <> text_to_string(rights) <> "\n" 53 | None -> "" 54 | } 55 | <> case feed.subtitle { 56 | Some(subtitle) -> "" <> subtitle <> "\n" 57 | None -> "" 58 | } 59 | <> list.map(feed.entries, atom_entry_to_string) 60 | |> list.reduce(fn(acc, entry) { acc <> entry }) 61 | |> result.unwrap("") 62 | } 63 | 64 | fn atom_entry_to_string(entry: AtomEntry) -> String { 65 | "\n" 66 | <> "" 67 | <> entry.id 68 | <> "\n" 69 | <> "" 70 | <> text_to_string(entry.title) 71 | <> "\n" 72 | <> "" 73 | <> birl.to_iso8601(entry.updated) 74 | <> "\n" 75 | <> list.map(entry.authors, person_to_string) 76 | |> list.reduce(fn(acc, author) { acc <> author }) 77 | |> result.unwrap("") 78 | <> case entry.content { 79 | Some(content) -> "" <> text_to_string(content) <> "\n" 80 | None -> "" 81 | } 82 | <> case entry.link { 83 | Some(link) -> link_to_string(link) 84 | None -> "" 85 | } 86 | <> case entry.summary { 87 | Some(summary) -> "" <> text_to_string(summary) <> "\n" 88 | None -> "" 89 | } 90 | <> list.map(entry.categories, category_to_string) 91 | |> list.reduce(fn(acc, category) { acc <> category }) 92 | |> result.unwrap("") 93 | <> list.map(entry.contributors, person_to_string) 94 | |> list.reduce(fn(acc, contributor) { acc <> contributor }) 95 | |> result.unwrap("") 96 | <> case entry.published { 97 | Some(published) -> 98 | "" <> birl.to_iso8601(published) <> "\n" 99 | None -> "" 100 | } 101 | <> case entry.rights { 102 | Some(rights) -> "" <> text_to_string(rights) <> "\n" 103 | None -> "" 104 | } 105 | <> case entry.source { 106 | Some(source) -> source_to_string(source) 107 | None -> "" 108 | } 109 | <> "\n" 110 | } 111 | 112 | fn person_to_string(person: Person) -> String { 113 | "\n" 114 | <> "" 115 | <> person.name 116 | <> "\n" 117 | <> case person.email { 118 | Some(email) -> "" <> email <> "\n" 119 | None -> "" 120 | } 121 | <> case person.uri { 122 | Some(uri) -> "" <> uri <> "\n" 123 | None -> "" 124 | } 125 | <> "\n" 126 | } 127 | 128 | fn link_to_string(link: Link) -> String { 129 | " link.href 131 | <> "\"" 132 | <> case link.rel { 133 | Some(rel) -> " rel=\"" <> rel <> "\"" 134 | None -> "" 135 | } 136 | <> case link.content_type { 137 | Some(content_type) -> " type=\"" <> content_type <> "\"" 138 | None -> "" 139 | } 140 | <> case link.hreflang { 141 | Some(hreflang) -> " hreflang=\"" <> hreflang <> "\"" 142 | None -> "" 143 | } 144 | <> case link.title { 145 | Some(title) -> " title=\"" <> title <> "\"" 146 | None -> "" 147 | } 148 | <> case link.length { 149 | Some(length) -> " length=\"" <> int.to_string(length) <> "\"" 150 | None -> "" 151 | } 152 | <> "/>\n" 153 | } 154 | 155 | fn category_to_string(category: Category) -> String { 156 | " category.term 158 | <> "\"" 159 | <> case category.scheme { 160 | Some(scheme) -> " scheme=\"" <> scheme <> "\"" 161 | None -> "" 162 | } 163 | <> case category.label { 164 | Some(label) -> " label=\"" <> label <> "\"" 165 | None -> "" 166 | } 167 | <> "/>\n" 168 | } 169 | 170 | fn generator_to_string(generator: Generator) -> String { 171 | " case generator.uri { 173 | Some(uri) -> " uri=\"" <> uri <> "\"" 174 | None -> "" 175 | } 176 | <> case generator.version { 177 | Some(version) -> " version=\"" <> version <> "\"" 178 | None -> "" 179 | } 180 | <> ">webls\n" 181 | } 182 | 183 | fn source_to_string(source: Source) -> String { 184 | "\n" 185 | <> "" 186 | <> source.id 187 | <> "\n" 188 | <> "" 189 | <> source.title 190 | <> "\n" 191 | <> "" 192 | <> birl.to_iso8601(source.updated) 193 | <> "\n" 194 | <> "\n" 195 | } 196 | 197 | fn text_to_string(text: Text) -> String { 198 | case text { 199 | PlainText(value) -> value 200 | Html(value) -> "" <> value <> "" 201 | XHtml(value) -> "" <> value <> "" 202 | } 203 | } 204 | 205 | // Builder Patern ------------------------------------------------------------- 206 | 207 | pub fn plain_text(input: String) -> Text { 208 | PlainText(input) 209 | } 210 | 211 | pub fn html(input: String) -> Text { 212 | Html(input) 213 | } 214 | 215 | pub fn xhtml(input: String) -> Text { 216 | XHtml(input) 217 | } 218 | 219 | pub fn link(href: String) -> Link { 220 | Link( 221 | href: href, 222 | rel: None, 223 | content_type: None, 224 | hreflang: None, 225 | title: None, 226 | length: None, 227 | ) 228 | } 229 | 230 | pub fn with_link_rel(link: Link, rel: String) -> Link { 231 | Link(..link, rel: Some(rel)) 232 | } 233 | 234 | pub fn with_link_content_type(link: Link, content_type: String) -> Link { 235 | Link(..link, content_type: Some(content_type)) 236 | } 237 | 238 | pub fn with_link_hreflang(link: Link, hreflang: String) -> Link { 239 | Link(..link, hreflang: Some(hreflang)) 240 | } 241 | 242 | pub fn with_link_title(link: Link, title: String) -> Link { 243 | Link(..link, title: Some(title)) 244 | } 245 | 246 | pub fn with_link_length(link: Link, length: Int) -> Link { 247 | Link(..link, length: Some(length)) 248 | } 249 | 250 | pub fn category(term: String) -> Category { 251 | Category(term: term, scheme: None, label: None) 252 | } 253 | 254 | pub fn with_category_scheme(category: Category, scheme: String) -> Category { 255 | Category(..category, scheme: Some(scheme)) 256 | } 257 | 258 | pub fn with_category_label(category: Category, label: String) -> Category { 259 | Category(..category, label: Some(label)) 260 | } 261 | 262 | pub fn person(name: String) -> Person { 263 | Person(name: name, email: None, uri: None) 264 | } 265 | 266 | pub fn with_person_email(person: Person, email: String) -> Person { 267 | Person(..person, email: Some(email)) 268 | } 269 | 270 | pub fn with_person_uri(person: Person, uri: String) -> Person { 271 | Person(..person, uri: Some(uri)) 272 | } 273 | 274 | pub fn feed(id: String, title: Text, updated: Time) -> AtomFeed { 275 | AtomFeed( 276 | id: id, 277 | title: title, 278 | updated: updated, 279 | authors: [], 280 | link: None, 281 | categories: [], 282 | contributors: [], 283 | generator: None, 284 | icon: None, 285 | logo: None, 286 | rights: None, 287 | subtitle: None, 288 | entries: [], 289 | ) 290 | } 291 | 292 | pub fn entry(id: String, title: Text, updated: Time) -> AtomEntry { 293 | AtomEntry( 294 | id: id, 295 | title: title, 296 | updated: updated, 297 | authors: [], 298 | content: None, 299 | link: None, 300 | summary: None, 301 | categories: [], 302 | contributors: [], 303 | published: None, 304 | rights: None, 305 | source: None, 306 | ) 307 | } 308 | 309 | pub fn with_entry_id(entry: AtomEntry, id: String) -> AtomEntry { 310 | AtomEntry(..entry, id: id) 311 | } 312 | 313 | pub fn with_entry_title(entry: AtomEntry, title: Text) -> AtomEntry { 314 | AtomEntry(..entry, title: title) 315 | } 316 | 317 | pub fn with_entry_updated(entry: AtomEntry, updated: Time) -> AtomEntry { 318 | AtomEntry(..entry, updated: updated) 319 | } 320 | 321 | pub fn with_entry_authors(entry: AtomEntry, authors: List(Person)) -> AtomEntry { 322 | AtomEntry(..entry, authors: list.flatten([entry.authors, authors])) 323 | } 324 | 325 | pub fn with_entry_content(entry: AtomEntry, content: Text) -> AtomEntry { 326 | AtomEntry(..entry, content: Some(content)) 327 | } 328 | 329 | pub fn with_entry_link(entry: AtomEntry, link: Link) -> AtomEntry { 330 | AtomEntry(..entry, link: Some(link)) 331 | } 332 | 333 | pub fn with_entry_summary(entry: AtomEntry, summary: Text) -> AtomEntry { 334 | AtomEntry(..entry, summary: Some(summary)) 335 | } 336 | 337 | pub fn with_entry_categories( 338 | entry: AtomEntry, 339 | categories: List(Category), 340 | ) -> AtomEntry { 341 | AtomEntry(..entry, categories: list.flatten([entry.categories, categories])) 342 | } 343 | 344 | pub fn with_entry_contributors( 345 | entry: AtomEntry, 346 | contributors: List(Person), 347 | ) -> AtomEntry { 348 | AtomEntry( 349 | ..entry, 350 | contributors: list.flatten([entry.contributors, contributors]), 351 | ) 352 | } 353 | 354 | pub fn with_entry_published(entry: AtomEntry, published: Time) -> AtomEntry { 355 | AtomEntry(..entry, published: Some(published)) 356 | } 357 | 358 | pub fn with_entry_rights(entry: AtomEntry, rights: Text) -> AtomEntry { 359 | AtomEntry(..entry, rights: Some(rights)) 360 | } 361 | 362 | pub fn with_entry_source(entry: AtomEntry, source: Source) -> AtomEntry { 363 | AtomEntry(..entry, source: Some(source)) 364 | } 365 | 366 | pub fn with_feed_author(feed: AtomFeed, author: Person) -> AtomFeed { 367 | AtomFeed(..feed, authors: [author, ..feed.authors]) 368 | } 369 | 370 | pub fn with_feed_authors(feed: AtomFeed, authors: List(Person)) -> AtomFeed { 371 | AtomFeed(..feed, authors: list.flatten([feed.authors, authors])) 372 | } 373 | 374 | pub fn with_feed_link(feed: AtomFeed, link: Link) -> AtomFeed { 375 | AtomFeed(..feed, link: Some(link)) 376 | } 377 | 378 | pub fn with_feed_category(feed: AtomFeed, category: Category) -> AtomFeed { 379 | AtomFeed(..feed, categories: [category, ..feed.categories]) 380 | } 381 | 382 | pub fn with_feed_categories( 383 | feed: AtomFeed, 384 | categories: List(Category), 385 | ) -> AtomFeed { 386 | AtomFeed(..feed, categories: list.flatten([feed.categories, categories])) 387 | } 388 | 389 | pub fn with_feed_contributor(feed: AtomFeed, contributor: Person) -> AtomFeed { 390 | AtomFeed(..feed, contributors: [contributor, ..feed.contributors]) 391 | } 392 | 393 | pub fn with_feed_contributors( 394 | feed: AtomFeed, 395 | contributors: List(Person), 396 | ) -> AtomFeed { 397 | AtomFeed( 398 | ..feed, 399 | contributors: list.flatten([feed.contributors, contributors]), 400 | ) 401 | } 402 | 403 | pub fn with_feed_generator(feed: AtomFeed, generator: Generator) -> AtomFeed { 404 | AtomFeed(..feed, generator: Some(generator)) 405 | } 406 | 407 | pub fn with_feed_icon(feed: AtomFeed, icon: String) -> AtomFeed { 408 | AtomFeed(..feed, icon: Some(icon)) 409 | } 410 | 411 | pub fn with_feed_logo(feed: AtomFeed, logo: String) -> AtomFeed { 412 | AtomFeed(..feed, logo: Some(logo)) 413 | } 414 | 415 | pub fn with_feed_rights(feed: AtomFeed, rights: Text) -> AtomFeed { 416 | AtomFeed(..feed, rights: Some(rights)) 417 | } 418 | 419 | pub fn with_feed_subtitle(feed: AtomFeed, subtitle: String) -> AtomFeed { 420 | AtomFeed(..feed, subtitle: Some(subtitle)) 421 | } 422 | 423 | pub fn with_feed_entry(feed: AtomFeed, entry: AtomEntry) -> AtomFeed { 424 | AtomFeed(..feed, entries: [entry, ..feed.entries]) 425 | } 426 | 427 | pub fn with_feed_entries(feed: AtomFeed, entries: List(AtomEntry)) -> AtomFeed { 428 | AtomFeed(..feed, entries: list.flatten([feed.entries, entries])) 429 | } 430 | 431 | // Types ---------------------------------------------------------------------- 432 | 433 | pub type AtomFeed { 434 | AtomFeed( 435 | id: String, 436 | title: Text, 437 | updated: Time, 438 | authors: List(Person), 439 | link: Option(Link), 440 | categories: List(Category), 441 | contributors: List(Person), 442 | generator: Option(Generator), 443 | icon: Option(String), 444 | logo: Option(String), 445 | rights: Option(Text), 446 | subtitle: Option(String), 447 | entries: List(AtomEntry), 448 | ) 449 | } 450 | 451 | pub type Person { 452 | Person(name: String, email: Option(String), uri: Option(String)) 453 | } 454 | 455 | pub type Generator { 456 | Generator(uri: Option(String), version: Option(String)) 457 | } 458 | 459 | pub type Link { 460 | Link( 461 | href: String, 462 | rel: Option(String), 463 | content_type: Option(String), 464 | hreflang: Option(String), 465 | title: Option(String), 466 | length: Option(Int), 467 | ) 468 | } 469 | 470 | pub type Category { 471 | Category(term: String, scheme: Option(String), label: Option(String)) 472 | } 473 | 474 | pub type AtomEntry { 475 | AtomEntry( 476 | id: String, 477 | title: Text, 478 | updated: Time, 479 | authors: List(Person), 480 | content: Option(Text), 481 | link: Option(Link), 482 | summary: Option(Text), 483 | categories: List(Category), 484 | contributors: List(Person), 485 | published: Option(Time), 486 | rights: Option(Text), 487 | source: Option(Source), 488 | ) 489 | } 490 | 491 | pub type Text { 492 | PlainText(value: String) 493 | Html(value: String) 494 | XHtml(value: String) 495 | } 496 | 497 | pub type Source { 498 | Source(id: String, title: String, updated: Time) 499 | } 500 | -------------------------------------------------------------------------------- /src/webls/robots.gleam: -------------------------------------------------------------------------------- 1 | import gleam/list 2 | import gleam/result 3 | 4 | // Stringify ------------------------------------------------------------------ 5 | // 6 | pub fn to_string(config: RobotsConfig) -> String { 7 | "Sitemap: " 8 | <> config.sitemap_url 9 | <> "\n\n" 10 | <> config.robots 11 | |> list.map(fn(robot) { robot |> robot_to_string }) 12 | |> list.reduce(fn(acc, line) { acc <> "\n\n" <> line }) 13 | |> result.unwrap("") 14 | } 15 | 16 | fn robot_to_string(robot: Robot) -> String { 17 | "User-agent: " 18 | <> robot.user_agent 19 | <> "\n" 20 | <> robot.allowed_routes 21 | |> list.map(fn(route) { "Allow: " <> route }) 22 | |> list.reduce(fn(acc, line) { acc <> "\n" <> line }) 23 | |> result.unwrap("") 24 | <> "\n" 25 | <> robot.disallowed_routes 26 | |> list.map(fn(route) { "Disallow: " <> route }) 27 | |> list.reduce(fn(acc, line) { acc <> "\n" <> line }) 28 | |> result.unwrap("") 29 | } 30 | 31 | // Builder Patern ------------------------------------------------------------- 32 | 33 | /// Creates a robots config with a sitemap url 34 | pub fn config(sitemap_url: String) -> RobotsConfig { 35 | RobotsConfig(sitemap_url: sitemap_url, robots: []) 36 | } 37 | 38 | /// Adds a list of robots to the robots config 39 | pub fn with_config_robots( 40 | config: RobotsConfig, 41 | robots: List(Robot), 42 | ) -> RobotsConfig { 43 | RobotsConfig(..config, robots: list.flatten([config.robots, robots])) 44 | } 45 | 46 | /// Adds a robot to the robots config 47 | pub fn with_config_robot(config: RobotsConfig, robot: Robot) -> RobotsConfig { 48 | RobotsConfig(..config, robots: [robot, ..config.robots]) 49 | } 50 | 51 | /// Creates a robot policy 52 | pub fn robot(user_agent: String) -> Robot { 53 | Robot(user_agent, [], []) 54 | } 55 | 56 | /// Adds a list of allowed routes to the robot policy 57 | pub fn with_robot_allowed_routes(robot: Robot, routes: List(String)) -> Robot { 58 | Robot(..robot, allowed_routes: list.flatten([robot.allowed_routes, routes])) 59 | } 60 | 61 | /// Adds a allowed route to the robot policy 62 | pub fn with_robot_allowed_route(robot: Robot, route: String) -> Robot { 63 | Robot(..robot, allowed_routes: [route, ..robot.allowed_routes]) 64 | } 65 | 66 | /// Adds a list of disallowed routes to the robot policy 67 | pub fn with_robot_disallowed_routes(robot: Robot, routes: List(String)) -> Robot { 68 | Robot( 69 | ..robot, 70 | disallowed_routes: list.flatten([robot.disallowed_routes, routes]), 71 | ) 72 | } 73 | 74 | /// Adds a disallowed route to the robot policy 75 | pub fn with_robot_disallowed_route(robot: Robot, route: String) -> Robot { 76 | Robot(..robot, disallowed_routes: [route, ..robot.disallowed_routes]) 77 | } 78 | 79 | // Types ---------------------------------------------------------------------- 80 | 81 | /// The configuration for a robots.txt file 82 | pub type RobotsConfig { 83 | RobotsConfig( 84 | /// The url of the sitemap for crawlers to use 85 | sitemap_url: String, 86 | /// A list of robot policies 87 | robots: List(Robot), 88 | ) 89 | } 90 | 91 | /// The policy for a specific robot 92 | pub type Robot { 93 | Robot( 94 | /// The user agent such as "googlebot" or "*" for catch all 95 | user_agent: String, 96 | /// The allowed routes such as "/posts/" and "/contact/" 97 | allowed_routes: List(String), 98 | /// The disallowed routes such as "/admin/" and "/private/" 99 | disallowed_routes: List(String), 100 | ) 101 | } 102 | -------------------------------------------------------------------------------- /src/webls/rss.gleam: -------------------------------------------------------------------------------- 1 | import birl.{type Time} 2 | import gleam/int 3 | import gleam/list 4 | import gleam/option.{type Option, None, Some} 5 | import gleam/result 6 | 7 | // Stringify ------------------------------------------------------------------ 8 | 9 | /// Converts a list of RSS channels to a string of a valid RSS 2.0.1 feed 10 | pub fn to_string(channels: List(RssChannel)) -> String { 11 | let channel_content = 12 | channels 13 | |> list.map(fn(channel) { channel |> rss_channel_to_string }) 14 | |> list.reduce(fn(acc, channel_string) { acc <> "\n" <> channel_string }) 15 | |> result.unwrap("") 16 | 17 | "\n" 18 | <> channel_content 19 | <> "\n" 20 | } 21 | 22 | fn rss_channel_to_string(channel: RssChannel) -> String { 23 | let channel_items: String = 24 | channel.items 25 | |> list.map(fn(rss_item) { rss_item |> rss_item_to_string }) 26 | |> list.reduce(fn(acc, rss_item_string) { acc <> "\n" <> rss_item_string }) 27 | |> result.unwrap("") 28 | 29 | "\n\n" 30 | <> "" 31 | <> channel.title 32 | <> "\n" 33 | <> "" 34 | <> channel.link 35 | <> "\n" 36 | <> "" 37 | <> channel.description 38 | <> "\n" 39 | <> case channel.language { 40 | Some(language) -> "" <> language <> "\n" 41 | _ -> "" 42 | } 43 | <> case channel.copyright { 44 | Some(copyright) -> "" <> copyright <> "\n" 45 | _ -> "" 46 | } 47 | <> case channel.managing_editor { 48 | Some(managing_editor) -> 49 | "" <> managing_editor <> "\n" 50 | _ -> "" 51 | } 52 | <> case channel.web_master { 53 | Some(web_master) -> "" <> web_master <> "\n" 54 | _ -> "" 55 | } 56 | <> case channel.pub_date { 57 | Some(pub_date) -> 58 | "" <> pub_date |> birl.to_iso8601 <> "\n" 59 | _ -> "" 60 | } 61 | <> case channel.last_build_date { 62 | Some(last_build_date) -> 63 | "" 64 | <> last_build_date |> birl.to_iso8601 65 | <> "\n" 66 | _ -> "" 67 | } 68 | <> channel.categories 69 | |> list.map(fn(category) { "" <> category <> "\n" }) 70 | |> list.reduce(fn(acc, category) { acc <> category }) 71 | |> result.unwrap("") 72 | <> case channel.generator { 73 | Some(generator) -> "" <> generator <> "\n" 74 | _ -> "" 75 | } 76 | <> case channel.docs { 77 | Some(docs) -> "" <> docs <> "\n" 78 | _ -> "" 79 | } 80 | <> case channel.cloud { 81 | Some(cloud) -> 82 | " cloud.domain 84 | <> "\" port=\"" 85 | <> int.to_string(cloud.port) 86 | <> "\" path=\"" 87 | <> cloud.path 88 | <> "\" registerProcedure=\"" 89 | <> cloud.register_procedure 90 | <> "\" protocol=\"" 91 | <> cloud.protocol 92 | <> "\"/>\n" 93 | _ -> "" 94 | } 95 | <> case channel.ttl { 96 | Some(ttl) -> "" <> int.to_string(ttl) <> "\n" 97 | _ -> "" 98 | } 99 | <> case channel.image { 100 | Some(image) -> 101 | "" 102 | <> "" 103 | <> image.url 104 | <> "\n" 105 | <> "" 106 | <> image.title 107 | <> "\n" 108 | <> "" 109 | <> image.link 110 | <> "\n" 111 | <> case image.description { 112 | Some(description) -> 113 | "" <> description <> "\n" 114 | _ -> "" 115 | } 116 | <> case image.width { 117 | Some(width) -> "" <> int.to_string(width) <> "\n" 118 | _ -> "" 119 | } 120 | <> case image.height { 121 | Some(height) -> "" <> int.to_string(height) <> "\n" 122 | _ -> "" 123 | } 124 | <> "\n" 125 | _ -> "" 126 | } 127 | <> case channel.text_input { 128 | Some(text_input) -> 129 | "" 130 | <> "" 131 | <> text_input.title 132 | <> "\n" 133 | <> "" 134 | <> text_input.description 135 | <> "\n" 136 | <> "" 137 | <> text_input.name 138 | <> "\n" 139 | <> "" 140 | <> text_input.link 141 | <> "\n" 142 | <> "\n" 143 | _ -> "" 144 | } 145 | <> case channel.skip_hours |> list.length > 0 { 146 | True -> { 147 | "" 148 | <> list.map(channel.skip_hours, fn(hour) { 149 | "" <> int.to_string(hour) <> "" 150 | }) 151 | |> list.reduce(fn(acc, hour) { acc <> "\n" <> hour }) 152 | |> result.unwrap("") 153 | <> "\n" 154 | } 155 | _ -> "" 156 | } 157 | <> case channel.skip_days |> list.length > 0 { 158 | True -> { 159 | "" 160 | <> list.map(channel.skip_days, fn(day) { 161 | "" <> day |> birl.weekday_to_string <> "" 162 | }) 163 | |> list.reduce(fn(acc, day) { acc <> "\n" <> day }) 164 | |> result.unwrap("") 165 | <> "\n" 166 | } 167 | _ -> "" 168 | } 169 | <> channel_items 170 | <> "" 171 | } 172 | 173 | fn rss_item_to_string(item: RssItem) -> String { 174 | "\n" 175 | <> "" 176 | <> item.title 177 | <> "\n" 178 | <> "" 179 | <> item.description 180 | <> "\n" 181 | <> case item.link { 182 | Some(link) -> "" <> link <> "\n" 183 | _ -> "" 184 | } 185 | <> case item.author { 186 | Some(author) -> "" <> author <> "\n" 187 | _ -> "" 188 | } 189 | <> case item.comments { 190 | Some(comments) -> "" <> comments <> "\n" 191 | _ -> "" 192 | } 193 | <> case item.source { 194 | Some(source) -> "" <> source <> "\n" 195 | _ -> "" 196 | } 197 | <> case item.pub_date { 198 | Some(pub_date) -> 199 | "" <> pub_date |> birl.to_iso8601 <> "\n" 200 | _ -> "" 201 | } 202 | <> item.categories 203 | |> list.map(fn(category) { "" <> category <> "\n" }) 204 | |> list.reduce(fn(acc, category) { acc <> category }) 205 | |> result.unwrap("") 206 | <> case item.enclosure { 207 | Some(enclosure) -> 208 | " enclosure.url 210 | <> "\" length=\"" 211 | <> int.to_string(enclosure.length) 212 | <> "\" type=\"" 213 | <> enclosure.enclosure_type 214 | <> "\"/>\n" 215 | _ -> "" 216 | } 217 | <> case item.guid { 218 | Some(guid) -> 219 | case guid { 220 | #(guid, Some(is_permalink)) -> 221 | " case is_permalink { 223 | True -> "true" 224 | False -> "false" 225 | } 226 | <> "\">" 227 | <> guid 228 | <> "\n" 229 | 230 | _ -> "" 231 | } 232 | _ -> "" 233 | } 234 | <> "" 235 | } 236 | 237 | // Builder Patern ------------------------------------------------------------- 238 | 239 | /// Creates a base RSS channel 240 | pub fn channel(title: String, description: String, link: String) -> RssChannel { 241 | RssChannel( 242 | title: title, 243 | description: description, 244 | link: link, 245 | language: None, 246 | copyright: None, 247 | managing_editor: None, 248 | web_master: None, 249 | pub_date: None, 250 | last_build_date: None, 251 | categories: [], 252 | generator: None, 253 | docs: None, 254 | cloud: None, 255 | ttl: None, 256 | image: None, 257 | text_input: None, 258 | skip_hours: [], 259 | skip_days: [], 260 | items: [], 261 | ) 262 | } 263 | 264 | /// Sets the language of the RSS channel example: "en-us" 265 | pub fn with_channel_language( 266 | channel: RssChannel, 267 | language: String, 268 | ) -> RssChannel { 269 | RssChannel(..channel, language: Some(language)) 270 | } 271 | 272 | /// Sets the copyright information for the RSS channel 273 | pub fn with_channel_copyright( 274 | channel: RssChannel, 275 | copyright: String, 276 | ) -> RssChannel { 277 | RssChannel(..channel, copyright: Some(copyright)) 278 | } 279 | 280 | /// Sets the managing editor's email address for the RSS channel 281 | pub fn with_channel_managing_editor( 282 | channel: RssChannel, 283 | managing_editor: String, 284 | ) -> RssChannel { 285 | RssChannel(..channel, managing_editor: Some(managing_editor)) 286 | } 287 | 288 | /// Sets the web master's email address for the RSS channel 289 | pub fn with_channel_web_master( 290 | channel: RssChannel, 291 | web_master: String, 292 | ) -> RssChannel { 293 | RssChannel(..channel, web_master: Some(web_master)) 294 | } 295 | 296 | /// Sets the publication date of the RSS channel 297 | pub fn with_channel_pub_date(channel: RssChannel, pub_date: Time) -> RssChannel { 298 | RssChannel(..channel, pub_date: Some(pub_date)) 299 | } 300 | 301 | /// Sets the last build date of the RSS channel 302 | pub fn with_channel_last_build_date( 303 | channel: RssChannel, 304 | last_build_date: Time, 305 | ) -> RssChannel { 306 | RssChannel(..channel, last_build_date: Some(last_build_date)) 307 | } 308 | 309 | /// Adds a category to the RSS channel 310 | pub fn with_channel_category( 311 | channel: RssChannel, 312 | category: String, 313 | ) -> RssChannel { 314 | RssChannel(..channel, categories: [category, ..channel.categories]) 315 | } 316 | 317 | /// Adds a list of categories to the RSS channel 318 | pub fn with_channel_categories( 319 | channel: RssChannel, 320 | categories: List(String), 321 | ) -> RssChannel { 322 | RssChannel( 323 | ..channel, 324 | categories: list.flatten([channel.categories, categories]), 325 | ) 326 | } 327 | 328 | /// Sets the generator element for the RSS channel with a custom string 329 | pub fn with_channel_custom_generator( 330 | channel: RssChannel, 331 | generator: String, 332 | ) -> RssChannel { 333 | RssChannel(..channel, generator: Some(generator)) 334 | } 335 | 336 | /// Sets the generator element for the RSS channel to webls 337 | pub fn with_channel_generator(channel: RssChannel) -> RssChannel { 338 | with_channel_custom_generator(channel, "webls") 339 | } 340 | 341 | /// Adds the RSS 2.0.1 spec documentation link to the RSS channel 342 | pub fn with_channel_docs(channel: RssChannel) -> RssChannel { 343 | RssChannel(..channel, docs: Some("https://www.rssboard.org/rss-2-0-1")) 344 | } 345 | 346 | /// Sets the cloud element for the RSS channel with a domain, port, path, 347 | /// register procedure, and protocol 348 | pub fn with_channel_cloud(channel: RssChannel, cloud: Cloud) -> RssChannel { 349 | RssChannel(..channel, cloud: Some(cloud)) 350 | } 351 | 352 | /// Sets the time-to-live (TTL) for the RSS channel in minutes 353 | pub fn with_channel_ttl(channel: RssChannel, ttl: Int) -> RssChannel { 354 | RssChannel(..channel, ttl: Some(ttl)) 355 | } 356 | 357 | /// Sets an optional image associated with the RSS channel 358 | pub fn with_channel_image(channel: RssChannel, image: Image) -> RssChannel { 359 | RssChannel(..channel, image: Some(image)) 360 | } 361 | 362 | /// Sets an optional text input for the RSS channel 363 | pub fn with_channel_text_input( 364 | channel: RssChannel, 365 | text_input: TextInput, 366 | ) -> RssChannel { 367 | RssChannel(..channel, text_input: Some(text_input)) 368 | } 369 | 370 | /// Sets a list of hours in GMT which content aggregation should be skipped 371 | pub fn with_channel_skip_hours( 372 | channel: RssChannel, 373 | skip_hours: List(Int), 374 | ) -> RssChannel { 375 | RssChannel(..channel, skip_hours: skip_hours) 376 | } 377 | 378 | /// Sets a list of days to skip in the RSS channel 379 | pub fn with_channel_skip_days( 380 | channel: RssChannel, 381 | skip_days: List(birl.Weekday), 382 | ) -> RssChannel { 383 | RssChannel(..channel, skip_days: skip_days) 384 | } 385 | 386 | /// Adds a list of items in the RSS channel 387 | pub fn with_channel_items( 388 | channel: RssChannel, 389 | items: List(RssItem), 390 | ) -> RssChannel { 391 | RssChannel(..channel, items: list.flatten([channel.items, items])) 392 | } 393 | 394 | /// Adds a RSS item to the RSS channel 395 | pub fn with_channel_item(channel: RssChannel, item: RssItem) -> RssChannel { 396 | RssChannel(..channel, items: [item, ..channel.items]) 397 | } 398 | 399 | /// Creates a base RSS item 400 | pub fn item(title: String, description: String) -> RssItem { 401 | RssItem( 402 | title: title, 403 | description: description, 404 | link: None, 405 | author: None, 406 | categories: [], 407 | comments: None, 408 | enclosure: None, 409 | guid: None, 410 | pub_date: None, 411 | source: None, 412 | ) 413 | } 414 | 415 | /// Sets the link to the page or source of the RSS item 416 | pub fn with_item_link(item: RssItem, link: String) -> RssItem { 417 | RssItem(..item, link: Some(link)) 418 | } 419 | 420 | /// Sets an optional author field, note RSS author must be email addresses 421 | pub fn with_item_author(item: RssItem, author: String) -> RssItem { 422 | RssItem(..item, author: Some(author)) 423 | } 424 | 425 | /// Sets a list of categories for the RSS item 426 | pub fn with_item_categories(item: RssItem, categories: List(String)) -> RssItem { 427 | RssItem(..item, categories: categories) 428 | } 429 | 430 | /// Sets a URL to the comments section for the RSS item 431 | pub fn with_item_comments(item: RssItem, comments: String) -> RssItem { 432 | RssItem(..item, comments: Some(comments)) 433 | } 434 | 435 | /// Sets an optional enclosure (media resource) for the RSS item 436 | pub fn with_item_enclosure(item: RssItem, enclosure: Enclosure) -> RssItem { 437 | RssItem(..item, enclosure: Some(enclosure)) 438 | } 439 | 440 | /// Sets the guid of the RSS item with an optional boolean for whether it is a permalink 441 | pub fn with_item_guid(item: RssItem, guid: #(String, Option(Bool))) -> RssItem { 442 | RssItem(..item, guid: Some(guid)) 443 | } 444 | 445 | /// Sets the publication date of the RSS item 446 | pub fn with_item_pub_date(item: RssItem, pub_date: Time) -> RssItem { 447 | RssItem(..item, pub_date: Some(pub_date)) 448 | } 449 | 450 | /// Sets the RSS channel the item came from 451 | pub fn with_item_source(item: RssItem, source: String) -> RssItem { 452 | RssItem(..item, source: Some(source)) 453 | } 454 | 455 | // Types ---------------------------------------------------------------------- 456 | 457 | /// RSS 2.0.1 spec compliant channel 458 | pub type RssChannel { 459 | RssChannel( 460 | /// The title of the RSS channel 461 | title: String, 462 | /// The link to the top page of the RSS channel 463 | link: String, 464 | /// The description of the RSS channel 465 | description: String, 466 | /// The language of the RSS channel 467 | language: Option(String), 468 | /// The copyright information for the RSS channel 469 | copyright: Option(String), 470 | /// The managing editor's email address 471 | managing_editor: Option(String), 472 | /// The web master’s email address 473 | web_master: Option(String), 474 | /// The publication date of the RSS channel 475 | pub_date: Option(Time), 476 | /// The last build date of the RSS channel 477 | last_build_date: Option(Time), 478 | /// A list of categories for the RSS channel 479 | categories: List(String), 480 | /// The generator program of the RSS channel, feel free to shout webls out! 481 | generator: Option(String), 482 | /// A link to the documentation for the RSS spec 483 | docs: Option(String), 484 | /// Cloud configuration for the RSS channel 485 | cloud: Option(Cloud), 486 | /// The time-to-live (TTL) for the RSS channel 487 | ttl: Option(Int), 488 | /// An optional image associated with the RSS channel 489 | image: Option(Image), 490 | /// An optional text input for the RSS channel 491 | text_input: Option(TextInput), 492 | /// A list of hours in GMT which content aggregation should be skipped 493 | skip_hours: List(Int), 494 | /// A list of days to skip in the RSS channel 495 | skip_days: List(birl.Weekday), 496 | /// A list of items in the RSS channel 497 | items: List(RssItem), 498 | ) 499 | } 500 | 501 | /// An image associated with a RSS channel 502 | pub type Image { 503 | Image( 504 | /// The URL of the image 505 | url: String, 506 | /// The title of the image 507 | title: String, 508 | /// The link associated with the image 509 | link: String, 510 | /// An optional description of the image 511 | description: Option(String), 512 | /// An optional width of the image in pixels 513 | width: Option(Int), 514 | /// An optional height of the image in pixels 515 | height: Option(Int), 516 | ) 517 | } 518 | 519 | /// Cloud configuration for an RSS channel 520 | pub type Cloud { 521 | Cloud( 522 | /// The domain of the cloud service 523 | domain: String, 524 | /// The port for the cloud service 525 | port: Int, 526 | /// The path for the cloud service 527 | path: String, 528 | /// The registration procedure for the cloud service (usually "http-post" or "xml-rpc") 529 | register_procedure: String, 530 | /// The protocol used for the cloud service 531 | protocol: String, 532 | ) 533 | } 534 | 535 | /// A text input field for an RSS channel 536 | pub type TextInput { 537 | TextInput( 538 | /// The title of the text input field 539 | title: String, 540 | /// A description of the text input field's purpose 541 | description: String, 542 | /// The name attribute for the text input field 543 | name: String, 544 | /// The link associated with the text input field 545 | link: String, 546 | ) 547 | } 548 | 549 | /// An enclosure resource for an RSS item 550 | pub type Enclosure { 551 | Enclosure( 552 | /// The URL of the enclosure resource 553 | url: String, 554 | /// The length of the enclosure in bytes 555 | length: Int, 556 | /// The type of the enclosure (e.g., audio/mpeg, video/mp4) 557 | enclosure_type: String, 558 | ) 559 | } 560 | 561 | /// RSS 2.0.1 spec compliant item 562 | pub type RssItem { 563 | RssItem( 564 | /// The title of the RSS item 565 | title: String, 566 | /// The description of the RSS item 567 | description: String, 568 | /// The link to the page or source of the RSS item 569 | link: Option(String), 570 | /// An optional author field, note RSS author must be email addresses 571 | author: Option(String), 572 | /// A URL to the comments section for the RSS item 573 | comments: Option(String), 574 | /// The RSS channel the item came from 575 | source: Option(String), 576 | /// The publication date of the RSS item 577 | pub_date: Option(Time), 578 | /// A list of categories for the RSS item 579 | categories: List(String), 580 | /// An optional enclosure resource for the RSS item 581 | enclosure: Option(Enclosure), 582 | /// A guid and an optional boolean for whether it is a permalink 583 | guid: Option(#(String, Option(Bool))), 584 | ) 585 | } 586 | -------------------------------------------------------------------------------- /src/webls/sitemap.gleam: -------------------------------------------------------------------------------- 1 | import birl.{type Time} 2 | import gleam/float 3 | import gleam/list 4 | import gleam/option.{type Option, None, Some} 5 | import gleam/result 6 | 7 | // Stringify ------------------------------------------------------------------ 8 | 9 | /// Generates a sitemap.xml string from a sitemap 10 | pub fn to_string(sitemap: Sitemap) -> String { 11 | let channel_content = 12 | sitemap.items 13 | |> list.map(fn(item) { item |> sitemap_item_to_string }) 14 | |> list.reduce(fn(acc, item_string) { acc <> "\n" <> item_string }) 15 | |> result.unwrap("") 16 | 17 | "\n\n" 18 | <> channel_content 19 | <> "\n" 20 | } 21 | 22 | fn sitemap_item_to_string(item: SitemapItem) -> String { 23 | "\n" 24 | <> "" 25 | <> item.loc 26 | <> "\n" 27 | <> case item.last_modified { 28 | Some(date) -> "" <> date |> birl.to_iso8601 <> "\n" 29 | _ -> "" 30 | } 31 | <> case item.change_frequency { 32 | Some(freq) -> 33 | "" 34 | <> case freq { 35 | Always -> "always" 36 | Hourly -> "hourly" 37 | Daily -> "daily" 38 | Weekly -> "weekly" 39 | Monthly -> "monthly" 40 | Yearly -> "yearly" 41 | Never -> "never" 42 | } 43 | <> "\n" 44 | _ -> "" 45 | } 46 | <> case item.priority { 47 | Some(priority) -> 48 | "" 49 | <> priority |> float.clamp(0.0, 1.0) |> float.to_string() 50 | <> "\n" 51 | _ -> "" 52 | } 53 | <> "" 54 | } 55 | 56 | // Builder Patern ------------------------------------------------------------- 57 | 58 | /// Create a sitemap with a url 59 | pub fn sitemap(url: String) -> Sitemap { 60 | Sitemap(url: url, last_modified: None, items: []) 61 | } 62 | 63 | /// Adds a list of sitemap items to the sitemap 64 | pub fn with_sitemap_items(sitemap: Sitemap, items: List(SitemapItem)) -> Sitemap { 65 | Sitemap(..sitemap, items: list.flatten([sitemap.items, items])) 66 | } 67 | 68 | /// Adds a sitemap item to the sitemap 69 | pub fn with_sitemap_item(sitemap: Sitemap, item: SitemapItem) -> Sitemap { 70 | Sitemap(..sitemap, items: [item, ..sitemap.items]) 71 | } 72 | 73 | /// Add a last modified time to the sitemap 74 | pub fn with_sitemap_last_modified( 75 | sitemap: Sitemap, 76 | last_modified: Time, 77 | ) -> Sitemap { 78 | Sitemap(..sitemap, last_modified: Some(last_modified)) 79 | } 80 | 81 | /// Create a base sitemap item with just the URL location 82 | pub fn item(loc: String) -> SitemapItem { 83 | SitemapItem( 84 | loc: loc, 85 | last_modified: None, 86 | change_frequency: None, 87 | priority: None, 88 | ) 89 | } 90 | 91 | /// Add a change frequency to the sitemap item 92 | pub fn with_item_frequency( 93 | item: SitemapItem, 94 | frequency: ChangeFrequency, 95 | ) -> SitemapItem { 96 | SitemapItem(..item, change_frequency: Some(frequency)) 97 | } 98 | 99 | /// Add a priority to the sitemap item 100 | pub fn with_item_priority(item: SitemapItem, priority: Float) -> SitemapItem { 101 | SitemapItem(..item, priority: Some(priority)) 102 | } 103 | 104 | /// Add a last modified time to the sitemap item 105 | pub fn with_item_last_modified(item: SitemapItem, modified: Time) -> SitemapItem { 106 | SitemapItem(..item, last_modified: Some(modified)) 107 | } 108 | 109 | // Types ---------------------------------------------------------------------- 110 | 111 | /// A complete sitemap 112 | pub type Sitemap { 113 | Sitemap( 114 | /// The url location of the sitemap 115 | url: String, 116 | /// The time of last modification of the sitemap 117 | last_modified: Option(Time), 118 | /// The list of items contained within the sitemap 119 | items: List(SitemapItem), 120 | ) 121 | } 122 | 123 | /// A item within a sitemap 124 | pub type SitemapItem { 125 | SitemapItem( 126 | /// The location/url of the page 127 | loc: String, 128 | /// The time of last modification of the page 129 | last_modified: Option(Time), 130 | /// How frequently the page is likely to continue to change 131 | change_frequency: Option(ChangeFrequency), 132 | /// The priority of the page compared to others within the sitemap 133 | /// Must be between 0.0 and 1.0 134 | priority: Option(Float), 135 | ) 136 | } 137 | 138 | /// The fequency at which a page tends to change 139 | pub type ChangeFrequency { 140 | Always 141 | Hourly 142 | Daily 143 | Weekly 144 | Monthly 145 | Yearly 146 | Never 147 | } 148 | -------------------------------------------------------------------------------- /test/robots.txt: -------------------------------------------------------------------------------- 1 | Sitemap: https://example.com/sitemap.xml 2 | 3 | User-agent: googlebot 4 | Allow: /posts/ 5 | Allow: /contact/ 6 | Disallow: /admin/ 7 | Disallow: /private/ 8 | 9 | User-agent: bingbot 10 | Allow: /posts/ 11 | Allow: /contact/ 12 | Allow: /private/ 13 | Disallow: / 14 | -------------------------------------------------------------------------------- /test/rss.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Gleam RSS 5 | https://gleam.run 6 | A test RSS feed 7 | en 8 | Releases 9 | 10 | Gleam 1.0 11 | Gleam 1.0 is here! 12 | https://gleam.run/blog/gleam-1.0 13 | 2024-08-11T20:22:50.481-05:00 14 | gleam 1.0 15 | 16 | 17 | Gleam 0.10 18 | Gleam 0.10 is here! 19 | https://gleam.run/blog/gleam-0.10 20 | user@example.com 21 | gleam 0.10 22 | 23 | 24 | -------------------------------------------------------------------------------- /test/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://gleam.run 5 | monthly 6 | 1.0 7 | 8 | 9 | https://gleam.run/blog 10 | weekly 11 | 12 | 13 | https://gleam.run/blog/gleam-1.0 14 | 15 | 16 | https://gleam.run/blog/gleam-1.1 17 | 18 | 19 | -------------------------------------------------------------------------------- /test/webls_test.gleam: -------------------------------------------------------------------------------- 1 | import birl 2 | import gleam/option.{Some} 3 | import gleam/string 4 | import gleeunit 5 | import gleeunit/should 6 | import webls/robots 7 | import webls/rss 8 | import webls/sitemap 9 | 10 | pub fn main() -> Nil { 11 | gleeunit.main() 12 | } 13 | 14 | /// Confirms that the RSS feed correctly stringifies using known length 15 | pub fn rss_to_string_test() -> Nil { 16 | let channels = [ 17 | rss.channel("Gleam RSS", "A test RSS feed", "https://gleam.run") 18 | |> rss.with_channel_category("Releases") 19 | |> rss.with_channel_language("en") 20 | |> rss.with_channel_items([ 21 | rss.item("Gleam 1.0", "Gleam 1.0 is here!") 22 | |> rss.with_item_link("https://gleam.run/blog/gleam-1.0") 23 | |> rss.with_item_pub_date(birl.now()) 24 | |> rss.with_item_guid(#("gleam 1.0", Some(False))), 25 | rss.item("Gleam 0.10", "Gleam 0.10 is here!") 26 | |> rss.with_item_link("https://gleam.run/blog/gleam-0.10") 27 | |> rss.with_item_author("user@example.com") 28 | |> rss.with_item_guid(#("gleam 0.10", Some(True))), 29 | ]), 30 | ] 31 | 32 | let length: Int = 33 | channels 34 | |> rss.to_string() 35 | |> string.length() 36 | 37 | { length > 600 } 38 | |> should.be_true 39 | } 40 | 41 | /// Confirms that the sitemap correctly stringifies using known length 42 | pub fn sitemap_to_string_test() -> Nil { 43 | let sitemap = 44 | sitemap.sitemap("https://gleam.run/sitemap.xml") 45 | |> sitemap.with_sitemap_last_modified(birl.now()) 46 | |> sitemap.with_sitemap_items([ 47 | sitemap.item("https://gleam.run") 48 | |> sitemap.with_item_frequency(sitemap.Monthly) 49 | |> sitemap.with_item_priority(1.0), 50 | sitemap.item("https://gleam.run/blog") 51 | |> sitemap.with_item_frequency(sitemap.Weekly), 52 | sitemap.item("https://gleam.run/blog/gleam-1.0"), 53 | sitemap.item("https://gleam.run/blog/gleam-1.1"), 54 | ]) 55 | 56 | let length: Int = 57 | sitemap 58 | |> sitemap.to_string() 59 | |> string.length() 60 | 61 | { length > 400 } 62 | |> should.be_true 63 | } 64 | 65 | /// Confirms that the robots.txt correctly stringifies using known length 66 | pub fn robots_to_string_test() -> Nil { 67 | let config = 68 | robots.config("https://example.com/sitemap.xml") 69 | |> robots.with_config_robots([ 70 | robots.robot("googlebot") 71 | |> robots.with_robot_allowed_routes(["/posts/", "/contact/"]) 72 | |> robots.with_robot_disallowed_routes(["/admin/", "/private/"]), 73 | robots.robot("bingbot") 74 | |> robots.with_robot_allowed_routes([ 75 | "/posts/", "/contact/", "/private/", 76 | ]) 77 | |> robots.with_robot_disallowed_routes(["/"]), 78 | ]) 79 | 80 | let length: Int = 81 | config 82 | |> robots.to_string 83 | |> string.length() 84 | 85 | { length > 200 } 86 | |> should.be_true 87 | } 88 | --------------------------------------------------------------------------------