├── .gitignore ├── .travis.yml ├── README.md ├── deps.edn ├── examples └── babashka │ ├── README.adoc │ ├── bb.edn │ └── script │ └── clj_rss_demo.clj ├── project.clj ├── src └── clj_rss │ └── core.clj └── test └── clj_rss └── core_test.clj /.gitignore: -------------------------------------------------------------------------------- 1 | pom.xml 2 | *jar 3 | /lib/ 4 | /classes/ 5 | .lein-deps-sum 6 | 7 | target 8 | 9 | .clj-kondo 10 | .cpcache 11 | .lsp 12 | .portal 13 | .lein-failures -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: clojure 2 | lein: lein 3 | jdk: 4 | - openjdk10 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # clj-rss 2 | 3 | A library for generating RSS feeds from Clojure 4 | 5 | [![Continuous Integration status](https://secure.travis-ci.org/yogthos/clj-rss.png)](http://travis-ci.org/yogthos/clj-rss) 6 | 7 | ## Installation 8 | 9 | Leiningen 10 | 11 | [![Clojars Project](http://clojars.org/clj-rss/latest-version.svg)](http://clojars.org/clj-rss) 12 | 13 | ## Usage 14 | 15 | The `channel-xml` function accepts a map of tags representing a channel, followed by 0 or more maps for items (or a seq of items) and outputs an XML string. 16 | Each item must be a map of valid RSS tags. 17 | 18 | The following characters in the content of :description, "content:encoded" and :title tags will be escaped: `<`, `&`, `>`, `"`. Both `:pubDate` and `:lastBuildDate` keys are expected to be instances 19 | of `java.time.Instant` or one of its subclasses. These will be converted to standard RSS date strings in the resulting XML. 20 | 21 | If you need to get the data in a structured format, use `channel` instead. 22 | 23 | The project works with babashka so you can generate rss feeds as a script. 24 | See examples/babashka directory. 25 | 26 | ### Examples 27 | 28 | Creating a channel with some items: 29 | ```clojure 30 | (require '[clj-rss.core :as rss]) 31 | 32 | (rss/channel-xml {:title "Foo" :link "http://foo/bar" :description "some channel"} 33 | {:title "Foo"} 34 | {:title "post" :author "author@foo.bar"} 35 | {:description "bar"} 36 | {:description "baz" "content:encoded" "Full content"}) 37 | ``` 38 | 39 | image tags can be inserted by providing the `:type` key: 40 | ```clojure 41 | (channel-xml {:title "Foo" :link "http://x" :description "some channel"} 42 | {:type :image 43 | :title "image" 44 | :url "http://foo.bar" 45 | :link "http://bar.baz"} 46 | {:title "foo" :link "bar"}) 47 | ``` 48 | 49 | Creating a feed from a sequence of items: 50 | ```clojure 51 | (let [items [{:title "Foo"} {:title "Bar"} {:title "Baz"}]] 52 | (rss/channel {:title "Foo" :link "http://foo/bar" :description "some channel"} 53 | items)) 54 | 55 | ;; Atom feed URL can be specified using :feed-url key: 56 | (rss/channel 57 | {:title "foo" :link "http://foo" :feed-url "http://feed-url" :description "bar"} 58 | {:type :image 59 | :title "Title" 60 | :url "http://bar" 61 | :link "http://baz"}) 62 | ``` 63 | 64 | Creating items with complex tags: 65 | ```clojure 66 | (rss/channel-xml {:title "Foo" :link "http://foo/bar" :description "some channel"} 67 | {:title "test" 68 | :category [{:domain "http://www.foo.com/bar"} "BAZ"]}) 69 | 70 | (rss/channel-xml {:title "Foo" :link "http://foo/bar" :description "some channel"} 71 | {:title "test" 72 | :category [[{:domain "http://www.microsoft.com"} "MSFT"] 73 | [{:domain "http://www.apple.com"} "AAPL"]]}) 74 | 75 | (rss/channel-xml {:title "Foo" :link "http://foo/bar" :description "some channel"} 76 | {:title "test" 77 | :category ["MSFT" "AAPL"]}) 78 | ``` 79 | 80 | Items can contain raw HTML if the tag is enclosed in ``: 81 | ```clojure 82 | {:title "HTML Item" 83 | :description "Foo ]]>" 84 | "content:encoded" "

Title

Hello World

]]>"} 85 | ``` 86 | 87 | To get the raw data structure use: 88 | ```clojure 89 | (rss/channel {:title "Foo" :link "http://foo/bar" :description "some channel"} 90 | {:title "test"}) 91 | ``` 92 | 93 | Pass in `false` as first parameter to disable content validation: 94 | ```clojure 95 | (rss/channel-xml false {:title "Foo" :link "http://foo/bar" :description "some channel"} 96 | {:title "test" 97 | :category [{:domain "http://www.foo.com/bar"} "BAZ"]}) 98 | 99 | (rss/channel false {:title "Foo" :link "http://foo/bar" :description "some channel"} 100 | {:title "test"}) 101 | ``` 102 | 103 | The output XML can be validated at http://validator.w3.org/feed/#validate_by_input 104 | 105 | For more information on valid RSS tags and their content please refer to the official RSS 2.0 specification http://cyber.law.harvard.edu/rss/rss.html 106 | 107 | ## License 108 | 109 | Copyright Yogthos 2012 110 | 111 | Distributed under the Eclipse Public License, the same as Clojure. 112 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src"] 2 | :deps {org.clojure/data.xml {:mvn/version "0.2.0-alpha6"}} 3 | :aliases 4 | {:test 5 | {:extra-deps 6 | {com.cognitect/test-runner 7 | {:git/url "https://github.com/cognitect-labs/test-runner.git" 8 | :sha "209b64504cb3bd3b99ecfec7937b358a879f55c1"} 9 | hickory/hickory {:mvn/version "0.7.1"}} 10 | :extra-paths ["test" "test-resources"], 11 | :main-opts ["-m" "cognitect.test-runner"]}}} -------------------------------------------------------------------------------- /examples/babashka/README.adoc: -------------------------------------------------------------------------------- 1 | = clj-rss works with babashka 2 | 3 | [source,shell] 4 | -- 5 | git clone git@github.com:yogthos/clj-rss.git 6 | cd examples/babshka 7 | bb -m clj-rss-demo 8 | -- -------------------------------------------------------------------------------- /examples/babashka/bb.edn: -------------------------------------------------------------------------------- 1 | {:paths ["script"] 2 | :deps {local/clj-rss {:local/root "../.."}}} 3 | 4 | -------------------------------------------------------------------------------- /examples/babashka/script/clj_rss_demo.clj: -------------------------------------------------------------------------------- 1 | (ns clj-rss-demo 2 | (:require [clj-rss.core :as rss])) 3 | 4 | 5 | (defn -main [& _args] 6 | (println (rss/channel-xml {:title "Foo" :link "http://foo/bar" :description "some channel"} 7 | {:title "Foo"} 8 | {:title "post" :author "author@foo.bar"} 9 | {:description "bar"} 10 | {:description "baz" "content:encoded" "Full content"}))) -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject clj-rss "0.4.0" 2 | :description "A library for generating RSS feeds from Clojure." 3 | :url "https://github.com/yogthos/clj-rss" 4 | :license {:name "Eclipse Public License" 5 | :url "http://www.eclipse.org/legal/epl-v10.html"} 6 | :dependencies [[org.clojure/clojure "1.10.1"] 7 | [org.clojure/data.xml "0.0.8"]] 8 | :profiles {:dev 9 | {:dependencies [[hickory "0.7.1"]]}}) 10 | -------------------------------------------------------------------------------- /src/clj_rss/core.clj: -------------------------------------------------------------------------------- 1 | (ns clj-rss.core 2 | (:require [clojure.data.xml :refer [cdata element emit-str]] 3 | [clojure.set :refer [difference]] 4 | [clojure.string :refer [join]]) 5 | (:import (java.time Instant ZoneOffset) 6 | java.time.format.DateTimeFormatter 7 | java.util.Locale)) 8 | 9 | (defn- format-time [^Instant t] 10 | (when t 11 | (.format (DateTimeFormatter/ofPattern "EEE, dd MMM yyyy HH:mm:ss ZZ" Locale/ENGLISH) 12 | (.atOffset t ZoneOffset/UTC)))) 13 | 14 | (defn- xml-str 15 | "Returns a value suitable for inclusion as an XML element. If the string 16 | is wrapped in , remove the tags and wrap in a CData record" 17 | [^String s] 18 | (if (and (.startsWith s "")) 20 | (-> s 21 | (.replace "" "") 23 | cdata) 24 | s)) 25 | 26 | (defmacro tag [id & xs] 27 | `(let [attrs# (map? (first '~xs)) 28 | content# [~@xs]] 29 | (element ~id 30 | (if attrs# (first '~xs)) 31 | (if attrs# (rest content#) content#)))) 32 | 33 | (defmacro functionize [macro] 34 | `(fn [& args#] (eval (cons '~macro args#)))) 35 | 36 | (defmacro apply-macro [macro args] 37 | `(apply (functionize ~macro) ~args)) 38 | 39 | (defn dissoc-nil 40 | "Returns a map containing only those entries in m whose val is not nil" 41 | [map] 42 | (let [non-nil-keys (for [[k v] map :when (not (nil? v))] k)] 43 | (select-keys map non-nil-keys))) 44 | 45 | (defn- validate-tags [tags valid-tags] 46 | (let [diff (difference (set tags) valid-tags)] 47 | (when (not-empty diff) 48 | (throw (new Exception (str "unrecognized tags in channel: " (join ", " diff))))))) 49 | 50 | (defn- validate-channel [tags & ks] 51 | (doseq [k ks] 52 | (or (get tags k) (throw (new Exception (str k " is a required element"))))) 53 | (validate-tags (keys tags) 54 | #{:title 55 | :link 56 | :feed-url 57 | :description 58 | :category 59 | :cloud 60 | :copyright 61 | :docs 62 | :image 63 | :language 64 | :lastBuildDate 65 | :managingEditor 66 | :pubDate 67 | :rating 68 | :skipDays 69 | :skipHours 70 | :ttl 71 | :webMaster})) 72 | 73 | (defn- validate-item [tags] 74 | (when (not (or (:title tags) (:image tags) (:description tags))) 75 | (throw (new Exception (str "item " tags " must contain one of title or description!")))) 76 | (when (and (get tags "content:encoded") (not (:description tags))) 77 | (throw (new Exception (str "item " tags " must contain a description since it contains a content:enclosed!")))) 78 | (validate-tags (keys tags) #{:type :image :url :title :link :description "content:encoded" :author :category :comments :enclosure :guid :pubDate :source})) 79 | 80 | 81 | 82 | (defn- make-tags [tags] 83 | (flatten 84 | (for [[k v] (seq tags)] 85 | (cond 86 | (and (coll? v) (map? (first v))) 87 | (apply-macro clj-rss.core/tag (into [k] v)) 88 | (coll? v) 89 | (map (fn [v] (make-tags {k v})) v) 90 | :else 91 | (let [v (cond 92 | (some #{k} [:pubDate :lastBuildDate]) (format-time v) 93 | (some #{k} [:description :title :link :author "content:encoded"]) (xml-str v) 94 | :else v)] 95 | (cond 96 | (= k :guid) 97 | (tag k {:isPermaLink "false"} v) 98 | :else 99 | (tag k v))))))) 100 | 101 | 102 | (defn- item [validate? tags] 103 | (when validate? (validate-item (dissoc-nil tags))) 104 | (let [;;"content:encoded" must come after "description" 105 | content (get tags "content:encoded") 106 | ordered (-> tags (dissoc "content:encoded") (assoc "content:encoded" content))] 107 | (element (or (:type tags) :item) 108 | nil 109 | (make-tags (dissoc-nil (dissoc ordered :type)))))) 110 | 111 | (defn- channel' 112 | "channel accepts a map of tags followed by 0 or more items 113 | 114 | channel: 115 | required tags: title, link, description 116 | optional tags: category, cloud, copyright, docs, image, language, lastBuildDate, managingEditor, pubDate, rating, skipDays, skipHours, textInput, ttl, webMaster 117 | 118 | item: 119 | optional tags: title, link, description, author, category, comments, enclosure, guid, pubDate, source 120 | one of title or description is required! 121 | 122 | tags can either be strings, dates, or collections 123 | 124 | :title \"Some title\" 125 | {:tag :title :attrs nil :content [\"Some title\"]} 126 | 127 | :category [{:domain \"http://www.foo.com/test\"} \"TEST\"] 128 | {:tag :category 129 | :attrs {:domain \"http://www.fool.com/cusips\"} 130 | :content (\"MSFT\")} 131 | 132 | official RSS specification: http://cyber.law.harvard.edu/rss/rss.html" 133 | [validate? tags & items] 134 | (when validate? (validate-channel tags :title :link :description)) 135 | (element :rss 136 | {:version "2.0" 137 | "xmlns:atom" "http://www.w3.org/2005/Atom" 138 | "xmlns:content""http://purl.org/rss/1.0/modules/content/"} 139 | [(element :channel 140 | nil 141 | (concat 142 | [(element "atom:link" 143 | {:href (or (:feed-url tags) (:link tags)) 144 | :rel "self" 145 | :type "application/rss+xml"})] 146 | (make-tags (-> tags (dissoc :feed-url) (conj {:generator "clj-rss"}))) 147 | (->> items 148 | flatten 149 | (map dissoc-nil) 150 | (map (partial item validate?)))))])) 151 | 152 | (defn channel [& content] 153 | (cond 154 | (every? #(if (coll? %) (empty? %) (nil? %)) content) 155 | (channel' false nil) 156 | (map? (first content)) 157 | (apply channel' (cons true content)) 158 | :else 159 | (apply channel' content))) 160 | 161 | (defn channel-xml 162 | "channel accepts a map of tags followed by 0 or more items and outputs an XML string, see channel docs for detailed description" 163 | [& content] 164 | (emit-str (apply channel content))) 165 | -------------------------------------------------------------------------------- /test/clj_rss/core_test.clj: -------------------------------------------------------------------------------- 1 | (ns clj-rss.core-test 2 | (:use clojure.test 3 | clj-rss.core 4 | hickory.core)) 5 | 6 | (deftest proper-message 7 | (is 8 | (= "Foohttp://foo/barsome channelclj-rssFoopostYogthosbar" 9 | (channel-xml {:title "Foo" :link "http://foo/bar" :description "some channel"} 10 | {:title "Foo"} 11 | {:title "post" :author "Yogthos"} 12 | {:description "bar"}) 13 | (channel-xml {:title "Foo" :link "http://foo/bar" :description "some channel"} 14 | {:title "Foo" :link nil :enclosure nil} 15 | {:title "post" :author "Yogthos"} 16 | {:description "bar"}) 17 | (channel-xml {:title "Foo" :link "http://foo/bar" :description "some channel"} 18 | [{:title "Foo"} 19 | {:title "post" :author "Yogthos"} 20 | {:description "bar"}])))) 21 | 22 | 23 | (deftest escaping-test 24 | (is (= 25 | #clojure.data.xml.Element 26 | {:tag :rss 27 | :attrs {:version "2.0" 28 | "xmlns:atom" "http://www.w3.org/2005/Atom" 29 | "xmlns:content" "http://purl.org/rss/1.0/modules/content/"} 30 | :content ([#clojure.data.xml.Element 31 | {:tag :channel 32 | :attrs {} 33 | :content ((#clojure.data.xml.Element{:tag "atom:link", :attrs {:href "http://foo", :rel "self", :type "application/rss+xml"}, :content ()} 34 | #clojure.data.xml.Element{:tag :title, :attrs {}, :content (["foo"])} 35 | #clojure.data.xml.Element{:tag :link, :attrs {}, :content (["http://foo"])} 36 | #clojure.data.xml.Element{:tag :description, :attrs {}, :content (["bar"])} 37 | #clojure.data.xml.Element{:tag :generator, :attrs {}, :content (["clj-rss"])} 38 | #clojure.data.xml.Element 39 | {:tag :image 40 | :attrs {} 41 | :content ((#clojure.data.xml.Element{:tag :title 42 | :attrs {} 43 | :content ([#clojure.data.xml.CData{:content " title "}])} 44 | #clojure.data.xml.Element{:tag :url 45 | :attrs {} 46 | :content ([""])} 47 | #clojure.data.xml.Element{:tag :link 48 | :attrs {} 49 | :content ([#clojure.data.xml.CData{:content " link "}])}))}))}])} 50 | (channel 51 | {:title "foo" :link "http://foo" :description "bar"} 52 | {:type :image 53 | :title "" 54 | :url "" 55 | :link ""})))) 56 | 57 | (deftest image-tag 58 | (is 59 | (= "Foohttp://xsome channelclj-rssimagehttp://foo.barhttp://bar.bazfoobar" 60 | (channel-xml {:title "Foo" :link "http://x" :description "some channel"} 61 | {:type :image 62 | :title "image" 63 | :url "http://foo.bar" 64 | :link "http://bar.baz"} 65 | {:title "foo" :link "bar"})))) 66 | 67 | (deftest feed-url 68 | (is 69 | (= 70 | #clojure.data.xml.Element 71 | {:tag :rss 72 | :attrs {:version "2.0" 73 | "xmlns:atom" "http://www.w3.org/2005/Atom" 74 | "xmlns:content" "http://purl.org/rss/1.0/modules/content/"} 75 | :content ([#clojure.data.xml.Element 76 | {:tag :channel 77 | :attrs {} 78 | :content ((#clojure.data.xml.Element{:tag "atom:link" 79 | :attrs {:href "http://foo" 80 | :rel "self" 81 | :type "application/rss+xml"} 82 | :content ()} 83 | #clojure.data.xml.Element{:tag :title, :attrs {}, :content (["foo"])} 84 | #clojure.data.xml.Element{:tag :link, :attrs {}, :content (["http://foo"])} 85 | #clojure.data.xml.Element{:tag :description, :attrs {}, :content (["bar"])} 86 | #clojure.data.xml.Element{:tag :generator, :attrs {}, :content (["clj-rss"])} 87 | #clojure.data.xml.Element 88 | {:tag :image 89 | :attrs {} 90 | :content ((#clojure.data.xml.Element{:tag :title, :attrs {}, :content (["Title"])} 91 | #clojure.data.xml.Element{:tag :url, :attrs {}, :content (["http://bar"])} 92 | #clojure.data.xml.Element{:tag :link, :attrs {}, :content (["http://baz"])}))}))}])} 93 | (channel 94 | {:title "foo" :link "http://foo" :description "bar"} 95 | {:type :image 96 | :title "Title" 97 | :url "http://bar" 98 | :link "http://baz"}))) 99 | (is 100 | (= 101 | #clojure.data.xml.Element 102 | {:tag :rss 103 | :attrs {:version "2.0" 104 | "xmlns:atom" "http://www.w3.org/2005/Atom" 105 | "xmlns:content" "http://purl.org/rss/1.0/modules/content/"} 106 | :content ([#clojure.data.xml.Element 107 | {:tag :channel 108 | :attrs {} 109 | :content ((#clojure.data.xml.Element{:tag "atom:link" 110 | :attrs {:href "http://feed-url" 111 | :rel "self" 112 | :type "application/rss+xml"} 113 | :content ()} 114 | #clojure.data.xml.Element{:tag :title, :attrs {}, :content (["foo"])} 115 | #clojure.data.xml.Element{:tag :link, :attrs {}, :content (["http://foo"])} 116 | #clojure.data.xml.Element{:tag :description, :attrs {}, :content (["bar"])} 117 | #clojure.data.xml.Element{:tag :generator, :attrs {}, :content (["clj-rss"])} 118 | #clojure.data.xml.Element{:tag :image 119 | :attrs {} 120 | :content ((#clojure.data.xml.Element{:tag :title 121 | :attrs {} 122 | :content (["Title"])} 123 | #clojure.data.xml.Element{:tag :url 124 | :attrs {} 125 | :content (["http://bar"])} 126 | #clojure.data.xml.Element{:tag :link 127 | :attrs {} 128 | :content (["http://baz"])}))}))}])} 129 | (channel 130 | {:title "foo" :link "http://foo" :feed-url "http://feed-url" :description "bar"} 131 | {:type :image 132 | :title "Title" 133 | :url "http://bar" 134 | :link "http://baz"})))) 135 | 136 | (deftest invalid-channel-tag 137 | (is 138 | (thrown? Exception 139 | (channel-xml {:title "Foo" :link "http://foo/bar" :description "some channel" :baz "invalid-tag"} 140 | {:title "Foo"} 141 | {:title "post" :author "Yogthos"} 142 | {:description "bar"})))) 143 | 144 | (deftest missing-channel-tag 145 | (is 146 | (thrown? Exception 147 | (channel-xml {:title "Foo" :link "http://foo/bar"} 148 | {:title "Foo"})))) 149 | 150 | (deftest invalid-item-tag 151 | (is 152 | (thrown? Exception 153 | (channel-xml {:title "Foo" :link "http://foo/bar" :description "some channel"} 154 | {:title "Foo" :invalid-tag "foo"})))) 155 | 156 | (deftest missing-item-tag 157 | (is 158 | (thrown? Exception 159 | (channel-xml {:title "Foo" :link "http://foo/bar" :description "some channel"} 160 | {:link "http://foo"})))) 161 | 162 | (deftest item-with-a-guid-tag 163 | (is 164 | (= 165 | "Foohttp://foo/barsome channelclj-rsstesthttp://foo/bar" 166 | (channel-xml {:title "Foo" :link "http://foo/bar" :description "some channel"} 167 | {:title "test" 168 | :guid "http://foo/bar"})))) 169 | 170 | (deftest complex-tag 171 | (is (= "Foohttp://foo/barsome channelclj-rsstestMSFT" 172 | (channel-xml {:title "Foo" :link "http://foo/bar" :description "some channel"} 173 | {:title "test" 174 | :category [{:domain "http://www.fool.com/cusips"} "MSFT"]})))) 175 | 176 | (deftest cdata-tag 177 | (is (= "Foohttp://foo/barsome channelclj-rssHTML ItemFoo ]]>" 178 | (channel-xml {:title "Foo" :link "http://foo/bar" :description "some channel"} 179 | {:title "HTML Item" :description "Foo ]]>"})))) 180 | 181 | (deftest validation-on 182 | (is 183 | (thrown? Exception 184 | (channel-xml true 185 | {:title "Foo" :description "Foo" :link "http://foo/bar"} 186 | {:foo "Foo"})))) 187 | 188 | (deftest validation-off 189 | (is (= "FooFoohttp://foo/barclj-rssFoo" 190 | (channel-xml false 191 | {:title "Foo" :description "Foo" :link "http://foo/bar"} 192 | {:foo "Foo"})))) 193 | 194 | (deftest test-dissoc-nil 195 | (is (= {:title "Foo" :description "Bar"} 196 | (dissoc-nil {:title "Foo" :description "Bar" 197 | :link nil :category nil})))) 198 | 199 | (deftest content-encoded-comes-after-description 200 | (is (= "Foohttp://foo/barsome channelclj-rsstestshortLONG CONTENT" 201 | (channel-xml {:title "Foo" :link "http://foo/bar" :description "some channel"} 202 | {:title "test" :description "short" "content:encoded" "LONG CONTENT"}) 203 | (channel-xml {:title "Foo" :link "http://foo/bar" :description "some channel"} 204 | {:title "test" "content:encoded" "LONG CONTENT" :description "short"})))) 205 | 206 | (deftest missing-description-when-content-given 207 | (is (thrown? Exception 208 | (channel-xml {:title "Foo" :link "http://foo/bar" :description "some channel"} 209 | {:title "test" "content:encoded" "LONG CONTENT"})))) 210 | 211 | (deftest format-time-supports-instant 212 | (is (= "Foohttp://foo/barsome channelThu, 01 Jan 1970 00:00:00 +0000clj-rssFooThu, 01 Jan 1970 00:00:00 +0000" 213 | (channel-xml {:title "Foo" :link "http://foo/bar" :description "some channel" :lastBuildDate (java.time.Instant/ofEpochSecond 0)} 214 | {:title "Foo" :pubDate (java.time.Instant/ofEpochSecond 0)})))) 215 | --------------------------------------------------------------------------------