├── .gitignore ├── .travis.yml ├── .jshintrc ├── examples ├── simple.js ├── podcast.js └── full.js ├── Gruntfile.js ├── package.json ├── LICENSE ├── README.md ├── test └── feedster-test.js └── lib └── feedster.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | npm-debug.log 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.10" 4 | - 0.12 5 | - iojs 6 | before_install: 7 | - npm install -g grunt-cli 8 | notifications: 9 | email: 10 | - andris@kreata.ee 11 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "indent": 4, 3 | "node": true, 4 | "globalstrict": true, 5 | "evil": true, 6 | "unused": true, 7 | "undef": true, 8 | "newcap": true, 9 | "esnext": true, 10 | "curly": true, 11 | "eqeqeq": true, 12 | "expr": true, 13 | 14 | "predef": [ 15 | "describe", 16 | "it", 17 | "beforeEach", 18 | "afterEach" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /examples/simple.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // this example uses only required elements 4 | 5 | var feedster = require('../lib/feedster'); 6 | 7 | var feed = feedster.createFeed({ 8 | title: 'My Awesome Blog' 9 | description: 'The best blog you have ever seen' 10 | link: 'http://path/to/blog' 11 | }); 12 | 13 | feed.addItem({ 14 | // an item needs to contain either title or description, all other fields are optional 15 | title: 'My first blog post', 16 | // pubDate is not required but really useful 17 | pubDate: '2011-01-01 11:12:34 +0000' 18 | }); 19 | 20 | // render RSS to console 21 | var rss = feed.render({ 22 | indent: ' ' 23 | }); 24 | 25 | console.log(rss); -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(grunt) { 2 | 'use strict'; 3 | 4 | // Project configuration. 5 | grunt.initConfig({ 6 | jshint: { 7 | all: ['lib/*.js', 'test/*.js', 'Gruntfile.js'], 8 | options: { 9 | jshintrc: '.jshintrc' 10 | } 11 | }, 12 | 13 | mochaTest: { 14 | all: { 15 | options: { 16 | reporter: 'spec' 17 | }, 18 | src: ['test/*-test.js'] 19 | } 20 | } 21 | }); 22 | 23 | // Load the plugin(s) 24 | grunt.loadNpmTasks('grunt-contrib-jshint'); 25 | grunt.loadNpmTasks('grunt-mocha-test'); 26 | 27 | // Tasks 28 | grunt.registerTask('default', ['jshint', 'mochaTest']); 29 | }; 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "feedster", 3 | "version": "1.0.1", 4 | "description": "Generate RSS feeds with support for a variety of extensions", 5 | "main": "lib/feedster.js", 6 | "scripts": { 7 | "test": "grunt" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/andris9/feedster.git" 12 | }, 13 | "keywords": [ 14 | "RSS", 15 | "Atom", 16 | "Feed" 17 | ], 18 | "author": "Andris Reinman", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/andris9/feedster/issues" 22 | }, 23 | "homepage": "https://github.com/andris9/feedster", 24 | "dependencies": { 25 | "libmime": "^0.1.7", 26 | "moment": "^2.9.0", 27 | "xml": "^1.0.0" 28 | }, 29 | "devDependencies": { 30 | "chai": "~2.1.2", 31 | "grunt": "~0.4.5", 32 | "grunt-contrib-jshint": "~0.11.0", 33 | "grunt-mocha-test": "~0.12.7", 34 | "sinon": "^1.14.1" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Andris Reinman 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 11 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 12 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 13 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 14 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 15 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 16 | SOFTWARE. 17 | 18 | -------------------------------------------------------------------------------- /examples/podcast.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var createFeed = require('../lib/feedster').createFeed; 4 | 5 | var feed = createFeed({ 6 | title: 'My Awesome Podcast', 7 | link: 'http://example.com/path/to/this/blog', 8 | description: 'The best blog and podcast in the world!', 9 | 10 | // meta info about the podcast 11 | itunes: { 12 | summary: 'The best podcast you\'ve ever heard of', 13 | author: 'My Name', 14 | explicit: false, 15 | image: 'http://example.com/path/to/podcast/logo.png', 16 | owner: { 17 | name: 'My Name', 18 | email: 'my.email@example.com' 19 | }, 20 | subtitle: 'Just another awesome podcast you\'ve never listened before', 21 | keywords: 'podcast, awesome, my blog', 22 | category: 'Music' 23 | } 24 | }); 25 | 26 | feed.addItem({ 27 | title: 'My first show', 28 | link: 'http://example.com/path/to/this/blog/post/1', 29 | pubDate: '2000-11-10 12:32:12 +0000', 30 | description: 'This is just an awesome podcast episode', 31 | enclosure: 'http://example.com/path/to/this/blog/assets/1.mp3', 32 | 33 | // meta info about the podcast episode 34 | itunes: { 35 | author: 'My Name', 36 | duration: '34:12' 37 | } 38 | }); 39 | 40 | // render RSS to console 41 | var rss = feed.render({ 42 | indent: ' ' 43 | }); 44 | 45 | console.log(rss); -------------------------------------------------------------------------------- /examples/full.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var createFeed = require('../lib/feedster').createFeed; 4 | 5 | var feed = createFeed({ 6 | 7 | // http://www.rssboard.org/rss-profile#element-channel-title 8 | title: 'My Awesome Blog and Podcast', 9 | 10 | // http://www.rssboard.org/rss-profile#namespace-elements-atom-link 11 | atomLink: { 12 | href: 'http://example.com/path/to/this/feed', 13 | rel: 'self', 14 | type: 'application/rss+xml' 15 | }, 16 | 17 | // http://www.rssboard.org/rss-profile#element-channel-link 18 | link: 'http://example.com/path/to/this/blog', 19 | 20 | // http://www.rssboard.org/rss-profile#element-channel-description 21 | description: 'The best blog and podcast in the world!', 22 | 23 | // http://www.rssboard.org/rss-profile#element-channel-language 24 | language: 'en', 25 | 26 | // http://web.resource.org/rss/1.0/modules/syndication/ 27 | updatePeriod: 'hourly', 28 | updateFrequency: 1, 29 | 30 | // http://www.rssboard.org/rss-profile#element-channel-generator 31 | generator: 'https://github.com/andris9/feedster', 32 | 33 | // https://www.apple.com/itunes/podcasts/specs.html 34 | // iTunes channel level elements 35 | itunes: { 36 | summary: 'The best podcast you\'ve ever heard of', 37 | author: 'My Name', 38 | explicit: false, 39 | image: 'http://example.com/path/to/podcast/logo.png', 40 | owner: { 41 | name: 'My Name', 42 | email: 'my.email@example.com' 43 | }, 44 | subtitle: 'Just another awesome podcast you\'ve never listened before', 45 | keywords: 'podcast, awesome, my blog', 46 | category: [ 47 | 48 | // top-level category 49 | 'Music', 50 | 51 | // top level category with sub-categories 52 | { 53 | value: 'Games & Hobbies', 54 | sub: ['Automotive', 'Aviation'] 55 | } 56 | ] 57 | }, 58 | 59 | // http://www.rssboard.org/rss-profile#element-channel-managingeditor 60 | managingEditor: { 61 | name: 'My Name', 62 | email: 'my.email@example.com' 63 | }, 64 | 65 | // http://www.rssboard.org/rss-profile#element-channel-copyright 66 | copyright: '© My Name', 67 | 68 | // http://www.rssboard.org/rss-profile#element-channel-image 69 | // if you want to specify link and title (by default feed title and link are used): 70 | // link: 'http://...', 71 | // title: 'my awesome image' 72 | image: { 73 | url: 'http://example.com/path/to/podcast/logo.png', 74 | } 75 | }); 76 | 77 | // Add an element to the feed 78 | feed.addItem({ 79 | 80 | // http://www.rssboard.org/rss-profile#element-channel-item-title 81 | title: 'My best blog post', 82 | 83 | // http://www.rssboard.org/rss-profile#element-channel-item-link 84 | link: 'http://example.com/path/to/this/blog/post/1', 85 | 86 | // http://www.rssboard.org/rss-profile#element-channel-item-guid 87 | // alternatively (uses default isPermaLink value): 88 | // guid: 'http://example.com/path/to/this/blog/post/1' 89 | guid: { 90 | value: 'http://example.com/path/to/this/blog/post/1', 91 | isPermaLink: true 92 | }, 93 | 94 | // http://www.rssboard.org/rss-profile#element-channel-item-pubdate 95 | // any valid date format is ok, no need to use special formatting 96 | pubDate: '2000-11-10 12:32:12 +0000', 97 | 98 | // http://www.rssboard.org/rss-profile#namespace-elements-dublin-creator 99 | creator: 'My Name', 100 | 101 | // http://www.rssboard.org/rss-profile#element-channel-item-category 102 | // alternatively if you only need to set a single category, you can use a string for it 103 | // category: 'single category' 104 | category: [ 105 | 106 | // category as a string 107 | 'First', 108 | 109 | // category with optional domain argument 110 | { 111 | value: 'Second/With/Domain', 112 | domain: 'dmoz' 113 | } 114 | ], 115 | 116 | // http://www.rssboard.org/rss-profile#element-channel-item-description 117 | description: 'This is just an awesome podcast episode', 118 | 119 | // http://www.rssboard.org/rss-profile#namespace-elements-content-encoded 120 | content: '

This is just an awesome podcast episode

', 121 | 122 | // http://www.rssboard.org/rss-profile#element-channel-item-comments 123 | comments: 'http://example.com/path/to/this/blog/post/1#comments', 124 | 125 | // http://bitworking.org/news/2012/08/wfw.html 126 | commentRss: 'http://example.com/path/to/this/blog/post/1/feed', 127 | 128 | // http://www.rssboard.org/rss-profile#namespace-elements-slash-comments 129 | commentCount: 15, 130 | 131 | // http://www.rssboard.org/rss-profile#element-channel-item-enclosure 132 | // if the url points to a file, then "type" is detected automatically 133 | // otherwise add 134 | // type: 'audio/mpeg' 135 | enclosure: { 136 | url: 'http://example.com/path/to/this/blog/assets/1.mp3', 137 | length: 55893808 138 | }, 139 | 140 | // https://www.apple.com/itunes/podcasts/specs.html 141 | // iTunes item level elements 142 | itunes: { 143 | keywords: 'awesome, podcast', 144 | subtitle: 'Another awesome episode', 145 | summary: 'Another awesome episode about awesome stuff', 146 | author: 'My Name', 147 | explicit: false, 148 | duration: '34:12' 149 | }, 150 | 151 | // http://www.rssboard.org/media-rss#media-content 152 | media: { 153 | url: 'http://example.com/path/to/this/blog/assets/1.jpg', 154 | medium: 'image', 155 | title: 'Attached image' 156 | } 157 | }); 158 | 159 | // render RSS to console 160 | var rss = feed.render({ 161 | indent: ' ' 162 | }); 163 | 164 | console.log(rss); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # feedster 2 | 3 | Easy RSS feed generation in Node.js, supports most used RSS extensions like `itunes` for podcast generation. 4 | 5 | [![Build Status](https://secure.travis-ci.org/andris9/feedster.svg)](http://travis-ci.org/andris9/feedster) 6 | NPM version 7 | 8 | ## Installation 9 | 10 | Install from [npm](http://npmjs.com/package/feedster): 11 | 12 | npm install feedster --save 13 | 14 | ## TL;DR 15 | 16 | Example script for generating a minimal RSS feed: 17 | 18 | ```javascript 19 | var feedster = require('feedster'); 20 | // create feed with required elements 21 | var feed = feedster.createFeed({ 22 | title: 'My Awesome Blog' 23 | description: 'The best blog you have ever seen' 24 | link: 'http://path/to/blog' 25 | }); 26 | // add new item to the feed 27 | feed.addItem({ 28 | // an item needs to contain either title or description, all other fields are optional 29 | title: 'My first blog post', 30 | // pubDate is not required but really useful 31 | pubDate: '2011-01-01 11:12:34 +0000' 32 | }); 33 | // generate a RSS string and print to console 34 | var rss = feed.render(); 35 | console.log(rss); 36 | ``` 37 | 38 | ## Usage 39 | 40 | Require feedster module 41 | 42 | ```javascript 43 | var feedster = require('feedster'); 44 | ``` 45 | 46 | ### Create new feed object 47 | 48 | ```javascript 49 | var feed = feedster.createFeed(headers) 50 | ``` 51 | 52 | Where 53 | 54 | * **headers** is an object with channel headers (see possible options [below](#channel-options)) 55 | * **feed** is the feed object 56 | 57 | **Example** 58 | 59 | ```javascript 60 | var feedster = require('feedster'); 61 | var feed = feedster.createFeed({ 62 | title: 'My Awesome Blog' 63 | }); 64 | ``` 65 | 66 | ### Add items to feed 67 | 68 | Add additional item to the feed with 69 | 70 | ```javascript 71 | feed.addItem(item) 72 | ``` 73 | 74 | Where 75 | 76 | * **item** is a feed item (see possible options [below](#item-options)) 77 | 78 | **Example** 79 | 80 | ```javascript 81 | var feedster = require('feedster'); 82 | var feed = feedster.createFeed(); 83 | feed.addItem({ 84 | title: 'My first blog post', 85 | pubDate: '2011-01-01 14:34:00' 86 | }) 87 | ``` 88 | 89 | ### Generate RSS feed 90 | 91 | Generate feed object into a rss feed string with 92 | 93 | ```javascript 94 | var rss = feed.render([options]) 95 | ``` 96 | 97 | Where 98 | 99 | * **options** is an optional options object 100 | * **options.indent** defines the look of the generated file. If false, then the XML is not indented. Use 2 spaces `" "` for 2-spaces indentation etc. 101 | 102 | **Example** 103 | 104 | ```javascript 105 | var feedster = require('feedster'); 106 | var feed = feedster.createFeed({ 107 | title: 'My Awesome Blog' 108 | }); 109 | feed.addItem({ 110 | title: 'My first blog post', 111 | // thats badly formatted date (no timezone etc.) but it works 112 | pubDate: '2011-01-01 14:34:00' 113 | }) 114 | var rss = feed.render({indent: ' '}); 115 | console.log(rss); 116 | ``` 117 | 118 | Output: 119 | 120 | ```xml 121 | 122 | 123 | 124 | My Awesome Blog 125 | Sat, 1 Jan 2011 14:34:00 +0200 126 | 127 | My first blog post 128 | Sat, 1 Jan 2011 14:34:00 +0200 129 | 130 | 131 | 132 | ``` 133 | 134 | ### Channel options 135 | 136 | #### category 137 | 138 | Defines a category or a tag to which the feed belongs. The value is either a string, or an object or an array of mixed strings and objects for multiple categories. Category object has two properties: 139 | 140 | * **value** - category title 141 | * **domain** - identifies the category's taxonomy 142 | 143 | Domain is rarely used, so just using strings with category names is sufficient for most cases 144 | 145 | ```javascript 146 | var headers = { 147 | category: [ 148 | // first category as a plain string 149 | 'Category1', 150 | // second category as an object 151 | { 152 | value: 'Category2/Subcategory/SubSub', 153 | domain: 'dmoz' 154 | } 155 | ] 156 | }; 157 | ``` 158 | 159 | #### cloud 160 | 161 | Indicates that updates to the feed can be monitored using a web service. Defined by an object with the following properties: 162 | 163 | * **domain** - identifies the host name or IP address of the web service that monitors updates to the feed 164 | * **path** - provides the web service's path 165 | * **port** - identifies the web service's TCP port 166 | * **protocol** - "xml-rpc" if the service employs XML-RPC or "soap" if it employs SOAP 167 | * **registerProcedure** - names the remote procedure to call when requesting notification of updates 168 | 169 | ```javascript 170 | var headers = { 171 | cloud: { 172 | domain: "server.example.com", 173 | path: "/rpc", 174 | port: "80", 175 | protocol: "xml-rpc", 176 | registerProcedure: "cloud.notify" 177 | } 178 | }; 179 | ``` 180 | 181 | #### copyright 182 | 183 | A string about the copyright holder. 184 | 185 | ```javascript 186 | var headers = { 187 | copyright: '© 2015 My Name' 188 | }; 189 | ``` 190 | 191 | #### description 192 | 193 | > **Required value** 194 | 195 | Summary of the feed. 196 | 197 | ```javascript 198 | var headers = { 199 | description: 'Latest posts from my awesome blog' 200 | }; 201 | ``` 202 | 203 | #### docs 204 | 205 | URL to RSS format specification. 206 | 207 | ```javascript 208 | var headers = { 209 | docs: 'http://www.rssboard.org/rss-specification' 210 | }; 211 | ``` 212 | 213 | #### generator 214 | 215 | Software that created the feed. 216 | 217 | ```javascript 218 | var headers = { 219 | generator: 'My Awesome Feed Generator v1.0' 220 | }; 221 | ``` 222 | 223 | #### image 224 | 225 | Graphical logo for the feed. Either an url to the image or an object with the following properties: 226 | 227 | * **url** - image location 228 | * **link** - url to the website. If not set the value from `headers.link` is used 229 | * **title** - title of the image. If not set the value from `headers.title` is used 230 | * **description** - image description 231 | * **width** - image width in pixels 232 | * **height** - image height in pixels 233 | 234 | ```javascript 235 | var headers = { 236 | image: 'http://image/location' 237 | }; 238 | var headers = { 239 | image: { 240 | url: 'http://image/location', 241 | link: 'http://link/to/blog', 242 | title: 'My Blog Logo' 243 | } 244 | }; 245 | ``` 246 | 247 | #### language 248 | 249 | Language code. 250 | 251 | ```javascript 252 | var headers = { 253 | language: 'en-us' 254 | }; 255 | ``` 256 | 257 | #### lastBuildDate 258 | 259 | Last modification date. If not set defaults to the time of the newest post in the feed. Recommended option is to not set it manually. 260 | 261 | ```javascript 262 | var headers = { 263 | lastBuildDate: '2011-01-01 13:34:11' 264 | }; 265 | ``` 266 | 267 | #### link 268 | 269 | > **Required value** 270 | 271 | URL of the website. 272 | 273 | ```javascript 274 | var headers = { 275 | link: 'http://path/to/my/blog' 276 | }; 277 | ``` 278 | 279 | #### managingEditor 280 | 281 | E-mail address and name of the person to contact regarding the editorial content of the feed. Has the following properties: 282 | 283 | * **name** is the name of the editor 284 | * **email** is the e-mail address of the editor 285 | 286 | ```javascript 287 | var headers = { 288 | managingEditor: { 289 | name: 'Editor Name', 290 | email: 'editor@example.com' 291 | } 292 | }; 293 | ``` 294 | 295 | #### pubDate 296 | 297 | Publication date and time of the feed's content. Recommended option is to skip it and rely only on auto-generated `lastBuildDate`. 298 | 299 | The value can either be a Date object or a datetime string (any valid format is ok, the value is converted to RSS date format automatically). 300 | 301 | ```javascript 302 | var headers = { 303 | pubDate: new Date() 304 | }; 305 | ``` 306 | 307 | #### rating 308 | 309 | Advisory label for the content in a feed. 310 | 311 | ```javascript 312 | var headers = { 313 | rating: '(PICS-1.1 "http://www.rsac.org/ratingsv01.html" l by "webmaster@example.com" on "2007.01.29T10:09-0800" r (n 0 s 0 v 0 l 0))' 314 | }; 315 | ``` 316 | 317 | #### textInput 318 | 319 | Defines a form to submit a text query to the feed's publisher. Nobody uses it, seems pretty strange but it is supported by feedster nevertheless. An object with the following properties: 320 | 321 | * **description** 322 | * **link** 323 | * **name** 324 | * **title** 325 | 326 | 327 | ```javascript 328 | var headers = { 329 | textInput: { 330 | description: 'Your aggregator supports the textInput element. What software are you using?', 331 | link: 'http://www.cadenhead.org/textinput.php', 332 | name: 'query', 333 | title: 'TextInput Inquiry' 334 | } 335 | }; 336 | ``` 337 | 338 | #### title 339 | 340 | > **Required value** 341 | 342 | The name of the feed. 343 | 344 | ```javascript 345 | var headers = { 346 | title: 'My Awesome Blog' 347 | }; 348 | ``` 349 | 350 | #### webMaster 351 | 352 | E-mail address and name of the person to contact regarding technical issues about the feed. Has the following properties: 353 | 354 | * **name** is the name of the web master 355 | * **email** is the e-mail address of the web master 356 | 357 | ```javascript 358 | var headers = { 359 | webMaster: { 360 | name: 'Webmaster Name', 361 | email: 'webmaster@example.com' 362 | } 363 | }; 364 | ``` 365 | 366 | ### Channel Extensions 367 | 368 | #### atomLink 369 | 370 | `atomLink` maps to `atom:link` from the [Atom specification](http://tools.ietf.org/html/rfc4287) that defines a relationship between a web resource (such as a page) and an RSS channel. For multiple link elements, use an array for the values. 371 | 372 | An object with the following properties: 373 | 374 | * **hreflang** - language code of the related resource 375 | * **length** - resource's size in bytes 376 | * **title** - description of the resource 377 | * **type** - mime-type of the resource 378 | * **rel** - keyword that identifies the nature of the relationship between the linked resouce and the element 379 | * *"alternate"* - alternate representation of the same resource 380 | * *"enclosure"* - media object, usually an audio or video file 381 | * *"related"* - related resource 382 | * *"self* - the feed itself 383 | * *"via"* - the original source of the entry 384 | 385 | ```javascript 386 | var headers = { 387 | atomLink: { 388 | href: 'http://path/to/feed', 389 | rel: 'self' // type is automatically for "self" 390 | } 391 | }; 392 | ``` 393 | 394 | #### hub 395 | 396 | `hub` maps to `atom:link` with `rel="hub"`. Takes a string value which is an URL pointing to the PubSubHubbub hub. 397 | 398 | ```javascript 399 | var headers = { 400 | hub: 'http://path/to/hub' 401 | }; 402 | ``` 403 | 404 | #### itunes 405 | 406 | `itunes` is an object that maps to the [itunes](https://www.apple.com/itunes/podcasts/specs.html) specification and is used for podcasting. Channel level *itunes* element supports the following properties: 407 | 408 | * **author** - string, artist column in iTunes 409 | * **block** - boolean, if true then the entire podcast is removed from the iTunes Store podcast directory 410 | * **category** - category element, see below for formatting 411 | * **image** - string, url to the logo of the podcast 412 | * **explicit** - boolean, if true then parental advisory graphic is shown under podcast details 413 | * **complete** - boolean, if true then indicates that no more episodes will be posted in the future 414 | * **new-feed-url** - string, reports new feed url for this podcast to iTunes 415 | * **owner** - feed owner object with properties `name` and `email` 416 | * **subtitle** - string, description column 417 | * **summary** - string, displayed when the circled i icon in the Description column in iTunes is clicked 418 | 419 | **Category format** 420 | 421 | * Plain string for a single top level category: `"Business"` 422 | * Array of categories for multiple: `["Business", "Technology"]` 423 | * Sub-categories: `{name: "Business", sub: ["Careers"]}` 424 | 425 | You can't use random values as categories, see available categories from the *iTunes Categories for Podcasting* section in itunes [module documentation](https://www.apple.com/itunes/podcasts/specs.html). 426 | 427 | ```javascript 428 | var headers = { 429 | itunes: { 430 | author: 'Your\'s truly', 431 | block: true, // hide this podcast from the directory 432 | explicit: true, // show parental advisory graphic 433 | owner: { 434 | name: 'My Name', 435 | email: 'my@example.com' 436 | }, 437 | category: { 438 | value: 'Business', // main category 439 | sub: ['Careers', 'Football'] // sub categories 440 | } 441 | } 442 | }; 443 | ``` 444 | 445 | #### updatePeriod 446 | 447 | `updatePeriod` maps to `sy:updatePeriod` from the Syndication module. Describes a period over which the channel format is updated. Acceptable values are: *hourly*, *daily*, *weekly*, *monthly*, *yearly*. Defaults to *daily*. 448 | 449 | ```javascript 450 | var headers = { 451 | updatePeriod: 'hourly' 452 | }; 453 | ``` 454 | 455 | #### updateFrequency 456 | 457 | `updateFrequency` maps to `sy:updateFrequency` from the Syndication module. A positive integer that describes how many times the channel is updated in selected update period. 458 | 459 | ```javascript 460 | var headers = { 461 | // updated once in a hour 462 | updatePeriod: 'hourly', 463 | updateFrequency: 1 464 | }; 465 | ``` 466 | 467 | ### Item options 468 | 469 | #### author 470 | 471 | The person who wrote the item. Has the following properties: 472 | 473 | * **name** is the name of the author 474 | * **email** is the e-mail address of the author 475 | 476 | ```javascript 477 | feed.additem({ 478 | author: { 479 | name: 'Author Name', 480 | email: 'author@example.com' 481 | } 482 | }); 483 | ``` 484 | 485 | > Recommended action is to use `creator` instead of `author` as `creator` does not require an e-mail address value 486 | 487 | #### category 488 | 489 | Defines a category or tag for the item. A string or an object. For multiple categories, use an array for the values. Category object has the following properties: 490 | 491 | * **value** is the category name 492 | * **domain** identifies the category's taxonomy 493 | 494 | Domain is rarely used, so just using strings with category names is sufficient for most cases 495 | 496 | ```javascript 497 | feed.additem({ 498 | category: [ 499 | // first category as a plain string 500 | 'Category1', 501 | // second category as an object 502 | { 503 | value: 'Category2/Subcategory/SubSub', 504 | domain: 'dmoz' 505 | } 506 | ] 507 | }); 508 | ``` 509 | 510 | #### comments 511 | 512 | Points to an URL where comments for the item can be found. 513 | 514 | ```javascript 515 | feed.additem({ 516 | comments: 'http://path/to/comments' 517 | }); 518 | ``` 519 | 520 | #### description 521 | 522 | Item's full content or a summary of its contents. Either `title` or `description` has to be set for the item, using both is optional. Usually `description` is used for a summary and the `content` extension element (see below) is used for the full contents. 523 | 524 | ```javascript 525 | feed.additem({ 526 | description: 'Just another rant about my awesomness' 527 | }); 528 | ``` 529 | 530 | #### enclosure 531 | 532 | Associates a media object, usually an audio or video file. The value can be an URL pointing to the media object or an object value with the following properties: 533 | 534 | * **url** - points to the URL of the file 535 | * **length** - the size of the file in bytes. Required, but can be set to "0" if the size not known 536 | * **type** - defines the mime-type of the resource 537 | 538 | If `length` is not defined, a default value of `"0"` is used. If `type` is missing, it is detected from the file extension of the url. 539 | 540 | ```javascript 541 | feed.additem({ 542 | enclosure: 'http://path/to/assets/podcast.mp3' 543 | }); 544 | feed.additem({ 545 | enclosure: { 546 | url: 'http://path/to/assets/podcast.mp3', 547 | type: 'audio/mpeg', 548 | length: 12345 549 | } 550 | }); 551 | ``` 552 | 553 | #### guid 554 | 555 | A string value that uniquely identifies the item. Recommended action would be to use the permalink to the item as guid, this is also the default if `isPermaLink` option is not used. 556 | 557 | If the value is permalink, then you can use the URL as the value, otherwise use an object with the following properties: 558 | 559 | * **value** - guid value 560 | * **isPermaLink** - boolean, if set to true or is missing then the value must be permalink to the item 561 | 562 | ```javascript 563 | feed.additem({ 564 | guid: 'http://path/to/posts/1' 565 | }); 566 | feed.additem({ 567 | guid: { 568 | value: 'some-unique-identifier', 569 | isPermaLink: false 570 | } 571 | }); 572 | ``` 573 | 574 | #### link 575 | 576 | The URL pointing to a web page associated with the item. 577 | 578 | ```javascript 579 | feed.additem({ 580 | link: 'http://path/to/posts/1' 581 | }); 582 | ``` 583 | 584 | #### pubDate 585 | 586 | Publication date and time of the item. The value can either be a Date object or a datetime string (any valid format is ok, the value is converted to RSS date format automatically). 587 | 588 | ```javascript 589 | feed.additem({ 590 | pubDate: new Date() 591 | }); 592 | ``` 593 | 594 | #### source 595 | 596 | Indicates the original source RSS of the entry. An object with the following properties: 597 | 598 | * **title** - the title of the original RSS feed 599 | * **url** - the URL to the original RSS feed 600 | 601 | ```javascript 602 | feed.additem({ 603 | source: { 604 | title: 'Some Other Publication', 605 | url: 'http://path/to/publication.rss' 606 | } 607 | }); 608 | ``` 609 | 610 | #### title 611 | 612 | Item's headline. Either `title` or `description` has to be set for the item, using both is optional. 613 | 614 | ```javascript 615 | feed.additem({ 616 | title: 617 | }); 618 | ``` 619 | 620 | ### Item Extensions 621 | 622 | #### commentCount 623 | 624 | `commentCount` maps to `slash:comments` from the [Slashdot module](http://web.resource.org/rss/1.0/modules/slash/) and contains a positive integer as the count of comments for the item. 625 | 626 | ```javascript 627 | feed.additem({ 628 | commentCount: 123 629 | }); 630 | ``` 631 | 632 | #### commentRss 633 | 634 | `commentRss` maps to `wfw:commentRss` from the Well Formed Web [Comments module](http://bitworking.org/news/2012/08/wfw.html) and includes an URL to the comments RSS feed for the item. 635 | 636 | ```javascript 637 | feed.additem({ 638 | commentRss: 'http://path/to/post/1/comments/feed' 639 | }); 640 | ``` 641 | 642 | #### content 643 | 644 | `content` maps to `content:encoded` from the [content module](http://web.resource.org/rss/1.0/modules/content/) and contains the full contents for the item. Mostly used together with the `description` element where `description` holds the summary. 645 | 646 | ```javascript 647 | feed.additem({ 648 | content: '

Full text contents

', 649 | description: 'Summary of the post' 650 | }); 651 | ``` 652 | 653 | #### creator 654 | 655 | `creator` maps to `dc:creator` from [Dublin Core](http://dublincore.org/documents/2012/06/14/dcmi-terms/?v=elements#) and is a string with the name of the person who created the item. 656 | 657 | ```javascript 658 | feed.additem({ 659 | creator: 'My name' 660 | }); 661 | ``` 662 | 663 | #### itunes 664 | 665 | `itunes` is an object that maps to the [itunes](https://www.apple.com/itunes/podcasts/specs.html) specification and is used for podcasting. Item level *itunes* element goes together with `enclosure` element that defines the actual podcast show, `itunes` only adds metadata to it. The object supports the following properties: 666 | 667 | * **author** - string, artist column in iTunes 668 | * **block** - boolean, if true then the this episode is removed from the iTunes Store 669 | * **duration** - string, formatted as *hh:mm:ss* or *mm:ss* 670 | * **explicit** - boolean, if true then parental advisory graphic is shown under podcast details 671 | * **image** - string, url to the logo of the podcast 672 | * **isClosedCaptioned** - boolean, if true shows Closed Caption graphic in Name column 673 | * **order** - number, if used overrides the ordering in itunes listing, for example, "1" sets this as the first item 674 | * **subtitle** - string, description column 675 | * **summary** - string, displayed when the circled i icon in the Description column in iTunes is clicked 676 | 677 | ```javascript 678 | var headers = { 679 | enclosure: 'http://path/to/assets/podcast.mp3', 680 | itunes: { 681 | author: 'Your\'s truly', 682 | duration: '13:34' 683 | } 684 | }; 685 | ``` 686 | 687 | #### lat and long 688 | 689 | `lat` and `long` map to `geo:lat` and `geo:long` from the [Basic Geo module](http://www.w3.org/2003/01/geo/). The values indicate a geographical point related to the item. 690 | 691 | ```javascript 692 | feed.additem({ 693 | lat: 55.701, 694 | long: 12.552 695 | }); 696 | ``` 697 | 698 | #### media 699 | 700 | `media` is an object that maps to `media:content` of the [Media RSS module](http://www.rssboard.org/media-rss#media-content). For multiple media elements, use an array for the values. 701 | 702 | Media object supports the following properties: 703 | 704 | * **url** 705 | * **fileSize** 706 | * **type** 707 | * **medium** 708 | * **duration** 709 | * **height** 710 | * **width** 711 | * **title** 712 | * **description** 713 | * **keywords** 714 | * **thumbnails** 715 | * **category** 716 | * **player** 717 | * **credit** 718 | * ... and many more, only values that require sub-elements are not supported 719 | 720 | ```javascript 721 | feed.additem({ 722 | media: { 723 | url: 'http://path/to/assets/1.jpg', 724 | medium: 'image', 725 | title: 'Attached image', 726 | restriction: { 727 | type: 'sharing', 728 | relationship: 'deny' 729 | } 730 | } 731 | }); 732 | ``` 733 | 734 | ## License 735 | 736 | **MIT** -------------------------------------------------------------------------------- /test/feedster-test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var feedster = require('../lib/feedster'); 4 | var chai = require('chai'); 5 | var expect = chai.expect; 6 | 7 | chai.config.includeStack = true; 8 | 9 | describe('feedster unit tests', function() { 10 | 11 | describe('#formatString', function() { 12 | it('should return string value', function() { 13 | var feed = feedster.createFeed(); 14 | expect(feed.formatString('test')).to.equal('test'); 15 | }); 16 | 17 | it('should rconvert Date to string', function() { 18 | var feed = feedster.createFeed(); 19 | expect(feed.formatString(new Date(2011, 10, 23, 15, 22, 43))).to.include('Wed, 23 Nov 2011 15:22:43 '); 20 | }); 21 | }); 22 | 23 | describe('#addItem', function() { 24 | it('should add new items', function() { 25 | var feed = feedster.createFeed(); 26 | 27 | feed.addItem({ 28 | title: 'test1' 29 | }); 30 | expect(feed._childNodes).to.deep.equal([{ 31 | item: [{ 32 | title: 'test1' 33 | }] 34 | }]); 35 | 36 | feed.addItem({ 37 | title: 'test2' 38 | }); 39 | expect(feed._childNodes).to.deep.equal([{ 40 | item: [{ 41 | title: 'test1' 42 | }] 43 | }, { 44 | item: [{ 45 | title: 'test2' 46 | }] 47 | }]); 48 | }); 49 | 50 | it('should store pubDate', function() { 51 | var feed = feedster.createFeed(); 52 | 53 | feed.addItem({ 54 | pubDate: new Date() 55 | }); 56 | 57 | expect(feed._childNodes[0].pubDate).to.exist; 58 | }); 59 | }); 60 | 61 | describe('#render', function() { 62 | it('should convert xml object to a string', function() { 63 | var feed = feedster.createFeed(); 64 | expect(feed.render({ 65 | indent: false 66 | })).to.equal(''); 67 | }); 68 | }); 69 | 70 | describe('#_build', function() { 71 | it('should generate XML object', function() { 72 | var feed = feedster.createFeed({ 73 | title: 'test' 74 | }); 75 | feed.addItem({ 76 | title: 'test' 77 | }); 78 | 79 | expect(feed._build()).to.deep.equal({ 80 | rss: [{ 81 | _attr: { 82 | version: '2.0' 83 | } 84 | }, { 85 | channel: [{ 86 | title: 'test' 87 | }, { 88 | item: [{ 89 | 'title': 'test' 90 | }] 91 | }] 92 | }] 93 | }); 94 | }); 95 | 96 | it('should add namespaces', function() { 97 | var feed = feedster.createFeed(); 98 | feed.addItem({ 99 | creator: 'test' 100 | }); 101 | 102 | expect(feed._build()).to.deep.equal({ 103 | rss: [{ 104 | _attr: { 105 | version: '2.0', 106 | 'xmlns:dc': 'http://purl.org/dc/elements/1.1/' 107 | } 108 | }, { 109 | channel: [{ 110 | item: [{ 111 | 'dc:creator': 'test' 112 | }] 113 | }] 114 | }] 115 | }); 116 | }); 117 | 118 | it('should sort child nodes by date', function() { 119 | var feed = feedster.createFeed(); 120 | 121 | feed.addItem({ 122 | pubDate: '2007-01-01' 123 | }); 124 | 125 | feed.addItem({ 126 | pubDate: '2006-01-01' 127 | }); 128 | 129 | feed.addItem({ 130 | pubDate: '2008-01-01' 131 | }); 132 | 133 | var channel = feed._build().rss[1].channel; 134 | 135 | expect(channel[0].lastBuildDate).to.include(' 2008 '); 136 | expect(channel[1].item[0].pubDate).to.include(' 2008 '); 137 | expect(channel[2].item[0].pubDate).to.include(' 2007 '); 138 | expect(channel[3].item[0].pubDate).to.include(' 2006 '); 139 | }); 140 | }); 141 | 142 | describe('#_handleTag', function() { 143 | it('should handle extension tag', function() { 144 | var feed = feedster.createFeed(); 145 | var node = []; 146 | feed._handleTag(node, 'creator', 'value'); 147 | expect(node).to.deep.equal([{ 148 | 'dc:creator': 'value' 149 | }]); 150 | }); 151 | 152 | it('should handle rss tag', function() { 153 | var feed = feedster.createFeed(); 154 | var node = []; 155 | feed._handleTag(node, 'pubDate', '2011-11-11'); 156 | expect(node[0].pubDate).to.include('Fri, 11 Nov 2011'); 157 | }); 158 | 159 | it('should handle unknown string tag', function() { 160 | var feed = feedster.createFeed(); 161 | var node = []; 162 | feed._handleTag(node, 'x-zzzzz', '2011-11-11'); 163 | expect(node).to.deep.equal([{ 164 | 'x-zzzzz': '2011-11-11' 165 | }]); 166 | }); 167 | }); 168 | 169 | describe('#_buildHeaders', function() { 170 | it('should attach header elements to channel', function() { 171 | var feed = feedster.createFeed(); 172 | var channelElement = []; 173 | feed._buildHeaders(channelElement, { 174 | title: 'test' 175 | }); 176 | expect(channelElement).to.deep.equal([{ 177 | title: 'test' 178 | }]); 179 | }); 180 | }); 181 | 182 | describe('_tagHandlers', function() { 183 | var node; 184 | var feed; 185 | 186 | beforeEach(function() { 187 | feed = feedster.createFeed(); 188 | node = []; 189 | }); 190 | 191 | describe('#pubDate', function() { 192 | it('should format date', function() { 193 | feed._tagHandlers.pubDate(feed, node, '2011-11-11'); 194 | expect(node[0].pubDate).to.include('Fri, 11 Nov 2011'); 195 | }); 196 | }); 197 | 198 | describe('#managingEditor', function() { 199 | it('should format name and email', function() { 200 | feed._tagHandlers.managingEditor(feed, node, { 201 | name: 'my name', 202 | email: 'my@example.com' 203 | }); 204 | expect(node[0].managingEditor).to.equal('my@example.com (my name)'); 205 | }); 206 | 207 | it('should not format a string', function() { 208 | feed._tagHandlers.managingEditor(feed, node, 'zzzzz'); 209 | expect(node[0].managingEditor).to.equal('zzzzz'); 210 | }); 211 | }); 212 | 213 | describe('#webMaster', function() { 214 | it('should format name and email', function() { 215 | feed._tagHandlers.webMaster(feed, node, { 216 | name: 'my name', 217 | email: 'my@example.com' 218 | }); 219 | expect(node[0].webMaster).to.equal('my@example.com (my name)'); 220 | }); 221 | 222 | it('should not format a string', function() { 223 | feed._tagHandlers.webMaster(feed, node, 'zzzzz'); 224 | expect(node[0].webMaster).to.equal('zzzzz'); 225 | }); 226 | }); 227 | 228 | describe('#author', function() { 229 | it('should format name and email', function() { 230 | feed._tagHandlers.author(feed, node, { 231 | name: 'my name', 232 | email: 'my@example.com' 233 | }); 234 | expect(node[0].author).to.equal('my@example.com (my name)'); 235 | }); 236 | 237 | it('should not format a string', function() { 238 | feed._tagHandlers.author(feed, node, 'zzzzz'); 239 | expect(node[0].author).to.equal('zzzzz'); 240 | }); 241 | }); 242 | 243 | describe('#category', function() { 244 | it('should format single string', function() { 245 | feed._tagHandlers.category(feed, node, 'test'); 246 | expect(node).to.deep.equal([{ 247 | category: 'test' 248 | }]); 249 | }); 250 | 251 | it('should format multiple strings', function() { 252 | feed._tagHandlers.category(feed, node, ['test1', 'test2']); 253 | expect(node).to.deep.equal([{ 254 | category: 'test1' 255 | }, { 256 | category: 'test2' 257 | }]); 258 | }); 259 | 260 | it('should format category object', function() { 261 | feed._tagHandlers.category(feed, node, { 262 | value: 'test', 263 | domain: 'zzz' 264 | }); 265 | expect(node).to.deep.equal([{ 266 | category: [{ 267 | _attr: { 268 | domain: 'zzz' 269 | } 270 | }, 'test'] 271 | }]); 272 | }); 273 | }); 274 | 275 | describe('#cloud', function() { 276 | it('should format a cloud object', function() { 277 | feed._tagHandlers.cloud(feed, node, { 278 | domain: 'example.com', 279 | port: 80 280 | }); 281 | 282 | expect(node).to.deep.equal([{ 283 | cloud: { 284 | _attr: { 285 | domain: 'example.com', 286 | port: 80 287 | } 288 | } 289 | }]); 290 | }); 291 | }); 292 | 293 | describe('#image', function() { 294 | it('should format plain url', function() { 295 | feed._tagHandlers.image(feed, node, 'http://www.example.com/image.png'); 296 | expect(node).to.deep.equal([{ 297 | image: [{ 298 | url: 'http://www.example.com/image.png' 299 | }] 300 | }]); 301 | }); 302 | 303 | it('should format image object', function() { 304 | feed._tagHandlers.image(feed, node, { 305 | url: 'http://www.example.com/image.png', 306 | title: 'test' 307 | }); 308 | expect(node).to.deep.equal([{ 309 | image: [{ 310 | url: 'http://www.example.com/image.png' 311 | }, { 312 | title: 'test' 313 | }] 314 | }]); 315 | }); 316 | 317 | it('should use defaults', function() { 318 | feed.headers.title = 'title-test'; 319 | feed.headers.link = 'link-test'; 320 | 321 | feed._tagHandlers.image(feed, node, 'http://www.example.com/image.png'); 322 | expect(node).to.deep.equal([{ 323 | image: [{ 324 | url: 'http://www.example.com/image.png' 325 | }, { 326 | title: 'title-test' 327 | }, { 328 | link: 'link-test' 329 | }] 330 | }]); 331 | }); 332 | }); 333 | 334 | describe('#textInput', function() { 335 | it('should format textInput', function() { 336 | feed._tagHandlers.textInput(feed, node, { 337 | description: 'some input', 338 | link: 'abs_path_to_script.php' 339 | }); 340 | 341 | expect(node).to.deep.equal([{ 342 | textInput: [{ 343 | description: 'some input' 344 | }, { 345 | link: 'abs_path_to_script.php' 346 | }] 347 | }]); 348 | }); 349 | }); 350 | 351 | describe('#guid', function() { 352 | it('should format guid string', function() { 353 | feed._tagHandlers.guid(feed, node, 'http://www.example.com/post.html'); 354 | expect(node).to.deep.equal([{ 355 | guid: 'http://www.example.com/post.html' 356 | }]); 357 | }); 358 | 359 | it('should format guid object', function() { 360 | feed._tagHandlers.guid(feed, node, { 361 | value: 'http://www.example.com/post.html', 362 | isPermaLink: true 363 | }); 364 | expect(node).to.deep.equal([{ 365 | guid: [{ 366 | _attr: { 367 | isPermaLink: 'true' 368 | } 369 | }, 'http://www.example.com/post.html'] 370 | }]); 371 | }); 372 | }); 373 | 374 | describe('#source', function() { 375 | it('should format source object', function() { 376 | feed._tagHandlers.source(feed, node, { 377 | url: 'http://www.example.com/rss', 378 | title: 'My other Blog' 379 | }); 380 | expect(node).to.deep.equal([{ 381 | source: [{ 382 | _attr: { 383 | url: 'http://www.example.com/rss' 384 | } 385 | }, 'My other Blog'] 386 | }]); 387 | }); 388 | }); 389 | 390 | describe('#enclosure', function() { 391 | it('should format url string', function() { 392 | feed._tagHandlers.enclosure(feed, node, 'http://www.example.com/show.mp3'); 393 | expect(node).to.deep.equal([{ 394 | enclosure: { 395 | _attr: { 396 | url: 'http://www.example.com/show.mp3', 397 | length: '0', 398 | type: 'audio/mpeg' 399 | } 400 | } 401 | }]); 402 | }); 403 | 404 | it('should format enclosure object', function() { 405 | feed._tagHandlers.enclosure(feed, node, { 406 | url: 'http://www.example.com/show.mp3', 407 | length: 12345, 408 | type: 'z/r' 409 | }); 410 | expect(node).to.deep.equal([{ 411 | enclosure: { 412 | _attr: { 413 | url: 'http://www.example.com/show.mp3', 414 | length: 12345, 415 | type: 'z/r' 416 | } 417 | } 418 | }]); 419 | }); 420 | }); 421 | }); 422 | 423 | describe('_extensionHandlers', function() { 424 | var node; 425 | var feed; 426 | 427 | beforeEach(function() { 428 | feed = feedster.createFeed(); 429 | node = []; 430 | }); 431 | 432 | describe('#creator', function() { 433 | it('should define dc:creator', function() { 434 | feed._extensionHandlers.creator.handler(feed, node, 'my name'); 435 | expect(node).to.deep.equal([{ 436 | 'dc:creator': 'my name' 437 | }]); 438 | }); 439 | }); 440 | 441 | describe('#updatePeriod', function() { 442 | it('should define sy.updatePeriod', function() { 443 | feed._extensionHandlers.updatePeriod.handler(feed, node, 'hourly'); 444 | expect(node).to.deep.equal([{ 445 | 'sy:updatePeriod': 'hourly' 446 | }]); 447 | }); 448 | }); 449 | 450 | describe('#updateFrequency', function() { 451 | it('should define sy.updateFrequency', function() { 452 | feed._extensionHandlers.updateFrequency.handler(feed, node, 1); 453 | expect(node).to.deep.equal([{ 454 | 'sy:updateFrequency': 1 455 | }]); 456 | }); 457 | }); 458 | 459 | describe('#atomLink', function() { 460 | it('should process single string', function() { 461 | feed._extensionHandlers.atomLink.handler(feed, node, 'http://www.example.com'); 462 | expect(node).to.deep.equal([{ 463 | 'atom:link': { 464 | _attr: { 465 | href: 'http://www.example.com' 466 | } 467 | } 468 | }]); 469 | }); 470 | 471 | it('should process link object', function() { 472 | feed._extensionHandlers.atomLink.handler(feed, node, { 473 | href: 'http://www.example.com/', 474 | rel: 'self', 475 | type: 'application/rss+xml' 476 | }); 477 | 478 | expect(node).to.deep.equal([{ 479 | 'atom:link': { 480 | _attr: { 481 | href: 'http://www.example.com/', 482 | rel: 'self', 483 | type: 'application/rss+xml' 484 | } 485 | } 486 | }]); 487 | }); 488 | 489 | it('should set type for self', function() { 490 | feed._extensionHandlers.atomLink.handler(feed, node, { 491 | href: 'http://www.example.com/', 492 | rel: 'self' 493 | }); 494 | 495 | expect(node).to.deep.equal([{ 496 | 'atom:link': { 497 | _attr: { 498 | href: 'http://www.example.com/', 499 | rel: 'self', 500 | type: 'application/rss+xml' 501 | } 502 | } 503 | }]); 504 | }); 505 | }); 506 | 507 | describe('#hub', function() { 508 | it('should process hub', function() { 509 | feed._extensionHandlers.hub.handler(feed, node, 'http://www.example.com/'); 510 | 511 | expect(node).to.deep.equal([{ 512 | 'atom:link': { 513 | _attr: { 514 | href: 'http://www.example.com/', 515 | rel: 'hub' 516 | } 517 | } 518 | }]); 519 | }); 520 | }); 521 | 522 | describe('#content', function() { 523 | it('should process content', function() { 524 | feed._extensionHandlers.content.handler(feed, node, 'test string'); 525 | expect(node).to.deep.equal([{ 526 | 'content:encoded': 'test string' 527 | }]); 528 | }); 529 | }); 530 | 531 | describe('#commentCount', function() { 532 | it('should process commentCount', function() { 533 | feed._extensionHandlers.commentCount.handler(feed, node, 123); 534 | expect(node).to.deep.equal([{ 535 | 'slash:comments': 123 536 | }]); 537 | }); 538 | }); 539 | 540 | describe('#commentRss', function() { 541 | it('should process commentRss', function() { 542 | feed._extensionHandlers.commentRss.handler(feed, node, 'http://www.example.com'); 543 | expect(node).to.deep.equal([{ 544 | 'wfw:commentRss': 'http://www.example.com' 545 | }]); 546 | }); 547 | }); 548 | 549 | describe('#lat', function() { 550 | it('should process lat', function() { 551 | feed._extensionHandlers.lat.handler(feed, node, '67.678'); 552 | expect(node).to.deep.equal([{ 553 | 'geo:lat': '67.678' 554 | }]); 555 | }); 556 | }); 557 | 558 | describe('#long', function() { 559 | it('should process long', function() { 560 | feed._extensionHandlers.long.handler(feed, node, '67.678'); 561 | expect(node).to.deep.equal([{ 562 | 'geo:long': '67.678' 563 | }]); 564 | }); 565 | }); 566 | 567 | describe('itunes', function() { 568 | 569 | describe('#category', function() { 570 | it('should process string category', function() { 571 | feed._extensionHandlers.itunes.handler(feed, node, { 572 | category: 'Business' 573 | }); 574 | expect(node).to.deep.equal([{ 575 | 'itunes:category': { 576 | _attr: { 577 | text: 'Business' 578 | } 579 | } 580 | }]); 581 | }); 582 | 583 | it('should process multiple categories', function() { 584 | feed._extensionHandlers.itunes.handler(feed, node, { 585 | category: ['Business', 'Technology'] 586 | }); 587 | expect(node).to.deep.equal([{ 588 | 'itunes:category': { 589 | _attr: { 590 | text: 'Business' 591 | } 592 | } 593 | }, { 594 | 'itunes:category': { 595 | _attr: { 596 | text: 'Technology' 597 | } 598 | } 599 | }]); 600 | }); 601 | 602 | it('should process sub categories', function() { 603 | feed._extensionHandlers.itunes.handler(feed, node, { 604 | category: { 605 | value: 'Business', 606 | sub: ['Careers', 'Football'] 607 | } 608 | }); 609 | expect(node).to.deep.equal([{ 610 | 'itunes:category': [{ 611 | _attr: { 612 | text: 'Business' 613 | } 614 | }, { 615 | 'itunes:category': { 616 | _attr: { 617 | text: 'Careers' 618 | } 619 | } 620 | }, { 621 | 'itunes:category': { 622 | _attr: { 623 | text: 'Football' 624 | } 625 | } 626 | }] 627 | }]); 628 | }); 629 | 630 | }); 631 | 632 | describe('#explicit', function() { 633 | it('should process boolean explicit', function() { 634 | feed._extensionHandlers.itunes.handler(feed, node, { 635 | explicit: true 636 | }); 637 | expect(node).to.deep.equal([{ 638 | 'itunes:explicit': 'Yes' 639 | }]); 640 | }); 641 | 642 | it('should process falsy boolean explicit', function() { 643 | feed._extensionHandlers.itunes.handler(feed, node, { 644 | explicit: false 645 | }); 646 | expect(node).to.deep.equal([{ 647 | 'itunes:explicit': 'No' 648 | }]); 649 | }); 650 | }); 651 | 652 | describe('#isClosedCaptioned', function() { 653 | it('should process boolean isClosedCaptioned', function() { 654 | feed._extensionHandlers.itunes.handler(feed, node, { 655 | isClosedCaptioned: true 656 | }); 657 | expect(node).to.deep.equal([{ 658 | 'itunes:isClosedCaptioned': 'Yes' 659 | }]); 660 | }); 661 | }); 662 | 663 | describe('#complete', function() { 664 | it('should process boolean complete', function() { 665 | feed._extensionHandlers.itunes.handler(feed, node, { 666 | complete: true 667 | }); 668 | expect(node).to.deep.equal([{ 669 | 'itunes:complete': 'Yes' 670 | }]); 671 | }); 672 | }); 673 | 674 | describe('#block', function() { 675 | it('should process boolean block', function() { 676 | feed._extensionHandlers.itunes.handler(feed, node, { 677 | block: true 678 | }); 679 | expect(node).to.deep.equal([{ 680 | 'itunes:block': 'Yes' 681 | }]); 682 | }); 683 | }); 684 | 685 | describe('#owner', function() { 686 | it('should process owner object', function() { 687 | feed._extensionHandlers.itunes.handler(feed, node, { 688 | owner: { 689 | name: 'my name', 690 | email: 'my@example.com' 691 | } 692 | }); 693 | expect(node).to.deep.equal([{ 694 | 'itunes:owner': [{ 695 | 'itunes:name': 'my name' 696 | }, { 697 | 'itunes:email': 'my@example.com' 698 | }] 699 | }]); 700 | }); 701 | }); 702 | 703 | describe('#image', function() { 704 | it('should process image', function() { 705 | feed._extensionHandlers.itunes.handler(feed, node, { 706 | image: 'http://www.example.com/logo.png' 707 | }); 708 | 709 | expect(node).to.deep.equal([{ 710 | 'itunes:image': { 711 | _attr: { 712 | href: 'http://www.example.com/logo.png' 713 | } 714 | } 715 | }]); 716 | }); 717 | }); 718 | 719 | describe('#default', function() { 720 | it('should process unknown key as string', function() { 721 | feed._extensionHandlers.itunes.handler(feed, node, { 722 | 'x-test': 'abcde' 723 | }); 724 | 725 | expect(node).to.deep.equal([{ 726 | 'itunes:x-test': 'abcde' 727 | }]); 728 | }); 729 | }); 730 | }); 731 | 732 | describe('#media', function() { 733 | it('should mix attributes and sub elements', function() { 734 | feed._extensionHandlers.media.handler(feed, node, { 735 | url: 'http://example.com/path/to/this/blog/assets/1.jpg', 736 | medium: 'image', 737 | title: 'Attached image', 738 | restriction: { 739 | type: 'sharing', 740 | relationship: 'deny' 741 | } 742 | }); 743 | 744 | expect(node).to.deep.equal([{ 745 | 'media:content': [{ 746 | _attr: { 747 | url: 'http://example.com/path/to/this/blog/assets/1.jpg', 748 | medium: 'image', 749 | type: 'image/jpeg' 750 | } 751 | }, { 752 | 'media:title': 'Attached image' 753 | }, { 754 | 'media:restriction': { 755 | _attr: { 756 | type: 'sharing', 757 | relationship: 'deny' 758 | } 759 | } 760 | }] 761 | }]); 762 | }); 763 | }); 764 | }); 765 | }); -------------------------------------------------------------------------------- /lib/feedster.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var xml = require('xml'); 4 | var moment = require('moment'); 5 | var libmime = require('libmime'); 6 | 7 | module.exports.Feed = Feed; 8 | module.exports.createFeed = function(headers) { 9 | return new Feed(headers); 10 | }; 11 | 12 | /** 13 | * Creates a RSS feed object 14 | * 15 | * @constructor 16 | * @param {Object} headers Key-value pairs for the RSS element 17 | */ 18 | function Feed(headers) { 19 | this.headers = headers || {}; 20 | 21 | /** 22 | * Keep count of the used namespaces - these will be referenced from the RSS header 23 | * @type {Array} 24 | */ 25 | this._usedNamespaces = []; 26 | 27 | /** 28 | * Stores elements 29 | * @type {Array} 30 | */ 31 | this._childNodes = []; 32 | } 33 | 34 | /** 35 | * Supported extensions 36 | * @type {Object} 37 | */ 38 | Feed.prototype.NS = { 39 | dc: 'http://purl.org/dc/elements/1.1/', 40 | sy: 'http://purl.org/rss/1.0/modules/syndication/', 41 | atom: 'http://www.w3.org/2005/Atom', 42 | slash: 'http://purl.org/rss/1.0/modules/slash/', 43 | itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd', 44 | content: 'http://purl.org/rss/1.0/modules/content/', 45 | wfw: 'http://wellformedweb.org/CommentAPI/', 46 | media: 'http://search.yahoo.com/mrss/', 47 | geo: 'http://www.w3.org/2003/01/geo/wgs84_pos#' 48 | }; 49 | 50 | /** 51 | * Helper function to process non-object values. Date objects are converted to RSS compatible 52 | * date format, other values are left as is 53 | * 54 | * @param {Mixed} value Node value 55 | * @returns {String} Formatted node value 56 | */ 57 | Feed.prototype.formatString = function(value) { 58 | switch (Object.prototype.toString.call(value)) { 59 | case '[object Date]': 60 | // Fri, 31 Oct 2014 18:12:21 +0000 61 | return moment(value, false, 'en').format('ddd, D MMM YYYY HH:mm:ss ZZ'); 62 | } 63 | 64 | if (value instanceof Buffer) { 65 | return value.toString(); 66 | } 67 | 68 | return value; 69 | }; 70 | 71 | /** 72 | * Adds an element to the feed 73 | * 74 | * @param {Object} post Key-value pairs for a RSS element 75 | */ 76 | Feed.prototype.addItem = function(item) { 77 | var childNode = { 78 | item: [] 79 | }; 80 | 81 | if (item.pubDate) { 82 | item.pubDate = moment(item.pubDate).toDate(); 83 | 84 | // Store pubDate for later sorting. 85 | // Normally we would use a WeakMap for storing object related metadata 86 | // but WeakMap is not available in Node v0.10 so we pass required information 87 | // as a non-enumerable property to keep it out of the generated XML 88 | Object.defineProperty(childNode, 'pubDate', { 89 | value: item.pubDate, 90 | enumerable: false 91 | }); 92 | } 93 | 94 | // process key-value pairs 95 | Object.keys(item || {}).forEach(function(key) { 96 | this._handleTag(childNode.item, key, item[key]); 97 | }.bind(this)); 98 | 99 | // Append created node to childNodes list 100 | this._childNodes.push(childNode); 101 | }; 102 | 103 | /** 104 | * Generate a RSS feed file 105 | * 106 | * @param {Object} options Optional options 107 | * @returns {String} Generated RSS 108 | */ 109 | Feed.prototype.render = function(options) { 110 | options = options || {}; 111 | 112 | return xml(this._build(), { 113 | declaration: true, 114 | indent: ('indent' in options) ? options.indent : ' ' 115 | }); 116 | }; 117 | 118 | /** 119 | * Composes XML structure from the input 120 | * 121 | * @returns {Object} XML structure for the RSS 122 | */ 123 | Feed.prototype._build = function() { 124 | var structure = { 125 | rss: [{ 126 | _attr: {} 127 | }, { 128 | channel: [] 129 | }] 130 | }; 131 | 132 | var rootElement = structure.rss; 133 | var channelElement = rootElement[1].channel; 134 | 135 | // Sort items by date descending 136 | this._childNodes.sort(function(itemA, itemB) { 137 | if (!itemA.pubDate && itemB.pubDate) { 138 | return 1; 139 | } 140 | 141 | if (itemA.pubDate && !itemB.pubDate) { 142 | return -1; 143 | } 144 | 145 | if (!itemA.pubDate && !itemB.pubDate) { 146 | return 0; 147 | } 148 | 149 | return moment(itemA.pubDate).isBefore(itemB.pubDate) ? 1 : -1; 150 | }.bind(this)); 151 | 152 | // set channel.lastBuildDate by using the date from the latest element 153 | if (!this.headers.lastBuildDate && this._childNodes.length && this._childNodes[0].pubDate) { 154 | this.headers.lastBuildDate = this._childNodes[0].pubDate; 155 | } 156 | 157 | // Add headers to 158 | this._buildHeaders(channelElement, this.headers); 159 | 160 | // Add sorted elements 161 | rootElement[1].channel = channelElement.concat(this._childNodes); 162 | 163 | // include references to all used namespaces in the root element 164 | this._usedNamespaces.forEach(function(ns) { 165 | rootElement[0]._attr['xmlns:' + ns] = this.NS[ns]; 166 | }.bind(this)); 167 | 168 | // append RSS version to header 169 | rootElement[0]._attr.version = '2.0'; 170 | 171 | return structure; 172 | }; 173 | 174 | /** 175 | * Processes a key-value pair for the XML. Checks if the key is a registered extension or RSS tag 176 | * and processes value with that handler. If no handlers are found, the key and value are passed to 177 | * XML structure as is 178 | * 179 | * @param {Objecy} node XML structure where to append the new node 180 | * @param {String} key Channel or item element key 181 | * @param {Mixed} value Value for the key 182 | */ 183 | Feed.prototype._handleTag = function(node, key, value) { 184 | if (this._extensionHandlers[key]) { 185 | if (this._usedNamespaces.indexOf(this._extensionHandlers[key].ns) < 0) { 186 | this._usedNamespaces.push(this._extensionHandlers[key].ns); 187 | } 188 | return this._extensionHandlers[key].handler(this, node, value); 189 | } 190 | 191 | if (this._tagHandlers[key]) { 192 | return this._tagHandlers[key](this, node, value); 193 | } 194 | 195 | var item = {}; 196 | item[key] = this.formatString(value); 197 | node.push(item); 198 | }; 199 | 200 | /** 201 | * Builds element XML 202 | * 203 | * @param {Object} channelElement XML structure for 204 | * @param {Object} headers Key-value pairs to add to the header 205 | */ 206 | Feed.prototype._buildHeaders = function(channelElement, headers) { 207 | 208 | if (headers.lastBuildDate) { 209 | headers.lastBuildDate = moment(headers.lastBuildDate).toDate(); 210 | } 211 | 212 | if (headers.pubDate) { 213 | headers.pubDate = moment(headers.pubDate).toDate(); 214 | } 215 | 216 | Object.keys(headers).forEach(function(key) { 217 | this._handleTag(channelElement, key, headers[key]); 218 | }.bind(this)); 219 | 220 | }; 221 | 222 | /** 223 | * Handlers for RSS tags. When a handler is not specified, then the value is inserted to XML as is. 224 | * @type {Object} 225 | */ 226 | Feed.prototype._tagHandlers = { 227 | 228 | /** 229 | * value can be any date or date formatted string 230 | * pubDate(.., .., '2012-01-01 12:34:12 +0000') 231 | * 232 | * http://www.rssboard.org/rss-profile#element-channel-item-pubdate 233 | */ 234 | pubDate: function(feed, node, value) { 235 | node.push({ 236 | pubDate: moment(value, false, 'en').format('ddd, D MMM YYYY HH:mm:ss ZZ') 237 | }); 238 | }, 239 | 240 | /** 241 | * value is an object with name and email properties 242 | * managingEditor(.., .., {name: 'my name', email: 'my@email.com'}) 243 | * 244 | * http://www.rssboard.org/rss-profile#element-channel-managingeditor 245 | */ 246 | managingEditor: function(feed, node, value) { 247 | if (typeof value === 'object') { 248 | value = [].concat(value.email || []).concat(value.name ? ('(' + value.name + ')') : []).join(' '); 249 | } 250 | 251 | node.push({ 252 | managingEditor: value 253 | }); 254 | }, 255 | 256 | /** 257 | * value is an object with name and email properties 258 | * 259 | * webMaster(.., .., {name: 'my name', email: 'my@email.com'}) 260 | * 261 | * http://www.rssboard.org/rss-profile#element-channel-webmaster 262 | */ 263 | webMaster: function(feed, node, value) { 264 | if (typeof value === 'object') { 265 | value = [].concat(value.email || []).concat(value.name ? ('(' + value.name + ')') : []).join(' '); 266 | } 267 | 268 | node.push({ 269 | webMaster: value 270 | }); 271 | }, 272 | 273 | /** 274 | * value is an object with name and email properties 275 | * 276 | * author(.., .., {name: 'my name', email: 'my@email.com'}) 277 | * 278 | * http://www.rssboard.org/rss-profile#element-channel-item-author 279 | */ 280 | author: function(feed, node, value) { 281 | if (typeof value === 'object') { 282 | value = [].concat(value.email || []).concat(value.name ? ('(' + value.name + ')') : []).join(' '); 283 | } 284 | 285 | node.push({ 286 | author: value 287 | }); 288 | }, 289 | 290 | /** 291 | * list is an array of category elements. Category element can be a string or an object. 292 | * Available properties for category object: value, domain 293 | * 294 | * category(.., .., ['cat1', {value: 'cat2', domain:' d1'}]) 295 | * 296 | * http://www.rssboard.org/rss-profile#element-channel-item-category 297 | */ 298 | category: function(feed, node, list) { 299 | [].concat(list || []).forEach(function(category) { 300 | if (category) { 301 | if (typeof category === 'object') { 302 | if (category.domain) { 303 | node.push({ 304 | category: [{ 305 | _attr: { 306 | domain: category.domain 307 | } 308 | }, category.value] 309 | }); 310 | return; 311 | } 312 | } 313 | node.push({ 314 | category: category && category.value || category 315 | }); 316 | } 317 | }); 318 | }, 319 | 320 | /** 321 | * values is an object where key-value pairs are used as attributes for element. 322 | * Available properties: domain, path, port, protocol, registerProcedure 323 | * 324 | * cloud(.., .., {domain: 'example.com', port: 80}) 325 | * 326 | * http://www.rssboard.org/rss-profile#element-channel-cloud 327 | */ 328 | cloud: function(feed, node, values) { 329 | var tag = { 330 | cloud: { 331 | _attr: {} 332 | } 333 | }; 334 | 335 | Object.keys(values || {}).forEach(function(key) { 336 | tag.cloud._attr[key] = values[key]; 337 | }); 338 | 339 | node.push(tag); 340 | }, 341 | 342 | /** 343 | * values is an url or an object where key-value pairs are used as child elements for element. 344 | * Available properties for image object: link, title, url, description, height, width. 345 | * If link or title is not set, channel defaults are used 346 | * 347 | * image(.., .., 'absolute_path_to_image.jpg') 348 | * image(.., .., {url: 'absolute_path_to_image.jpg'}) 349 | * image(.., .., {url: 'absolute_path_to_image.jpg', title: 'my image'}) 350 | * 351 | * http://www.rssboard.org/rss-profile#element-channel-image 352 | */ 353 | image: function(feed, node, values) { 354 | var tag = { 355 | image: [] 356 | }; 357 | 358 | values = values || {}; 359 | 360 | if (typeof values === 'string') { 361 | values = { 362 | url: values 363 | }; 364 | } 365 | 366 | if (!values.title && feed.headers.title) { 367 | values.title = feed.headers.title; 368 | } 369 | 370 | if (!values.link && feed.headers.link) { 371 | values.link = feed.headers.link; 372 | } 373 | 374 | Object.keys(values || {}).forEach(function(key) { 375 | var item = {}; 376 | item[key] = values[key]; 377 | tag.image.push(item); 378 | }); 379 | 380 | node.push(tag); 381 | }, 382 | 383 | /** 384 | * Nobody seem to understand what textInput really does. Anyway if you need it, then values 385 | * is a key-value pairs object where values will be used for sub elements. 386 | * Available properties: description, link, name, title 387 | * 388 | * textInput(.., .., {description: 'some input', link: 'abs_path_to_script.php'}) 389 | * 390 | * http://www.rssboard.org/rss-profile#element-channel-textinput 391 | */ 392 | textInput: function(feed, node, values) { 393 | var tag = { 394 | textInput: [] 395 | }; 396 | 397 | Object.keys(values || {}).forEach(function(key) { 398 | var item = {}; 399 | item[key] = values[key]; 400 | tag.textInput.push(item); 401 | }); 402 | 403 | node.push(tag); 404 | }, 405 | 406 | /** 407 | * guid is a string or an object 408 | * Available properties for guid object: value, isPermaLink (boolean) 409 | * 410 | * guid(.., .., 'http://...') 411 | * guid(.., .., {value: 'http://...'}) 412 | * guid(.., .., {value: 'http://...', isPermaLink: true}) 413 | * 414 | * http://www.rssboard.org/rss-profile#element-channel-item-guid 415 | */ 416 | guid: function(feed, node, guid) { 417 | if (typeof guid === 'object') { 418 | if (typeof guid.isPermaLink === 'boolean') { 419 | node.push({ 420 | guid: [{ 421 | _attr: { 422 | isPermaLink: guid.isPermaLink ? 'true' : 'false' 423 | } 424 | }, guid.value] 425 | }); 426 | return; 427 | } 428 | } 429 | node.push({ 430 | guid: guid && guid.value || guid 431 | }); 432 | }, 433 | 434 | /** 435 | * source is an object with properties url and title 436 | * 437 | * source(.., .., {title: 'My other Blog', url: 'abs_url_to_blog_feed'}) 438 | * 439 | * http://www.rssboard.org/rss-profile#element-channel-item-source 440 | */ 441 | source: function(feed, node, source) { 442 | node.push({ 443 | source: [{ 444 | _attr: { 445 | url: source.url 446 | } 447 | }, source.title] 448 | }); 449 | }, 450 | 451 | /** 452 | * values is an url or key-value pairs object for the enclosure. 453 | * Available properties: url, type, length 454 | * 455 | * enclosure(.., .., 'path_to_file.mp3') 456 | * enclosure(.., .., {url: 'path_to_file.mp3'}) 457 | * enclosure(.., .., {url: 'path_to_file.mp3', length: 1234567}) 458 | * 459 | * http://www.rssboard.org/rss-profile#element-channel-item-enclosure 460 | */ 461 | enclosure: function(feed, node, values) { 462 | var tag = { 463 | enclosure: { 464 | _attr: {} 465 | } 466 | }; 467 | 468 | values = values || {}; 469 | 470 | if (typeof values === 'string') { 471 | values = { 472 | url: values 473 | }; 474 | } 475 | 476 | if (!values.length) { 477 | values.length = "0"; 478 | } 479 | 480 | if (!values.type && values.url) { 481 | values.type = libmime.detectMimeType(values.url); 482 | } 483 | 484 | Object.keys(values || {}).forEach(function(key) { 485 | tag.enclosure._attr[key] = values[key]; 486 | }); 487 | 488 | node.push(tag); 489 | } 490 | }; 491 | 492 | /** 493 | * Handlers for RSS extensions. Here are defined additional keys for and elements but 494 | * instead of using these keys directly, the value is passed to a handler 495 | * @type {Object} 496 | */ 497 | Feed.prototype._extensionHandlers = { 498 | 499 | /* 500 | * Dublin Core 501 | * http://dublincore.org/documents/2012/06/14/dcmi-terms/?v=elements# 502 | * 503 | * Only dc:creator (as creator) is currently supported. 504 | * 505 | * Usage: 506 | * 507 | * creator: 'my name' 508 | */ 509 | creator: { 510 | ns: 'dc', 511 | handler: function(feed, node, creator) { 512 | node.push({ 513 | 'dc:creator': feed.formatString(creator) 514 | }); 515 | } 516 | }, 517 | 518 | /* 519 | * Syndication 520 | * http://web.resource.org/rss/1.0/modules/syndication/ 521 | * 522 | * sy:updateBase is not supported 523 | * 524 | * Usage: 525 | * 526 | * updatePeriod: 'hourly', 527 | * updateFrequency: 1 528 | */ 529 | updatePeriod: { 530 | ns: 'sy', 531 | handler: function(feed, node, period) { 532 | node.push({ 533 | 'sy:updatePeriod': feed.formatString(period) 534 | }); 535 | } 536 | }, 537 | 538 | updateFrequency: { 539 | ns: 'sy', 540 | handler: function(feed, node, frequency) { 541 | node.push({ 542 | 'sy:updateFrequency': frequency 543 | }); 544 | } 545 | }, 546 | 547 | /* 548 | * Atom 549 | * http://tools.ietf.org/html/rfc4287 550 | * 551 | * atom:link (as atomLink) and shorthand for atom:link,rel=hub (as hub) are supported 552 | * 553 | * Usage: 554 | * 555 | * atomLink: [{href: 'http://', rel: 'self', type: 'application/rss+xml'}, 'http://...'], 556 | * hub: 'http://path/to/pubsubhubbub' 557 | */ 558 | atomLink: { 559 | ns: 'atom', 560 | handler: function(feed, node, links) { 561 | 562 | [].concat(links || []).forEach(function(link) { 563 | link = link || {}; 564 | 565 | if (typeof link === 'string') { 566 | link = { 567 | href: link 568 | }; 569 | } 570 | 571 | if (link.rel === 'self' && !link.type) { 572 | link.type = 'application/rss+xml'; 573 | } 574 | 575 | node.push({ 576 | 'atom:link': { 577 | _attr: link 578 | } 579 | }); 580 | }); 581 | 582 | } 583 | }, 584 | 585 | hub: { 586 | ns: 'atom', 587 | handler: function(feed, node, url) { 588 | node.push({ 589 | 'atom:link': { 590 | _attr: { 591 | rel: 'hub', 592 | href: url 593 | } 594 | } 595 | }); 596 | } 597 | }, 598 | 599 | /* 600 | * Content 601 | * http://web.resource.org/rss/1.0/modules/content/ 602 | * 603 | * content:encoded (as content) is supported 604 | * 605 | * Usage: 606 | * 607 | * content: '

HTML contents

' 608 | */ 609 | content: { 610 | ns: 'content', 611 | handler: function(feed, node, content) { 612 | node.push({ 613 | 'content:encoded': content 614 | }); 615 | } 616 | }, 617 | 618 | /* 619 | * Slash 620 | * http://web.resource.org/rss/1.0/modules/slash/ 621 | * 622 | * Only slash:comments (as commentCount) is supported 623 | * 624 | * Usage: 625 | * 626 | * commentCount: 123 627 | */ 628 | commentCount: { 629 | ns: 'slash', 630 | handler: function(feed, node, count) { 631 | node.push({ 632 | 'slash:comments': count 633 | }); 634 | } 635 | }, 636 | 637 | /* 638 | * WFW CommentAPI 639 | * http://bitworking.org/news/2012/08/wfw.html 640 | * 641 | * wfw:commentRss (as commentRss) is supported 642 | * 643 | * Usage: 644 | * 645 | * commentRss: 'http://path/to/comment.rss' 646 | */ 647 | commentRss: { 648 | ns: 'wfw', 649 | handler: function(feed, node, rss) { 650 | node.push({ 651 | 'wfw:commentRss': rss 652 | }); 653 | } 654 | }, 655 | 656 | /* 657 | * Basic Geo 658 | * http://www.w3.org/2003/01/geo/ 659 | * 660 | * geo:lat (as lat) and geo:long (as long) are supported 661 | * 662 | * Usage: 663 | * 664 | * lat: 55.701, 665 | * long: 12.552 666 | */ 667 | lat: { 668 | ns: 'geo', 669 | handler: function(feed, node, lat) { 670 | node.push({ 671 | 'geo:lat': lat 672 | }); 673 | } 674 | }, 675 | 676 | long: { 677 | ns: 'geo', 678 | handler: function(feed, node, long) { 679 | node.push({ 680 | 'geo:long': long 681 | }); 682 | } 683 | }, 684 | 685 | /* 686 | * iTunes 687 | * https://www.apple.com/itunes/podcasts/specs.html 688 | * 689 | * Supports all options. Keys need to be enclosed into an 'itunes' object. 690 | * 691 | * Usage: 692 | * 693 | * itunes: { 694 | * explicit: true, 695 | * image: 'http://www.example.com/image.png', 696 | * ... 697 | * } 698 | * 699 | * Category format: 700 | * 701 | * * Plain string for a single top level category: 'Business' 702 | * * Array of categories for multiple: ['Business', 'Technology'] 703 | * * Sub-categories: {name: 'Business', sub: ['Careers']} 704 | * 705 | * Boolean values can either be strings (passed as is) or true|false (converted to "Yes"|"No") 706 | * 707 | * Owner is an object of name, email 708 | */ 709 | itunes: { 710 | ns: 'itunes', 711 | handler: function(feed, node, itunes) { 712 | var tag; 713 | itunes = itunes || {}; 714 | 715 | Object.keys(itunes).forEach(function(key) { 716 | var element; 717 | 718 | switch (key) { 719 | 720 | case 'category': 721 | /* 722 | category: 'Business' 723 | category: ['Business'] 724 | category: [{name: 'Business', sub: ['Careers']}, 'Technology'] 725 | */ 726 | [].concat(itunes.category).forEach(function(category) { 727 | tag = {}; 728 | 729 | if (typeof category !== 'object') { 730 | category = { 731 | value: category 732 | }; 733 | } 734 | 735 | tag['itunes:category'] = { 736 | _attr: { 737 | text: category.value 738 | } 739 | }; 740 | 741 | if (category.sub) { 742 | tag['itunes:category'] = [tag['itunes:category']]; 743 | [].concat(category.sub).forEach(function(subCategory) { 744 | tag['itunes:category'].push({ 745 | 'itunes:category': { 746 | _attr: { 747 | text: subCategory.value || subCategory 748 | } 749 | } 750 | }); 751 | }); 752 | } 753 | node.push(tag); 754 | }); 755 | break; 756 | 757 | case 'explicit': 758 | case 'isClosedCaptioned': 759 | case 'complete': 760 | case 'block': 761 | element = {}; 762 | element['itunes:' + key] = typeof itunes[key] === 'boolean' ? (itunes[key] ? 'Yes' : 'No') : itunes[key]; 763 | node.push(element); 764 | break; 765 | 766 | case 'owner': 767 | // owner: {name: 'abc', email:'abc@example.com'} 768 | tag = { 769 | 'itunes:owner': [] 770 | }; 771 | 772 | if (itunes.owner.name) { 773 | tag['itunes:owner'].push({ 774 | 'itunes:name': itunes.owner.name 775 | }); 776 | } 777 | 778 | if (itunes.owner.email) { 779 | tag['itunes:owner'].push({ 780 | 'itunes:email': itunes.owner.email 781 | }); 782 | } 783 | 784 | node.push(tag); 785 | break; 786 | 787 | case 'image': 788 | // image: 'http://www.example.com/image.png' 789 | node.push({ 790 | 'itunes:image': { 791 | _attr: { 792 | href: itunes.image 793 | } 794 | } 795 | }); 796 | break; 797 | 798 | default: 799 | tag = {}; 800 | tag['itunes:' + key] = itunes[key]; 801 | node.push(tag); 802 | } 803 | }); 804 | } 805 | }, 806 | 807 | /* 808 | * Media 809 | * http://www.rssboard.org/media-rss#media-content 810 | * 811 | * Partially supported, supports most elements and attributes. Media objects are wrapped into 812 | * a media:content element. Elements with sub elements (eg. media:community) are not supported 813 | * 814 | * Usage: 815 | * 816 | * media: [{ 817 | * url: 'http://example.com/path/to/this/blog/assets/1.jpg', 818 | * medium: 'image', 819 | * title: 'Attached image', 820 | * restriction: { 821 | * type: 'sharing', 822 | * relationship: 'deny' 823 | * } 824 | * }] 825 | * 826 | * Attributes and sub elements are mixed as object keys (ie. 'url' is an attribute, 'title' is a sub element) 827 | */ 828 | media: { 829 | ns: 'media', 830 | handler: function(feed, node, list) { 831 | var attribs = [ 832 | 'url', 833 | 'fileSize', 834 | 'type', 835 | 'medium', 836 | 'isDefault', 837 | 'expression', 838 | 'bitrate', 839 | 'bitrate', 840 | 'samplingrate', 841 | 'channels', 842 | 'duration', 843 | 'height', 844 | 'width', 845 | 'lang' 846 | ]; 847 | 848 | [].concat(list || []).forEach(function(media) { 849 | var tag = { 850 | 'media:content': [{ 851 | _attr: {} 852 | }] 853 | }; 854 | var type; 855 | var attr = tag['media:content'][0]._attr; 856 | 857 | if (typeof media === 'string') { 858 | media = { 859 | url: media 860 | }; 861 | } 862 | 863 | if (!media.type) { 864 | type = libmime.detectMimeType(media.url); 865 | if (type !== 'application/octet-stream') { 866 | media.type = type; 867 | } 868 | } 869 | 870 | Object.keys(media).forEach(function(key) { 871 | var element = {}; 872 | var value = media[key]; 873 | 874 | if (attribs.indexOf(key) >= 0) { 875 | if (key === 'isDefault' && typeof value === 'boolean') { 876 | value = value ? 'true' : 'false'; 877 | } 878 | attr[key] = value; 879 | return; 880 | } 881 | 882 | if (typeof value === 'string') { 883 | element['media:' + key] = value; 884 | } else { 885 | element['media:' + key] = [{ 886 | _attr: {} 887 | }]; 888 | 889 | Object.keys(value).forEach(function(elementKey) { 890 | if (elementKey !== 'value') { 891 | element['media:' + key][0]._attr[elementKey] = value[elementKey]; 892 | } 893 | }); 894 | 895 | if (value.value) { 896 | element['media:' + key].push(value.value); 897 | } else { 898 | element['media:' + key] = element['media:' + key].shift(); 899 | } 900 | } 901 | 902 | tag['media:content'].push(element); 903 | return; 904 | 905 | }); 906 | 907 | if (tag['media:content'].length === 1) { 908 | tag['media:content'] = tag['media:content'].shift(); 909 | } 910 | 911 | node.push(tag); 912 | }); 913 | } 914 | }, 915 | }; --------------------------------------------------------------------------------