├── .editorconfig ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── examples ├── index.tera ├── metadata.yaml └── post.tera └── src ├── atom.xml.tera ├── build.rs ├── cli.rs ├── config.rs ├── entry.rs ├── error.rs ├── examples ├── gempost.yaml ├── index.gmi ├── index.tera ├── metadata.yaml.tera ├── post.gmi └── post.tera ├── feed.rs ├── init.rs ├── main.rs ├── metadata.yaml.tera ├── new.rs └── template.rs /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.html] 12 | indent_size = 2 13 | 14 | [*.yaml] 15 | indent_size = 2 16 | 17 | [*.xml] 18 | indent_size = 2 19 | 20 | [*.tera] 21 | indent_size = 2 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /debug/ 2 | /target/ 3 | **/*.rs.bk 4 | *.pdb 5 | 6 | # This directory is for local testing. 7 | /capsule/ 8 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "addr2line" 7 | version = "0.21.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler" 16 | version = "1.0.2" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" 19 | 20 | [[package]] 21 | name = "aho-corasick" 22 | version = "1.1.2" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" 25 | dependencies = [ 26 | "memchr", 27 | ] 28 | 29 | [[package]] 30 | name = "android-tzdata" 31 | version = "0.1.1" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" 34 | 35 | [[package]] 36 | name = "android_system_properties" 37 | version = "0.1.5" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 40 | dependencies = [ 41 | "libc", 42 | ] 43 | 44 | [[package]] 45 | name = "anstream" 46 | version = "0.6.5" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "d664a92ecae85fd0a7392615844904654d1d5f5514837f471ddef4a057aba1b6" 49 | dependencies = [ 50 | "anstyle", 51 | "anstyle-parse", 52 | "anstyle-query", 53 | "anstyle-wincon", 54 | "colorchoice", 55 | "utf8parse", 56 | ] 57 | 58 | [[package]] 59 | name = "anstyle" 60 | version = "1.0.4" 61 | source = "registry+https://github.com/rust-lang/crates.io-index" 62 | checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" 63 | 64 | [[package]] 65 | name = "anstyle-parse" 66 | version = "0.2.3" 67 | source = "registry+https://github.com/rust-lang/crates.io-index" 68 | checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" 69 | dependencies = [ 70 | "utf8parse", 71 | ] 72 | 73 | [[package]] 74 | name = "anstyle-query" 75 | version = "1.0.2" 76 | source = "registry+https://github.com/rust-lang/crates.io-index" 77 | checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" 78 | dependencies = [ 79 | "windows-sys", 80 | ] 81 | 82 | [[package]] 83 | name = "anstyle-wincon" 84 | version = "3.0.2" 85 | source = "registry+https://github.com/rust-lang/crates.io-index" 86 | checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" 87 | dependencies = [ 88 | "anstyle", 89 | "windows-sys", 90 | ] 91 | 92 | [[package]] 93 | name = "autocfg" 94 | version = "1.1.0" 95 | source = "registry+https://github.com/rust-lang/crates.io-index" 96 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 97 | 98 | [[package]] 99 | name = "backtrace" 100 | version = "0.3.69" 101 | source = "registry+https://github.com/rust-lang/crates.io-index" 102 | checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" 103 | dependencies = [ 104 | "addr2line", 105 | "cc", 106 | "cfg-if", 107 | "libc", 108 | "miniz_oxide", 109 | "object", 110 | "rustc-demangle", 111 | ] 112 | 113 | [[package]] 114 | name = "bitflags" 115 | version = "1.3.2" 116 | source = "registry+https://github.com/rust-lang/crates.io-index" 117 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 118 | 119 | [[package]] 120 | name = "block-buffer" 121 | version = "0.10.4" 122 | source = "registry+https://github.com/rust-lang/crates.io-index" 123 | checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 124 | dependencies = [ 125 | "generic-array", 126 | ] 127 | 128 | [[package]] 129 | name = "bstr" 130 | version = "1.9.0" 131 | source = "registry+https://github.com/rust-lang/crates.io-index" 132 | checksum = "c48f0051a4b4c5e0b6d365cd04af53aeaa209e3cc15ec2cdb69e73cc87fbd0dc" 133 | dependencies = [ 134 | "memchr", 135 | "serde", 136 | ] 137 | 138 | [[package]] 139 | name = "bumpalo" 140 | version = "3.14.0" 141 | source = "registry+https://github.com/rust-lang/crates.io-index" 142 | checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" 143 | 144 | [[package]] 145 | name = "cc" 146 | version = "1.0.83" 147 | source = "registry+https://github.com/rust-lang/crates.io-index" 148 | checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" 149 | dependencies = [ 150 | "libc", 151 | ] 152 | 153 | [[package]] 154 | name = "cfg-if" 155 | version = "1.0.0" 156 | source = "registry+https://github.com/rust-lang/crates.io-index" 157 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 158 | 159 | [[package]] 160 | name = "chrono" 161 | version = "0.4.31" 162 | source = "registry+https://github.com/rust-lang/crates.io-index" 163 | checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" 164 | dependencies = [ 165 | "android-tzdata", 166 | "iana-time-zone", 167 | "num-traits", 168 | "windows-targets 0.48.5", 169 | ] 170 | 171 | [[package]] 172 | name = "chrono-tz" 173 | version = "0.8.5" 174 | source = "registry+https://github.com/rust-lang/crates.io-index" 175 | checksum = "91d7b79e99bfaa0d47da0687c43aa3b7381938a62ad3a6498599039321f660b7" 176 | dependencies = [ 177 | "chrono", 178 | "chrono-tz-build", 179 | "phf", 180 | ] 181 | 182 | [[package]] 183 | name = "chrono-tz-build" 184 | version = "0.2.1" 185 | source = "registry+https://github.com/rust-lang/crates.io-index" 186 | checksum = "433e39f13c9a060046954e0592a8d0a4bcb1040125cbf91cb8ee58964cfb350f" 187 | dependencies = [ 188 | "parse-zoneinfo", 189 | "phf", 190 | "phf_codegen", 191 | ] 192 | 193 | [[package]] 194 | name = "clap" 195 | version = "4.4.13" 196 | source = "registry+https://github.com/rust-lang/crates.io-index" 197 | checksum = "52bdc885e4cacc7f7c9eedc1ef6da641603180c783c41a15c264944deeaab642" 198 | dependencies = [ 199 | "clap_builder", 200 | "clap_derive", 201 | ] 202 | 203 | [[package]] 204 | name = "clap_builder" 205 | version = "4.4.12" 206 | source = "registry+https://github.com/rust-lang/crates.io-index" 207 | checksum = "fb7fb5e4e979aec3be7791562fcba452f94ad85e954da024396433e0e25a79e9" 208 | dependencies = [ 209 | "anstream", 210 | "anstyle", 211 | "clap_lex", 212 | "strsim", 213 | ] 214 | 215 | [[package]] 216 | name = "clap_derive" 217 | version = "4.4.7" 218 | source = "registry+https://github.com/rust-lang/crates.io-index" 219 | checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" 220 | dependencies = [ 221 | "heck", 222 | "proc-macro2", 223 | "quote", 224 | "syn", 225 | ] 226 | 227 | [[package]] 228 | name = "clap_lex" 229 | version = "0.6.0" 230 | source = "registry+https://github.com/rust-lang/crates.io-index" 231 | checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" 232 | 233 | [[package]] 234 | name = "color-eyre" 235 | version = "0.6.2" 236 | source = "registry+https://github.com/rust-lang/crates.io-index" 237 | checksum = "5a667583cca8c4f8436db8de46ea8233c42a7d9ae424a82d338f2e4675229204" 238 | dependencies = [ 239 | "backtrace", 240 | "color-spantrace", 241 | "eyre", 242 | "indenter", 243 | "once_cell", 244 | "owo-colors", 245 | "tracing-error", 246 | ] 247 | 248 | [[package]] 249 | name = "color-spantrace" 250 | version = "0.2.1" 251 | source = "registry+https://github.com/rust-lang/crates.io-index" 252 | checksum = "cd6be1b2a7e382e2b98b43b2adcca6bb0e465af0bdd38123873ae61eb17a72c2" 253 | dependencies = [ 254 | "once_cell", 255 | "owo-colors", 256 | "tracing-core", 257 | "tracing-error", 258 | ] 259 | 260 | [[package]] 261 | name = "colorchoice" 262 | version = "1.0.0" 263 | source = "registry+https://github.com/rust-lang/crates.io-index" 264 | checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" 265 | 266 | [[package]] 267 | name = "core-foundation-sys" 268 | version = "0.8.6" 269 | source = "registry+https://github.com/rust-lang/crates.io-index" 270 | checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" 271 | 272 | [[package]] 273 | name = "cpufeatures" 274 | version = "0.2.12" 275 | source = "registry+https://github.com/rust-lang/crates.io-index" 276 | checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" 277 | dependencies = [ 278 | "libc", 279 | ] 280 | 281 | [[package]] 282 | name = "crossbeam-deque" 283 | version = "0.8.5" 284 | source = "registry+https://github.com/rust-lang/crates.io-index" 285 | checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" 286 | dependencies = [ 287 | "crossbeam-epoch", 288 | "crossbeam-utils", 289 | ] 290 | 291 | [[package]] 292 | name = "crossbeam-epoch" 293 | version = "0.9.18" 294 | source = "registry+https://github.com/rust-lang/crates.io-index" 295 | checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" 296 | dependencies = [ 297 | "crossbeam-utils", 298 | ] 299 | 300 | [[package]] 301 | name = "crossbeam-utils" 302 | version = "0.8.19" 303 | source = "registry+https://github.com/rust-lang/crates.io-index" 304 | checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" 305 | 306 | [[package]] 307 | name = "crypto-common" 308 | version = "0.1.6" 309 | source = "registry+https://github.com/rust-lang/crates.io-index" 310 | checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" 311 | dependencies = [ 312 | "generic-array", 313 | "typenum", 314 | ] 315 | 316 | [[package]] 317 | name = "deunicode" 318 | version = "1.4.2" 319 | source = "registry+https://github.com/rust-lang/crates.io-index" 320 | checksum = "3ae2a35373c5c74340b79ae6780b498b2b183915ec5dacf263aac5a099bf485a" 321 | 322 | [[package]] 323 | name = "digest" 324 | version = "0.10.7" 325 | source = "registry+https://github.com/rust-lang/crates.io-index" 326 | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 327 | dependencies = [ 328 | "block-buffer", 329 | "crypto-common", 330 | ] 331 | 332 | [[package]] 333 | name = "equivalent" 334 | version = "1.0.1" 335 | source = "registry+https://github.com/rust-lang/crates.io-index" 336 | checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 337 | 338 | [[package]] 339 | name = "eyre" 340 | version = "0.6.11" 341 | source = "registry+https://github.com/rust-lang/crates.io-index" 342 | checksum = "b6267a1fa6f59179ea4afc8e50fd8612a3cc60bc858f786ff877a4a8cb042799" 343 | dependencies = [ 344 | "indenter", 345 | "once_cell", 346 | ] 347 | 348 | [[package]] 349 | name = "form_urlencoded" 350 | version = "1.2.1" 351 | source = "registry+https://github.com/rust-lang/crates.io-index" 352 | checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" 353 | dependencies = [ 354 | "percent-encoding", 355 | ] 356 | 357 | [[package]] 358 | name = "gempost" 359 | version = "0.3.0" 360 | dependencies = [ 361 | "chrono", 362 | "clap", 363 | "color-eyre", 364 | "eyre", 365 | "serde", 366 | "serde_yaml", 367 | "tera", 368 | "thiserror", 369 | "url", 370 | "uuid", 371 | ] 372 | 373 | [[package]] 374 | name = "generic-array" 375 | version = "0.14.7" 376 | source = "registry+https://github.com/rust-lang/crates.io-index" 377 | checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" 378 | dependencies = [ 379 | "typenum", 380 | "version_check", 381 | ] 382 | 383 | [[package]] 384 | name = "getrandom" 385 | version = "0.2.12" 386 | source = "registry+https://github.com/rust-lang/crates.io-index" 387 | checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" 388 | dependencies = [ 389 | "cfg-if", 390 | "libc", 391 | "wasi", 392 | ] 393 | 394 | [[package]] 395 | name = "gimli" 396 | version = "0.28.1" 397 | source = "registry+https://github.com/rust-lang/crates.io-index" 398 | checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" 399 | 400 | [[package]] 401 | name = "globset" 402 | version = "0.4.14" 403 | source = "registry+https://github.com/rust-lang/crates.io-index" 404 | checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1" 405 | dependencies = [ 406 | "aho-corasick", 407 | "bstr", 408 | "log", 409 | "regex-automata", 410 | "regex-syntax", 411 | ] 412 | 413 | [[package]] 414 | name = "globwalk" 415 | version = "0.8.1" 416 | source = "registry+https://github.com/rust-lang/crates.io-index" 417 | checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" 418 | dependencies = [ 419 | "bitflags", 420 | "ignore", 421 | "walkdir", 422 | ] 423 | 424 | [[package]] 425 | name = "hashbrown" 426 | version = "0.14.3" 427 | source = "registry+https://github.com/rust-lang/crates.io-index" 428 | checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" 429 | 430 | [[package]] 431 | name = "heck" 432 | version = "0.4.1" 433 | source = "registry+https://github.com/rust-lang/crates.io-index" 434 | checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" 435 | 436 | [[package]] 437 | name = "humansize" 438 | version = "2.1.3" 439 | source = "registry+https://github.com/rust-lang/crates.io-index" 440 | checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" 441 | dependencies = [ 442 | "libm", 443 | ] 444 | 445 | [[package]] 446 | name = "iana-time-zone" 447 | version = "0.1.59" 448 | source = "registry+https://github.com/rust-lang/crates.io-index" 449 | checksum = "b6a67363e2aa4443928ce15e57ebae94fd8949958fd1223c4cfc0cd473ad7539" 450 | dependencies = [ 451 | "android_system_properties", 452 | "core-foundation-sys", 453 | "iana-time-zone-haiku", 454 | "js-sys", 455 | "wasm-bindgen", 456 | "windows-core", 457 | ] 458 | 459 | [[package]] 460 | name = "iana-time-zone-haiku" 461 | version = "0.1.2" 462 | source = "registry+https://github.com/rust-lang/crates.io-index" 463 | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 464 | dependencies = [ 465 | "cc", 466 | ] 467 | 468 | [[package]] 469 | name = "idna" 470 | version = "0.5.0" 471 | source = "registry+https://github.com/rust-lang/crates.io-index" 472 | checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" 473 | dependencies = [ 474 | "unicode-bidi", 475 | "unicode-normalization", 476 | ] 477 | 478 | [[package]] 479 | name = "ignore" 480 | version = "0.4.22" 481 | source = "registry+https://github.com/rust-lang/crates.io-index" 482 | checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1" 483 | dependencies = [ 484 | "crossbeam-deque", 485 | "globset", 486 | "log", 487 | "memchr", 488 | "regex-automata", 489 | "same-file", 490 | "walkdir", 491 | "winapi-util", 492 | ] 493 | 494 | [[package]] 495 | name = "indenter" 496 | version = "0.3.3" 497 | source = "registry+https://github.com/rust-lang/crates.io-index" 498 | checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" 499 | 500 | [[package]] 501 | name = "indexmap" 502 | version = "2.1.0" 503 | source = "registry+https://github.com/rust-lang/crates.io-index" 504 | checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" 505 | dependencies = [ 506 | "equivalent", 507 | "hashbrown", 508 | ] 509 | 510 | [[package]] 511 | name = "itoa" 512 | version = "1.0.10" 513 | source = "registry+https://github.com/rust-lang/crates.io-index" 514 | checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" 515 | 516 | [[package]] 517 | name = "js-sys" 518 | version = "0.3.66" 519 | source = "registry+https://github.com/rust-lang/crates.io-index" 520 | checksum = "cee9c64da59eae3b50095c18d3e74f8b73c0b86d2792824ff01bbce68ba229ca" 521 | dependencies = [ 522 | "wasm-bindgen", 523 | ] 524 | 525 | [[package]] 526 | name = "lazy_static" 527 | version = "1.4.0" 528 | source = "registry+https://github.com/rust-lang/crates.io-index" 529 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 530 | 531 | [[package]] 532 | name = "libc" 533 | version = "0.2.152" 534 | source = "registry+https://github.com/rust-lang/crates.io-index" 535 | checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7" 536 | 537 | [[package]] 538 | name = "libm" 539 | version = "0.2.8" 540 | source = "registry+https://github.com/rust-lang/crates.io-index" 541 | checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" 542 | 543 | [[package]] 544 | name = "log" 545 | version = "0.4.20" 546 | source = "registry+https://github.com/rust-lang/crates.io-index" 547 | checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" 548 | 549 | [[package]] 550 | name = "memchr" 551 | version = "2.7.1" 552 | source = "registry+https://github.com/rust-lang/crates.io-index" 553 | checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" 554 | 555 | [[package]] 556 | name = "miniz_oxide" 557 | version = "0.7.1" 558 | source = "registry+https://github.com/rust-lang/crates.io-index" 559 | checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" 560 | dependencies = [ 561 | "adler", 562 | ] 563 | 564 | [[package]] 565 | name = "num-traits" 566 | version = "0.2.17" 567 | source = "registry+https://github.com/rust-lang/crates.io-index" 568 | checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c" 569 | dependencies = [ 570 | "autocfg", 571 | ] 572 | 573 | [[package]] 574 | name = "object" 575 | version = "0.32.2" 576 | source = "registry+https://github.com/rust-lang/crates.io-index" 577 | checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" 578 | dependencies = [ 579 | "memchr", 580 | ] 581 | 582 | [[package]] 583 | name = "once_cell" 584 | version = "1.19.0" 585 | source = "registry+https://github.com/rust-lang/crates.io-index" 586 | checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" 587 | 588 | [[package]] 589 | name = "owo-colors" 590 | version = "3.5.0" 591 | source = "registry+https://github.com/rust-lang/crates.io-index" 592 | checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" 593 | 594 | [[package]] 595 | name = "parse-zoneinfo" 596 | version = "0.3.0" 597 | source = "registry+https://github.com/rust-lang/crates.io-index" 598 | checksum = "c705f256449c60da65e11ff6626e0c16a0a0b96aaa348de61376b249bc340f41" 599 | dependencies = [ 600 | "regex", 601 | ] 602 | 603 | [[package]] 604 | name = "percent-encoding" 605 | version = "2.3.1" 606 | source = "registry+https://github.com/rust-lang/crates.io-index" 607 | checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" 608 | 609 | [[package]] 610 | name = "pest" 611 | version = "2.7.6" 612 | source = "registry+https://github.com/rust-lang/crates.io-index" 613 | checksum = "1f200d8d83c44a45b21764d1916299752ca035d15ecd46faca3e9a2a2bf6ad06" 614 | dependencies = [ 615 | "memchr", 616 | "thiserror", 617 | "ucd-trie", 618 | ] 619 | 620 | [[package]] 621 | name = "pest_derive" 622 | version = "2.7.6" 623 | source = "registry+https://github.com/rust-lang/crates.io-index" 624 | checksum = "bcd6ab1236bbdb3a49027e920e693192ebfe8913f6d60e294de57463a493cfde" 625 | dependencies = [ 626 | "pest", 627 | "pest_generator", 628 | ] 629 | 630 | [[package]] 631 | name = "pest_generator" 632 | version = "2.7.6" 633 | source = "registry+https://github.com/rust-lang/crates.io-index" 634 | checksum = "2a31940305ffc96863a735bef7c7994a00b325a7138fdbc5bda0f1a0476d3275" 635 | dependencies = [ 636 | "pest", 637 | "pest_meta", 638 | "proc-macro2", 639 | "quote", 640 | "syn", 641 | ] 642 | 643 | [[package]] 644 | name = "pest_meta" 645 | version = "2.7.6" 646 | source = "registry+https://github.com/rust-lang/crates.io-index" 647 | checksum = "a7ff62f5259e53b78d1af898941cdcdccfae7385cf7d793a6e55de5d05bb4b7d" 648 | dependencies = [ 649 | "once_cell", 650 | "pest", 651 | "sha2", 652 | ] 653 | 654 | [[package]] 655 | name = "phf" 656 | version = "0.11.2" 657 | source = "registry+https://github.com/rust-lang/crates.io-index" 658 | checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" 659 | dependencies = [ 660 | "phf_shared", 661 | ] 662 | 663 | [[package]] 664 | name = "phf_codegen" 665 | version = "0.11.2" 666 | source = "registry+https://github.com/rust-lang/crates.io-index" 667 | checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" 668 | dependencies = [ 669 | "phf_generator", 670 | "phf_shared", 671 | ] 672 | 673 | [[package]] 674 | name = "phf_generator" 675 | version = "0.11.2" 676 | source = "registry+https://github.com/rust-lang/crates.io-index" 677 | checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" 678 | dependencies = [ 679 | "phf_shared", 680 | "rand", 681 | ] 682 | 683 | [[package]] 684 | name = "phf_shared" 685 | version = "0.11.2" 686 | source = "registry+https://github.com/rust-lang/crates.io-index" 687 | checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" 688 | dependencies = [ 689 | "siphasher", 690 | ] 691 | 692 | [[package]] 693 | name = "pin-project-lite" 694 | version = "0.2.13" 695 | source = "registry+https://github.com/rust-lang/crates.io-index" 696 | checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" 697 | 698 | [[package]] 699 | name = "ppv-lite86" 700 | version = "0.2.17" 701 | source = "registry+https://github.com/rust-lang/crates.io-index" 702 | checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" 703 | 704 | [[package]] 705 | name = "proc-macro2" 706 | version = "1.0.76" 707 | source = "registry+https://github.com/rust-lang/crates.io-index" 708 | checksum = "95fc56cda0b5c3325f5fbbd7ff9fda9e02bb00bb3dac51252d2f1bfa1cb8cc8c" 709 | dependencies = [ 710 | "unicode-ident", 711 | ] 712 | 713 | [[package]] 714 | name = "quote" 715 | version = "1.0.35" 716 | source = "registry+https://github.com/rust-lang/crates.io-index" 717 | checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" 718 | dependencies = [ 719 | "proc-macro2", 720 | ] 721 | 722 | [[package]] 723 | name = "rand" 724 | version = "0.8.5" 725 | source = "registry+https://github.com/rust-lang/crates.io-index" 726 | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 727 | dependencies = [ 728 | "libc", 729 | "rand_chacha", 730 | "rand_core", 731 | ] 732 | 733 | [[package]] 734 | name = "rand_chacha" 735 | version = "0.3.1" 736 | source = "registry+https://github.com/rust-lang/crates.io-index" 737 | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 738 | dependencies = [ 739 | "ppv-lite86", 740 | "rand_core", 741 | ] 742 | 743 | [[package]] 744 | name = "rand_core" 745 | version = "0.6.4" 746 | source = "registry+https://github.com/rust-lang/crates.io-index" 747 | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 748 | dependencies = [ 749 | "getrandom", 750 | ] 751 | 752 | [[package]] 753 | name = "regex" 754 | version = "1.10.2" 755 | source = "registry+https://github.com/rust-lang/crates.io-index" 756 | checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" 757 | dependencies = [ 758 | "aho-corasick", 759 | "memchr", 760 | "regex-automata", 761 | "regex-syntax", 762 | ] 763 | 764 | [[package]] 765 | name = "regex-automata" 766 | version = "0.4.3" 767 | source = "registry+https://github.com/rust-lang/crates.io-index" 768 | checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" 769 | dependencies = [ 770 | "aho-corasick", 771 | "memchr", 772 | "regex-syntax", 773 | ] 774 | 775 | [[package]] 776 | name = "regex-syntax" 777 | version = "0.8.2" 778 | source = "registry+https://github.com/rust-lang/crates.io-index" 779 | checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" 780 | 781 | [[package]] 782 | name = "rustc-demangle" 783 | version = "0.1.23" 784 | source = "registry+https://github.com/rust-lang/crates.io-index" 785 | checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" 786 | 787 | [[package]] 788 | name = "ryu" 789 | version = "1.0.16" 790 | source = "registry+https://github.com/rust-lang/crates.io-index" 791 | checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" 792 | 793 | [[package]] 794 | name = "same-file" 795 | version = "1.0.6" 796 | source = "registry+https://github.com/rust-lang/crates.io-index" 797 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 798 | dependencies = [ 799 | "winapi-util", 800 | ] 801 | 802 | [[package]] 803 | name = "serde" 804 | version = "1.0.195" 805 | source = "registry+https://github.com/rust-lang/crates.io-index" 806 | checksum = "63261df402c67811e9ac6def069e4786148c4563f4b50fd4bf30aa370d626b02" 807 | dependencies = [ 808 | "serde_derive", 809 | ] 810 | 811 | [[package]] 812 | name = "serde_derive" 813 | version = "1.0.195" 814 | source = "registry+https://github.com/rust-lang/crates.io-index" 815 | checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c" 816 | dependencies = [ 817 | "proc-macro2", 818 | "quote", 819 | "syn", 820 | ] 821 | 822 | [[package]] 823 | name = "serde_json" 824 | version = "1.0.111" 825 | source = "registry+https://github.com/rust-lang/crates.io-index" 826 | checksum = "176e46fa42316f18edd598015a5166857fc835ec732f5215eac6b7bdbf0a84f4" 827 | dependencies = [ 828 | "itoa", 829 | "ryu", 830 | "serde", 831 | ] 832 | 833 | [[package]] 834 | name = "serde_yaml" 835 | version = "0.9.30" 836 | source = "registry+https://github.com/rust-lang/crates.io-index" 837 | checksum = "b1bf28c79a99f70ee1f1d83d10c875d2e70618417fda01ad1785e027579d9d38" 838 | dependencies = [ 839 | "indexmap", 840 | "itoa", 841 | "ryu", 842 | "serde", 843 | "unsafe-libyaml", 844 | ] 845 | 846 | [[package]] 847 | name = "sha2" 848 | version = "0.10.8" 849 | source = "registry+https://github.com/rust-lang/crates.io-index" 850 | checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" 851 | dependencies = [ 852 | "cfg-if", 853 | "cpufeatures", 854 | "digest", 855 | ] 856 | 857 | [[package]] 858 | name = "sharded-slab" 859 | version = "0.1.7" 860 | source = "registry+https://github.com/rust-lang/crates.io-index" 861 | checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 862 | dependencies = [ 863 | "lazy_static", 864 | ] 865 | 866 | [[package]] 867 | name = "siphasher" 868 | version = "0.3.11" 869 | source = "registry+https://github.com/rust-lang/crates.io-index" 870 | checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" 871 | 872 | [[package]] 873 | name = "slug" 874 | version = "0.1.5" 875 | source = "registry+https://github.com/rust-lang/crates.io-index" 876 | checksum = "3bd94acec9c8da640005f8e135a39fc0372e74535e6b368b7a04b875f784c8c4" 877 | dependencies = [ 878 | "deunicode", 879 | "wasm-bindgen", 880 | ] 881 | 882 | [[package]] 883 | name = "strsim" 884 | version = "0.10.1" 885 | source = "registry+https://github.com/rust-lang/crates.io-index" 886 | checksum = "ccbca6f34534eb78dbee83f6b2c9442fea7113f43d9e80ea320f0972ae5dc08d" 887 | 888 | [[package]] 889 | name = "syn" 890 | version = "2.0.48" 891 | source = "registry+https://github.com/rust-lang/crates.io-index" 892 | checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" 893 | dependencies = [ 894 | "proc-macro2", 895 | "quote", 896 | "unicode-ident", 897 | ] 898 | 899 | [[package]] 900 | name = "tera" 901 | version = "1.19.1" 902 | source = "registry+https://github.com/rust-lang/crates.io-index" 903 | checksum = "970dff17c11e884a4a09bc76e3a17ef71e01bb13447a11e85226e254fe6d10b8" 904 | dependencies = [ 905 | "chrono", 906 | "chrono-tz", 907 | "globwalk", 908 | "humansize", 909 | "lazy_static", 910 | "percent-encoding", 911 | "pest", 912 | "pest_derive", 913 | "rand", 914 | "regex", 915 | "serde", 916 | "serde_json", 917 | "slug", 918 | "unic-segment", 919 | ] 920 | 921 | [[package]] 922 | name = "thiserror" 923 | version = "1.0.56" 924 | source = "registry+https://github.com/rust-lang/crates.io-index" 925 | checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" 926 | dependencies = [ 927 | "thiserror-impl", 928 | ] 929 | 930 | [[package]] 931 | name = "thiserror-impl" 932 | version = "1.0.56" 933 | source = "registry+https://github.com/rust-lang/crates.io-index" 934 | checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" 935 | dependencies = [ 936 | "proc-macro2", 937 | "quote", 938 | "syn", 939 | ] 940 | 941 | [[package]] 942 | name = "thread_local" 943 | version = "1.1.7" 944 | source = "registry+https://github.com/rust-lang/crates.io-index" 945 | checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" 946 | dependencies = [ 947 | "cfg-if", 948 | "once_cell", 949 | ] 950 | 951 | [[package]] 952 | name = "tinyvec" 953 | version = "1.6.0" 954 | source = "registry+https://github.com/rust-lang/crates.io-index" 955 | checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" 956 | dependencies = [ 957 | "tinyvec_macros", 958 | ] 959 | 960 | [[package]] 961 | name = "tinyvec_macros" 962 | version = "0.1.1" 963 | source = "registry+https://github.com/rust-lang/crates.io-index" 964 | checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" 965 | 966 | [[package]] 967 | name = "tracing" 968 | version = "0.1.40" 969 | source = "registry+https://github.com/rust-lang/crates.io-index" 970 | checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" 971 | dependencies = [ 972 | "pin-project-lite", 973 | "tracing-core", 974 | ] 975 | 976 | [[package]] 977 | name = "tracing-core" 978 | version = "0.1.32" 979 | source = "registry+https://github.com/rust-lang/crates.io-index" 980 | checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" 981 | dependencies = [ 982 | "once_cell", 983 | "valuable", 984 | ] 985 | 986 | [[package]] 987 | name = "tracing-error" 988 | version = "0.2.0" 989 | source = "registry+https://github.com/rust-lang/crates.io-index" 990 | checksum = "d686ec1c0f384b1277f097b2f279a2ecc11afe8c133c1aabf036a27cb4cd206e" 991 | dependencies = [ 992 | "tracing", 993 | "tracing-subscriber", 994 | ] 995 | 996 | [[package]] 997 | name = "tracing-subscriber" 998 | version = "0.3.18" 999 | source = "registry+https://github.com/rust-lang/crates.io-index" 1000 | checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" 1001 | dependencies = [ 1002 | "sharded-slab", 1003 | "thread_local", 1004 | "tracing-core", 1005 | ] 1006 | 1007 | [[package]] 1008 | name = "typenum" 1009 | version = "1.17.0" 1010 | source = "registry+https://github.com/rust-lang/crates.io-index" 1011 | checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" 1012 | 1013 | [[package]] 1014 | name = "ucd-trie" 1015 | version = "0.1.6" 1016 | source = "registry+https://github.com/rust-lang/crates.io-index" 1017 | checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" 1018 | 1019 | [[package]] 1020 | name = "unic-char-property" 1021 | version = "0.9.0" 1022 | source = "registry+https://github.com/rust-lang/crates.io-index" 1023 | checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" 1024 | dependencies = [ 1025 | "unic-char-range", 1026 | ] 1027 | 1028 | [[package]] 1029 | name = "unic-char-range" 1030 | version = "0.9.0" 1031 | source = "registry+https://github.com/rust-lang/crates.io-index" 1032 | checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" 1033 | 1034 | [[package]] 1035 | name = "unic-common" 1036 | version = "0.9.0" 1037 | source = "registry+https://github.com/rust-lang/crates.io-index" 1038 | checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" 1039 | 1040 | [[package]] 1041 | name = "unic-segment" 1042 | version = "0.9.0" 1043 | source = "registry+https://github.com/rust-lang/crates.io-index" 1044 | checksum = "e4ed5d26be57f84f176157270c112ef57b86debac9cd21daaabbe56db0f88f23" 1045 | dependencies = [ 1046 | "unic-ucd-segment", 1047 | ] 1048 | 1049 | [[package]] 1050 | name = "unic-ucd-segment" 1051 | version = "0.9.0" 1052 | source = "registry+https://github.com/rust-lang/crates.io-index" 1053 | checksum = "2079c122a62205b421f499da10f3ee0f7697f012f55b675e002483c73ea34700" 1054 | dependencies = [ 1055 | "unic-char-property", 1056 | "unic-char-range", 1057 | "unic-ucd-version", 1058 | ] 1059 | 1060 | [[package]] 1061 | name = "unic-ucd-version" 1062 | version = "0.9.0" 1063 | source = "registry+https://github.com/rust-lang/crates.io-index" 1064 | checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" 1065 | dependencies = [ 1066 | "unic-common", 1067 | ] 1068 | 1069 | [[package]] 1070 | name = "unicode-bidi" 1071 | version = "0.3.14" 1072 | source = "registry+https://github.com/rust-lang/crates.io-index" 1073 | checksum = "6f2528f27a9eb2b21e69c95319b30bd0efd85d09c379741b0f78ea1d86be2416" 1074 | 1075 | [[package]] 1076 | name = "unicode-ident" 1077 | version = "1.0.12" 1078 | source = "registry+https://github.com/rust-lang/crates.io-index" 1079 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 1080 | 1081 | [[package]] 1082 | name = "unicode-normalization" 1083 | version = "0.1.22" 1084 | source = "registry+https://github.com/rust-lang/crates.io-index" 1085 | checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" 1086 | dependencies = [ 1087 | "tinyvec", 1088 | ] 1089 | 1090 | [[package]] 1091 | name = "unsafe-libyaml" 1092 | version = "0.2.10" 1093 | source = "registry+https://github.com/rust-lang/crates.io-index" 1094 | checksum = "ab4c90930b95a82d00dc9e9ac071b4991924390d46cbd0dfe566148667605e4b" 1095 | 1096 | [[package]] 1097 | name = "url" 1098 | version = "2.5.0" 1099 | source = "registry+https://github.com/rust-lang/crates.io-index" 1100 | checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" 1101 | dependencies = [ 1102 | "form_urlencoded", 1103 | "idna", 1104 | "percent-encoding", 1105 | ] 1106 | 1107 | [[package]] 1108 | name = "utf8parse" 1109 | version = "0.2.1" 1110 | source = "registry+https://github.com/rust-lang/crates.io-index" 1111 | checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" 1112 | 1113 | [[package]] 1114 | name = "uuid" 1115 | version = "1.7.0" 1116 | source = "registry+https://github.com/rust-lang/crates.io-index" 1117 | checksum = "f00cc9702ca12d3c81455259621e676d0f7251cec66a21e98fe2e9a37db93b2a" 1118 | dependencies = [ 1119 | "getrandom", 1120 | ] 1121 | 1122 | [[package]] 1123 | name = "valuable" 1124 | version = "0.1.0" 1125 | source = "registry+https://github.com/rust-lang/crates.io-index" 1126 | checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" 1127 | 1128 | [[package]] 1129 | name = "version_check" 1130 | version = "0.9.4" 1131 | source = "registry+https://github.com/rust-lang/crates.io-index" 1132 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 1133 | 1134 | [[package]] 1135 | name = "walkdir" 1136 | version = "2.4.0" 1137 | source = "registry+https://github.com/rust-lang/crates.io-index" 1138 | checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee" 1139 | dependencies = [ 1140 | "same-file", 1141 | "winapi-util", 1142 | ] 1143 | 1144 | [[package]] 1145 | name = "wasi" 1146 | version = "0.11.0+wasi-snapshot-preview1" 1147 | source = "registry+https://github.com/rust-lang/crates.io-index" 1148 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 1149 | 1150 | [[package]] 1151 | name = "wasm-bindgen" 1152 | version = "0.2.89" 1153 | source = "registry+https://github.com/rust-lang/crates.io-index" 1154 | checksum = "0ed0d4f68a3015cc185aff4db9506a015f4b96f95303897bfa23f846db54064e" 1155 | dependencies = [ 1156 | "cfg-if", 1157 | "wasm-bindgen-macro", 1158 | ] 1159 | 1160 | [[package]] 1161 | name = "wasm-bindgen-backend" 1162 | version = "0.2.89" 1163 | source = "registry+https://github.com/rust-lang/crates.io-index" 1164 | checksum = "1b56f625e64f3a1084ded111c4d5f477df9f8c92df113852fa5a374dbda78826" 1165 | dependencies = [ 1166 | "bumpalo", 1167 | "log", 1168 | "once_cell", 1169 | "proc-macro2", 1170 | "quote", 1171 | "syn", 1172 | "wasm-bindgen-shared", 1173 | ] 1174 | 1175 | [[package]] 1176 | name = "wasm-bindgen-macro" 1177 | version = "0.2.89" 1178 | source = "registry+https://github.com/rust-lang/crates.io-index" 1179 | checksum = "0162dbf37223cd2afce98f3d0785506dcb8d266223983e4b5b525859e6e182b2" 1180 | dependencies = [ 1181 | "quote", 1182 | "wasm-bindgen-macro-support", 1183 | ] 1184 | 1185 | [[package]] 1186 | name = "wasm-bindgen-macro-support" 1187 | version = "0.2.89" 1188 | source = "registry+https://github.com/rust-lang/crates.io-index" 1189 | checksum = "f0eb82fcb7930ae6219a7ecfd55b217f5f0893484b7a13022ebb2b2bf20b5283" 1190 | dependencies = [ 1191 | "proc-macro2", 1192 | "quote", 1193 | "syn", 1194 | "wasm-bindgen-backend", 1195 | "wasm-bindgen-shared", 1196 | ] 1197 | 1198 | [[package]] 1199 | name = "wasm-bindgen-shared" 1200 | version = "0.2.89" 1201 | source = "registry+https://github.com/rust-lang/crates.io-index" 1202 | checksum = "7ab9b36309365056cd639da3134bf87fa8f3d86008abf99e612384a6eecd459f" 1203 | 1204 | [[package]] 1205 | name = "winapi" 1206 | version = "0.3.9" 1207 | source = "registry+https://github.com/rust-lang/crates.io-index" 1208 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 1209 | dependencies = [ 1210 | "winapi-i686-pc-windows-gnu", 1211 | "winapi-x86_64-pc-windows-gnu", 1212 | ] 1213 | 1214 | [[package]] 1215 | name = "winapi-i686-pc-windows-gnu" 1216 | version = "0.4.0" 1217 | source = "registry+https://github.com/rust-lang/crates.io-index" 1218 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 1219 | 1220 | [[package]] 1221 | name = "winapi-util" 1222 | version = "0.1.6" 1223 | source = "registry+https://github.com/rust-lang/crates.io-index" 1224 | checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" 1225 | dependencies = [ 1226 | "winapi", 1227 | ] 1228 | 1229 | [[package]] 1230 | name = "winapi-x86_64-pc-windows-gnu" 1231 | version = "0.4.0" 1232 | source = "registry+https://github.com/rust-lang/crates.io-index" 1233 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 1234 | 1235 | [[package]] 1236 | name = "windows-core" 1237 | version = "0.52.0" 1238 | source = "registry+https://github.com/rust-lang/crates.io-index" 1239 | checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" 1240 | dependencies = [ 1241 | "windows-targets 0.52.0", 1242 | ] 1243 | 1244 | [[package]] 1245 | name = "windows-sys" 1246 | version = "0.52.0" 1247 | source = "registry+https://github.com/rust-lang/crates.io-index" 1248 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 1249 | dependencies = [ 1250 | "windows-targets 0.52.0", 1251 | ] 1252 | 1253 | [[package]] 1254 | name = "windows-targets" 1255 | version = "0.48.5" 1256 | source = "registry+https://github.com/rust-lang/crates.io-index" 1257 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 1258 | dependencies = [ 1259 | "windows_aarch64_gnullvm 0.48.5", 1260 | "windows_aarch64_msvc 0.48.5", 1261 | "windows_i686_gnu 0.48.5", 1262 | "windows_i686_msvc 0.48.5", 1263 | "windows_x86_64_gnu 0.48.5", 1264 | "windows_x86_64_gnullvm 0.48.5", 1265 | "windows_x86_64_msvc 0.48.5", 1266 | ] 1267 | 1268 | [[package]] 1269 | name = "windows-targets" 1270 | version = "0.52.0" 1271 | source = "registry+https://github.com/rust-lang/crates.io-index" 1272 | checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" 1273 | dependencies = [ 1274 | "windows_aarch64_gnullvm 0.52.0", 1275 | "windows_aarch64_msvc 0.52.0", 1276 | "windows_i686_gnu 0.52.0", 1277 | "windows_i686_msvc 0.52.0", 1278 | "windows_x86_64_gnu 0.52.0", 1279 | "windows_x86_64_gnullvm 0.52.0", 1280 | "windows_x86_64_msvc 0.52.0", 1281 | ] 1282 | 1283 | [[package]] 1284 | name = "windows_aarch64_gnullvm" 1285 | version = "0.48.5" 1286 | source = "registry+https://github.com/rust-lang/crates.io-index" 1287 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 1288 | 1289 | [[package]] 1290 | name = "windows_aarch64_gnullvm" 1291 | version = "0.52.0" 1292 | source = "registry+https://github.com/rust-lang/crates.io-index" 1293 | checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" 1294 | 1295 | [[package]] 1296 | name = "windows_aarch64_msvc" 1297 | version = "0.48.5" 1298 | source = "registry+https://github.com/rust-lang/crates.io-index" 1299 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 1300 | 1301 | [[package]] 1302 | name = "windows_aarch64_msvc" 1303 | version = "0.52.0" 1304 | source = "registry+https://github.com/rust-lang/crates.io-index" 1305 | checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" 1306 | 1307 | [[package]] 1308 | name = "windows_i686_gnu" 1309 | version = "0.48.5" 1310 | source = "registry+https://github.com/rust-lang/crates.io-index" 1311 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 1312 | 1313 | [[package]] 1314 | name = "windows_i686_gnu" 1315 | version = "0.52.0" 1316 | source = "registry+https://github.com/rust-lang/crates.io-index" 1317 | checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" 1318 | 1319 | [[package]] 1320 | name = "windows_i686_msvc" 1321 | version = "0.48.5" 1322 | source = "registry+https://github.com/rust-lang/crates.io-index" 1323 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 1324 | 1325 | [[package]] 1326 | name = "windows_i686_msvc" 1327 | version = "0.52.0" 1328 | source = "registry+https://github.com/rust-lang/crates.io-index" 1329 | checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" 1330 | 1331 | [[package]] 1332 | name = "windows_x86_64_gnu" 1333 | version = "0.48.5" 1334 | source = "registry+https://github.com/rust-lang/crates.io-index" 1335 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 1336 | 1337 | [[package]] 1338 | name = "windows_x86_64_gnu" 1339 | version = "0.52.0" 1340 | source = "registry+https://github.com/rust-lang/crates.io-index" 1341 | checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" 1342 | 1343 | [[package]] 1344 | name = "windows_x86_64_gnullvm" 1345 | version = "0.48.5" 1346 | source = "registry+https://github.com/rust-lang/crates.io-index" 1347 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 1348 | 1349 | [[package]] 1350 | name = "windows_x86_64_gnullvm" 1351 | version = "0.52.0" 1352 | source = "registry+https://github.com/rust-lang/crates.io-index" 1353 | checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" 1354 | 1355 | [[package]] 1356 | name = "windows_x86_64_msvc" 1357 | version = "0.48.5" 1358 | source = "registry+https://github.com/rust-lang/crates.io-index" 1359 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 1360 | 1361 | [[package]] 1362 | name = "windows_x86_64_msvc" 1363 | version = "0.52.0" 1364 | source = "registry+https://github.com/rust-lang/crates.io-index" 1365 | checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" 1366 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "gempost" 3 | version = "0.3.0" 4 | edition = "2021" 5 | description = "A simple static site generator for creating a blog on the Gemini protocol" 6 | authors = ["Lark "] 7 | homepage = "https://github.com/justlark/gempost" 8 | repository = "https://github.com/justlark/gempost" 9 | readme = "README.md" 10 | license = "MIT" 11 | keywords = ["gemini", "gemlog"] 12 | categories = ["command-line-utilities"] 13 | rust-version = "1.74.0" 14 | 15 | [dependencies] 16 | chrono = { version = "0.4.31", default-features = false, features = ["alloc"] } 17 | clap = { version = "4.4.13", features = ["derive"] } 18 | color-eyre = "0.6.2" 19 | eyre = "0.6.11" 20 | serde = { version = "1.0.195", features = ["derive"] } 21 | serde_yaml = "0.9.30" 22 | tera = "1.19.1" 23 | thiserror = "1.0.56" 24 | url = "2.5.0" 25 | uuid = { version = "1.7.0", features = ["v4"] } 26 | 27 | [lints.rust] 28 | unsafe_code = "forbid" 29 | missing_debug_implementations = "warn" 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright © 2024 Lark 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gempost 2 | 3 | gempost is a minimal static site generator for publishing a blog (gemlog) on 4 | the [Gemini protocol](https://geminiprotocol.net/). 5 | 6 | You store metadata about each gemlog post in a sidecar YAML file, and gempost 7 | generates a gemtext index page and an Atom feed. 8 | 9 | You can use a [Tera](https://keats.github.io/tera/) template to customize the 10 | format of the index page. You can also use a template to customize the format 11 | of the gemlog posts themselves, such as to add a copyright footer or a 12 | navigation header to each post. See [Examples](#Examples) for examples of both. 13 | 14 | The metadata in the sidecar YAML file allows you to generate an Atom feed with 15 | rich metadata, but most of this metadata is optional and not necessary to 16 | generate a working feed. 17 | 18 | ## Getting started 19 | 20 | ### Installing gempost 21 | 22 | To install gempost, you must first [install 23 | Rust](https://www.rust-lang.org/tools/install). Then, you can install gempost 24 | with Cargo. 25 | 26 | ```shell 27 | cargo install gempost 28 | ``` 29 | 30 | ### Creating a new gempost project 31 | 32 | You can initialize a new gempost project like this: 33 | 34 | ```shell 35 | gempost init ./capsule 36 | ``` 37 | 38 | This will create a directory `./capsule/` that looks like this: 39 | 40 | ``` 41 | capsule/ 42 | ├── gempost.yaml 43 | ├── posts/ 44 | │ ├── hello-world.gmi 45 | │ └── hello-world.yaml 46 | ├── static/ 47 | │ └── index.gmi 48 | └── templates/ 49 | ├── index.tera 50 | └── post.tera 51 | ``` 52 | 53 | This includes: 54 | 55 | - An example `gempost.yaml` config file to get you started. You'll need to edit 56 | this to set your capsule's title and URL. 57 | - Some basic templates you can use as-is or customize. 58 | - A "hello world" example post for your gemlog, with its accompanying sidecar 59 | metadata file. 60 | - A static `index.gmi` for your capsule root. 61 | 62 | Edit the `gempost.yaml` and you're ready to build your capsule! 63 | 64 | ### Building your capsule 65 | 66 | ```shell 67 | cd ./capsule 68 | gempost build 69 | ``` 70 | 71 | Your capsule will be generated in the `./public/` directory. You'll need a 72 | Gemini server like [Agate](https://github.com/mbrubeck/agate) to actually serve 73 | your capsule over the Gemini protocol. Check out [Awesome 74 | Gemini](https://github.com/kr1sp1n/awesome-gemini#servers) for a more complete 75 | list of Gemini servers. 76 | 77 | ### Creating a new post 78 | 79 | You can add a new post to your gemlog with `gempost new `. This creates a 80 | `.gmi` file in the `./posts/` directory with an accompanying `.yaml` metadata 81 | file. See [examples/metadata.yaml](./examples/metadata.yaml) for an example of 82 | all the different values you can set in the YAML metadata file. Only some are 83 | required. 84 | 85 | ### Adding static content 86 | 87 | You can add new static content to your capsule (anything that's not your 88 | gemlog) by putting it in the `./static/` directory. If a file in the static 89 | directory conflicts with one generated by gempost, the one if the static 90 | directory will win. 91 | 92 | ### Customizing templates 93 | 94 | You can customize the index page and post page templates in the `./templates/` 95 | directory from their defaults. They use the 96 | [Tera](https://keats.github.io/tera/) text templating language, which is 97 | similar to the popular Jinja templating language. See the 98 | [Templates](#templates) section below for a list of all the variables that are 99 | available inside these template. 100 | 101 | ## Examples 102 | 103 | Running `gempost init` will generate minimal index page and post page templates 104 | you can use to get started. These will probably be fine for most users. 105 | 106 | However, if you want to see more complex examples of what you can do with 107 | templates, the examples below make use of more of the post metadata to provide 108 | more rich output. You can use these templates as-is, or as a guide to write 109 | your own. 110 | 111 | - See [examples/index.tera](./examples/index.tera) for an example of an index 112 | page template. 113 | - See [examples/post.tera](./examples/post.tera) for an example of a post page 114 | template. 115 | 116 | Additionally, see [examples/metadata.yaml](./examples/metadata.yaml) for an 117 | example of a sidecar gemlog post metadata file showing all the possible fields. 118 | 119 | ## Templates 120 | 121 | The index page template has access to: 122 | - A `feed` variable which is a Feed object. 123 | 124 | The post page template has access to: 125 | - A `feed` variable which is a Feed object. 126 | - An `entry` variable which is an Entry object for the current post. 127 | 128 | All dates are in RFC 3339 format, which looks like this: 129 | 130 | ``` 131 | 2006-01-02T15:04:05Z07:00 132 | ``` 133 | 134 | ### Author object 135 | 136 | - `name` *(string)* The name of the author 137 | - `email` *(string, optional)* The author's email address 138 | - `uri` *(string, optional)* A URI describing the author 139 | 140 | ### Entry object 141 | 142 | - `url` *(string)* The URL of the post 143 | - `title` *(string)* The title of the post 144 | - `body` *(string)* The gemtext body of the post 145 | - `updated` *(string)* When the post was last updated 146 | - `summary` *(string, optional)* The summary of the post 147 | - `published` *(string, optional)* When the post was originally published 148 | - `author` *(Author object, optional)* The author of the post 149 | - `rights` *(string, optional)* The copyright and license information for the post 150 | - `lang` *(string, optional)* The RFC 5646 language code for the language the 151 | post is written in (e.g. `en`, `de`) 152 | - `categories` *(array of strings)* The list of categories the post belongs to 153 | 154 | ### Feed object 155 | 156 | - `capsule_url` *(string)* The URL of your capsule's homepage 157 | - `feed_url` *(string)* The URL of the Atom feed 158 | - `index_url` *(string)* The URL of the gemlog index page 159 | - `title` *(string)* The title of the feed 160 | - `updated` *(string)* When any post in the feed was last updated 161 | - `subtitle` *(string, optional)* The subtitle of the feed 162 | - `rights` *(string, optional)* The copyright and license information for the feed 163 | - `author` *(Author object, optional)* The primary author of the feed 164 | - `entries` *(array of Entry objects)* The list of posts in the feed, sorted 165 | reverse-chronologically by publish date or, if no publish date, last updated 166 | date 167 | 168 | ## Suggestions 169 | 170 | Here are some miscellaneous suggestions for working with gempost. 171 | 172 | You can check your gempost project directory into a VCS of your choice if you 173 | like; just make sure you configure it to ignore the `./public/` directory! 174 | 175 | If your Gemini server expects to find your capsule in a particular directory, 176 | you can change the location of the `./public/` directory from its default in 177 | the `gempost.yaml`. Note that file paths in the `gempost.yaml` do not support 178 | tilde expansion. 179 | 180 | Every post must have a unique ID to generate the Atom feed. Atom require that 181 | this be a globally unique URI that never ever changes. So, as an alternative to 182 | using your post URL, which might change, you can use a UUID URN: 183 | 184 | ``` 185 | urn:uuid:165b10e8-78c9-45ba-83ef-2f7bd5d89725 186 | ``` 187 | 188 | Running `gempost new` will automatically assign a UUID post ID. 189 | 190 | Each post must have a time last updated and, optionally, time originally 191 | published. To get the current time in RFC 3339 format—the format gempost 192 | expects—you can use this command on \*nix platforms: 193 | 194 | ```shell 195 | date --rfc-3339 seconds 196 | ``` 197 | 198 | ## Similar tools 199 | 200 | Check out these other awesome static site generators for gemlogs: 201 | 202 | - [gloggery](https://github.com/kconner/gloggery) 203 | - [gssg](https://git.sr.ht/~gsthnz/gssg) 204 | - [kiln](https://git.sr.ht/~adnano/kiln) 205 | -------------------------------------------------------------------------------- /examples/index.tera: -------------------------------------------------------------------------------- 1 | {# 2 | This is an example of a Tera template for your gemlog's index page. 3 | 4 | This example provides more rich output that makes use of more of the metadata 5 | provided in the sidecar YAML files, such as post summaries. 6 | #}# {{ feed.title }} 7 | 8 | {% if feed.subtitle -%} 9 | ## {{ feed.subtitle }} 10 | {%- endif %} 11 | 12 | {% for entry in feed.entries -%} 13 | => {{ entry.url }} {{ entry.published | default(value=entry.updated) | date(format="%d %b %Y") }} • {{ entry.title }} 14 | 15 | {% if entry.summary -%} 16 | {{ entry.summary }} 17 | 18 | {% endif -%} 19 | 20 | {%- if entry.categories -%} 21 | {% for category in entry.categories %}#{{ category }}{% if not loop.last %} {% endif %}{% endfor %} 22 | 23 | {% endif %} 24 | 25 | {%- endfor -%} 26 | 27 | ───── 28 | 29 | => {{ feed.feed_url }} Atom feed 30 | => {{ feed.capsule_url }} Home 31 | 32 | {% if feed.rights -%} 33 | {{ feed.rights }} 34 | {%- endif %} 35 | -------------------------------------------------------------------------------- /examples/metadata.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # This is an example of a YAML sidecar metadata file showing all possible 3 | # metadata values. 4 | # 5 | 6 | # A universally unique URI that will never change. We recommend using a UUID. (required) 7 | id: "urn:uuid:165b10e8-78c9-45ba-83ef-2f7bd5d89725" 8 | 9 | # The title of your post. (required) 10 | title: "Hello World" 11 | 12 | # When your post was last updated. (required) 13 | updated: "2024-01-11T09:41:00-05:00" 14 | 15 | # A brief summary of your post. (optional) 16 | summary: >- 17 | My first post on Gemini! 18 | 19 | # When your post was originally published. (optional) 20 | published: "2024-01-11T08:35:00-05:00" 21 | 22 | # The primary author of your post. (optional) 23 | author: 24 | name: "Jane Doe" # Required 25 | email: "jane@example.com" # Optional 26 | uri: "gemini://jane.example.com" # Optional 27 | 28 | # The license and copyright information for your post. (optional) 29 | rights: "CC BY-SA" 30 | 31 | # The language code for the language this post is written in. (optional) 32 | lang: "en-US" 33 | 34 | # The categories this post belongs to. (optional) 35 | categories: 36 | - "Programming" 37 | - "DIY" 38 | 39 | # Whether this post is a draft. Draft posts will not be published. (optional) 40 | draft: true 41 | -------------------------------------------------------------------------------- /examples/post.tera: -------------------------------------------------------------------------------- 1 | {# 2 | This is an example of a Tera template for each individual post. 3 | 4 | This example adds a header with the title from the sidecar YAML metadata 5 | file, some metadata at the top of the post body, and a footer with navigation 6 | links and the copyright/license information. 7 | #}# {{ entry.title }} 8 | 9 | {% if entry.published -%} 10 | * Originally Posted: {{ entry.published | date(format="%d %b %Y") }} 11 | {% endif -%} 12 | * Last Updated: {{ entry.updated | date(format="%d %b %Y") }} 13 | {% if entry.author -%} 14 | * Author: {{ entry.author.name }} 15 | {% endif -%} 16 | {% if entry.categories -%} 17 | * Categories: {{ entry.categories | join(sep=", ") }} 18 | {% endif %} 19 | {{ entry.body }} 20 | ───── 21 | 22 | => {{ feed.index_url }} Posts 23 | => {{ feed.capsule_url }} Home 24 | 25 | {% if entry.rights -%} 26 | {{ entry.rights }} 27 | {%- endif %} 28 | -------------------------------------------------------------------------------- /src/atom.xml.tera: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ feed.capsule_url | safe }} 4 | {{ feed.title }} 5 | {% if feed.subtitle -%} 6 | {{ feed.subtitle }} 7 | {% endif -%} 8 | {{ feed.updated }} 9 | {% if feed.rights -%} 10 | {{ feed.rights }} 11 | {% endif -%} 12 | {% if feed.author -%} 13 | 14 | {{ feed.author.name }} 15 | {% if feed.author.email %}{{ feed.author.email }}{% endif %} 16 | {% if feed.author.uri %}{{ feed.author.uri | safe }}{% endif %} 17 | 18 | {% endif -%} 19 | 20 | 21 | {%- for entry in feed.entries %} 22 | 23 | {{ entry.id }} 24 | {{ entry.title }} 25 | {% if entry.summary -%} 26 | {{ entry.summary }} 27 | {% endif -%} 28 | {% if entry.published -%} 29 | {{ entry.published }} 30 | {% endif -%} 31 | {{ entry.updated }} 32 | 33 | {% if entry.rights -%} 34 | {{ entry.rights }} 35 | {% endif -%} 36 | {% if entry.author -%} 37 | 38 | {{ entry.author.name }} 39 | {% if entry.author.email %}{{ entry.author.email }}{% endif %} 40 | {% if entry.author.uri %}{{ entry.author.uri | safe }}{% endif %} 41 | 42 | {%- endif -%} 43 | {% for category in entry.categories %} 44 | 45 | {%- endfor %} 46 | 47 | {%- endfor %} 48 | 49 | -------------------------------------------------------------------------------- /src/build.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::io; 3 | use std::path::{Path, PathBuf}; 4 | 5 | use eyre::{bail, WrapErr}; 6 | 7 | use crate::config::Config; 8 | use crate::feed::Feed; 9 | use crate::template::{EntryTemplateData, FeedTemplateData}; 10 | 11 | const FEED_TEMPLATE: &str = include_str!("atom.xml.tera"); 12 | 13 | fn url_to_filepath(base_path: &Path, url_path: &str) -> PathBuf { 14 | base_path.join(PathBuf::from_iter( 15 | url_path.split('/').filter(|segment| !segment.is_empty()), 16 | )) 17 | } 18 | 19 | // Recursively copy a directory. 20 | fn copy_dir(src: &Path, dest: &Path) -> eyre::Result<()> { 21 | fs::create_dir_all(dest).wrap_err("failed creating dest directory")?; 22 | 23 | let src_entries = fs::read_dir(src).wrap_err("failed reading directory contents")?; 24 | 25 | for src_entry_result in src_entries { 26 | let src_entry = src_entry_result.wrap_err("failed reading directory entry")?; 27 | 28 | let file_type = src_entry.file_type().wrap_err("failed reading file type")?; 29 | 30 | let src_path = src_entry.path(); 31 | let dest_path = dest.join(src_path.strip_prefix(src)?); 32 | 33 | if file_type.is_file() { 34 | // Truncate the dest file if it already exists. 35 | fs::copy(&src_path, &dest_path).wrap_err("failed copying regular file")?; 36 | } else if file_type.is_dir() { 37 | // Don't fail if the dest dir already exists. 38 | fs::create_dir_all(&dest_path).wrap_err("failed creating new directory in dest dir")?; 39 | 40 | // Recursively copy contents. 41 | copy_dir(&src_path, &dest_path)?; 42 | } else if file_type.is_symlink() { 43 | let link_dest = fs::read_link(&src_path).wrap_err("failed reading link dest")?; 44 | 45 | if !cfg!(target_family = "unix") { 46 | bail!( 47 | "Symlinks in the static directory are only supported on Unix-like platforms." 48 | ); 49 | } 50 | 51 | // Overwrite the original symlink if it exists. Do nothing if it does not. 52 | match fs::remove_file(&dest_path) { 53 | Err(err) if err.kind() != io::ErrorKind::NotFound => Err(err) 54 | .wrap_err("failed removing original symlink so we can create a new one")?, 55 | _ => {} 56 | } 57 | 58 | #[cfg(target_family = "unix")] 59 | std::os::unix::fs::symlink(link_dest, &dest_path) 60 | .wrap_err("failed creating symlink in dest dir")?; 61 | } else { 62 | bail!("There is a file in the static directory which is not a regular file, directory, or symbolic link."); 63 | } 64 | } 65 | 66 | Ok(()) 67 | } 68 | 69 | pub fn build_capsule(config: &Config) -> eyre::Result<()> { 70 | let warn_handler = |msg: &str| eprintln!("Warning: {}", msg); 71 | 72 | let feed = Feed::from_config(config, warn_handler).wrap_err("failed parsing config file")?; 73 | let feed_data = FeedTemplateData::from(feed.clone()); 74 | 75 | // Delete the public dir. We do this because static files might have been removed since the 76 | // last build, and posts might have been removed or converted to drafts. It's easier to just 77 | // start with a new empty directory. 78 | 79 | match fs::remove_dir_all(&config.public_dir) { 80 | // The public dir not existing is not an error. 81 | Err(err) if err.kind() != io::ErrorKind::NotFound => { 82 | Err(err).wrap_err("failed removing the public directory")? 83 | } 84 | _ => {} 85 | } 86 | 87 | fs::create_dir_all(&config.public_dir).wrap_err("failed creating the public directory")?; 88 | 89 | // Generate the index page. 90 | 91 | let index_page_path = url_to_filepath(&config.public_dir, &config.index_path); 92 | feed_data 93 | .render_index(&config.index_template_file, &index_page_path) 94 | .wrap_err("failed rendering index page")?; 95 | 96 | // Generate the Atom feed. 97 | 98 | let feed_path = url_to_filepath(&config.public_dir, &config.feed_path); 99 | feed_data 100 | .render_feed(FEED_TEMPLATE, &feed_path) 101 | .wrap_err("failed rendering Atom feed")?; 102 | 103 | // Generate the individual posts. 104 | 105 | for entry in feed.entries { 106 | let post_path = config.public_dir.join(&entry.path); 107 | 108 | EntryTemplateData::from(entry) 109 | .render(&feed_data, &config.post_template_file, &post_path) 110 | .wrap_err(format!( 111 | "failed rendering post: {}", 112 | post_path.to_string_lossy() 113 | ))?; 114 | } 115 | 116 | // Copy over static content. This clobbers any files generated in previous steps. 117 | 118 | copy_dir(&config.static_dir, &config.public_dir) 119 | .wrap_err("failed copying static content to the public directory")?; 120 | 121 | Ok(()) 122 | } 123 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use clap::{Args, Parser, Subcommand}; 4 | 5 | #[derive(Parser, Clone)] 6 | #[command(author, version, about)] 7 | pub struct Cli { 8 | #[command(subcommand)] 9 | pub command: Commands, 10 | } 11 | 12 | #[derive(Args, Clone)] 13 | pub struct Init { 14 | /// The directory to create the new project in 15 | /// 16 | /// This will not overwrite any files already in the directory. 17 | pub directory: Option, 18 | } 19 | 20 | #[derive(Args, Clone)] 21 | pub struct Build { 22 | /// The path of the gempost config file 23 | #[arg(short, long, value_name = "PATH", default_value = "./gempost.yaml")] 24 | pub config: PathBuf, 25 | } 26 | 27 | #[derive(Args, Clone)] 28 | pub struct New { 29 | /// The URL slug of the post to create 30 | pub slug: String, 31 | 32 | /// The title of the post 33 | #[arg(short, long)] 34 | pub title: Option, 35 | 36 | /// The path of the gempost config file 37 | #[arg(short, long, value_name = "PATH", default_value = "./gempost.yaml")] 38 | pub config: PathBuf, 39 | } 40 | 41 | #[derive(Subcommand, Clone)] 42 | pub enum Commands { 43 | /// Create a new gempost project 44 | /// 45 | /// This initializes the project with some basic templates and an example gemlog post. 46 | Init(Init), 47 | 48 | /// Build your capsule 49 | /// 50 | /// This builds the gempost project in your current working directory. 51 | Build(Build), 52 | 53 | /// Create a new post 54 | /// 55 | /// This generates an empty gemtext file and YAML metadata file, automatically assigning a post ID. 56 | New(New), 57 | } 58 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | use std::path::PathBuf; 3 | use std::{fs::File, path::Path}; 4 | 5 | use eyre::{bail, WrapErr}; 6 | use serde::Deserialize; 7 | use url::Url; 8 | 9 | use crate::error::Error; 10 | 11 | #[derive(Debug, PartialEq, Eq, Deserialize)] 12 | pub struct RawAuthorConfig { 13 | pub name: String, 14 | pub email: Option, 15 | pub uri: Option, 16 | } 17 | 18 | #[derive(Debug, PartialEq, Eq, Deserialize)] 19 | struct RawConfig { 20 | #[serde(default = "defaults::public_dir")] 21 | public_dir: PathBuf, 22 | #[serde(default = "defaults::static_dir")] 23 | static_dir: PathBuf, 24 | #[serde(default = "defaults::posts_dir")] 25 | posts_dir: PathBuf, 26 | #[serde(default = "defaults::index_template_file")] 27 | index_template_file: PathBuf, 28 | #[serde(default = "defaults::post_template_file")] 29 | post_template_file: PathBuf, 30 | #[serde(default = "defaults::post_path")] 31 | post_path: String, 32 | #[serde(default = "defaults::index_path")] 33 | index_path: String, 34 | #[serde(default = "defaults::feed_path")] 35 | feed_path: String, 36 | title: String, 37 | url: String, 38 | subtitle: Option, 39 | rights: Option, 40 | author: Option, 41 | } 42 | 43 | mod defaults { 44 | use std::path::PathBuf; 45 | 46 | pub fn public_dir() -> PathBuf { 47 | PathBuf::from("./public/") 48 | } 49 | 50 | pub fn static_dir() -> PathBuf { 51 | PathBuf::from("./static/") 52 | } 53 | 54 | pub fn posts_dir() -> PathBuf { 55 | PathBuf::from("./posts/") 56 | } 57 | 58 | pub fn index_template_file() -> PathBuf { 59 | PathBuf::from("./templates/index.tera") 60 | } 61 | 62 | pub fn post_template_file() -> PathBuf { 63 | PathBuf::from("./templates/post.tera") 64 | } 65 | 66 | pub fn post_path() -> String { 67 | String::from("/posts/{{ slug }}.gmi") 68 | } 69 | 70 | pub fn index_path() -> String { 71 | String::from("/posts/index.gmi") 72 | } 73 | 74 | pub fn feed_path() -> String { 75 | String::from("/posts/atom.xml") 76 | } 77 | } 78 | 79 | impl RawConfig { 80 | fn read(path: &Path) -> eyre::Result { 81 | let config_file = match File::open(path) { 82 | Ok(file) => file, 83 | Err(err) if err.kind() == io::ErrorKind::NotFound => { 84 | bail!(Error::NonexistentConfigFile { 85 | path: path.to_owned(), 86 | }) 87 | } 88 | Err(err) => bail!(err), 89 | }; 90 | 91 | match serde_yaml::from_reader(config_file) { 92 | Ok(config) => Ok(config), 93 | Err(err) => bail!(Error::InvalidConfigFile { 94 | path: path.to_owned(), 95 | reason: err.to_string(), 96 | }), 97 | } 98 | } 99 | } 100 | 101 | #[derive(Debug, Clone, PartialEq, Eq)] 102 | pub struct AuthorConfig { 103 | pub name: String, 104 | pub email: Option, 105 | pub uri: Option, 106 | } 107 | 108 | impl From for AuthorConfig { 109 | fn from(raw: RawAuthorConfig) -> Self { 110 | Self { 111 | name: raw.name, 112 | email: raw.email, 113 | uri: raw.uri, 114 | } 115 | } 116 | } 117 | 118 | #[derive(Debug)] 119 | pub struct Config { 120 | pub public_dir: PathBuf, 121 | pub static_dir: PathBuf, 122 | pub posts_dir: PathBuf, 123 | pub index_template_file: PathBuf, 124 | pub post_template_file: PathBuf, 125 | pub post_path: String, 126 | pub index_path: String, 127 | pub feed_path: String, 128 | pub title: String, 129 | pub url: Url, 130 | pub subtitle: Option, 131 | pub rights: Option, 132 | pub author: Option, 133 | } 134 | 135 | impl Config { 136 | pub fn read(path: &Path) -> eyre::Result { 137 | let raw = RawConfig::read(path).wrap_err("failed reading config file")?; 138 | 139 | Ok(Self { 140 | public_dir: raw.public_dir, 141 | static_dir: raw.static_dir, 142 | posts_dir: raw.posts_dir, 143 | index_template_file: raw.index_template_file, 144 | post_template_file: raw.post_template_file, 145 | post_path: raw.post_path, 146 | index_path: raw.index_path, 147 | feed_path: raw.feed_path, 148 | title: raw.title, 149 | url: Url::parse(&raw.url).map_err(|_| Error::InvalidCapsuleUrl { url: raw.url })?, 150 | subtitle: raw.subtitle, 151 | rights: raw.rights, 152 | author: raw.author.map(Into::into), 153 | }) 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/entry.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | use std::fs; 3 | use std::path::Path; 4 | use std::{fs::File, path::PathBuf}; 5 | 6 | use chrono::{DateTime, FixedOffset}; 7 | use eyre::{bail, eyre, WrapErr}; 8 | use serde::Deserialize; 9 | use url::Url; 10 | 11 | use crate::error::Error; 12 | 13 | const POST_FILE_EXT: &str = "gmi"; 14 | const METADATA_FILE_EXT: &str = "yaml"; 15 | 16 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] 17 | struct RawAuthorMetadata { 18 | name: String, 19 | email: Option, 20 | uri: Option, 21 | } 22 | 23 | #[derive(Debug, PartialEq, Eq, Deserialize)] 24 | struct RawEntryMetadata { 25 | id: String, 26 | title: String, 27 | updated: String, 28 | summary: Option, 29 | published: Option, 30 | author: Option, 31 | rights: Option, 32 | lang: Option, 33 | categories: Option>, 34 | draft: Option, 35 | } 36 | 37 | // This example comes from the Go standard library. 38 | const EXAMPLE_RFC3339: &str = "2006-01-02T15:04:05Z07:00"; 39 | 40 | impl RawEntryMetadata { 41 | pub fn read(path: &Path) -> eyre::Result { 42 | let metadata_file = File::open(path)?; 43 | 44 | let metadata: Self = match serde_yaml::from_reader(metadata_file) { 45 | Ok(config) => config, 46 | Err(err) => bail!(Error::InvalidMetadataFile { 47 | path: path.to_owned(), 48 | reason: err.to_string(), 49 | }), 50 | }; 51 | 52 | Ok(metadata) 53 | } 54 | } 55 | 56 | #[derive(Debug, Clone, PartialEq, Eq)] 57 | pub struct AuthorMetadata { 58 | pub name: String, 59 | pub email: Option, 60 | pub uri: Option, 61 | } 62 | 63 | impl From for AuthorMetadata { 64 | fn from(raw: RawAuthorMetadata) -> Self { 65 | Self { 66 | name: raw.name, 67 | email: raw.email, 68 | uri: raw.uri, 69 | } 70 | } 71 | } 72 | 73 | #[derive(Debug, Clone, PartialEq, Eq)] 74 | pub struct EntryMetadata { 75 | pub id: String, 76 | pub title: String, 77 | pub updated: DateTime, 78 | pub summary: Option, 79 | pub published: Option>, 80 | pub author: Option, 81 | pub rights: Option, 82 | pub lang: Option, 83 | pub categories: Vec, 84 | pub draft: bool, 85 | } 86 | 87 | impl EntryMetadata { 88 | pub fn read(path: &Path) -> eyre::Result { 89 | let raw = RawEntryMetadata::read(path).wrap_err(format!( 90 | "failed reading metadata file: {}", 91 | path.to_string_lossy() 92 | ))?; 93 | 94 | Ok(Self { 95 | id: raw.id, 96 | title: raw.title, 97 | updated: DateTime::parse_from_rfc3339(&raw.updated).map_err(|_| { 98 | Error::InvalidMetadataFile { 99 | path: path.to_owned(), 100 | reason: format!( 101 | "The post `updated` time must be in RFC 3339 format (e.g. {EXAMPLE_RFC3339})." 102 | ), 103 | } 104 | })?, 105 | summary: raw.summary, 106 | published: raw 107 | .published 108 | .as_ref() 109 | .map(|published| DateTime::parse_from_rfc3339(published).map_err(|_| { 110 | Error::InvalidMetadataFile { 111 | path: path.to_owned(), 112 | reason: format!( 113 | "The post `published` time must be in RFC 3339 format (e.g. {EXAMPLE_RFC3339})." 114 | ), 115 | } 116 | })) 117 | .transpose()?, 118 | author: raw.author.map(Into::into), 119 | rights: raw.rights, 120 | lang: raw.lang, 121 | categories: raw.categories.unwrap_or_default(), 122 | // If the `draft` property is missing, we assume it's not a draft. 123 | draft: raw.draft.unwrap_or(false), 124 | }) 125 | } 126 | } 127 | 128 | #[derive(Debug, Clone, PartialEq, Eq)] 129 | pub struct Entry { 130 | pub metadata: EntryMetadata, 131 | pub body: String, 132 | pub url: Url, 133 | pub path: PathBuf, 134 | } 135 | 136 | pub struct PostLocation { 137 | pub url: Url, 138 | pub path: PathBuf, 139 | } 140 | 141 | pub struct PostLocationParams<'a> { 142 | pub metadata: &'a EntryMetadata, 143 | pub slug: &'a str, 144 | } 145 | 146 | // This returns `None` when either: 147 | // - There is no filename. 148 | // - The path is empty or the root path. 149 | fn change_file_ext(path: &Path, new_ext: &str) -> Option { 150 | let original_filename = path.file_stem()?; 151 | let new_filename = format!("{}.{}", original_filename.to_string_lossy(), new_ext); 152 | Some(path.parent()?.join(new_filename)) 153 | } 154 | 155 | struct PostPathPair { 156 | gemtext: PathBuf, 157 | metadata: PathBuf, 158 | } 159 | 160 | // Remove paths from each set that do not have an accompanying path in the other set. Emit warnings 161 | // when this happens. 162 | fn check_mismatched_post_files( 163 | post_paths: HashSet, 164 | metadata_paths: &HashSet, 165 | warn_handler: impl Fn(&str), 166 | ) -> eyre::Result> { 167 | // Warn about metadata files that don't have an accompanying gemtext file. 168 | for metadata_path in metadata_paths.iter() { 169 | let maybe_post_path = match change_file_ext(metadata_path, POST_FILE_EXT) { 170 | Some(path) => path, 171 | None => bail!("This file has no filename, even though we've already checked for one. This is a bug."), 172 | }; 173 | 174 | if !post_paths.contains(&maybe_post_path) { 175 | warn_handler(&format!( 176 | "This YAML metadata file does not have an accompanying gemtext file: {}", 177 | metadata_path.to_string_lossy() 178 | )); 179 | } 180 | } 181 | 182 | let mut pairs = Vec::new(); 183 | 184 | // Filter out gemtext files that don't have an accompanying metadata file. 185 | for post_path in post_paths.into_iter() { 186 | let maybe_metadata_path = match change_file_ext(&post_path, METADATA_FILE_EXT) { 187 | Some(path) => path, 188 | None => bail!("This file has no filename, even though we've already checked for one. This is a bug."), 189 | }; 190 | 191 | if metadata_paths.contains(&maybe_metadata_path) { 192 | pairs.push(PostPathPair { 193 | gemtext: post_path, 194 | metadata: maybe_metadata_path, 195 | }); 196 | } else { 197 | warn_handler(&format!( 198 | "This gemtext file does not have an accompanying YAML metadata file: {}", 199 | post_path.to_string_lossy() 200 | )); 201 | } 202 | } 203 | 204 | Ok(pairs) 205 | } 206 | 207 | impl Entry { 208 | fn from_post_paths( 209 | path_pairs: &Vec, 210 | locator: impl Fn(PostLocationParams) -> eyre::Result, 211 | ) -> eyre::Result> { 212 | let mut entries = Vec::new(); 213 | 214 | // By this point, we've already removed post paths from the set that do not have an 215 | // accompanying metadata file. 216 | for PostPathPair { 217 | gemtext: gemtext_path, 218 | metadata: metadata_path, 219 | } in path_pairs 220 | { 221 | let post_body = String::from_utf8( 222 | fs::read(gemtext_path).wrap_err("failed reading gemtext post body")?, 223 | ) 224 | .wrap_err("gemtext post body is not valid UTF-8")?; 225 | 226 | let post_metadata = EntryMetadata::read(metadata_path)?; 227 | 228 | // We do not publish draft posts. 229 | if post_metadata.draft { 230 | continue; 231 | } 232 | 233 | let post_slug = gemtext_path 234 | .file_stem() 235 | .ok_or(eyre!( 236 | "This filename does not have a file stem. This is a bug.\n{}", 237 | gemtext_path.to_string_lossy() 238 | ))? 239 | .to_string_lossy(); 240 | 241 | let post_location = locator(PostLocationParams { 242 | metadata: &post_metadata, 243 | slug: &post_slug, 244 | })?; 245 | 246 | entries.push(Entry { 247 | metadata: post_metadata, 248 | body: post_body, 249 | url: post_location.url, 250 | path: post_location.path, 251 | }); 252 | } 253 | 254 | Ok(entries) 255 | } 256 | 257 | pub fn from_posts( 258 | posts_dir: &Path, 259 | locator: impl Fn(PostLocationParams) -> eyre::Result, 260 | warn_handler: impl Fn(&str), 261 | ) -> eyre::Result> { 262 | let file_entries = fs::read_dir(posts_dir).wrap_err("failed reading posts directory")?; 263 | 264 | let mut post_paths = HashSet::new(); 265 | let mut metadata_paths = HashSet::new(); 266 | 267 | let warn_unexpected_file_ext = |path: &Path| { 268 | warn_handler(&format!( 269 | "This is not a .gmi or .yaml file: {}", 270 | path.as_os_str().to_string_lossy() 271 | )); 272 | }; 273 | 274 | for entry_result in file_entries { 275 | let entry_path = entry_result 276 | .wrap_err("failed reading posts directory")? 277 | .path(); 278 | 279 | let path_ext = match entry_path.extension() { 280 | Some(extension) => extension, 281 | None => { 282 | warn_unexpected_file_ext(&entry_path); 283 | continue; 284 | } 285 | }; 286 | 287 | match path_ext.to_string_lossy().as_ref() { 288 | POST_FILE_EXT => post_paths.insert(entry_path), 289 | METADATA_FILE_EXT => metadata_paths.insert(entry_path), 290 | _ => { 291 | warn_unexpected_file_ext(&entry_path); 292 | continue; 293 | } 294 | }; 295 | } 296 | 297 | let path_pairs = check_mismatched_post_files(post_paths, &metadata_paths, warn_handler) 298 | .wrap_err("failed checking for mismatched post files")?; 299 | 300 | Self::from_post_paths(&path_pairs, locator) 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use thiserror::Error; 4 | 5 | /// A error type for user-facing errors. 6 | /// 7 | /// This type represents errors expected in common usage of the program that should trigger a 8 | /// readable error message instead of a stack trace. 9 | #[derive(Debug, Error, PartialEq, Eq)] 10 | pub enum Error { 11 | #[error("There is no config file at `{path}`.")] 12 | NonexistentConfigFile { path: PathBuf }, 13 | 14 | #[error("There is a problem with the config file at `{path}`.\n\n{reason}")] 15 | InvalidConfigFile { path: PathBuf, reason: String }, 16 | 17 | #[error("There is a problem with the post metadata file at `{path}`.\n\n{reason}")] 18 | InvalidMetadataFile { path: PathBuf, reason: String }, 19 | 20 | #[error("You cannot initialize this directory as a gempost project because this file already exists: {path}")] 21 | ExampleFileAlreadyExists { path: PathBuf }, 22 | 23 | #[error("There is already a post with this slug: {slug}")] 24 | PostAlreadyExists { slug: String }, 25 | 26 | #[error("There was an issue generating the index page.\n\n{reason}")] 27 | InvalidIndexPageTemplate { reason: String }, 28 | 29 | #[error("There was an issue generating a post page.\n\n{reason}")] 30 | InvalidPostPageTemplate { path: PathBuf, reason: String }, 31 | 32 | #[error("The post path template in your gempost.yaml is invalid.\n\nTemplate: `{template}`\n\n{reason}")] 33 | InvalidPostPath { template: String, reason: String }, 34 | 35 | #[error("The capsule URL you provided is not a valid URL: {url}")] 36 | InvalidCapsuleUrl { url: String }, 37 | } 38 | -------------------------------------------------------------------------------- /src/examples/gempost.yaml: -------------------------------------------------------------------------------- 1 | # The directory to generate the capsule at (required). 2 | public_dir: "./public/" 3 | 4 | # The directory for static assets that are copied to the generated capsule 5 | # verbatim (required). 6 | static_dir: "./static/" 7 | 8 | # The directory for gemtext gemlog posts and their sidecar metadata files 9 | # (required). 10 | posts_dir: "./posts/" 11 | 12 | # The path of the Tera template used to generate the gemlog index page 13 | # (required). 14 | index_template_file: "./templates/index.tera" 15 | 16 | # The path of the Tera template used to generate each gemlog post page 17 | # (required). 18 | post_template_file: "./templates/post.tera" 19 | 20 | # A Tera template which specifies the URL path for posts (required). 21 | # 22 | # This template has access to the following variables: 23 | # - `year`: The four-digit year of publication, if a publication date was provided 24 | # - `month`: The two-digit month of publication, if a publication date was provided 25 | # - `day`: The two-digit day of publication, if a publication date was provided 26 | # - `slug`: The name of the gemtext source file, sans file extension 27 | # 28 | # Docs for the Tera templating language: 29 | # https://keats.github.io/tera/docs/#templates 30 | post_path: "/posts/{{ slug }}.gmi" 31 | 32 | # The URL path of the index page for your gemlog (required). 33 | index_path: "/posts/index.gmi" 34 | 35 | # The URL path to serve your capsule's Atom feed at (required). 36 | feed_path: "/posts/atom.xml" 37 | 38 | # The title of your gemlog (required). 39 | #title: "My Gemlog" 40 | 41 | # The gemini:// URL of your capsule's homepage (required). 42 | #url: "gemini://example.com" 43 | 44 | # A subtitle for your gemlog (optional). 45 | #subtitle: "My personal gemlog about cool stuff" 46 | 47 | # The copyright and licensing information for your gemlog (optional). 48 | #rights: "CC BY-SA" 49 | 50 | # The primary author of your gemlog (optional). 51 | #author: 52 | # name: "Jane Doe" # Required 53 | # email: "jane@example.com" # Optional 54 | # uri: "gemini://jane.example.com" # Optional 55 | -------------------------------------------------------------------------------- /src/examples/index.gmi: -------------------------------------------------------------------------------- 1 | # My Capsule 2 | 3 | This is an example homepage for your capsule. 4 | 5 | => gemini://example.com/posts/ Gemlog 6 | -------------------------------------------------------------------------------- /src/examples/index.tera: -------------------------------------------------------------------------------- 1 | {# 2 | This is an example of a Tera template for your gemlog's index page. 3 | 4 | This example formats your index page in the standard gemsub format that 5 | Gemini clients can subscribe to. Note that gempost already generates an Atom 6 | feed that clients should be able to subscribe to. 7 | 8 | Here is the documentation for the gemsub format: 9 | https://geminiprotocol.net/docs/companion/subscription.gmi 10 | #}# {{ feed.title }} 11 | 12 | {% if feed.subtitle -%} 13 | ## {{ feed.subtitle }} 14 | 15 | {% endif -%} 16 | 17 | {% for entry in feed.entries -%} 18 | => {{ entry.url }} {{ entry.updated | date(format="%Y-%m-%d") }} - {{ entry.title }} 19 | {% endfor %} 20 | => {{ feed.feed_url }} Atom feed 21 | -------------------------------------------------------------------------------- /src/examples/metadata.yaml.tera: -------------------------------------------------------------------------------- 1 | # 2 | # This is an example of a YAML sidecar metadata file. 3 | # 4 | 5 | # A universally unique URI that will never change. We recommend using a UUID. 6 | id: "{{ id }}" 7 | 8 | # The title of your post. 9 | title: "Hello World" 10 | 11 | # When your post was originally published. 12 | published: "{{ timestamp }}" 13 | 14 | # When your post was last updated. 15 | updated: "{{ timestamp }}" 16 | -------------------------------------------------------------------------------- /src/examples/post.gmi: -------------------------------------------------------------------------------- 1 | Hello, world! 2 | 3 | This is an example gemlog post. 4 | -------------------------------------------------------------------------------- /src/examples/post.tera: -------------------------------------------------------------------------------- 1 | {# 2 | This is an example of a Tera template for each individual post. 3 | 4 | This is a minimal example that just adds a header with the title from the 5 | sidecar YAML metadata file. 6 | #}# {{ entry.title }} 7 | 8 | {{ entry.body }} 9 | -------------------------------------------------------------------------------- /src/feed.rs: -------------------------------------------------------------------------------- 1 | use std::cmp; 2 | use std::path::PathBuf; 3 | 4 | use chrono::{DateTime, FixedOffset, Local}; 5 | use eyre::bail; 6 | use url::Url; 7 | 8 | use crate::config::{AuthorConfig, Config}; 9 | use crate::entry::{Entry, PostLocation, PostLocationParams}; 10 | use crate::template::{PostPathParams, PostPathTemplateData}; 11 | 12 | #[derive(Debug, Clone, PartialEq, Eq)] 13 | pub struct FeedAuthor { 14 | pub name: String, 15 | pub email: Option, 16 | pub uri: Option, 17 | } 18 | 19 | impl From for FeedAuthor { 20 | fn from(value: AuthorConfig) -> Self { 21 | Self { 22 | name: value.name, 23 | email: value.email, 24 | uri: value.uri, 25 | } 26 | } 27 | } 28 | 29 | #[derive(Debug, Clone, PartialEq, Eq)] 30 | pub struct Feed { 31 | pub capsule_url: Url, 32 | pub feed_url: Url, 33 | pub index_url: Url, 34 | pub title: String, 35 | pub updated: DateTime, 36 | pub subtitle: Option, 37 | pub rights: Option, 38 | pub author: Option, 39 | pub entries: Vec, 40 | } 41 | 42 | impl Feed { 43 | pub fn from_config(config: &Config, warn_handler: impl Fn(&str)) -> eyre::Result { 44 | let locator = |params: PostLocationParams| -> eyre::Result { 45 | let mut post_url = config.url.clone(); 46 | 47 | let path_params = PostPathTemplateData::from(PostPathParams { 48 | slug: params.slug.to_owned(), 49 | published: params.metadata.published, 50 | }); 51 | 52 | let post_path = path_params.render(&config.post_path)?; 53 | 54 | let mut url_segments = match post_url.path_segments_mut() { 55 | Ok(segments) => segments, 56 | Err(()) => bail!("capsule URL cannot be a base URL"), 57 | }; 58 | 59 | let mut post_filepath = PathBuf::new(); 60 | 61 | for segment in post_path.split('/') { 62 | url_segments.push(segment); 63 | post_filepath.push(segment); 64 | } 65 | 66 | drop(url_segments); 67 | 68 | Ok(PostLocation { 69 | url: post_url, 70 | path: post_filepath, 71 | }) 72 | }; 73 | 74 | let mut entries = Entry::from_posts(&config.posts_dir, locator, warn_handler)?; 75 | 76 | // Sort entries in reverse-chronological order by publish time or, if there is no publish 77 | // time by last updated time. 78 | entries.sort_by_key(|entry| { 79 | cmp::Reverse(entry.metadata.published.unwrap_or(entry.metadata.updated)) 80 | }); 81 | 82 | // Get the time the most recently updated post was updated. 83 | let last_updated = entries 84 | .iter() 85 | .max_by_key(|entry| entry.metadata.updated) 86 | .map(|entry| entry.metadata.updated) 87 | .unwrap_or_else(|| Local::now().fixed_offset()); 88 | 89 | let mut feed_url = config.url.clone(); 90 | feed_url.set_path(&config.feed_path); 91 | 92 | let mut index_url = config.url.clone(); 93 | index_url.set_path(&config.index_path); 94 | 95 | Ok(Feed { 96 | capsule_url: config.url.clone(), 97 | feed_url, 98 | index_url, 99 | title: config.title.clone(), 100 | updated: last_updated, 101 | subtitle: config.subtitle.clone(), 102 | rights: config.rights.clone(), 103 | author: config.author.as_ref().cloned().map(Into::into), 104 | entries, 105 | }) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/init.rs: -------------------------------------------------------------------------------- 1 | use std::fs::{self, OpenOptions}; 2 | use std::io::{self, Write}; 3 | use std::path::Path; 4 | 5 | use chrono::{Local, SecondsFormat}; 6 | use eyre::{bail, WrapErr}; 7 | use tera::{Context, Tera}; 8 | use uuid::Uuid; 9 | 10 | use crate::error::Error; 11 | 12 | // We need to use conditional compilation here because `include_str` interprets the path in a 13 | // platform-specific way at compile time. 14 | // 15 | // https://doc.rust-lang.org/std/macro.include_str.html 16 | 17 | #[cfg(windows)] 18 | const CONFIG_FILE: &str = include_str!(r"examples\gempost.yaml"); 19 | 20 | #[cfg(not(windows))] 21 | const CONFIG_FILE: &str = include_str!(r"examples/gempost.yaml"); 22 | 23 | #[cfg(windows)] 24 | const CAPSULE_INDEX_FILE: &str = include_str!(r"examples\index.gmi"); 25 | 26 | #[cfg(not(windows))] 27 | const CAPSULE_INDEX_FILE: &str = include_str!(r"examples/index.gmi"); 28 | 29 | #[cfg(windows)] 30 | const INDEX_TEMPLATE_FILE: &str = include_str!(r"examples\index.tera"); 31 | 32 | #[cfg(not(windows))] 33 | const INDEX_TEMPLATE_FILE: &str = include_str!(r"examples/index.tera"); 34 | 35 | #[cfg(windows)] 36 | const POST_TEMPLATE_FILE: &str = include_str!(r"examples\post.tera"); 37 | 38 | #[cfg(not(windows))] 39 | const POST_TEMPLATE_FILE: &str = include_str!(r"examples/post.tera"); 40 | 41 | #[cfg(windows)] 42 | const GEMLOG_POST_FILE: &str = include_str!(r"examples\post.gmi"); 43 | 44 | #[cfg(not(windows))] 45 | const GEMLOG_POST_FILE: &str = include_str!(r"examples/post.gmi"); 46 | 47 | #[cfg(windows)] 48 | const POST_METADATA_FILE: &str = include_str!(r"examples\metadata.yaml.tera"); 49 | 50 | #[cfg(not(windows))] 51 | const POST_METADATA_FILE: &str = include_str!(r"examples/metadata.yaml.tera"); 52 | 53 | fn put_file(file: &Path, contents: &str) -> eyre::Result<()> { 54 | match file.parent() { 55 | None => { 56 | bail!("failed creating file's parent directory because it has no parent path (this is a bug)") 57 | } 58 | Some(parent) => { 59 | fs::create_dir_all(parent).wrap_err("failed creating file's parent directory")? 60 | } 61 | }; 62 | 63 | let mut example_file = match OpenOptions::new().write(true).create_new(true).open(file) { 64 | Ok(file) => file, 65 | Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { 66 | bail!(Error::ExampleFileAlreadyExists { 67 | path: file.to_owned() 68 | }); 69 | } 70 | Err(err) => Err(err).wrap_err("failed creating example file")?, 71 | }; 72 | 73 | example_file 74 | .write_all(contents.as_bytes()) 75 | .wrap_err("failed writing contents to example file")?; 76 | 77 | Ok(()) 78 | } 79 | 80 | fn generate_example_metadata_file(template: &str) -> eyre::Result { 81 | let mut tera = Tera::default(); 82 | 83 | tera.add_raw_template("metadata", template) 84 | .wrap_err("Example metadata file template is invalid. This is a bug.")?; 85 | 86 | let mut context = Context::new(); 87 | context.insert("id", &format!("urn:uuid:{}", Uuid::new_v4())); 88 | context.insert( 89 | "timestamp", 90 | &Local::now().to_rfc3339_opts(SecondsFormat::Secs, false), 91 | ); 92 | 93 | tera.render("metadata", &context) 94 | .wrap_err("Failed to render example metadata file template. This is a bug.") 95 | } 96 | 97 | pub fn init_project(dir: &Path) -> eyre::Result<()> { 98 | put_file(&dir.join("gempost.yaml"), CONFIG_FILE)?; 99 | put_file(&dir.join("static").join("index.gmi"), CAPSULE_INDEX_FILE)?; 100 | put_file( 101 | &dir.join("templates").join("index.tera"), 102 | INDEX_TEMPLATE_FILE, 103 | )?; 104 | put_file(&dir.join("templates").join("post.tera"), POST_TEMPLATE_FILE)?; 105 | put_file(&dir.join("posts").join("hello-world.gmi"), GEMLOG_POST_FILE)?; 106 | put_file( 107 | &dir.join("posts").join("hello-world.yaml"), 108 | &generate_example_metadata_file(POST_METADATA_FILE)?, 109 | )?; 110 | 111 | Ok(()) 112 | } 113 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod build; 2 | mod cli; 3 | mod config; 4 | mod entry; 5 | mod error; 6 | mod feed; 7 | mod init; 8 | mod new; 9 | mod template; 10 | 11 | use std::path::Path; 12 | use std::process::ExitCode; 13 | 14 | use clap::Parser; 15 | use eyre::WrapErr; 16 | use new::create_new_post; 17 | 18 | use crate::build::build_capsule; 19 | use crate::cli::Cli; 20 | use crate::config::Config; 21 | use crate::error::Error; 22 | use crate::init::init_project; 23 | 24 | fn run() -> eyre::Result<()> { 25 | let args = Cli::parse(); 26 | 27 | match args.command { 28 | cli::Commands::Init(init) => { 29 | init_project(init.directory.as_deref().unwrap_or(Path::new("."))) 30 | .wrap_err("failed initializing the project")?; 31 | 32 | println!("Remember to edit the `gempost.yaml` to set your capsule's title and URL!") 33 | } 34 | cli::Commands::Build(build) => { 35 | let config = 36 | Config::read(&build.config).wrap_err("failed reading the gempost config file")?; 37 | 38 | build_capsule(&config).wrap_err("failed building the capsule")?; 39 | } 40 | cli::Commands::New(new) => { 41 | let config = 42 | Config::read(&new.config).wrap_err("failed reading the gempost config file")?; 43 | 44 | create_new_post(&config.posts_dir, &new.slug, new.title.as_deref()) 45 | .wrap_err("failed creating new gemlog post")?; 46 | } 47 | } 48 | 49 | Ok(()) 50 | } 51 | 52 | fn main() -> eyre::Result { 53 | color_eyre::install()?; 54 | 55 | if let Err(err) = run() { 56 | // User-facing errors should not show a stack trace. 57 | if let Some(user_err) = err.downcast_ref::() { 58 | eprintln!("{}", user_err); 59 | return Ok(ExitCode::FAILURE); 60 | } 61 | 62 | return Err(err); 63 | } 64 | 65 | Ok(ExitCode::SUCCESS) 66 | } 67 | -------------------------------------------------------------------------------- /src/metadata.yaml.tera: -------------------------------------------------------------------------------- 1 | id: "{{ id }}" 2 | title: "{{ title }}" 3 | published: "{{ timestamp }}" 4 | updated: "{{ timestamp }}" 5 | -------------------------------------------------------------------------------- /src/new.rs: -------------------------------------------------------------------------------- 1 | use std::fs::OpenOptions; 2 | use std::io::{self, Write}; 3 | use std::path::Path; 4 | 5 | use chrono::{Local, SecondsFormat}; 6 | use eyre::{bail, WrapErr}; 7 | use tera::{Context, Tera}; 8 | use uuid::Uuid; 9 | 10 | use crate::error::Error; 11 | 12 | const METADATA_TEMPLATE: &str = include_str!("metadata.yaml.tera"); 13 | 14 | fn generate_metadata_file(template: &str, title: Option<&str>) -> eyre::Result { 15 | let mut tera = Tera::default(); 16 | 17 | tera.add_raw_template("metadata", template) 18 | .wrap_err("New metadata file template is invalid. This is a bug.")?; 19 | 20 | let mut context = Context::new(); 21 | context.insert("id", &format!("urn:uuid:{}", Uuid::new_v4())); 22 | context.insert("title", title.unwrap_or_default()); 23 | context.insert( 24 | "timestamp", 25 | &Local::now().to_rfc3339_opts(SecondsFormat::Secs, false), 26 | ); 27 | 28 | tera.render("metadata", &context) 29 | .wrap_err("Failed to render new metadata file template. This is a bug.") 30 | } 31 | 32 | pub fn create_new_post(posts_dir: &Path, slug: &str, title: Option<&str>) -> eyre::Result<()> { 33 | let gemtext_path = posts_dir.join(format!("{slug}.gmi")); 34 | let metadata_path = posts_dir.join(format!("{slug}.yaml")); 35 | 36 | // Generate an empty gemtext file. 37 | 38 | match OpenOptions::new() 39 | .write(true) 40 | .create_new(true) 41 | .open(gemtext_path) 42 | { 43 | Ok(file) => file, 44 | Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { 45 | bail!(Error::PostAlreadyExists { 46 | slug: slug.to_owned() 47 | }); 48 | } 49 | Err(err) => Err(err).wrap_err("failed creating new post gemtext file")?, 50 | }; 51 | 52 | // Generate a metadata YAML file. 53 | 54 | let mut metadata_file = match OpenOptions::new() 55 | .write(true) 56 | .create_new(true) 57 | .open(metadata_path) 58 | { 59 | Ok(file) => file, 60 | Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { 61 | bail!(Error::PostAlreadyExists { 62 | slug: slug.to_owned() 63 | }); 64 | } 65 | Err(err) => Err(err).wrap_err("failed creating new post metadata file")?, 66 | }; 67 | 68 | let metadata_file_contents = generate_metadata_file(METADATA_TEMPLATE, title) 69 | .wrap_err("failed generating contents for new post metadata file")?; 70 | 71 | metadata_file 72 | .write_all(metadata_file_contents.as_bytes()) 73 | .wrap_err("failed writing contents to new post metadata file")?; 74 | 75 | Ok(()) 76 | } 77 | -------------------------------------------------------------------------------- /src/template.rs: -------------------------------------------------------------------------------- 1 | use std::fs::{self, File}; 2 | use std::path::Path; 3 | 4 | use chrono::{DateTime, Datelike, FixedOffset}; 5 | use eyre::{bail, eyre, WrapErr}; 6 | use serde::{Deserialize, Serialize}; 7 | use tera::{Context, Tera}; 8 | 9 | use crate::entry::{AuthorMetadata, Entry}; 10 | use crate::error::Error; 11 | use crate::feed::{Feed, FeedAuthor}; 12 | 13 | #[derive(Debug, PartialEq, Eq, Serialize)] 14 | pub struct EntryAuthorTemplateData { 15 | pub name: String, 16 | pub email: Option, 17 | pub uri: Option, 18 | } 19 | 20 | impl From for EntryAuthorTemplateData { 21 | fn from(value: AuthorMetadata) -> Self { 22 | Self { 23 | name: value.name, 24 | email: value.email, 25 | uri: value.uri, 26 | } 27 | } 28 | } 29 | 30 | #[derive(Debug, PartialEq, Eq, Serialize)] 31 | pub struct EntryTemplateData { 32 | pub id: String, 33 | pub url: String, 34 | pub title: String, 35 | pub body: String, 36 | pub updated: String, 37 | pub summary: Option, 38 | pub published: Option, 39 | pub author: Option, 40 | pub rights: Option, 41 | pub lang: Option, 42 | pub categories: Vec, 43 | } 44 | 45 | impl From for EntryTemplateData { 46 | fn from(params: Entry) -> Self { 47 | Self { 48 | id: params.metadata.id, 49 | url: params.url.to_string(), 50 | title: params.metadata.title, 51 | body: params.body, 52 | updated: params.metadata.updated.to_rfc3339(), 53 | summary: params.metadata.summary, 54 | published: params 55 | .metadata 56 | .published 57 | .as_ref() 58 | .map(DateTime::::to_rfc3339), 59 | author: params.metadata.author.map(Into::into), 60 | rights: params.metadata.rights, 61 | lang: params.metadata.lang, 62 | categories: params.metadata.categories, 63 | } 64 | } 65 | } 66 | 67 | impl EntryTemplateData { 68 | pub fn render( 69 | &self, 70 | feed: &FeedTemplateData, 71 | template: &Path, 72 | output: &Path, 73 | ) -> eyre::Result<()> { 74 | let mut tera = Tera::default(); 75 | 76 | if let Err(err) = tera.add_template_file(template, Some("post")) { 77 | bail!(Error::InvalidPostPageTemplate { 78 | path: output.to_owned(), 79 | reason: err.to_string(), 80 | }); 81 | } 82 | 83 | let mut context = Context::new(); 84 | context.insert("entry", self); 85 | context.insert("feed", feed); 86 | 87 | let dest_file = File::create(output).wrap_err("failed creating gemlog post page file")?; 88 | 89 | if let Err(err) = tera.render_to("post", &context, dest_file) { 90 | bail!(Error::InvalidPostPageTemplate { 91 | path: output.to_owned(), 92 | reason: err.to_string(), 93 | }); 94 | } 95 | 96 | Ok(()) 97 | } 98 | } 99 | 100 | impl FeedTemplateData { 101 | pub fn render_index(&self, template: &Path, output: &Path) -> eyre::Result<()> { 102 | let mut tera = Tera::default(); 103 | 104 | if let Err(err) = tera.add_template_file(template, Some("index")) { 105 | bail!(Error::InvalidIndexPageTemplate { 106 | reason: err.to_string() 107 | }); 108 | } 109 | 110 | let mut context = Context::new(); 111 | context.insert("feed", self); 112 | 113 | let parent_dir = output.parent().ok_or(eyre!( 114 | "Could not get parent directory of index page file. This is a bug." 115 | ))?; 116 | 117 | fs::create_dir_all(parent_dir).wrap_err("failed creating parent directory")?; 118 | 119 | let dest_file = File::create(output).wrap_err("failed creating gemlog index page file")?; 120 | 121 | if let Err(err) = tera.render_to("index", &context, dest_file) { 122 | bail!(Error::InvalidIndexPageTemplate { 123 | reason: err.to_string(), 124 | }); 125 | } 126 | 127 | Ok(()) 128 | } 129 | 130 | pub fn render_feed(&self, template: &str, output: &Path) -> eyre::Result<()> { 131 | let mut tera = Tera::default(); 132 | 133 | // The template name needs the `.xml` extension to signal to Tera that all input should be 134 | // XML-escaped. 135 | tera.add_raw_template("feed.xml", template) 136 | .wrap_err("The bundled Atom feed template is invalid. This is a bug.")?; 137 | 138 | let mut context = Context::new(); 139 | context.insert("feed", self); 140 | 141 | let parent_dir = output.parent().ok_or(eyre!( 142 | "Could not get parent directory of Atom feed file. This is a bug." 143 | ))?; 144 | 145 | fs::create_dir_all(parent_dir).wrap_err("failed creating parent directory")?; 146 | 147 | let dest_file = File::create(output).wrap_err("failed creating gemlog Atom feed file")?; 148 | 149 | tera.render_to("feed.xml", &context, dest_file) 150 | .wrap_err("failed generating the Atom feed")?; 151 | 152 | Ok(()) 153 | } 154 | } 155 | 156 | #[derive(Debug)] 157 | pub struct PostPathParams { 158 | pub slug: String, 159 | pub published: Option>, 160 | } 161 | 162 | #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] 163 | pub struct PostPathTemplateData { 164 | pub year: String, 165 | pub month: String, 166 | pub day: String, 167 | pub slug: String, 168 | } 169 | 170 | impl From for PostPathTemplateData { 171 | fn from(params: PostPathParams) -> Self { 172 | Self { 173 | // If there is no publish date, these are empty strings. 174 | year: params 175 | .published 176 | .map(|published| format!("{:0>4}", published.year())) 177 | .unwrap_or_default(), 178 | month: params 179 | .published 180 | .map(|published| format!("{:0>2}", published.month())) 181 | .unwrap_or_default(), 182 | day: params 183 | .published 184 | .map(|published| format!("{:0>2}", published.day())) 185 | .unwrap_or_default(), 186 | slug: params.slug, 187 | } 188 | } 189 | } 190 | 191 | impl PostPathTemplateData { 192 | pub fn render(&self, template: &str) -> eyre::Result { 193 | let mut tera = Tera::default(); 194 | 195 | if let Err(err) = tera.add_raw_template("path", template) { 196 | bail!(Error::InvalidPostPath { 197 | template: template.to_owned(), 198 | reason: err.to_string(), 199 | }); 200 | } 201 | 202 | let mut context = Context::new(); 203 | context.insert("year", &self.year); 204 | context.insert("month", &self.month); 205 | context.insert("day", &self.day); 206 | context.insert("slug", &self.slug); 207 | 208 | match tera.render("path", &context) { 209 | Ok(path) => Ok(path), 210 | Err(err) => bail!(Error::InvalidPostPath { 211 | template: template.to_owned(), 212 | reason: err.to_string(), 213 | }), 214 | } 215 | } 216 | } 217 | 218 | #[derive(Debug, PartialEq, Eq, Serialize)] 219 | pub struct FeedAuthorTemplateData { 220 | pub name: String, 221 | pub email: Option, 222 | pub uri: Option, 223 | } 224 | 225 | impl From for FeedAuthorTemplateData { 226 | fn from(value: FeedAuthor) -> Self { 227 | Self { 228 | name: value.name, 229 | email: value.email, 230 | uri: value.uri, 231 | } 232 | } 233 | } 234 | 235 | #[derive(Debug, PartialEq, Eq, Serialize)] 236 | pub struct FeedTemplateData { 237 | pub capsule_url: String, 238 | pub feed_url: String, 239 | pub index_url: String, 240 | pub title: String, 241 | pub updated: String, 242 | pub subtitle: Option, 243 | pub rights: Option, 244 | pub author: Option, 245 | pub entries: Vec, 246 | } 247 | 248 | impl From for FeedTemplateData { 249 | fn from(feed: Feed) -> Self { 250 | Self { 251 | capsule_url: feed.capsule_url.to_string(), 252 | feed_url: feed.feed_url.to_string(), 253 | index_url: feed.index_url.to_string(), 254 | title: feed.title, 255 | updated: feed.updated.to_rfc3339(), 256 | subtitle: feed.subtitle, 257 | rights: feed.rights, 258 | author: feed.author.map(Into::into), 259 | entries: feed.entries.into_iter().map(Into::into).collect(), 260 | } 261 | } 262 | } 263 | --------------------------------------------------------------------------------