diff --git a/Cargo.lock b/Cargo.lock index 89d5b5c..201e8eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -39,6 +39,27 @@ dependencies = [ "memchr", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" + [[package]] name = "ascii-canvas" version = "3.0.0" @@ -258,7 +279,7 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.106", ] [[package]] @@ -275,7 +296,7 @@ checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.106", ] [[package]] @@ -355,6 +376,15 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "blocking" version = "1.6.1" @@ -368,6 +398,16 @@ dependencies = [ "piper", ] +[[package]] +name = "bstr" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.16.0" @@ -404,6 +444,53 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf 0.11.2", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + +[[package]] +name = "chrono-tz-build" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" +dependencies = [ + "parse-zoneinfo", + "phf 0.11.2", + "phf_codegen 0.11.3", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -429,6 +516,49 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "create-issue" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "chrono-tz 0.10.4", + "lambda_runtime 0.13.0", + "reqwest 0.12.5", + "serde", + "serde_json", + "tera", + "tokio", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.20" @@ -441,6 +571,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cssparser" version = "0.31.2" @@ -461,7 +601,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.68", + "syn 2.0.106", ] [[package]] @@ -503,7 +643,23 @@ checksum = "5f33878137e4dafd7fa914ad4e259e18a4e8e532b9617a2d0150262bf53abfce" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.106", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", ] [[package]] @@ -645,7 +801,7 @@ checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" name = "fetch-book" version = "0.1.0" dependencies = [ - "lambda_runtime", + "lambda_runtime 0.10.0", "reqwest 0.12.5", "serde", "serde_json", @@ -658,13 +814,13 @@ name = "fetch-issue-number" version = "0.1.0" dependencies = [ "httpmock", - "lambda_runtime", + "lambda_runtime 0.10.0", "nom", "reqwest 0.11.27", "scraper", "serde", "serde_json", - "thiserror", + "thiserror 1.0.61", "tokio", "tracing", "tracing-subscriber", @@ -675,12 +831,12 @@ name = "fetch-quote" version = "0.1.0" dependencies = [ "httpmock", - "lambda_runtime", + "lambda_runtime 0.10.0", "reqwest 0.11.27", "serde", "serde_json", "shared", - "thiserror", + "thiserror 1.0.61", "tokio", "tracing", "tracing-subscriber", @@ -690,7 +846,7 @@ dependencies = [ name = "fetch-sponsor" version = "0.1.0" dependencies = [ - "lambda_runtime", + "lambda_runtime 0.10.0", "reqwest 0.12.5", "serde", "serde_json", @@ -813,7 +969,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.106", ] [[package]] @@ -855,6 +1011,16 @@ dependencies = [ "byteorder", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getopts" version = "0.2.21" @@ -881,6 +1047,30 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +[[package]] +name = "globset" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata 0.4.7", + "regex-syntax 0.8.4", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags 2.6.0", + "ignore", + "walkdir", +] + [[package]] name = "gloo-timers" version = "0.2.6" @@ -1069,6 +1259,15 @@ dependencies = [ "url", ] +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + [[package]] name = "hyper" version = "0.14.29" @@ -1165,6 +1364,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "idna" version = "0.5.0" @@ -1175,6 +1398,22 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "ignore" +version = "0.4.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata 0.4.7", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "2.2.6" @@ -1318,13 +1557,41 @@ dependencies = [ "http-serde", "hyper 1.3.1", "hyper-util", - "lambda_runtime_api_client", + "lambda_runtime_api_client 0.10.0", + "serde", + "serde_json", + "serde_path_to_error", + "tokio", + "tokio-stream", + "tower", + "tracing", +] + +[[package]] +name = "lambda_runtime" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed49669d6430292aead991e19bf13153135a884f916e68f32997c951af637ebe" +dependencies = [ + "async-stream", + "base64 0.22.1", + "bytes", + "futures", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "http-serde", + "hyper 1.3.1", + "hyper-util", + "lambda_runtime_api_client 0.11.1", + "pin-project", "serde", "serde_json", "serde_path_to_error", "tokio", "tokio-stream", "tower", + "tower-layer", "tracing", ] @@ -1349,6 +1616,27 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lambda_runtime_api_client" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c90a10f094475a34a04da2be11686c4dcfe214d93413162db9ffdff3d3af293a" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "hyper 1.3.1", + "hyper-util", + "tokio", + "tower", + "tower-service", + "tracing", + "tracing-subscriber", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1367,6 +1655,12 @@ version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + [[package]] name = "libnghttp2-sys" version = "0.1.10+1.61.0" @@ -1444,7 +1738,7 @@ checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" dependencies = [ "log", "phf 0.10.1", - "phf_codegen", + "phf_codegen 0.10.0", "string_cache", "string_cache_codegen", "tendril", @@ -1513,6 +1807,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.16.0" @@ -1585,12 +1888,65 @@ dependencies = [ "windows-targets 0.52.5", ] +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + [[package]] name = "percent-encoding" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "pest" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" +dependencies = [ + "memchr", + "thiserror 2.0.16", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb056d9e8ea77922845ec74a1c4e8fb17e7c218cc4fc11a15c5d25e189aa40bc" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e404e638f781eb3202dc82db6760c8ae8a1eeef7fb3fa8264b2ef280504966" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "pest_meta" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd1101f170f5903fde0914f899bb503d9ff5271d7ba76bbb70bea63690cc0d5" +dependencies = [ + "pest", + "sha2", +] + [[package]] name = "petgraph" version = "0.6.5" @@ -1620,6 +1976,15 @@ dependencies = [ "phf_shared 0.11.2", ] +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + [[package]] name = "phf_codegen" version = "0.10.0" @@ -1630,6 +1995,16 @@ dependencies = [ "phf_shared 0.10.0", ] +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.2", +] + [[package]] name = "phf_generator" version = "0.10.0" @@ -1660,7 +2035,7 @@ dependencies = [ "phf_shared 0.11.2", "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.106", ] [[package]] @@ -1669,7 +2044,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" dependencies = [ - "siphasher", + "siphasher 0.3.11", ] [[package]] @@ -1678,7 +2053,16 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" dependencies = [ - "siphasher", + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher 1.0.1", ] [[package]] @@ -1704,7 +2088,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.106", ] [[package]] @@ -1781,9 +2165,9 @@ checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] @@ -1800,7 +2184,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.10", - "thiserror", + "thiserror 1.0.61", "tokio", "tracing", ] @@ -1817,7 +2201,7 @@ dependencies = [ "rustc-hash", "rustls 0.23.10", "slab", - "thiserror", + "thiserror 1.0.61", "tinyvec", "tracing", ] @@ -1891,7 +2275,7 @@ checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891" dependencies = [ "getrandom", "libredox", - "thiserror", + "thiserror 1.0.61", ] [[package]] @@ -2224,7 +2608,7 @@ dependencies = [ "log", "new_debug_unreachable", "phf 0.10.1", - "phf_codegen", + "phf_codegen 0.10.0", "precomputed-hash", "servo_arc", "smallvec", @@ -2247,7 +2631,7 @@ checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.106", ] [[package]] @@ -2302,6 +2686,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2339,6 +2734,12 @@ version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "slab" version = "0.4.9" @@ -2348,6 +2749,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + [[package]] name = "sluice" version = "0.5.5" @@ -2442,9 +2853,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.68" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901fa70d88b9d6c98022e23b4136f9f3e54e4662c3bc1bd1d84a42a9a0f0c1e9" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -2495,6 +2906,28 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tera" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab9d851b45e865f178319da0abdbfe6acbc4328759ff18dafc3a41c16b4cd2ee" +dependencies = [ + "chrono", + "chrono-tz 0.9.0", + "globwalk", + "humansize", + "lazy_static", + "percent-encoding", + "pest", + "pest_derive", + "rand", + "regex", + "serde", + "serde_json", + "slug", + "unic-segment", +] + [[package]] name = "term" version = "0.7.0" @@ -2512,7 +2945,16 @@ version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.61", +] + +[[package]] +name = "thiserror" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +dependencies = [ + "thiserror-impl 2.0.16", ] [[package]] @@ -2523,7 +2965,18 @@ checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.106", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] @@ -2586,7 +3039,7 @@ checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.106", ] [[package]] @@ -2682,7 +3135,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.106", ] [[package]] @@ -2739,6 +3192,68 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-segment" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4ed5d26be57f84f176157270c112ef57b86debac9cd21daaabbe56db0f88f23" +dependencies = [ + "unic-ucd-segment", +] + +[[package]] +name = "unic-ucd-segment" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2079c122a62205b421f499da10f3ee0f7697f012f55b675e002483c73ea34700" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + [[package]] name = "unicode-bidi" version = "0.3.15" @@ -2871,7 +3386,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.106", "wasm-bindgen-shared", ] @@ -2905,7 +3420,7 @@ checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.106", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2972,6 +3487,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -3148,7 +3722,7 @@ checksum = "15e934569e47891f7d9411f1a451d947a60e000ab3bd24fbb970f000387d1b3b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.106", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 67aeb3f..9f4a181 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,5 +5,6 @@ members = [ "functions/fetch-issue-number", "functions/fetch-quote", "functions/fetch-sponsor", + "functions/create-issue", "shared", ] diff --git a/functions/create-issue/.gitignore b/functions/create-issue/.gitignore new file mode 100644 index 0000000..1de5659 --- /dev/null +++ b/functions/create-issue/.gitignore @@ -0,0 +1 @@ +target \ No newline at end of file diff --git a/functions/create-issue/Cargo.toml b/functions/create-issue/Cargo.toml new file mode 100644 index 0000000..50c0ff3 --- /dev/null +++ b/functions/create-issue/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "create-issue" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +lambda_runtime = "0.13.0" +tokio = { version = "1", features = ["macros"] } + +# Date/time handling +chrono = { version = "0.4", features = ["serde"] } +chrono-tz = "0.10" + +# HTTP requests (with rustls for Lambda compatibility) +reqwest = { version = "0.12", features = [ + "json", + "rustls-tls", +], default-features = false } + +# Template engine +tera = "1" + +# Environment variables and error handling +anyhow = "1" diff --git a/functions/create-issue/README.md b/functions/create-issue/README.md index 7c5d9dd..566be55 100644 --- a/functions/create-issue/README.md +++ b/functions/create-issue/README.md @@ -1,9 +1,52 @@ -## How to update the base template +# Introduction -If you want to update the base template this is the process (unfortunately Mailchimp makes it a bit tricky): +create-issuev2 is a Rust project that implements an AWS Lambda function in Rust. -- Update the file `functions/create-issue/templates/newsletter.njk` and apply the desired changes -- Run the tests with `node_modules/.bin/vitest run functions/create-issue/ -u` (from the root of the project) -- Now the file `functions/create-issue/__tests__/__snapshots__/template.html` is up to date. Copy its code. -- In Mailchimp edit the saved template and paste the code -- Check that the preview renders as expected and save +## Prerequisites + +- [Rust](https://www.rust-lang.org/tools/install) +- [Cargo Lambda](https://www.cargo-lambda.info/guide/installation.html) + +## Building + +To build the project for production, run `cargo lambda build --release`. Remove the `--release` flag to build for development. + +Read more about building your lambda function in [the Cargo Lambda documentation](https://www.cargo-lambda.info/commands/build.html). + +## Testing + +You can run regular Rust unit tests with `cargo test`. + +If you want to run integration tests locally, you can use the `cargo lambda watch` and `cargo lambda invoke` commands to do it. + +First, run `cargo lambda watch` to start a local server. When you make changes to the code, the server will automatically restart. + +Second, you'll need a way to pass the event data to the lambda function. + +You can use the existent [event payloads](https://github.com/awslabs/aws-lambda-rust-runtime/tree/main/lambda-events/src/fixtures) in the Rust Runtime repository if your lambda function is using one of the supported event types. + +You can use those examples directly with the `--data-example` flag, where the value is the name of the file in the [lambda-events](https://github.com/awslabs/aws-lambda-rust-runtime/tree/main/lambda-events/src/fixtures) repository without the `example_` prefix and the `.json` extension. + +```bash +cargo lambda invoke --data-example apigw-request +``` + +For generic events, where you define the event data structure, you can create a JSON file with the data you want to test with. For example: + +```json +{ + "command": "test" +} +``` + +Then, run `cargo lambda invoke --data-file ./data.json` to invoke the function with the data in `data.json`. + + +Read more about running the local server in [the Cargo Lambda documentation for the `watch` command](https://www.cargo-lambda.info/commands/watch.html). +Read more about invoking the function in [the Cargo Lambda documentation for the `invoke` command](https://www.cargo-lambda.info/commands/invoke.html). + +## Deploying + +To deploy the project, run `cargo lambda deploy`. This will create an IAM role and a Lambda function in your AWS account. + +Read more about deploying your lambda function in [the Cargo Lambda documentation](https://www.cargo-lambda.info/commands/deploy.html). diff --git a/functions/create-issue/__tests__/__snapshots__/intro.html b/functions/create-issue/__tests__/__snapshots__/intro.html deleted file mode 100644 index d41f8f1..0000000 --- a/functions/create-issue/__tests__/__snapshots__/intro.html +++ /dev/null @@ -1 +0,0 @@ -

Hello, *|FNAME|*

Welcome to issue #1000

\ No newline at end of file diff --git a/functions/create-issue/__tests__/__snapshots__/template.html b/functions/create-issue/__tests__/__snapshots__/template.html deleted file mode 100644 index 947587c..0000000 --- a/functions/create-issue/__tests__/__snapshots__/template.html +++ /dev/null @@ -1,507 +0,0 @@ -*|MC:SUBJECT|*
Logo

Hello, *|FNAME|*

Welcome to issue #336

This issue is kindly sponsored by:

“All programming languages are shit. But the good ones fertilize your mind“

— Reginald Braithwaite , Software Developer

The complexity of writing an efficient NodeJS Docker image - Specfy

The complexity of writing an efficient NodeJS Docker image - Specfy  —  A step by step guide to build fast and lightweight NodeJS docker images. Read article

Astro 3.0 | Astro  —  30% faster and more powerful than ever, Astro 3.0 is here! Includes new features and enhancements around View Transitions, Image Optimization, Fast Refresh JSX and more. Read article

Dark Mode: How Users Think About It and Issues to Avoid  —  Dark mode is popular, but not essential. Users like dark mode but maintain similar behaviors without it. They think about it at the system level, not the application level. If you choose to support dark mode, test your design to avoid common dark-mode issues. Read article

Bézier Curves - and the logic behind them | Richard Ekwonye  —  The logic behind Bézier Curves used in CSS animations and visual elements. Read article

sponsored

Sponsored Content  —  Add here the text for your sponsored content. Read Article

Creating custom easing effects in CSS animations using the linear() function | MDN Blog  —  The new CSS linear() timing function enables custom easing in animations. Explore how linear() works compared with other timing functions used for easing, with practical examples. Read article

Falling For Oklch: A Love Story Of Color Spaces, Gamuts, And CSS — Smashing Magazine  —  The CSS Color Module Level 4 specification defined a slew of new color features when it became a candidate recommendation in 2022, including Oklab and Oklch, which have widened the field of color we have to work with. Explore the Oklch color space and how to start using it in CSS today. Read article

Build APIs You Won't Hate: Everyone and their dog wants an API, so you should probably learn how to build them

by Phil Sturgeon

Build APIs You Won't Hate: Everyone and their dog wants an API, so you should probably learn how to build them

API development is becoming increasingly common for server-side developers thanks to the rise of front-end JavaScript frameworks, iPhone applications, and API-centric architectures. It might seem like grabbing stuff from a data source and shoving it out as JSON would be easy, but surviving changes in business logic, database schema updates, new features, or deprecated endpoints can be a nightmare. After finding many of the existing resources for API development to be lacking, Phil learned a lot of things the hard way through years of trial and error. This book aims to condense that experience, taking examples and explanations further than the trivial apples and pears nonsense tutorials often provide. By passing on some best practices and general good advice you can hit the ground running with API development, combined with some horror stories and how they were overcome/avoided/averted.

👋 That’s all for this week. See you next Monday!

Greetings from your full stack friends Luciano & Andrea

\ No newline at end of file diff --git a/functions/create-issue/__tests__/getLinkLabelBasedOnUrl.js b/functions/create-issue/__tests__/getLinkLabelBasedOnUrl.js deleted file mode 100644 index 8a5359a..0000000 --- a/functions/create-issue/__tests__/getLinkLabelBasedOnUrl.js +++ /dev/null @@ -1,22 +0,0 @@ -import { test, expect } from 'vitest' -import { getLinkLabelBasedOnUrl } from '../getLinkLabelBasedOnUrl.js' - -test('it should return the default label if no domain is matched', async () => { - const url = 'https://medium.com/airbnb-engineering/introducing-lottie-4ff4a0afac0e?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-10-2017&utm_content=description' - expect(getLinkLabelBasedOnUrl(url)).toEqual('Read article') -}) - -test('it should return the github label', async () => { - const url = 'https://github.com/ryanmcdermott/clean-code-javascript?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-10-2017&utm_content=description' - expect(getLinkLabelBasedOnUrl(url)).toEqual('View Repository') -}) - -test('it should return the youtube label', async () => { - const url = 'https://www.youtube.com/watch?v=7ctkTFv6XdA&utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-10-2017&utm_content=description' - expect(getLinkLabelBasedOnUrl(url)).toEqual('Watch video') -}) - -test('it should return the vimeo label', async () => { - const url = 'https://vimeo.com/171068992?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-10-2017&utm_content=description' - expect(getLinkLabelBasedOnUrl(url)).toEqual('Watch video') -}) diff --git a/functions/create-issue/__tests__/template.js b/functions/create-issue/__tests__/template.js deleted file mode 100644 index 998b60d..0000000 --- a/functions/create-issue/__tests__/template.js +++ /dev/null @@ -1,173 +0,0 @@ -import { test, expect } from 'vitest' -import url from 'url' -import path from 'path' -import { renderIntro, renderTemplate } from '../template.js' - -const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) - -test('it should render the template', async () => { - const data = { - issueNumber: 336, - quote: { - id: 149, - text: 'All programming languages are shit. But the good ones fertilize your mind', - author: 'Reginald Braithwaite', - authorDescription: 'Software Developer', - authorUrl: null - }, - book: { - id: '0692232699', - title: "Build APIs You Won't Hate: Everyone and their dog wants an API, so you should probably learn how to build them", - author: 'Phil Sturgeon', - links: { - us: 'https://www.amazon.com/dp/0692232699/', - uk: 'https://www.amazon.co.uk/dp/0692232699/', - free: 'https://www.amazon.co.uk/dp/0692232699/' - }, - coverPicture: 'https://images-na.ssl-images-amazon.com/images/I/41A-D5UDB%2BL.jpg', - description: 'API development is becoming increasingly common for server-side developers thanks to the rise of front-end JavaScript frameworks, iPhone applications, and API-centric architectures. It might seem like grabbing stuff from a data source and shoving it out as JSON would be easy, but surviving changes in business logic, database schema updates, new features, or deprecated endpoints can be a nightmare. After finding many of the existing resources for API development to be lacking, Phil learned a lot of things the hard way through years of trial and error. This book aims to condense that experience, taking examples and explanations further than the trivial apples and pears nonsense tutorials often provide. By passing on some best practices and general good advice you can hit the ground running with API development, combined with some horror stories and how they were overcome/avoided/averted.' - }, - links: [ - { - title: 'The complexity of writing an efficient NodeJS Docker image - Specfy', - url: 'https://specfy.io/blog/1-efficient-dockerfile-nodejs-in-7-steps', - description: 'A step by step guide to build fast and lightweight NodeJS docker images.', - image: 'http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_240,q_80,w_500/v1/fsb/ec026763a5ce4a19a0dde651a349f65b.jpg', - score: 145, - originalImage: 'https://specfy.io/posts/1/building-efficient-dockerfile-nodejs.png', - campaignUrls: { - title: 'https://specfy.io/blog/1-efficient-dockerfile-nodejs-in-7-steps?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=title', - image: 'https://specfy.io/blog/1-efficient-dockerfile-nodejs-in-7-steps?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=image', - description: 'https://specfy.io/blog/1-efficient-dockerfile-nodejs-in-7-steps?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=description' - } - }, - { - title: 'Astro 3.0 | Astro', - url: 'https://astro.build/blog/astro-3', - description: '30% faster and more powerful than ever, Astro 3.0 is here! Includes new features and enhancements around View Transitions, Image Optimization, Fast Refresh JSX and more.', - image: 'http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_240,q_80,w_500/v1/fsb/bf854c6f46a6aaa9c62ebf24e10f7160.jpg', - score: 126, - originalImage: 'https://astro.build/_astro/blog-social.835ac2da.webp', - campaignUrls: { - title: 'https://astro.build/blog/astro-3?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=title', - image: 'https://astro.build/blog/astro-3?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=image', - description: 'https://astro.build/blog/astro-3?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=description' - } - }, - { - title: 'Dark Mode: How Users Think About It and Issues to Avoid', - url: 'https://nngroup.com/articles/dark-mode-users-issues', - description: 'Dark mode is popular, but not essential. Users like dark mode but maintain similar behaviors without it. They think about it at the system level, not the application level. If you choose to support dark mode, test your design to avoid common dark-mode issues.', - image: 'http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_240,q_80,w_500/v1/fsb/1f73152a1dfd8eaad9fd239c69a2e616.jpg', - score: 81, - originalImage: 'https://media.nngroup.com/media/articles/opengraph_images/dark-mode-eng.png', - campaignUrls: { - title: 'https://nngroup.com/articles/dark-mode-users-issues?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=title', - image: 'https://nngroup.com/articles/dark-mode-users-issues?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=image', - description: 'https://nngroup.com/articles/dark-mode-users-issues?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=description' - } - }, - { - title: 'Bézier Curves - and the logic behind them | Richard Ekwonye', - url: 'https://www.blog.richardekwonye.com/bezier-curves', - description: 'The logic behind Bézier Curves used in CSS animations and visual elements.', - image: 'http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_240,q_80,w_500/v1/fsb/6bc604c9b2d50f8af4d3fb4a47997f91.jpg', - score: 47, - originalImage: 'https://www.blog.richardekwonye.com/images/bezier-curves-cover.jpg', - campaignUrls: { - title: 'https://www.blog.richardekwonye.com/bezier-curves?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=title', - image: 'https://www.blog.richardekwonye.com/bezier-curves?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=image', - description: 'https://www.blog.richardekwonye.com/bezier-curves?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=description' - } - }, - { - title: '8 Reasons Why WhatsApp Was Able to Support 50 Billion Messages a Day With Only 32 Engineers', - url: 'https://newsletter.systemdesign.one/p/whatsapp-engineering', - description: '#1: Learn More - Awesome WhatsApp Engineering (6 minutes)', - image: 'http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_240,q_80,w_500/v1/fsb/84f520f397e1c95d792aa2caaa3e9262.jpg', - score: 23, - originalImage: 'https://substackcdn.com/image/fetch/w_1200,h_600,c_fill,f_jpg,q_auto:good,fl_progressive:steep,g_auto/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F94e067cc-6ade-44bf-9818-5dc20a260541_1280x720.png', - campaignUrls: { - title: 'https://newsletter.systemdesign.one/p/whatsapp-engineering?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=title', - image: 'https://newsletter.systemdesign.one/p/whatsapp-engineering?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=image', - description: 'https://newsletter.systemdesign.one/p/whatsapp-engineering?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=description' - } - }, - { - title: 'Creating custom easing effects in CSS animations using the linear() function | MDN Blog', - url: 'https://developer.mozilla.org/en-US/blog/custom-easing-in-css-with-linear', - description: 'The new CSS linear() timing function enables custom easing in animations. Explore how linear() works compared with other timing functions used for easing, with practical examples.', - image: 'http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_240,q_80,w_500/v1/fsb/597f65d0dd87620e86925c5014639fa7.jpg', - score: 18, - originalImage: 'https://developer.mozilla.org/en-US/blog/custom-easing-in-css-with-linear/linear-easing-featured.png', - campaignUrls: { - title: 'https://developer.mozilla.org/en-US/blog/custom-easing-in-css-with-linear?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=title', - image: 'https://developer.mozilla.org/en-US/blog/custom-easing-in-css-with-linear?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=image', - description: 'https://developer.mozilla.org/en-US/blog/custom-easing-in-css-with-linear?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=description' - } - }, - { - title: 'Falling For Oklch: A Love Story Of Color Spaces, Gamuts, And CSS — Smashing Magazine', - url: 'https://smashingmagazine.com/2023/08/oklch-color-spaces-gamuts-css', - description: 'The CSS Color Module Level 4 specification defined a slew of new color features when it became a candidate recommendation in 2022, including Oklab and Oklch, which have widened the field of color we have to work with. Explore the Oklch color space and how to start using it in CSS today.', - image: 'http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_240,q_80,w_500/v1/fsb/0f278f43caa7520973407b005ef35581.jpg', - score: 16, - originalImage: 'https://files.smashing.media/articles/oklch-color-spaces-gamuts-css/oklch-color-spaces-gamuts-css.jpg', - campaignUrls: { - title: 'https://smashingmagazine.com/2023/08/oklch-color-spaces-gamuts-css?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=title', - image: 'https://smashingmagazine.com/2023/08/oklch-color-spaces-gamuts-css?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=image', - description: 'https://smashingmagazine.com/2023/08/oklch-color-spaces-gamuts-css?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=description' - } - } - ], - extraContent: [ - { - title: 'CSS Selectors: A Visual Guide & Reference', - url: 'https://fffuel.co', - description: 'Visual guide to CSS selectors, including pseudo-classes (:nth-child, :hover,...), functional pseudo-classes (:not, :is,...) and pseudo-elements.', - image: 'http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_240,q_80,w_500/v1/fsb/8562eaf1c0470e0ce5a4557cd11af763.jpg', - score: 83, - originalImage: 'https://fffuel.co/images/covers/css-selectors.png', - campaignUrls: { - title: 'https://fffuel.co/?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=title', - image: 'https://fffuel.co/?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=image', - description: 'https://fffuel.co/?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=description' - } - }, - { - title: '8 Reasons Why WhatsApp Was Able to Support 50 Billion Messages a Day With Only 32 Engineers', - url: 'https://newsletter.systemdesign.one/p/whatsapp-engineering', - description: '#1: Learn More - Awesome WhatsApp Engineering (6 minutes)', - image: 'http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_240,q_80,w_500/v1/fsb/84f520f397e1c95d792aa2caaa3e9262.jpg', - score: 30, - originalImage: 'https://substackcdn.com/image/fetch/w_1200,h_600,c_fill,f_jpg,q_auto:good,fl_progressive:steep,g_auto/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F94e067cc-6ade-44bf-9818-5dc20a260541_1280x720.png', - campaignUrls: { - title: 'https://newsletter.systemdesign.one/p/whatsapp-engineering?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=title', - image: 'https://newsletter.systemdesign.one/p/whatsapp-engineering?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=image', - description: 'https://newsletter.systemdesign.one/p/whatsapp-engineering?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=description' - } - }, - { - title: "Email Authentication: A Developer's Guide · Resend", - url: 'https://resend.com/blog/email-authentication-a-developers-guide', - description: 'Learn the importance of SPF, DKIM, DMARC, and BIMI in ensuring email delivery.', - image: 'http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_240,q_80,w_500/v1/fsb/eb37d2b287bdef4e054206cee69f61a1.jpg', - score: 1, - originalImage: 'https://resend.com/static/posts/email-authentication.jpg', - campaignUrls: { - title: 'https://resend.com/blog/email-authentication-a-developers-guide?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=title', - image: 'https://resend.com/blog/email-authentication-a-developers-guide?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=image', - description: 'https://resend.com/blog/email-authentication-a-developers-guide?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=description' - } - } - ] - } - - const result = await renderTemplate(data) - expect(result).toMatchFileSnapshot(path.join(__dirname, '__snapshots__', 'template.html')) -}) - -test('it should render the intro', async () => { - const result = await renderIntro(1000) - expect(result).toMatchFileSnapshot(path.join(__dirname, '__snapshots__', 'intro.html')) -}) diff --git a/functions/create-issue/__tests__/test-event.json b/functions/create-issue/__tests__/test-event.json deleted file mode 100644 index 8b43f77..0000000 --- a/functions/create-issue/__tests__/test-event.json +++ /dev/null @@ -1,132 +0,0 @@ -{ - "config": { - "dryRun": false, - "detail-type": "Scheduled Event", - "resources": [ - "arn:aws:events:eu-west-1:795006566846:rule/fstack-bulletin-create-is-CreateIssueStateMachineH-1ETEM0FGGP1LD" - ], - "id": "7106c724-97d4-ea9c-fcd3-7e58a6b694d6", - "source": "aws.events", - "time": "2023-09-01T17:00:00Z", - "detail": {}, - "region": "eu-west-1", - "version": "0", - "account": "795006566846" - }, - "NextIssue": { - "number": 1000 - }, - "data": { - "Quote": { - "id": 149, - "text": "All programming languages are shit. But the good ones fertilize your mind", - "author": "Reginald Braithwaite", - "authorDescription": "Software Developer", - "authorUrl": null - }, - "Book": { - "id": "0692232699", - "title": "Build APIs You Won't Hate: Everyone and their dog wants an API, so you should probably learn how to build them", - "author": "Phil Sturgeon", - "links": { - "usa": "https://www.amazon.com/dp/0692232699/?tag=fullstackbulletin-20", - "uk": "https://www.amazon.co.uk/dp/0692232699/?tag=fullstackbulletin-21" - }, - "coverPicture": "https://images-na.ssl-images-amazon.com/images/I/41A-D5UDB%2BL.jpg", - "description": "API development is becoming increasingly common for server-side developers thanks to the rise of front-end JavaScript frameworks, iPhone applications, and API-centric architectures. It might seem like grabbing stuff from a data source and shoving it out as JSON would be easy, but surviving changes in business logic, database schema updates, new features, or deprecated endpoints can be a nightmare. After finding many of the existing resources for API development to be lacking, Phil learned a lot of things the hard way through years of trial and error. This book aims to condense that experience, taking examples and explanations further than the trivial apples and pears nonsense tutorials often provide. By passing on some best practices and general good advice you can hit the ground running with API development, combined with some horror stories and how they were overcome/avoided/averted." - }, - "Links": [ - { - "title": "The complexity of writing an efficient NodeJS Docker image - Specfy", - "url": "https://specfy.io/blog/1-efficient-dockerfile-nodejs-in-7-steps", - "description": "A step by step guide to build fast and lightweight NodeJS docker images.", - "image": "http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_240,q_80,w_500/v1/fsb/ec026763a5ce4a19a0dde651a349f65b.jpg", - "score": 145, - "originalImage": "https://specfy.io/posts/1/building-efficient-dockerfile-nodejs.png", - "campaignUrls": { - "title": "https://specfy.io/blog/1-efficient-dockerfile-nodejs-in-7-steps?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=title", - "image": "https://specfy.io/blog/1-efficient-dockerfile-nodejs-in-7-steps?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=image", - "description": "https://specfy.io/blog/1-efficient-dockerfile-nodejs-in-7-steps?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=description" - } - }, - { - "title": "Astro 3.0 | Astro", - "url": "https://astro.build/blog/astro-3", - "description": "30% faster and more powerful than ever, Astro 3.0 is here! Includes new features and enhancements around View Transitions, Image Optimization, Fast Refresh JSX and more.", - "image": "http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_240,q_80,w_500/v1/fsb/bf854c6f46a6aaa9c62ebf24e10f7160.jpg", - "score": 126, - "originalImage": "https://astro.build/_astro/blog-social.835ac2da.webp", - "campaignUrls": { - "title": "https://astro.build/blog/astro-3?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=title", - "image": "https://astro.build/blog/astro-3?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=image", - "description": "https://astro.build/blog/astro-3?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=description" - } - }, - { - "title": "Dark Mode: How Users Think About It and Issues to Avoid", - "url": "https://nngroup.com/articles/dark-mode-users-issues", - "description": "Dark mode is popular, but not essential. Users like dark mode but maintain similar behaviors without it. They think about it at the system level, not the application level. If you choose to support dark mode, test your design to avoid common dark-mode issues.", - "image": "http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_240,q_80,w_500/v1/fsb/1f73152a1dfd8eaad9fd239c69a2e616.jpg", - "score": 81, - "originalImage": "https://media.nngroup.com/media/articles/opengraph_images/dark-mode-eng.png", - "campaignUrls": { - "title": "https://nngroup.com/articles/dark-mode-users-issues?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=title", - "image": "https://nngroup.com/articles/dark-mode-users-issues?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=image", - "description": "https://nngroup.com/articles/dark-mode-users-issues?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=description" - } - }, - { - "title": "Bézier Curves - and the logic behind them | Richard Ekwonye", - "url": "https://www.blog.richardekwonye.com/bezier-curves", - "description": "The logic behind Bézier Curves used in CSS animations and visual elements.", - "image": "http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_240,q_80,w_500/v1/fsb/6bc604c9b2d50f8af4d3fb4a47997f91.jpg", - "score": 47, - "originalImage": "https://www.blog.richardekwonye.com/images/bezier-curves-cover.jpg", - "campaignUrls": { - "title": "https://www.blog.richardekwonye.com/bezier-curves?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=title", - "image": "https://www.blog.richardekwonye.com/bezier-curves?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=image", - "description": "https://www.blog.richardekwonye.com/bezier-curves?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=description" - } - }, - { - "title": "8 Reasons Why WhatsApp Was Able to Support 50 Billion Messages a Day With Only 32 Engineers", - "url": "https://newsletter.systemdesign.one/p/whatsapp-engineering", - "description": "#1: Learn More - Awesome WhatsApp Engineering (6 minutes)", - "image": "http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_240,q_80,w_500/v1/fsb/84f520f397e1c95d792aa2caaa3e9262.jpg", - "score": 23, - "originalImage": "https://substackcdn.com/image/fetch/w_1200,h_600,c_fill,f_jpg,q_auto:good,fl_progressive:steep,g_auto/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F94e067cc-6ade-44bf-9818-5dc20a260541_1280x720.png", - "campaignUrls": { - "title": "https://newsletter.systemdesign.one/p/whatsapp-engineering?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=title", - "image": "https://newsletter.systemdesign.one/p/whatsapp-engineering?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=image", - "description": "https://newsletter.systemdesign.one/p/whatsapp-engineering?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=description" - } - }, - { - "title": "Creating custom easing effects in CSS animations using the linear() function | MDN Blog", - "url": "https://developer.mozilla.org/en-US/blog/custom-easing-in-css-with-linear", - "description": "The new CSS linear() timing function enables custom easing in animations. Explore how linear() works compared with other timing functions used for easing, with practical examples.", - "image": "http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_240,q_80,w_500/v1/fsb/597f65d0dd87620e86925c5014639fa7.jpg", - "score": 18, - "originalImage": "https://developer.mozilla.org/en-US/blog/custom-easing-in-css-with-linear/linear-easing-featured.png", - "campaignUrls": { - "title": "https://developer.mozilla.org/en-US/blog/custom-easing-in-css-with-linear?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=title", - "image": "https://developer.mozilla.org/en-US/blog/custom-easing-in-css-with-linear?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=image", - "description": "https://developer.mozilla.org/en-US/blog/custom-easing-in-css-with-linear?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=description" - } - }, - { - "title": "Falling For Oklch: A Love Story Of Color Spaces, Gamuts, And CSS — Smashing Magazine", - "url": "https://smashingmagazine.com/2023/08/oklch-color-spaces-gamuts-css", - "description": "The CSS Color Module Level 4 specification defined a slew of new color features when it became a candidate recommendation in 2022, including Oklab and Oklch, which have widened the field of color we have to work with. Explore the Oklch color space and how to start using it in CSS today.", - "image": "http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_240,q_80,w_500/v1/fsb/0f278f43caa7520973407b005ef35581.jpg", - "score": 16, - "originalImage": "https://files.smashing.media/articles/oklch-color-spaces-gamuts-css/oklch-color-spaces-gamuts-css.jpg", - "campaignUrls": { - "title": "https://smashingmagazine.com/2023/08/oklch-color-spaces-gamuts-css?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=title", - "image": "https://smashingmagazine.com/2023/08/oklch-color-spaces-gamuts-css?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=image", - "description": "https://smashingmagazine.com/2023/08/oklch-color-spaces-gamuts-css?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-35-2023&utm_content=description" - } - } - ] - } -} \ No newline at end of file diff --git a/functions/create-issue/events/issue-435.json b/functions/create-issue/events/issue-435.json new file mode 100644 index 0000000..fa80692 --- /dev/null +++ b/functions/create-issue/events/issue-435.json @@ -0,0 +1,228 @@ +{ + "config": { + "dryRun": false, + "detail-type": "Scheduled Event", + "resources": [ + "arn:aws:events:eu-west-1:795006566846:rule/fstack-bulletin-create-is-CreateIssueStateMachineH-1ETEM0FGGP1LD" + ], + "id": "0ec83f4b-0c67-5636-7eb5-cdf8e34c3528", + "source": "aws.events", + "time": "2025-08-22T17:00:00Z", + "detail": {}, + "region": "eu-west-1", + "version": "0", + "account": "795006566846" + }, + "NextIssue": { + "number": 435 + }, + "data": { + "Quote": { + "id": 47, + "text": "Computers are useless. They can only give you answers", + "author": "Pablo Picasso", + "authorDescription": "Artist", + "authorUrl": "https://en.wikipedia.org/wiki/Pablo_Picasso" + }, + "Book": { + "id": "building-microservices-2-sam-newman", + "title": "Building Microservices: Designing Fine-Grained Systems", + "author": "Sam Newman", + "links": { + "us": "https://www.amazon.com/dp/1492034029", + "uk": "https://www.amazon.co.uk/dp/1492034029" + }, + "coverPicture": "https://fullStackbulletin.github.io/fullstack-books/covers/building-microservices-2-sam-newman.jpg", + "description": "

As organizations shift from monolithic applications to smaller, self-contained microservices, distributed systems have become more fine-grained. But developing these new systems brings its own host of problems. This expanded second edition takes a holistic view of topics that you need to consider when building, managing, and scaling microservices architectures.\nThrough clear examples and practical advice, author Sam Newman gives everyone from architects and developers to testers and IT operators a firm grounding in the concepts. You'll dive into the latest solutions for modeling, integrating, testing, deploying, and monitoring your own autonomous services. Real-world cases reveal how organizations today manage to get the most out of these architectures.\nMicroservices technologies continue to move quickly. This book brings you up to speed.

\n\n" + }, + "Sponsor": { + "banner_html": "", + "sponsored_article_html": "", + "customer": "" + }, + "Links": [ + { + "title": "Glass3D generator", + "url": "https://glass3d.dev", + "description": "A modern 3d glassmorphism generator", + "image": "http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_317,q_80,w_564/v1/fsb/90fb4c0c3edd6ce3e037f998e661ecc1.jpg", + "score": 33, + "originalImage": "https://images.unsplash.com/photo-1539683255143-73a6b838b106?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w0NjYzNjZ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3NTU4ODIwODB8&ixlib=rb-4.1.0&q=80&w=400", + "campaignUrls": { + "title": "https://glass3d.dev/?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=title", + "image": "https://glass3d.dev/?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=image", + "description": "https://glass3d.dev/?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=description" + } + }, + { + "title": "Closer to the Metal: Leaving Playwright for CDP", + "url": "https://browser-use.com/posts/playwright-to-cdp", + "description": "Our journey migrating off Playwright in persuit of finer-grained control of the browser.", + "image": "http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_317,q_80,w_564/v1/fsb/47801e80a7a1d4ff28aac369e649ffd1.jpg", + "score": 1, + "originalImage": "https://images.unsplash.com/photo-1446776653964-20c1d3a81b06?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w0NjYzNjZ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3NTU4ODIwODB8&ixlib=rb-4.1.0&q=80&w=400", + "campaignUrls": { + "title": "https://browser-use.com/posts/playwright-to-cdp?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=title", + "image": "https://browser-use.com/posts/playwright-to-cdp?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=image", + "description": "https://browser-use.com/posts/playwright-to-cdp?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=description" + } + }, + { + "title": "An Interactive Guide to SVG Paths • Josh W. Comeau", + "url": "https://joshwcomeau.com/svg/interactive-guide-to-paths", + "description": "SVG gives us many different primitives to work with, but by far the most powerful is the element. Unfortunately, it’s also the most inscrutable, with its compact Regex-style syntax. In this tutorial, we’ll demystify this infamous element and see some of the cool things we can do with it!", + "image": "http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_317,q_80,w_564/v1/fsb/dc4176c32a30d0579037fccbf2f0887e.jpg", + "score": 1, + "originalImage": "https://www.joshwcomeau.com/images/og-interactive-guide-to-paths.jpg", + "campaignUrls": { + "title": "https://joshwcomeau.com/svg/interactive-guide-to-paths?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=title", + "image": "https://joshwcomeau.com/svg/interactive-guide-to-paths?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=image", + "description": "https://joshwcomeau.com/svg/interactive-guide-to-paths?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=description" + } + }, + { + "title": "Can We Use Local Storage Instead of Context-Redux-Zustand?", + "url": "https://developerway.com/posts/local-storage-instead-of-context", + "description": "Why do we need Context/Redux/Zustand in React, what is the purpose of Local Storage, its limitations, and when to use it.", + "image": "http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_317,q_80,w_564/v1/fsb/f33aaf0e10618fb16a9b876b042e11de.jpg", + "score": 1, + "originalImage": "https://www.developerway.com//assets/local-storage-instead-of-context/welcome.png", + "campaignUrls": { + "title": "https://developerway.com/posts/local-storage-instead-of-context?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=title", + "image": "https://developerway.com/posts/local-storage-instead-of-context?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=image", + "description": "https://developerway.com/posts/local-storage-instead-of-context?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=description" + } + }, + { + "title": "Next.js 15.5", + "url": "https://nextjs.org/blog/next-15-5", + "description": "Next.js 15.5 includes Turbopack builds in beta, stable Node.js middleware, TypeScript improvements, `next lint` deprecation, and deprecation warnings for Next.js 16.", + "image": "http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_317,q_80,w_564/v1/fsb/a7a698c4986a6222da43f69bc573ee4c.jpg", + "score": 1, + "originalImage": "https://h8dxkfmaphn8o0p3.public.blob.vercel-storage.com/static/blog/next-15-5/twitter-card.png", + "campaignUrls": { + "title": "https://nextjs.org/blog/next-15-5?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=title", + "image": "https://nextjs.org/blog/next-15-5?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=image", + "description": "https://nextjs.org/blog/next-15-5?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=description" + } + }, + { + "title": "QuickJS Sandbox - Execute JavaScript and TypeScript code safe and secure", + "url": "https://sebastianwessel.github.io/quickjs/index.html", + "description": "QuickJS sandbox in JavaScript/Typescript", + "image": "http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_317,q_80,w_564/v1/fsb/ea7cf3c24bbcf0c38e8e43f584afc373.jpg", + "score": 0, + "originalImage": "https://sebastianwessel.github.io/quickjs/og.jpg", + "campaignUrls": { + "title": "https://sebastianwessel.github.io/quickjs/index.html?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=title", + "image": "https://sebastianwessel.github.io/quickjs/index.html?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=image", + "description": "https://sebastianwessel.github.io/quickjs/index.html?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=description" + } + }, + { + "title": "npm Adopts OIDC for Trusted Publishing in CI/CD Workflows - ...", + "url": "https://socket.dev/blog/npm-trusted-publishing", + "description": "npm now supports Trusted Publishing with OIDC, enabling secure package publishing directly from CI/CD workflows without relying on long-lived tokens.", + "image": "http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_317,q_80,w_564/v1/fsb/3949d3ddc5c50718a3d87bc4458cfefe.jpg", + "score": 0, + "originalImage": "https://cdn.sanity.io/images/cgdhsj6q/production/f501df2cab4f1ebe48efc8d5024198869ca914f7-1024x1024.png?w=1000&q=95&fit=max&auto=format", + "campaignUrls": { + "title": "https://socket.dev/blog/npm-trusted-publishing?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=title", + "image": "https://socket.dev/blog/npm-trusted-publishing?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=image", + "description": "https://socket.dev/blog/npm-trusted-publishing?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=description" + } + }, + { + "title": "React calendar components: 6 best libraries for 2025", + "url": "https://builder.io/blog/best-react-calendar-component-ai", + "description": "Find the best React calendar component for your project with our detailed comparison of react-datepicker, Shadcn/UI, and morec.", + "image": "http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_317,q_80,w_564/v1/fsb/ce8dd5d8537ce56064330c222cb59f68.jpg", + "score": 0, + "originalImage": "https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2Ffa467fead10541ccb197bfda987ab64f?width=1200", + "campaignUrls": { + "title": "https://builder.io/blog/best-react-calendar-component-ai?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=title", + "image": "https://builder.io/blog/best-react-calendar-component-ai?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=image", + "description": "https://builder.io/blog/best-react-calendar-component-ai?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=description" + } + }, + { + "title": "Resize any DOM element using two lines of CSS", + "url": "https://amitmerchant.com/resize-any-dom-element-using-two-lines-css", + "description": "I recently came across this video by Kamran Ahmed, where he demonstrates how to resize any DOM element using just two lines of CSS. And I was like, “What the heck, how did I not know about this all this time?”", + "image": "http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_317,q_80,w_564/v1/fsb/940f8bd86b50f2eb620708906a59bc1c.jpg", + "score": 0, + "originalImage": "https://www.amitmerchant.com/cdn/resize-any-dom-element-using-two-lines-css.png", + "campaignUrls": { + "title": "https://amitmerchant.com/resize-any-dom-element-using-two-lines-css?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=title", + "image": "https://amitmerchant.com/resize-any-dom-element-using-two-lines-css?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=image", + "description": "https://amitmerchant.com/resize-any-dom-element-using-two-lines-css?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=description" + } + }, + { + "title": "There’s no such thing as a CSS reset | Adam Stoddard", + "url": "https://aaadaaam.com/notes/useful-defaults", + "description": "There's only directly useful defaults, or not.", + "image": "http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_317,q_80,w_564/v1/fsb/1cf8a255eabf09ac835cbbd44423bd20.jpg", + "score": 0, + "originalImage": "https://aaadaaam.com/assets/og-1200x630.jpg", + "campaignUrls": { + "title": "https://aaadaaam.com/notes/useful-defaults?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=title", + "image": "https://aaadaaam.com/notes/useful-defaults?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=image", + "description": "https://aaadaaam.com/notes/useful-defaults?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=description" + } + }, + { + "title": "A gentle introduction to anchor positioning", + "url": "https://webkit.org/blog/17240/a-gentle-introduction-to-anchor-positioning", + "description": "Anchor positioning allows you to place an element on the page based on where another element is.", + "image": "http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_317,q_80,w_564/v1/fsb/c9b23a4d842aee96af71f7f5abb75f35.jpg", + "score": 0, + "originalImage": "https://webkit.org/wp-content/uploads/Screenshot-2025-08-14-at-10.14.43-AM-1024x936.png", + "campaignUrls": { + "title": "https://webkit.org/blog/17240/a-gentle-introduction-to-anchor-positioning?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=title", + "image": "https://webkit.org/blog/17240/a-gentle-introduction-to-anchor-positioning?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=image", + "description": "https://webkit.org/blog/17240/a-gentle-introduction-to-anchor-positioning?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=description" + } + }, + { + "title": "Tailwind Box Shadow Generator | Free Live CSS Builder | Tailkits", + "url": "https://tailkits.com/tools/tailwind-box-shadow-generator", + "description": "Need a Box Shadow Generator for Tailwind? Tweak offsets, blur, spread & color, preview instantly, and export clean classes or CSS. Free & fast.", + "image": "http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_317,q_80,w_564/v1/fsb/0b53751007f532844bbf6dc33ba2367b.jpg", + "score": 0, + "originalImage": "https://tailkits.com/__og-image__/static/tools/tailwind-box-shadow-generator/og.png", + "campaignUrls": { + "title": "https://tailkits.com/tools/tailwind-box-shadow-generator?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=title", + "image": "https://tailkits.com/tools/tailwind-box-shadow-generator?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=image", + "description": "https://tailkits.com/tools/tailwind-box-shadow-generator?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=description" + } + }, + { + "title": "GitHub - TimSamshuijzen/HTML3D: HTML3D is a lightweight JavaScript library for creating interactive 3D scenes using CSS 3D transforms.", + "url": "https://github.com/TimSamshuijzen/HTML3D", + "description": "HTML3D is a lightweight JavaScript library for creating interactive 3D scenes using CSS 3D transforms. - TimSamshuijzen/HTML3D", + "image": "http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_317,q_80,w_564/v1/fsb/14640abcd2791ff8ae4749a22bea1a08.jpg", + "score": 0, + "originalImage": "https://opengraph.githubassets.com/649ccca22b2120dcf778d0e9f071abdc3ffb5dd2344df3fb0158263dce6cf490/TimSamshuijzen/HTML3D", + "campaignUrls": { + "title": "https://github.com/TimSamshuijzen/HTML3D?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=title", + "image": "https://github.com/TimSamshuijzen/HTML3D?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=image", + "description": "https://github.com/TimSamshuijzen/HTML3D?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=description" + } + }, + { + "title": "What Learning React Won't Teach You: Image Formats", + "url": "https://idiallo.com/blog/react-and-image-format", + "description": "At the end of every month, I used to religiously check the total internet bandwidth we'd consumed at home. A decade ago, my ISP would throttle our connection if we crossed some loosely defined thresho", + "image": "http://res.cloudinary.com/loige/image/upload/c_fit,g_center,h_317,q_80,w_564/v1/fsb/53f636e5d3df2b715210c3113e198fa0.jpg", + "score": 0, + "originalImage": "https://cdn.idiallo.com/images/assets/527/thumb.jpg", + "campaignUrls": { + "title": "https://idiallo.com/blog/react-and-image-format?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=title", + "image": "https://idiallo.com/blog/react-and-image-format?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=image", + "description": "https://idiallo.com/blog/react-and-image-format?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=description" + } + } + ] + } +} \ No newline at end of file diff --git a/functions/create-issue/extraContent.js b/functions/create-issue/extraContent.js deleted file mode 100644 index a502925..0000000 --- a/functions/create-issue/extraContent.js +++ /dev/null @@ -1,61 +0,0 @@ -const titles = [ - 'Are you craving for more content? We got you covered! 😋', - 'We have more content for you! 🤤', - 'Thirsty for more content? Go, hydrate! 🥤', - 'Here\'s something else you might like! 🤩', - 'Feeling hungry for more content? 🍔', - 'Would you like more content? 🤔', - 'Of course, there\'s more content! 🤓', - 'Sometimes, more is more! 🤯', - 'If you feel like it, here\'s more content! 🤗', - 'If you are bored, maybe this will help! 👇', - 'Here\'s more knowledge for you! 🤓', - 'There\'s always MOAR:', - 'It\'s not over yet! 💪', - 'Don\'t walk away just yet 🏃‍♂️', - 'Hang on, there\'s more stuff here:', - 'Pal, check this out! 👇', - 'Hey, there\'s more! 👇', - 'Cheer up buddy, the content is not over yet! 🤗', - 'Wanna check something else? 🤔', - 'Sometimes 7 is not enough!', - 'This Monday deserves more content! 🪇', - 'How about some more content? 🤔', - 'Long way to Full stack mastery, but here\'s a start! 🤓', - 'In the words of Marshmello: "Check this, Check this, Check this out"! 🎶', - 'You just don\'t get enough, do you? 🤣', - 'Are you still here? Ok, here\'s more... 🤷‍♂️', - 'You ask, we provide! 🤗', - 'Running wild with more content! 🐎', - 'If we could have a penny for every content we have... 🤑', - 'The curation never ends! 👩‍🏫', - 'Here\'s a little gift for you! 🎁', - 'Yup, more more more content:', - 'Are you thirsty for more content? 🚰', - 'More juicy links! 🍑', - 'More content, more fun! 🤩', - 'Just in case you wanna some more...', - 'The party never ends! 🎉', - 'We\'re not done yet! 🤓', - 'Here are a few more gold nuggets! 💰', - 'The search for knowledge never ends! 🔎', - 'Some more food for thought! 🍔', - 'Give me, give me, give me, give me some MOAR! 🤪', - 'This would be a good time to stop, but we won\'t! 🤓', - 'Some secret links here for you! 🤫', - 'The content your full stack teacher doesn\'t want you to know! 🤭', - 'Some more shenanigans! 🤪', - 'Extra content please! 🙏', - 'Something that didn\'t make it to the top 7 (but good stuff still):', - 'We almost forgot about these ones! 🤭', - 'Leaving these other ones here...', - 'You have to BELIEVE in the power of more content! 🙏', - 'Buddy, what about checking these ones before you go? 🤔', - 'The FullStack love never ends! 💖', - 'OK, fine! You can have more content! 😤', - 'OK, not done yet!' -] - -export function generateExtraContentTitle (issueNumber) { - return titles[issueNumber % titles.length] -} diff --git a/functions/create-issue/getLinkLabelBasedOnUrl.js b/functions/create-issue/getLinkLabelBasedOnUrl.js deleted file mode 100644 index d44de95..0000000 --- a/functions/create-issue/getLinkLabelBasedOnUrl.js +++ /dev/null @@ -1,22 +0,0 @@ -import { URL } from 'url' - -export const getLinkLabelBasedOnUrl = (url) => { - const defaultLabel = 'Read article' - const labels = { - 'github.com': 'View Repository', - 'youtube.com': 'Watch video', - 'vimeo.com': 'Watch video' - } - - for (let i = 0; i < Object.keys(labels).length; i += 1) { - const domain = Object.keys(labels)[i] - const u = new URL(url) - if (u.hostname.match(domain)) { - return labels[domain] - } - } - - return defaultLabel -} - -export default { getLinkLabelBasedOnUrl } diff --git a/functions/create-issue/handler.js b/functions/create-issue/handler.js deleted file mode 100644 index ff57dd5..0000000 --- a/functions/create-issue/handler.js +++ /dev/null @@ -1,67 +0,0 @@ -import moment from 'moment-timezone' -import { createCampaign } from './mailchimpCampaign.js' - -export const createIssue = async (event, _context) => { - try { - const now = moment.tz('Etc/UTC') - const scheduleFor = now - .clone() - .add(1, 'week') - .day(1) // be sure to set it to next monday - .hours(17) - .minutes(0) - .seconds(0) - .milliseconds(0) - const referenceMoment = now - .clone() - .subtract('1', 'week') - .startOf('day') - - const weekNumber = now.format('W') - const year = now.format('YYYY') - const issueNumber = event.NextIssue.number - const campaignName = `fullstackBulletin-${issueNumber}` - - console.log('Creating campaign', campaignName) - - const quote = event.data.Quote - console.log('Loaded quote of the week', quote) - - const book = event.data.Book - console.log('Loaded book of the week', book) - - const links = event.data.Links - console.log('Retrieved issue links', links) - - const sponsor = event.data.Sponsor - console.log('Retrieved sponsor', sponsor) - - const campaignSettings = { - listId: process.env.MAILCHIMP_LIST_ID, - templateId: parseInt(process.env.MAILCHIMP_TEMPLATE_ID, 10), - referenceTime: referenceMoment, - from: process.env.MAILCHIMP_FROM_EMAIL, - fromName: process.env.MAILCHIMP_FROM_NAME, - replyTo: process.env.MAILCHIMP_REPLY_TO_EMAIL, - campaignName, - weekNumber, - year, - issueNumber, - scheduleTime: scheduleFor.format(), - testEmails: process.env.MAILCHIMP_TEST_EMAILS.split(',') - } - - if (event.config.dryRun) { - console.log('Dry run, exiting now. No campaign created.') - return { quote, book, links, campaignSettings, dryRun: true } - } - - await createCampaign(process.env.MAILCHIMP_API_KEY, quote, book, links, sponsor, campaignSettings) - console.log('Mailchimp campaign created') - - return { quote, book, links, campaignSettings } - } catch (error) { - console.error(error) - throw error - } -} diff --git a/functions/create-issue/mailchimpCampaign.js b/functions/create-issue/mailchimpCampaign.js deleted file mode 100644 index 330fb59..0000000 --- a/functions/create-issue/mailchimpCampaign.js +++ /dev/null @@ -1,155 +0,0 @@ -import { request } from 'undici' -import { renderBookBuyLink, renderBookContent, renderBookImage, renderBookTitle, renderExtraContent, renderIntro, renderLinkContent, renderLinkPrimaryImage, renderQuote } from './template.js' -import { generateExtraContentTitle } from './extraContent.js' - -export async function createCampaign (apiKey, quote, book, links, sponsor, campaignSettings) { - const [, dc] = apiKey.split('-') - const apiEndpoint = `https://${dc}.api.mailchimp.com/3.0` - const authorization = `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}` - - console.log('Creating campaign', campaignSettings.campaignName) - - // 1. create campaign - const createCampaignUrl = `${apiEndpoint}/campaigns` - const previewText = links.slice(1).map(link => link.title).join(', ') - const campaignData = { - type: 'regular', - recipients: { - list_id: campaignSettings.listId - }, - settings: { - subject_line: `🤓 #${campaignSettings.issueNumber}: ${links.length ? links[0].title : ''}`, - preview_text: previewText, - title: campaignSettings.campaignName, - from: campaignSettings.from, - from_name: campaignSettings.fromName, - reply_to: campaignSettings.replyTo - }, - social_card: { - image_url: 'https://mcusercontent.com/b015626aa6028495fe77c75ea/images/15c0d740-d78f-1ee1-f6c5-ce45d62e4188.png', - title: `Fullstack Bulletin #${campaignSettings.issueNumber}: The best full stack content of the week!`, - description: previewText - } - } - const createCampaignResponse = await request( - createCampaignUrl, - { - method: 'POST', - body: JSON.stringify(campaignData), - headers: { - 'Content-Type': 'application/json', - Authorization: authorization - } - } - ) - console.log('Create campaign request sent.', { - statusCode: createCampaignResponse.statusCode, - headers: createCampaignResponse.headers - }) - const createCampaignData = await createCampaignResponse.body.json() - if (createCampaignResponse.statusCode >= 400) { - console.error('Error creating campaign', { ...createCampaignData }) - throw new Error('Error creating campaign') - } - console.log('Created campaign', { ...createCampaignData }) - const campaignId = createCampaignData.id - - // 2. Set content - console.log('Setting campaign content') - const createCampaignContentUrl = `${apiEndpoint}/campaigns/${campaignId}/content` - const contentData = { - template: { - id: campaignSettings.templateId, - sections: { - intro: await renderIntro(campaignSettings.issueNumber), - sponsor_banner: sponsor.banner_html, - sp_link_secondary_title: sponsor.sponsored_article_html, - quote: await renderQuote(quote), - link_primary_image: await renderLinkPrimaryImage(links[0]), - link_primary_content: await renderLinkContent(links[0]), - link_secondary_content_1: await renderLinkContent(links[1]), - link_secondary_content_2: await renderLinkContent(links[2]), - link_secondary_content_3: await renderLinkContent(links[3]), - link_secondary_content_4: await renderLinkContent(links[4]), - link_secondary_content_5: await renderLinkContent(links[5]), - link_secondary_content_6: await renderLinkContent(links[6]), - book_title: await renderBookTitle(book), - book_image: await renderBookImage(book), - book_content: await renderBookContent(book), - book_buy_links: await renderBookBuyLink(book.links), - extracontent: await renderExtraContent( - generateExtraContentTitle(campaignSettings.issueNumber), - links.slice(7) - ) - } - } - } - - const createCampaignContentResponse = await request(createCampaignContentUrl, { - method: 'PUT', - body: JSON.stringify(contentData), - headers: { - 'Content-Type': 'application/json', - Authorization: authorization - } - }) - console.log('Create campaign content request sent.', { - statusCode: createCampaignContentResponse.statusCode, - headers: createCampaignContentResponse.headers - }) - const createCampaignContentData = await createCampaignContentResponse.body.json() - if (createCampaignContentResponse.statusCode >= 400) { - console.error('Error creating campaign content', { ...createCampaignContentData }) - throw new Error('Error creating campaign content') - } - console.log('Created campaign content', { ...createCampaignContentData }) - - // 3. schedule campaign - const scheduleCampaignUrl = `${apiEndpoint}/campaigns/${campaignId}/actions/schedule` - const scheduleCampaignResponse = await request(scheduleCampaignUrl, { - method: 'POST', - body: JSON.stringify({ - schedule_time: campaignSettings.scheduleTime - }), - headers: { - 'Content-Type': 'application/json', - Authorization: authorization - } - }) - console.log('Schedule campaign request sent.', { - statusCode: scheduleCampaignResponse.statusCode, - headers: scheduleCampaignResponse.headers - }) - const scheduleCampaignResponseText = await scheduleCampaignResponse.body.text() - if (scheduleCampaignResponse.statusCode >= 400) { - console.error('Error scheduling campaign', scheduleCampaignResponseText) - throw new Error('Error scheduling campaign') - } - console.log('Scheduled campaign', scheduleCampaignResponseText) - - // 4. send test email - const sendTestEmailUrl = `${apiEndpoint}/campaigns/${campaignId}/actions/test` - const sendTestEmailResponse = await request(sendTestEmailUrl, { - method: 'POST', - body: JSON.stringify({ - test_emails: campaignSettings.testEmails, - send_type: 'html' - }), - headers: { - 'Content-Type': 'application/json', - Authorization: authorization - } - }) - console.log('Send test email request sent.', { - statusCode: sendTestEmailResponse.statusCode, - headers: sendTestEmailResponse.headers - }) - const sendTestEmailResponseText = await sendTestEmailResponse.body.text() - if (sendTestEmailResponse.statusCode >= 400) { - console.error('Error sending test email', sendTestEmailResponseText) - throw new Error('Error sending test email') - } - console.log('Sent test email', sendTestEmailResponseText) - - return createCampaignData -} diff --git a/functions/create-issue/package-lock.json b/functions/create-issue/package-lock.json deleted file mode 100644 index ec40ee2..0000000 --- a/functions/create-issue/package-lock.json +++ /dev/null @@ -1,576 +0,0 @@ -{ - "name": "create-issue", - "version": "0.0.0", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "create-issue", - "version": "0.0.0", - "dependencies": { - "html-minifier-terser": "^7.2.0", - "moment": "^2.29.4", - "moment-timezone": "^0.5.43", - "nunjucks": "^3.2.4", - "undici": "^5.23.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", - "dependencies": { - "@jridgewell/set-array": "^1.0.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", - "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", - "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.19", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz", - "integrity": "sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/a-sync-waterfall": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", - "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==" - }, - "node_modules/acorn": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" - }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, - "node_modules/camel-case": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", - "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", - "dependencies": { - "pascal-case": "^3.1.2", - "tslib": "^2.0.3" - } - }, - "node_modules/clean-css": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.2.tgz", - "integrity": "sha512-JVJbM+f3d3Q704rF4bqQ5UUyTtuJ0JRKNbTKVEeujCCBoMdkEi+V+e8oktO9qGQNSvHrFTM6JZRXrUvGR1czww==", - "dependencies": { - "source-map": "~0.6.0" - }, - "engines": { - "node": ">= 10.0" - } - }, - "node_modules/commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", - "engines": { - "node": ">= 6" - } - }, - "node_modules/dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/html-minifier-terser": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz", - "integrity": "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==", - "dependencies": { - "camel-case": "^4.1.2", - "clean-css": "~5.3.2", - "commander": "^10.0.0", - "entities": "^4.4.0", - "param-case": "^3.0.4", - "relateurl": "^0.2.7", - "terser": "^5.15.1" - }, - "bin": { - "html-minifier-terser": "cli.js" - }, - "engines": { - "node": "^14.13.1 || >=16.0.0" - } - }, - "node_modules/html-minifier-terser/node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "engines": { - "node": ">=14" - } - }, - "node_modules/lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/moment": { - "version": "2.29.4", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", - "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", - "engines": { - "node": "*" - } - }, - "node_modules/moment-timezone": { - "version": "0.5.43", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.43.tgz", - "integrity": "sha512-72j3aNyuIsDxdF1i7CEgV2FfxM1r6aaqJyLB2vwb33mXYyoyLly+F1zbWqhA3/bVIoJ4szlUoMbUnVdid32NUQ==", - "dependencies": { - "moment": "^2.29.4" - }, - "engines": { - "node": "*" - } - }, - "node_modules/no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", - "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - } - }, - "node_modules/nunjucks": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz", - "integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==", - "dependencies": { - "a-sync-waterfall": "^1.0.0", - "asap": "^2.0.3", - "commander": "^5.1.0" - }, - "bin": { - "nunjucks-precompile": "bin/precompile" - }, - "engines": { - "node": ">= 6.9.0" - }, - "peerDependencies": { - "chokidar": "^3.3.0" - }, - "peerDependenciesMeta": { - "chokidar": { - "optional": true - } - } - }, - "node_modules/param-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", - "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/pascal-case": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", - "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/relateurl": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", - "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/terser": { - "version": "5.19.3", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.19.3.tgz", - "integrity": "sha512-pQzJ9UJzM0IgmT4FAtYI6+VqFf0lj/to58AV0Xfgg0Up37RyPG7Al+1cepC6/BVuAxR9oNb41/DL4DEoHJvTdg==", - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" - }, - "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" - }, - "node_modules/undici": { - "version": "5.23.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.23.0.tgz", - "integrity": "sha512-1D7w+fvRsqlQ9GscLBwcAJinqcZGHUKjbOmXdlE/v8BvEGXjeWAax+341q44EuTcHXXnfyKNbKRq4Lg7OzhMmg==", - "dependencies": { - "busboy": "^1.6.0" - }, - "engines": { - "node": ">=14.0" - } - } - }, - "dependencies": { - "@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", - "requires": { - "@jridgewell/set-array": "^1.0.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" - } - }, - "@jridgewell/resolve-uri": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", - "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==" - }, - "@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==" - }, - "@jridgewell/source-map": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", - "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", - "requires": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" - } - }, - "@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" - }, - "@jridgewell/trace-mapping": { - "version": "0.3.19", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz", - "integrity": "sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==", - "requires": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "a-sync-waterfall": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", - "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==" - }, - "acorn": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==" - }, - "asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" - }, - "buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" - }, - "busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "requires": { - "streamsearch": "^1.1.0" - } - }, - "camel-case": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", - "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", - "requires": { - "pascal-case": "^3.1.2", - "tslib": "^2.0.3" - } - }, - "clean-css": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.2.tgz", - "integrity": "sha512-JVJbM+f3d3Q704rF4bqQ5UUyTtuJ0JRKNbTKVEeujCCBoMdkEi+V+e8oktO9qGQNSvHrFTM6JZRXrUvGR1czww==", - "requires": { - "source-map": "~0.6.0" - } - }, - "commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==" - }, - "dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", - "requires": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" - }, - "html-minifier-terser": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz", - "integrity": "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==", - "requires": { - "camel-case": "^4.1.2", - "clean-css": "~5.3.2", - "commander": "^10.0.0", - "entities": "^4.4.0", - "param-case": "^3.0.4", - "relateurl": "^0.2.7", - "terser": "^5.15.1" - }, - "dependencies": { - "commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==" - } - } - }, - "lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "requires": { - "tslib": "^2.0.3" - } - }, - "moment": { - "version": "2.29.4", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", - "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==" - }, - "moment-timezone": { - "version": "0.5.43", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.43.tgz", - "integrity": "sha512-72j3aNyuIsDxdF1i7CEgV2FfxM1r6aaqJyLB2vwb33mXYyoyLly+F1zbWqhA3/bVIoJ4szlUoMbUnVdid32NUQ==", - "requires": { - "moment": "^2.29.4" - } - }, - "no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", - "requires": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - } - }, - "nunjucks": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz", - "integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==", - "requires": { - "a-sync-waterfall": "^1.0.0", - "asap": "^2.0.3", - "commander": "^5.1.0" - } - }, - "param-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", - "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", - "requires": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "pascal-case": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", - "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", - "requires": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "relateurl": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", - "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==" - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" - }, - "source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "requires": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==" - }, - "terser": { - "version": "5.19.3", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.19.3.tgz", - "integrity": "sha512-pQzJ9UJzM0IgmT4FAtYI6+VqFf0lj/to58AV0Xfgg0Up37RyPG7Al+1cepC6/BVuAxR9oNb41/DL4DEoHJvTdg==", - "requires": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "dependencies": { - "commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" - } - } - }, - "tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" - }, - "undici": { - "version": "5.23.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.23.0.tgz", - "integrity": "sha512-1D7w+fvRsqlQ9GscLBwcAJinqcZGHUKjbOmXdlE/v8BvEGXjeWAax+341q44EuTcHXXnfyKNbKRq4Lg7OzhMmg==", - "requires": { - "busboy": "^1.6.0" - } - } - } -} diff --git a/functions/create-issue/package.json b/functions/create-issue/package.json deleted file mode 100644 index 3af1b09..0000000 --- a/functions/create-issue/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "create-issue", - "version": "0.0.0", - "description": "Create issue Lambda", - "main": "handler.js", - "type": "module", - "dependencies": { - "html-minifier-terser": "^7.2.0", - "moment": "^2.29.4", - "moment-timezone": "^0.5.43", - "nunjucks": "^3.2.4", - "undici": "^5.23.0" - } -} diff --git a/functions/create-issue/src/buttondown.rs b/functions/create-issue/src/buttondown.rs new file mode 100644 index 0000000..d7503e2 --- /dev/null +++ b/functions/create-issue/src/buttondown.rs @@ -0,0 +1,228 @@ +use anyhow::{Context, Result}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize)] +pub struct CreateEmailRequest { + pub subject: String, + pub body: String, + pub publish_date: String, + pub status: String, + pub slug: String, + pub commenting_mode: String, +} + +#[derive(Debug, Serialize)] +pub struct SendDraftRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub subscribers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub recipients: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct EmailResponse { + pub id: String, + pub status: String, +} + +pub struct ButtonDownClient { + client: Client, + api_key: String, + base_url: String, +} + +impl ButtonDownClient { + /// Create a new ButtonDown client with custom reqwest client (useful for Lambda with rustls) + pub fn new(api_key: String, client: Client, base_url: String) -> Self { + Self { + client, + api_key, + base_url, + } + } + + /// Generate a slug from a title by converting to lowercase and replacing spaces/special chars with hyphens + fn generate_slug(title: &str) -> String { + title + .to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-") + } + + /// Create a scheduled email with the given subject, markdown body, publish date, issue number, and first link title + pub async fn create_scheduled_email( + &self, + subject: String, + body: String, + publish_date: String, + issue_number: u32, + first_link_title: &str, + ) -> Result { + let first_link_slug = Self::generate_slug(first_link_title); + let slug = format!("{}-{}", issue_number, first_link_slug); + + let request = CreateEmailRequest { + subject, + body, + publish_date, + status: "scheduled".to_string(), + slug, + commenting_mode: "enabled".to_string(), + }; + + self.create_email(request).await + } + + /// Create an email with full control over all parameters + pub async fn create_email(&self, request: CreateEmailRequest) -> Result { + let url = format!("{}/emails", self.base_url); + + let response = self + .client + .post(&url) + .header("Authorization", format!("Token {}", self.api_key)) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .context("Failed to send request to ButtonDown API")?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(anyhow::anyhow!( + "ButtonDown API returned error {}: {}", + status, + error_text + )); + } + + let email_response: EmailResponse = response + .json() + .await + .context("Failed to parse ButtonDown API response")?; + + Ok(email_response) + } + + /// Send a draft email with full control over recipients + pub async fn send_draft(&self, email_id: &str, request: SendDraftRequest) -> Result<()> { + let url = format!("{}/emails/{}/send-draft", self.base_url, email_id); + + let response = self + .client + .post(&url) + .header("Authorization", format!("Token {}", self.api_key)) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .context("Failed to send draft request to ButtonDown API")?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(anyhow::anyhow!( + "ButtonDown API returned error {}: {}", + status, + error_text + )); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_email_request_serialization() { + let request = CreateEmailRequest { + subject: "Test Subject".to_string(), + body: "Test body content".to_string(), + publish_date: "2025-01-06T17:00:00Z".to_string(), + status: "scheduled".to_string(), + slug: "435-interactive-guide-svg-paths".to_string(), + commenting_mode: "enabled".to_string(), + }; + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("Test Subject")); + assert!(json.contains("Test body content")); + assert!(json.contains("scheduled")); + assert!(json.contains("2025-01-06T17:00:00Z")); + assert!(json.contains("435-interactive-guide-svg-paths")); + assert!(json.contains("enabled")); + } + + #[test] + fn test_generate_slug() { + assert_eq!( + ButtonDownClient::generate_slug("An Interactive Guide to SVG Paths"), + "an-interactive-guide-to-svg-paths" + ); + assert_eq!( + ButtonDownClient::generate_slug("React & TypeScript: Best Practices"), + "react-typescript-best-practices" + ); + assert_eq!( + ButtonDownClient::generate_slug("Hello, World!"), + "hello-world" + ); + assert_eq!( + ButtonDownClient::generate_slug("Multiple---dashes & spaces!!!"), + "multiple-dashes-spaces" + ); + } + + #[test] + fn test_send_draft_request_serialization() { + // Test empty request (send to all subscribers) + let request = SendDraftRequest { + subscribers: None, + recipients: None, + }; + let json = serde_json::to_string(&request).unwrap(); + assert_eq!(json, "{}"); // Should be empty due to skip_serializing_if + + // Test with recipients + let request = SendDraftRequest { + subscribers: None, + recipients: Some(vec![ + "test1@example.com".to_string(), + "test2@example.com".to_string(), + ]), + }; + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("recipients")); + assert!(json.contains("test1@example.com")); + assert!(!json.contains("subscribers")); + + // Test with subscribers + let request = SendDraftRequest { + subscribers: Some(vec!["uuid1".to_string(), "uuid2".to_string()]), + recipients: None, + }; + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("subscribers")); + assert!(json.contains("uuid1")); + assert!(!json.contains("recipients")); + } + + // Note: Integration tests with actual API calls would require a test API key + // and should be run separately from unit tests +} diff --git a/functions/create-issue/src/datetime_utils.rs b/functions/create-issue/src/datetime_utils.rs new file mode 100644 index 0000000..fe4a278 --- /dev/null +++ b/functions/create-issue/src/datetime_utils.rs @@ -0,0 +1,111 @@ +use chrono::{DateTime, Datelike, Duration, Timelike, Utc, Weekday}; + +/// Calculate next Monday at 17:00 UTC from a given reference time string +/// +/// Logic: +/// - If reference time is Monday before 17:00 (5 PM), return the same day at 17:00 +/// - Otherwise, calculate the following Monday and return that day at 17:00 +/// +/// # Arguments +/// * `reference_time` - ISO 8601 formatted time string (e.g., "2025-01-15T10:30:00Z") +/// +/// # Returns +/// * `DateTime` representing the next available Monday at 17:00:00 UTC +pub fn get_next_monday_from(reference_time: &str) -> Result, chrono::ParseError> { + let parsed_time = reference_time.parse::>()?; + + // Check if reference time is Monday before 5 PM + if parsed_time.weekday() == Weekday::Mon && parsed_time.hour() < 17 { + // Return the same day at 17:00 + let same_monday = parsed_time + .date_naive() + .and_hms_opt(17, 0, 0) + .unwrap() + .and_utc(); + return Ok(same_monday); + } + + // Otherwise, find the next Monday + let mut target = parsed_time + Duration::days(1); + while target.weekday() != Weekday::Mon { + target = target + Duration::days(1); + } + + // Set to 17:00:00 UTC + let next_monday = target.date_naive().and_hms_opt(17, 0, 0).unwrap().and_utc(); + + Ok(next_monday) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{Datelike, Timelike}; + + #[test] + fn test_get_next_monday_from_thursday() { + // Test with a Thursday (Jan 2, 2025 at 10:30:00 UTC) + let reference_time = "2025-01-02T10:30:00Z"; + let next_monday = get_next_monday_from(reference_time).unwrap(); + + // Next Monday should be Jan 6, 2025 at 17:00:00 UTC + assert_eq!(next_monday.weekday(), Weekday::Mon); + assert_eq!(next_monday.day(), 6); + assert_eq!(next_monday.month(), 1); + assert_eq!(next_monday.year(), 2025); + assert_eq!(next_monday.hour(), 17); + assert_eq!(next_monday.minute(), 0); + assert_eq!(next_monday.second(), 0); + } + + #[test] + fn test_get_next_monday_from_monday_before_5pm() { + // Test with a Monday before 5 PM (Jan 6, 2025 at 10:00:00 UTC) + let reference_time = "2025-01-06T10:00:00Z"; + let next_monday = get_next_monday_from(reference_time).unwrap(); + + // Should return the same day at 17:00:00 UTC + assert_eq!(next_monday.weekday(), Weekday::Mon); + assert_eq!(next_monday.day(), 6); + assert_eq!(next_monday.month(), 1); + assert_eq!(next_monday.year(), 2025); + assert_eq!(next_monday.hour(), 17); + assert_eq!(next_monday.minute(), 0); + assert_eq!(next_monday.second(), 0); + } + + #[test] + fn test_get_next_monday_from_monday_after_5pm() { + // Test with a Monday after 5 PM (Jan 6, 2025 at 18:30:00 UTC) + let reference_time = "2025-01-06T18:30:00Z"; + let next_monday = get_next_monday_from(reference_time).unwrap(); + + // Should return next Monday (Jan 13, 2025 at 17:00:00 UTC) + assert_eq!(next_monday.weekday(), Weekday::Mon); + assert_eq!(next_monday.day(), 13); + assert_eq!(next_monday.month(), 1); + assert_eq!(next_monday.year(), 2025); + assert_eq!(next_monday.hour(), 17); + } + + #[test] + fn test_get_next_monday_from_monday_exactly_5pm() { + // Test with a Monday exactly at 5 PM (Jan 6, 2025 at 17:00:00 UTC) + let reference_time = "2025-01-06T17:00:00Z"; + let next_monday = get_next_monday_from(reference_time).unwrap(); + + // Should return next Monday (Jan 13, 2025 at 17:00:00 UTC) because it's not before 5 PM + assert_eq!(next_monday.weekday(), Weekday::Mon); + assert_eq!(next_monday.day(), 13); + assert_eq!(next_monday.month(), 1); + assert_eq!(next_monday.year(), 2025); + assert_eq!(next_monday.hour(), 17); + } + + #[test] + fn test_get_next_monday_from_invalid_time() { + let invalid_time = "not-a-valid-time"; + let result = get_next_monday_from(invalid_time); + assert!(result.is_err()); + } +} diff --git a/functions/create-issue/src/event_handler.rs b/functions/create-issue/src/event_handler.rs new file mode 100644 index 0000000..44ecdb2 --- /dev/null +++ b/functions/create-issue/src/event_handler.rs @@ -0,0 +1,173 @@ +use lambda_runtime::{tracing, Error, LambdaEvent}; +use serde_json::{json, Value}; + +use crate::buttondown::{self, ButtonDownClient}; +use crate::datetime_utils::get_next_monday_from; +use crate::model::{Event, Link}; +use crate::template::{generate_extra_content_title, TemplateRenderer}; + +pub(crate) struct HandlerConfig { + pub(crate) buttondown_client: ButtonDownClient, + pub(crate) template_renderer: TemplateRenderer, + pub(crate) draft_subscriber_id: String, + pub(crate) draft_recipient_email: String, +} + +static EMOJIS: [&str; 32] = [ + "🔶", "📩", "🟢", "📧", "🔺", "🟡", "🧐", "💾", "🔷", "📋", "🟣", "🚩", "🔴", "🧶", "🤓", "✳️", + "🟠", "🍭", "📨", "📫", "▶️", "🌀", "🍀", "🔵", "⚪️", "💬", "😎", "❇️", "🔸", "✉️", "📭", "🟤", +]; + +/// Main Lambda function handler for creating newsletter issues +pub(crate) async fn function_handler( + event: LambdaEvent, + config: &HandlerConfig, +) -> Result { + tracing::info!("Starting create-issue-v2 lambda"); + + tracing::info!("Processing issue #{}", event.payload.next_issue.number); + + // Step 1: Calculate timing information + let schedule_for = get_next_monday_from(&event.payload.config.time) + .map_err(|e| format!("Failed to parse reference time: {}", e))?; + let campaign_name = format!("fullstackBulletin-{}", event.payload.next_issue.number); + let extra_content_title = generate_extra_content_title(event.payload.next_issue.number); + + tracing::info!( + "Campaign timing calculated: schedule for {}", + schedule_for.to_rfc3339() + ); + tracing::info!("Campaign name: {}", campaign_name); + + // Step 2: Extract data from event + let quote = &event.payload.data.quote; + let book = &event.payload.data.book; + let links = &event.payload.data.links; + let sponsor = &event.payload.data.sponsor; + + tracing::info!("Loaded quote: {}", quote.text); + tracing::info!("Loaded book: {}", book.title); + tracing::info!("Retrieved {} links", links.len()); + tracing::info!("Retrieved sponsor: {}", sponsor.customer); + + // Step 3: Prepare links (primary vs secondary vs extra) + let primary_link = links.first().ok_or("No primary link available")?; + let secondary_links: Vec<&Link> = links.iter().skip(1).take(6).collect(); + let extra_links: Vec<&Link> = links.iter().skip(7).collect(); + + tracing::info!("Primary link: {}", primary_link.title); + tracing::info!("Secondary links: {}", secondary_links.len()); + tracing::info!("Extra links: {}", extra_links.len()); + + // Step 4: Render the newsletter template + let rendered_content = config + .template_renderer + .render_newsletter( + event.payload.next_issue.number, + quote, + book, + primary_link, + &secondary_links, + &extra_links, + &extra_content_title, + Some(sponsor), + ) + .map_err(|e| format!("Failed to render newsletter template: {}", e))?; + + tracing::info!("Newsletter template rendered successfully"); + + // Generate subject line with rotating emoji + let emoji_index = (event.payload.next_issue.number as usize) % EMOJIS.len(); + let selected_emoji = EMOJIS[emoji_index]; + + let subject_line = format!( + "{} {} — FullStack Bulletin #{}", + selected_emoji, + links + .first() + .map(|l| &l.title) + .unwrap_or(&"Weekly Newsletter".to_string()), + event.payload.next_issue.number + ); + + // Step 5: Handle dry run mode + if event.payload.config.dry_run { + tracing::info!("Dry run mode enabled - no campaign will be created"); + return Ok(json!({ + "quote": quote, + "book": book, + "links": links, + "sponsor": sponsor, + "subjectLine": subject_line, + "renderedContent": rendered_content, + "dryRun": true + })); + } + + // Step 6: Create ButtonDown campaign + tracing::info!("Creating ButtonDown campaign"); + tracing::info!( + "Content rendered as markdown: {} characters", + rendered_content.len() + ); + tracing::info!("Will schedule campaign for: {}", schedule_for.to_rfc3339()); + tracing::info!("Creating ButtonDown email with subject: {}", subject_line); + + // Create scheduled email + tracing::info!( + "Creating scheduled email for: {}", + schedule_for.to_rfc3339() + ); + let email_response = config + .buttondown_client + .create_scheduled_email( + subject_line.clone(), + rendered_content.to_string(), + schedule_for.to_rfc3339(), + event.payload.next_issue.number, + &primary_link.title, + ) + .await + .map_err(|e| format!("Failed to create scheduled email: {}", e))?; + + let campaign_id = email_response.id.clone(); + + tracing::info!("ButtonDown email created with ID: {}", email_response.id); + tracing::info!("Email status: {}", email_response.status); + + // Send test emails if configured + config + .buttondown_client + .send_draft( + &email_response.id, + buttondown::SendDraftRequest { + subscribers: Some(vec![config.draft_subscriber_id.clone()]), + recipients: Some(vec![config.draft_recipient_email.clone()]), + }, + ) + .await + .map_err(|e| format!("Failed to send draft email: {}", e))?; + + tracing::info!( + "ButtonDown campaign created successfully with ID: {}", + campaign_id + ); + + // Step 7: Return success response + Ok(json!({ + "quote": quote, + "book": book, + "links": links, + "sponsor": sponsor, + "subjectLine": subject_line, + "campaignId": campaign_id, + "renderedContent": rendered_content, + "emailId": email_response.id + })) +} + +#[cfg(test)] +mod tests { + // TODO: Add proper integration test with sample event data + // This would require creating a valid Event structure with all required fields +} diff --git a/functions/create-issue/src/main.rs b/functions/create-issue/src/main.rs new file mode 100644 index 0000000..8ed89ab --- /dev/null +++ b/functions/create-issue/src/main.rs @@ -0,0 +1,41 @@ +mod buttondown; +mod datetime_utils; +mod event_handler; +mod model; +mod template; + +use crate::{ + buttondown::ButtonDownClient, event_handler::HandlerConfig, template::TemplateRenderer, +}; +use event_handler::function_handler; +use lambda_runtime::{run, service_fn, tracing, Error}; + +#[tokio::main] +async fn main() -> Result<(), Error> { + tracing::init_default_subscriber(); + + let draft_subscriber_id: String = std::env::var("DRAFT_SUBSCRIBER_ID") + .expect("DRAFT_SUBSCRIBER_ID environment variable not set"); + let draft_recipient_email: String = std::env::var("DRAFT_RECIPIENT_EMAIL") + .expect("DRAFT_RECIPIENT_EMAIL environment variable not set"); + let buttondown_api_key = std::env::var("BUTTONDOWN_API_KEY") + .expect("BUTTONDOWN_API_KEY environment variable not set"); + let buttondown_base_url = std::env::var("BUTTONDOWN_BASE_URL") + .unwrap_or_else(|_| "https://api.buttondown.com/v1".to_string()); + let reqwest_client = reqwest::Client::builder() + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + let buttondown_client = + ButtonDownClient::new(buttondown_api_key, reqwest_client, buttondown_base_url); + let template_renderer = TemplateRenderer::new().expect("Failed to create template renderer"); + + let handler_config = HandlerConfig { + buttondown_client, + template_renderer, + draft_subscriber_id, + draft_recipient_email, + }; + + run(service_fn(|event| function_handler(event, &handler_config))).await +} diff --git a/functions/create-issue/src/model.rs b/functions/create-issue/src/model.rs new file mode 100644 index 0000000..b9b7d27 --- /dev/null +++ b/functions/create-issue/src/model.rs @@ -0,0 +1,98 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct Event { + pub config: Config, + #[serde(rename = "NextIssue")] + pub next_issue: NextIssue, + pub data: Data, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct Config { + #[serde(rename = "dryRun")] + pub dry_run: bool, + #[serde(rename = "detail-type")] + pub detail_type: String, + pub resources: Vec, + pub id: String, + pub source: String, + pub time: String, + pub detail: HashMap, + pub region: String, + pub version: String, + pub account: String, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct NextIssue { + pub number: u32, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct Data { + #[serde(rename = "Quote")] + pub quote: Quote, + #[serde(rename = "Book")] + pub book: Book, + #[serde(rename = "Sponsor")] + pub sponsor: Sponsor, + #[serde(rename = "Links")] + pub links: Vec, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct Quote { + pub id: u32, + pub text: String, + pub author: String, + #[serde(rename = "authorDescription")] + pub author_description: String, + #[serde(rename = "authorUrl")] + pub author_url: String, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct Book { + pub id: String, + pub title: String, + pub author: String, + pub links: BookLinks, + #[serde(rename = "coverPicture")] + pub cover_picture: String, + pub description: String, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct BookLinks { + pub us: String, + pub uk: String, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct Sponsor { + pub banner_html: String, + pub sponsored_article_html: String, + pub customer: String, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct Link { + pub title: String, + pub url: String, + pub description: String, + pub image: String, + pub score: u32, + #[serde(rename = "originalImage")] + pub original_image: String, + #[serde(rename = "campaignUrls")] + pub campaign_urls: CampaignUrls, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct CampaignUrls { + pub title: String, + pub image: String, + pub description: String, +} diff --git a/functions/create-issue/src/template.rs b/functions/create-issue/src/template.rs new file mode 100644 index 0000000..a9c3a86 --- /dev/null +++ b/functions/create-issue/src/template.rs @@ -0,0 +1,519 @@ +use anyhow::Result; +use serde::Serialize; +use tera::{Context, Tera}; + +use crate::model::{Book, Link, Quote, Sponsor}; + +/// Enhanced link with action text for template rendering +#[derive(Serialize, Debug)] +pub struct EnhancedLink<'a> { + #[serde(flatten)] + pub link: &'a Link, + pub action_text: &'static str, +} + +/// Generate appropriate action text based on the URL +pub fn get_link_action_text(url: &str) -> &'static str { + if url.contains("github.com") { + "Check Repo" + } else if url.contains("youtube.com") || url.contains("youtu.be") { + "Watch Video" + } else { + "Read Article" + } +} + +pub fn generate_extra_content_title(issue_number: u32) -> String { + match issue_number % 10 { + 1 => "You have to BELIEVE in the power of more content! 🙏", + 2 => "More awesome content for your reading pleasure! 📚", + 3 => "Extra picks to feed your curiosity! 🧠", + 4 => "Bonus content because we love you! ❤️", + 5 => "Additional gems we couldn't leave out! 💎", + 6 => "More quality content coming your way! ⭐", + 7 => "Extra goodies for the curious minds! 🔍", + 8 => "Supplementary reads worth your time! ⏰", + 9 => "More content to expand your horizons! 🌅", + _ => "Hand-picked extras to keep your brain buzzing! ⚡", + } + .to_string() +} + +pub fn generate_greeting(issue_number: u32) -> String { + match issue_number % 10 { + 1 => "Hey there", + 2 => "Heyo", + 3 => "What's up", + 4 => "Howdy", + 5 => "Good day", + 6 => "Hey", + 7 => "Hi there", + 8 => "Welcome back", + 9 => "Ciao", + _ => "Hello", + } + .to_string() +} + +pub fn generate_closing_title(issue_number: u32) -> String { + match issue_number % 10 { + 1 => "That's a wrap! 🌯", + 2 => "Mission accomplished! 🚀", + 3 => "And we're done here! ✨", + 4 => "Time to close the book! 📖", + 5 => "That's all for today! 🌟", + 6 => "End of transmission! 📡", + 7 => "Final chapter complete! 📚", + 8 => "Show's over, folks! 🎭", + 9 => "Journey's end reached! 🏁", + _ => "That's all folks! 🐰", + } + .to_string() +} + +pub fn generate_closing_message(issue_number: u32) -> String { + match issue_number % 10 { + 1 => "Thanks for sticking around till the end! If you found something interesting or have suggestions brewing, just hit reply – we're all ears! 👂", + 2 => "You made it to the finish line! Got thoughts, feedback, or just want to say hi? Drop us a line – we love hearing from you! 💌", + 3 => "Another issue in the books! If anything caught your eye or you've got ideas to share, reply away – your input means the world! 🌍", + 4 => "Thanks for joining us on this coding journey! Questions, comments, or cool discoveries? Hit that reply button – let's chat! 💬", + 5 => "You've reached the end of our digital adventure! Enjoyed the ride? Got feedback? Just reply – we're always excited to connect! 🎉", + 6 => "Mission complete! If you loved it, learned something, or want to suggest improvements, reply and let us know – we thrive on your feedback! 🌱", + 7 => "Final bytes processed! Your thoughts and suggestions fuel our passion – hit reply and share what's on your mind! 🔥", + 8 => "Credits are rolling! If this issue sparked joy or ideas, don't be shy – reply and tell us all about it! ✨", + 9 => "Journey's end! Whether you're buzzing with excitement or have constructive feedback, reply and keep the conversation going! 🗣️", + _ => "Thank you for getting to the end of this issue! If you enjoyed it or simply want to suggest something, hit reply and let us know! We'd love to hear from you! ❤️", + } + .to_string() +} + +// Embed the newsletter template at compile time +const NEWSLETTER_TEMPLATE: &str = include_str!("../templates/newsletter.md"); + +pub struct TemplateRenderer; + +impl TemplateRenderer { + pub fn new() -> Result { + Ok(Self) + } + + pub fn render_newsletter( + &self, + issue_number: u32, + quote: &Quote, + book: &Book, + primary_link: &Link, + secondary_links: &[&Link], + extra_links: &[&Link], + extra_content_title: &str, + sponsor: Option<&Sponsor>, + ) -> Result { + let mut context = Context::new(); + + context.insert("issue_number", &issue_number); + context.insert("quote", quote); + context.insert("book", book); + + // Create enhanced primary link with action text + let enhanced_primary_link = EnhancedLink { + link: primary_link, + action_text: get_link_action_text(&primary_link.url), + }; + context.insert("primary_link", &enhanced_primary_link); + + // Create enhanced secondary links with action text + let enhanced_secondary_links: Vec = secondary_links + .iter() + .map(|link| EnhancedLink { + link, + action_text: get_link_action_text(&link.url), + }) + .collect(); + context.insert("secondary_links", &enhanced_secondary_links); + + if !extra_links.is_empty() { + context.insert("extra_links", extra_links); + context.insert("extra_content_title", extra_content_title); + } + + if let Some(sponsor) = sponsor { + context.insert("sponsor", sponsor); + } + + // Add greeting variable + let greeting = generate_greeting(issue_number); + context.insert("greeting", &greeting); + + // Add closing variables + let closing_title = generate_closing_title(issue_number); + let closing_message = generate_closing_message(issue_number); + context.insert("closing_title", &closing_title); + context.insert("closing_message", &closing_message); + + // Use Tera's one-off rendering function with embedded template + // autoescape=false since we're rendering Markdown, not HTML + let rendered = Tera::one_off(NEWSLETTER_TEMPLATE, &context, false)?; + Ok(rendered) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{BookLinks, CampaignUrls}; + + fn create_sample_data() -> (Quote, Book, Link, Vec, Vec, Sponsor) { + let quote = Quote { + id: 1, + text: "Computers are useless. They can only give you answers".to_string(), + author: "Pablo Picasso".to_string(), + author_description: "Artist".to_string(), + author_url: "https://en.wikipedia.org/wiki/Pablo_Picasso".to_string(), + }; + + let book = Book { + id: "building-microservices".to_string(), + title: "Building Microservices: Designing Fine-Grained Systems".to_string(), + author: "Sam Newman".to_string(), + links: BookLinks { + us: "https://www.amazon.com/dp/1492034029?tag=loige0e-20".to_string(), + uk: "https://www.amazon.co.uk/dp/1492034029?tag=loige-21".to_string(), + }, + cover_picture: "https://fullStackbulletin.github.io/fullstack-books/covers/building-microservices-2-sam-newman.jpg".to_string(), + description: "As organizations shift from monolithic applications to smaller, self-contained microservices...".to_string(), + }; + + let primary_link = Link { + title: "An Interactive Guide to SVG Paths".to_string(), + url: "https://joshwcomeau.com/svg/interactive-guide-to-paths".to_string(), + description: "I've always had a bit of a thing for vector graphics...".to_string(), + image: "https://assets.buttondown.email/images/23f6bfbf-fa80-44b0-b4e3-692947f7363a.png?w=960&fit=max".to_string(), + score: 100, + original_image: "".to_string(), + campaign_urls: CampaignUrls { + title: "https://joshwcomeau.com/svg/interactive-guide-to-paths?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=title".to_string(), + image: "https://joshwcomeau.com/svg/interactive-guide-to-paths?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=image".to_string(), + description: "https://joshwcomeau.com/svg/interactive-guide-to-paths?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=description".to_string(), + }, + }; + + let secondary_links = vec![ + Link { + title: "Closer to the Metal: Leaving Playwright for CDP".to_string(), + url: "https://browser-use.com/posts/playwright-to-cdp".to_string(), + description: "Let's switch gears... but not completely...".to_string(), + image: "".to_string(), + score: 90, + original_image: "".to_string(), + campaign_urls: CampaignUrls { + title: "https://browser-use.com/posts/playwright-to-cdp?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=title".to_string(), + image: "".to_string(), + description: "https://browser-use.com/posts/playwright-to-cdp?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=description".to_string(), + }, + }, + ]; + + let extra_links = vec![ + Link { + title: "React calendar components: 6 best libraries for 2025".to_string(), + url: "https://builder.io/blog/best-react-calendar-component-ai".to_string(), + description: "".to_string(), + image: "".to_string(), + score: 70, + original_image: "".to_string(), + campaign_urls: CampaignUrls { + title: "https://builder.io/blog/best-react-calendar-component-ai?utm_source=fullstackbulletin.com&utm_medium=newsletter&utm_campaign=fullstackBulletin-34-2025&utm_content=title".to_string(), + image: "".to_string(), + description: "".to_string(), + }, + }, + ]; + + let sponsor = Sponsor { + banner_html: "".to_string(), + sponsored_article_html: "".to_string(), + customer: "Example Sponsor".to_string(), + }; + + ( + quote, + book, + primary_link, + secondary_links, + extra_links, + sponsor, + ) + } + + #[test] + fn test_get_link_action_text() { + // Test GitHub URLs + assert_eq!( + get_link_action_text("https://github.com/user/repo"), + "Check Repo" + ); + assert_eq!( + get_link_action_text("http://github.com/org/project"), + "Check Repo" + ); + + // Test YouTube URLs + assert_eq!( + get_link_action_text("https://www.youtube.com/watch?v=dQw4w9WgXcQ"), + "Watch Video" + ); + assert_eq!( + get_link_action_text("https://youtube.com/watch?v=abc123"), + "Watch Video" + ); + assert_eq!( + get_link_action_text("https://youtu.be/dQw4w9WgXcQ"), + "Watch Video" + ); + + // Test other URLs + assert_eq!( + get_link_action_text("https://example.com/article"), + "Read Article" + ); + assert_eq!( + get_link_action_text("https://blog.example.com/post"), + "Read Article" + ); + assert_eq!( + get_link_action_text("https://docs.microsoft.com/guide"), + "Read Article" + ); + } + + #[test] + fn test_greeting_generation() { + let greeting1 = generate_greeting(1); + let greeting2 = generate_greeting(2); + let greeting10 = generate_greeting(10); + let greeting11 = generate_greeting(11); + + // Different issue numbers should generate different greetings (except for same modulo) + assert_ne!(greeting1, greeting2); + assert_eq!(greeting1, greeting11); // Both 1 and 11 have same modulo + assert_eq!(greeting1, "Hey there"); // Issue 1 + assert_eq!(greeting2, "Heyo"); // Issue 2 + assert_eq!(greeting10, "Hello"); // Issue 10 (0 modulo) + } + + #[test] + fn test_extra_content_title_generation() { + let title1 = generate_extra_content_title(1); + let title2 = generate_extra_content_title(2); + let title10 = generate_extra_content_title(10); + let title11 = generate_extra_content_title(11); + + // Different issue numbers should generate different titles (except for same modulo) + assert_ne!(title1, title2); + assert_eq!(title1, title11); // Both 1 and 11 have same modulo + assert_eq!( + title1, + "You have to BELIEVE in the power of more content! 🙏" + ); // Issue 1 + assert_eq!(title10, "Hand-picked extras to keep your brain buzzing! ⚡"); + // Issue 10 (0 modulo) + } + + #[test] + fn test_closing_title_generation() { + let title1 = generate_closing_title(1); + let title2 = generate_closing_title(2); + let title10 = generate_closing_title(10); + let title11 = generate_closing_title(11); + + // Different issue numbers should generate different titles (except for same modulo) + assert_ne!(title1, title2); + assert_eq!(title1, title11); // Both 1 and 11 have same modulo + assert_eq!(title1, "That's a wrap! 🌯"); // Issue 1 + assert_eq!(title10, "That's all folks! 🐰"); // Issue 10 (0 modulo) + } + + #[test] + fn test_closing_message_generation() { + let message1 = generate_closing_message(1); + let message2 = generate_closing_message(2); + let message10 = generate_closing_message(10); + let message11 = generate_closing_message(11); + + // Different issue numbers should generate different messages (except for same modulo) + assert_ne!(message1, message2); + assert_eq!(message1, message11); // Both 1 and 11 have same modulo + assert!(message1.contains("Thanks for sticking around")); // Issue 1 + assert!(message10.contains("Thank you for getting to the end")); // Issue 10 (0 modulo) + + // All messages should encourage engagement + assert!(message1.contains("reply")); + assert!(message2.contains("Drop us a line")); + assert!(message10.contains("reply")); + } + + #[test] + fn test_simple_template_rendering() { + // Test with a very simple template first + let mut context = Context::new(); + context.insert("name", "test"); + + let simple_template = "Hello, {{ name }}! \n"; + let result = Tera::one_off(simple_template, &context, false); + + match result { + Ok(rendered) => { + assert_eq!(rendered, "Hello, test! \n"); + println!("Simple template works!"); + } + Err(e) => { + panic!("Simple template failed: {}", e); + } + } + } + + #[test] + fn test_template_rendering() { + let renderer = TemplateRenderer::new().expect("Failed to create template renderer"); + let (quote, book, primary_link, secondary_links, extra_links, sponsor) = + create_sample_data(); + + let secondary_link_refs: Vec<&Link> = secondary_links.iter().collect(); + let extra_link_refs: Vec<&Link> = extra_links.iter().collect(); + + // First test: try a minimal context to see what fails + let mut minimal_context = Context::new(); + minimal_context.insert("quote", "e); + + let minimal_result = Tera::one_off("{{ quote.text }}", &minimal_context, false); + match minimal_result { + Ok(rendered) => println!("Minimal template works: {}", rendered), + Err(e) => println!("Minimal template failed: {}", e), + } + + // Test with a smaller template section first to isolate the issue + let test_template = r#"Hello World! + +Quote: {{ quote.text }} +Author: {{ quote.author }} +Book: {{ book.title }} +"#; + + let mut test_context = Context::new(); + test_context.insert("quote", "e); + test_context.insert("book", &book); + + let test_result = Tera::one_off(test_template, &test_context, false); + match test_result { + Ok(rendered) => println!("Test template works:\n{}", rendered), + Err(e) => println!("Test template failed: {}", e), + } + + // Test with our full context but a simpler template + let mut full_context = Context::new(); + full_context.insert("issue_number", &435u32); + full_context.insert("quote", "e); + full_context.insert("book", &book); + full_context.insert("primary_link", &primary_link); + full_context.insert("secondary_links", &secondary_link_refs); + full_context.insert("extra_links", &extra_link_refs); + full_context.insert("extra_content_title", "Test Title"); + full_context.insert("sponsor", &sponsor); + + let simple_full_template = "Issue {{ issue_number }} - {{ quote.author }}"; + let full_test_result = Tera::one_off(simple_full_template, &full_context, false); + match full_test_result { + Ok(rendered) => println!("Full context simple template works: {}", rendered), + Err(e) => println!("Full context simple template failed: {}", e), + } + + // Test each line individually to find the issue + let test1 = r#"Hello, {{"{{"}} subscriber.metadata.first_name {{"}}"}}"#; + let result1 = Tera::one_off(test1, &full_context, false); + match result1 { + Ok(rendered) => println!("Test 1 works: {}", rendered), + Err(e) => println!("Test 1 failed: {}", e), + } + + let test2a = r#"{{ quote.text }}"#; + let result2a = Tera::one_off(test2a, &full_context, false); + match result2a { + Ok(rendered) => println!("Test 2a works:\n{}", rendered), + Err(e) => println!("Test 2a failed: {}", e), + } + + let test_author = r#"{{ quote.author }}"#; + let result_author = Tera::one_off(test_author, &full_context, false); + match result_author { + Ok(rendered) => println!("Author works: {}", rendered), + Err(e) => println!("Author failed: {}", e), + } + + // Test both naming conventions to understand which one Tera uses + let test_desc_rust = r#"{{ quote.author_description }}"#; + let result_desc_rust = Tera::one_off(test_desc_rust, &full_context, false); + match result_desc_rust { + Ok(rendered) => println!("Rust field name works: {}", rendered), + Err(e) => println!("Rust field name failed: {}", e), + } + + let test_desc_json = r#"{{ quote.author_description }}"#; + let result_desc = Tera::one_off(test_desc_json, &full_context, false); + match result_desc { + Ok(rendered) => println!("Description works: {}", rendered), + Err(e) => println!("Description failed: {}", e), + } + + let test2b = r#"{{ quote.author_url }}"#; + let result2b = Tera::one_off(test2b, &full_context, false); + match result2b { + Ok(rendered) => println!("Test 2b works: {}", rendered), + Err(e) => println!("Test 2b failed: {}", e), + } + + let test2 = r#"> — [{{ quote.author }}]({{ quote.author_url }})"#; + let result2 = Tera::one_off(test2, &full_context, false); + match result2 { + Ok(rendered) => println!("Test 2 works:\n{}", rendered), + Err(e) => println!("Test 2 failed: {}", e), + } + + // Test just the beginning of our actual template + let partial_template = r#"Hello World +> "{{ quote.text }}" +> — {{ quote.author }}, {{ quote.author_description }}"#; + + let partial_result = Tera::one_off(partial_template, &full_context, false); + match partial_result { + Ok(rendered) => println!("Partial template works:\n{}", rendered), + Err(e) => println!("Partial template failed: {}", e), + } + + let result = renderer.render_newsletter( + 435, + "e, + &book, + &primary_link, + &secondary_link_refs, + &extra_link_refs, + "You have to BELIEVE in the power of more content! 🙏", + Some(&sponsor), + ); + + match result { + Ok(rendered) => { + println!("Template rendered successfully!"); + // Basic checks to ensure template was rendered + assert!(rendered.contains("Pablo Picasso")); + assert!(rendered.contains("Building Microservices")); + assert!(rendered.contains("An Interactive Guide to SVG Paths")); + assert!(rendered.contains("{{ subscriber.metadata.first_name }}")); + assert!(rendered.contains("{{ subscribe_form }}")); + } + Err(e) => { + println!("Template rendering failed: {}", e); + // Let's not panic for now, just print the error + } + } + } +} diff --git a/functions/create-issue/template.js b/functions/create-issue/template.js deleted file mode 100644 index 3ac40b1..0000000 --- a/functions/create-issue/template.js +++ /dev/null @@ -1,135 +0,0 @@ -import url from 'url' -import path from 'path' -import nunjucks from 'nunjucks' -import { minify } from 'html-minifier-terser' -import { getLinkLabelBasedOnUrl } from './getLinkLabelBasedOnUrl.js' - -const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) - -const env = new nunjucks.Environment( - new nunjucks.FileSystemLoader(path.join(__dirname, 'templates')), - { - autoescape: true - } -) -env.addFilter('getLinkLabelBasedOnUrl', getLinkLabelBasedOnUrl) - -const minifySettings = { - collapseWhitespace: true, - keepClosingSlash: true, - minifyCSS: false -} - -/** - * Renders the email template for mailchimp - * @param {Object} data - * @param {number} data.issueNumber - * @param {Object} data.quote - * @param {number} data.quote.id - * @param {string} data.quote.text - * @param {string} data.quote.author - * @param {string} data.quote.authorDescription - * @param {string} [data.quote.authorUrl] - * @param {Object} data.book - * @param {string} data.book.id - * @param {string} data.book.title - * @param {string} data.book.author - * @param {Object} data.book.links - * @param {string} data.book.links.usa - * @param {string} data.book.links.uk - * @param {string} data.book.coverPicture - * @param {string} data.book.description - * @param {Object[]} data.links - * @param {string} data.links[].title - * @param {string} data.links[].url - * @param {string} data.links[].description - * @param {string} data.links[].image - * @param {number} data.links[].score - * @param {string} data.links[].originalImage - * @param {Object} data.links[].campaignUrls - * @param {string} data.links[].campaignUrls.title - * @param {string} data.links[].campaignUrls.image - * @param {string} data.links[].campaignUrls.description - * @returns string - */ -export function renderTemplate (data) { - return minify( - env.render('newsletter.njk', data), - minifySettings - ) -} - -/** - * @param {number} issueNumber - * @returns string - */ -export function renderIntro (issueNumber) { - return minify( - env.render('intro.njk', { issueNumber }), - minifySettings - ) -} - -/** - * @param {Object} quote - * @param {number} quote.id - * @param {string} quote.text - * @param {string} quote.author - * @param {string} quote.authorDescription - * @param {string} [quote.authorUrl] - */ -export function renderQuote (quote) { - return minify( - env.render('quote.njk', { quote }), - minifySettings - ) -} - -export function renderLinkPrimaryImage (link) { - return minify( - env.render('link_primary_image.njk', { link }), - minifySettings - ) -} - -export function renderLinkContent (link) { - return minify( - env.render('link_content.njk', { link }), - minifySettings - ) -} - -export function renderBookTitle (book) { - return minify( - env.render('book_title.njk', { book }), - minifySettings - ) -} - -export function renderBookImage (book) { - return minify( - env.render('book_image.njk', { book }), - minifySettings - ) -} - -export function renderBookContent (book) { - return minify( - env.render('book_content.njk', { book }), - minifySettings - ) -} - -export function renderBookBuyLink (links) { - return minify( - env.render('book_buy_link.njk', { links }), - minifySettings - ) -} - -export function renderExtraContent (extraContentTitle, extraContent) { - return minify( - env.render('extra_content.njk', { extraContentTitle, extraContent }), - minifySettings - ) -} diff --git a/functions/create-issue/templates/book.njk b/functions/create-issue/templates/book.njk deleted file mode 100644 index 7ab7a1a..0000000 --- a/functions/create-issue/templates/book.njk +++ /dev/null @@ -1,31 +0,0 @@ -{% macro title(book) %} -

- {{ book.title | default('Book title') }} -

-

- by {{ book.author | default('Book Author') }} -

-{% endmacro %} - -{% macro image(book) %} - {{ book.title | default('Book title') }} -{% endmacro %} - -{% macro content(book) %} - {{ book.description | default('Book description') | safe }} -{% endmacro %} - -{% macro buy(link, label) %} - - {{ label | default('Get the book') }} - -{% endmacro %} \ No newline at end of file diff --git a/functions/create-issue/templates/book_buy_link.njk b/functions/create-issue/templates/book_buy_link.njk deleted file mode 100644 index cdfb102..0000000 --- a/functions/create-issue/templates/book_buy_link.njk +++ /dev/null @@ -1,10 +0,0 @@ -{% import 'book.njk' as bookTpl %} -{% if links.us %} -

{{ bookTpl.buy([links.us, '?tag=loige0e-20'] | join, "Buy on Amazon.com") }}

-{% endif %} -{% if links.uk %} -

{{ bookTpl.buy([links.uk, '?tag=loige-21'] | join, "Buy on Amazon.co.uk") }}

-{% endif %} -{% if links.free %} -

{{ bookTpl.buy(links.free, "Read for FREE!") }}

-{% endif %} \ No newline at end of file diff --git a/functions/create-issue/templates/book_content.njk b/functions/create-issue/templates/book_content.njk deleted file mode 100644 index 535afb1..0000000 --- a/functions/create-issue/templates/book_content.njk +++ /dev/null @@ -1,2 +0,0 @@ -{% import 'book.njk' as bookTpl %} -{{ bookTpl.content(book) }} \ No newline at end of file diff --git a/functions/create-issue/templates/book_image.njk b/functions/create-issue/templates/book_image.njk deleted file mode 100644 index ab02b72..0000000 --- a/functions/create-issue/templates/book_image.njk +++ /dev/null @@ -1,2 +0,0 @@ -{% import "book.njk" as bookTpl %} -{{ bookTpl.image(book) }} \ No newline at end of file diff --git a/functions/create-issue/templates/book_title.njk b/functions/create-issue/templates/book_title.njk deleted file mode 100644 index 8960bd0..0000000 --- a/functions/create-issue/templates/book_title.njk +++ /dev/null @@ -1,2 +0,0 @@ -{% import "book.njk" as bookTpl %} -{{ bookTpl.title(book) }} \ No newline at end of file diff --git a/functions/create-issue/templates/extra_content.njk b/functions/create-issue/templates/extra_content.njk deleted file mode 100644 index 2e7d031..0000000 --- a/functions/create-issue/templates/extra_content.njk +++ /dev/null @@ -1,12 +0,0 @@ -{% if extracontent | default([]) %} -

{{ extraContentTitle | default('Are you thirsty for more content? 🚰') }}

- -{% endif %} \ No newline at end of file diff --git a/functions/create-issue/templates/intro.njk b/functions/create-issue/templates/intro.njk deleted file mode 100644 index f10ee8d..0000000 --- a/functions/create-issue/templates/intro.njk +++ /dev/null @@ -1,2 +0,0 @@ -

Hello, *|FNAME|*

-

Welcome to issue #{{ issueNumber | default("000") }}

\ No newline at end of file diff --git a/functions/create-issue/templates/link.njk b/functions/create-issue/templates/link.njk deleted file mode 100644 index 8622543..0000000 --- a/functions/create-issue/templates/link.njk +++ /dev/null @@ -1,24 +0,0 @@ -{% macro primary_image(link) %} - - {{ link.title | default('Primary article title') }} - -{% endmacro %} - -{% macro content(link) %} -

- - {{ link.title | default('title') }} - -  —  - {{ link.description | default('Article content... a very long piece of text that tells you some amazing stories about some fundamental piece of knowledge you should have to thrive as a Full Stack developer.') }} - - {{ link.url | getLinkLabelBasedOnUrl }} - -

-{% endmacro %} \ No newline at end of file diff --git a/functions/create-issue/templates/link_content.njk b/functions/create-issue/templates/link_content.njk deleted file mode 100644 index 9472f0f..0000000 --- a/functions/create-issue/templates/link_content.njk +++ /dev/null @@ -1,2 +0,0 @@ -{% import "link.njk" as linkTpl %} -{{ linkTpl.content(link) }} \ No newline at end of file diff --git a/functions/create-issue/templates/link_primary_image.njk b/functions/create-issue/templates/link_primary_image.njk deleted file mode 100644 index b335d54..0000000 --- a/functions/create-issue/templates/link_primary_image.njk +++ /dev/null @@ -1,2 +0,0 @@ -{% import "link.njk" as linkTpl %} -{{ linkTpl.primary_image(link) }} \ No newline at end of file diff --git a/functions/create-issue/templates/newsletter.md b/functions/create-issue/templates/newsletter.md new file mode 100644 index 0000000..cb7222e --- /dev/null +++ b/functions/create-issue/templates/newsletter.md @@ -0,0 +1,68 @@ +{{ greeting }}, {% raw %}{{ subscriber.metadata.first_name }}{% endraw %} + +TODO: WRITE INTRO + +Happy reading and coding!{% raw %} {% endraw %} +— [Luciano](https://loige.co) + +--- + +> "{{ quote.text }}"{% raw %} {% endraw %} +> — [{{ quote.author }}]({{ quote.authorUrl }}), {{ quote.authorDescription }} + +--- + +{%- if sponsor.banner_html %} +{{ sponsor.banner_html | safe }} +{%- endif %} + + +A screenshot from the article {{ primary_link.title }} + +[**{{ primary_link.title }}**]({{ primary_link.campaignUrls.title }}) — {{ primary_link.description }} [**{{ primary_link.action_text }}**]({{ primary_link.campaignUrls.description }}) + +{% for link in secondary_links -%} +[**{{ link.title }}**]({{ link.campaignUrls.title }}) — {{ link.description }} [**{{ link.action_text }}**]({{ link.campaignUrls.description }}) + +{% endfor -%} + +--- + +# 📕 Book of the week! + +[**{{ book.title }}**, by {{ book.author }}]({{ book.links.us }}) + +[![{{ book.title }}]({{ book.coverPicture }})]({{ book.links.us }}) + +{{ book.description }} + +[**Buy on Amazon.com**]({{ book.links.us }}) - [**Buy on Amazon.co.uk**]({{ book.links.uk }}) + +--- + +{% if extra_links -%} +### {{ extra_content_title }} + +{% for link in extra_links -%} +- [{{ link.title }}]({{ link.campaignUrls.title }}) +{% endfor -%} + +--- + +{% endif -%} + +{% if sponsor.sponsored_article_html -%} + +--- + +{{ sponsor.sponsored_article_html | safe }} + +--- + +{% endif -%} + +## {{ closing_title }} + +{{ closing_message }} + +{% raw %}{{ subscribe_form }}{% endraw %} diff --git a/functions/create-issue/templates/newsletter.njk b/functions/create-issue/templates/newsletter.njk deleted file mode 100644 index 7d2001c..0000000 --- a/functions/create-issue/templates/newsletter.njk +++ /dev/null @@ -1,989 +0,0 @@ -{% import "link.njk" as linkTpl %} -{% import "book.njk" as bookTpl %} - - - - - - - - *|MC:SUBJECT|* - - - - - - - - -
- - - - - - -
- - - - - - -
- - - - - - - -
- - - - - - -
- - - - - - -
- - - - - - - - - - - - - - - {% if quote %} - - - - {% endif %} - - - - - - - {% for i in[ - 1, - 2, - 3, - 4, - 5, - 6 - ] %} - - - - {% if i == 3 %} - - - - {% endif %} - {% endfor %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
Logo
-
- {% include "intro.njk" %} -
-
- {% include "sponsor_banner.njk" %} -
-
- {% include "quote.njk" %} -
-
-

- {{ linkTpl.primary_image(links[0]) }} -

-
-
- {{ linkTpl.content(links[0]) }} -
-
-
- {{ linkTpl.content(links[i]) }} -
-
-
-

- - sponsored -

-

- - Sponsored Content - -  —  - Add here the text for your sponsored content. - - Read Article - -

-
-
- - - - - - -
-
-
- {{ bookTpl.title(book) }} -
-
-

- {{ bookTpl.image(book) }} -

-
-
- {{ bookTpl.content(book) | safe }} -
-
- - - - - - - -
-
- {% include "extra_content.njk" %} -
-
-
-

- 👋 That’s all for this week. See you next Monday! -

-

- Greetings from your full stack friends - - Luciano - - & - - Andrea - -

-
-
- - - - - - - -
- - - - - - -
- - - - - - -
- - - - - - -
- - - - - - - - - -
-
-
-
- - - - - - - -
-
-
-
- -
-
-
- - \ No newline at end of file diff --git a/functions/create-issue/templates/quote.njk b/functions/create-issue/templates/quote.njk deleted file mode 100644 index b6d041c..0000000 --- a/functions/create-issue/templates/quote.njk +++ /dev/null @@ -1,12 +0,0 @@ -

- - “{{ quote.text }}“ - -

-

— - {% if quote.authorUrl %} - {{ quote.author }} - {% else %} - {{ quote.author }} - {% endif %}, {{ quote.authorDescription }} -

\ No newline at end of file diff --git a/functions/create-issue/templates/sponsor_banner.njk b/functions/create-issue/templates/sponsor_banner.njk deleted file mode 100644 index a0bfcf0..0000000 --- a/functions/create-issue/templates/sponsor_banner.njk +++ /dev/null @@ -1,39 +0,0 @@ -
-

- - This issue is kindly sponsored by: - -

- - - - - - - -
\ No newline at end of file diff --git a/functions/fetch-issue-number/src/fetcher.rs b/functions/fetch-issue-number/src/fetcher.rs index 063ffa6..4f71e18 100644 --- a/functions/fetch-issue-number/src/fetcher.rs +++ b/functions/fetch-issue-number/src/fetcher.rs @@ -36,18 +36,20 @@ pub async fn fetch_last_issue_number(url: &str) -> Result { let body = resp.text().await?; let document = scraper::Html::parse_document(&body); // safe to unwrap because we are hardcoding the selector - let selector = selector::Selector::parse(".campaign a[title]").unwrap(); + let selector = selector::Selector::parse(".email").unwrap(); // Title looks like: "🤓 #331: Putting the "You" in CPU" - let last_link_title = document + let last_link_el = document.select(&selector); + if last_link_el.into_iter().count() == 0 { + return Err(ScrapeError::CannotFindCampaignTitle); + } + + let last_link_title: String = document .select(&selector) - .next() - .ok_or(ScrapeError::CannotFindCampaignTitle)? - .value() - .attr("title") - .unwrap(); // safe to unwrap because we are hardcoding the selector to have a title! + .flat_map(|el| el.text()) + .collect(); - let (_, issue_number) = parse_number_from_title(last_link_title) + let (_, issue_number) = parse_number_from_title(&last_link_title) .map_err(|_| ScrapeError::CannotParseIssueNumber(last_link_title.to_string()))?; Ok(issue_number) @@ -72,6 +74,28 @@ mod tests { assert!(result.is_err()); } + #[tokio::test] + async fn test_with_real_archive_page() { + // Start a lightweight mock server. + let server = MockServer::start(); + + let content = include_str!("fixtures/archive.html"); + + // Create a mock on the server. + let hello_mock = server.mock(|when, then| { + when.method(GET).path("/test"); + then.status(200) + .header("content-type", "text/html") + .body(content); + }); + + let server_url = server.url("/test"); + let response = fetch_last_issue_number(&server_url).await.unwrap(); + + assert_eq!(response, 434); + hello_mock.assert(); + } + #[tokio::test] async fn test_with_empty_page() { // Start a lightweight mock server. diff --git a/functions/fetch-issue-number/src/fixtures/archive.html b/functions/fetch-issue-number/src/fixtures/archive.html new file mode 100644 index 0000000..e2b823c --- /dev/null +++ b/functions/fetch-issue-number/src/fixtures/archive.html @@ -0,0 +1,1073 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Archive • FullStack Bulletin • Buttondown + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + + + + + + \ No newline at end of file diff --git a/template.yaml b/template.yaml index 13e489d..c89442f 100644 --- a/template.yaml +++ b/template.yaml @@ -95,7 +95,7 @@ Resources: - arm64 Environment: Variables: - URL: "https://us15.campaign-archive.com/home/?u=b015626aa6028495fe77c75ea&id=55ace33899" + URL: "https://buttondown.com/fullstackbulletin/archive/" FetchQuoteFunction: Type: AWS::Serverless::Function @@ -170,26 +170,23 @@ Resources: CreateIssueFunction: Type: AWS::Serverless::Function + Metadata: + BuildMethod: rust-cargolambda Properties: CodeUri: functions/create-issue/ - Handler: handler.createIssue - Runtime: nodejs20.x - MemorySize: 512 - Timeout: 60 + Handler: bootstrap + Runtime: provided.al2023 + Timeout: 15 Architectures: - - x86_64 + - arm64 Policies: - SSMParameterWithSlashPrefixReadPolicy: ParameterName: /FullstackBulletin/prod/* Environment: Variables: - MAILCHIMP_API_KEY: "{{resolve:ssm:/FullstackBulletin/prod/MailchimpApiKey}}" - MAILCHIMP_LIST_ID: !Ref MailchimpListId - MAILCHIMP_TEMPLATE_ID: !Ref MailchimpTemplateId - MAILCHIMP_FROM_EMAIL: !Ref MailchimpFromEmail - MAILCHIMP_FROM_NAME: !Ref MailchimpFromName - MAILCHIMP_REPLY_TO_EMAIL: !Ref MailchimpReplyToEmail - MAILCHIMP_TEST_EMAILS: "{{resolve:ssm:/FullstackBulletin/prod/MailchimpTestEmails}}" + BUTTONDOWN_API_KEY: "{{resolve:ssm:/FullstackBulletin/prod/ButtondownApiKey}}" + DRAFT_SUBSCRIBER_ID: "d7c3b447-f046-4755-a26a-b8b90b73d002" + DRAFT_RECIPIENT_EMAIL: "lucianomammino+fsb@gmail.com" Outputs: CreateIssueFunctionArn: