├── .eslintrc.yml ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── check.yml │ └── test.yml ├── .gitignore ├── .prettierrc.yml ├── .travis.yml ├── .vscode └── settings.json ├── AUTHORS ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── CONTRIBUTORS.md ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── pnpm-lock.yaml ├── src ├── __tests__ │ ├── __snapshots__ │ │ ├── atom1.spec.ts.snap │ │ ├── json.spec.ts.snap │ │ └── rss2.spec.ts.snap │ ├── atom1.spec.ts │ ├── json.spec.ts │ ├── rss2.spec.ts │ ├── setup.ts │ └── utils.spec.ts ├── atom1.ts ├── config │ └── index.ts ├── feed.ts ├── json.ts ├── rss2.ts ├── typings │ └── index.ts └── utils.ts ├── tsconfig.json └── tsdown.config.ts /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | es2021: true 4 | extends: 5 | - eslint:recommended 6 | - plugin:@typescript-eslint/recommended 7 | parser: "@typescript-eslint/parser" 8 | parserOptions: 9 | ecmaVersion: latest 10 | sourceType: module 11 | plugins: 12 | - "@typescript-eslint" 13 | rules: {} 14 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: jpmonette 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Actual behavior** 21 | A clear and concise description of what is the actual outcome. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Versions (please complete the following information):** 27 | - NodeJS: 28 | - TypeScript: 29 | - npm/yarn: 30 | - feed: 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. Also make sure to add any documentation related. 18 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Prettier Check 2 | 3 | on: 4 | - pull_request 5 | 6 | jobs: 7 | prettier: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v4 12 | 13 | - uses: pnpm/action-setup@v4 14 | 15 | - name: Install dependencies 16 | run: pnpm install 17 | 18 | - name: Run Prettier 19 | run: pnpm pretty:check 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | - pull_request 5 | 6 | jobs: 7 | prettier: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: pnpm/action-setup@v4 12 | - run: pnpm install 13 | - run: pnpm run test 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | lib 3 | node_modules 4 | yarn-error.log 5 | .yarn/* 6 | !.yarn/patches 7 | !.yarn/plugins 8 | !.yarn/releases 9 | !.yarn/sdks 10 | !.yarn/versions 11 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | printWidth: 120 -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | cache: 5 | directories: 6 | - node_modules 7 | before_install: 8 | - export TZ=Europe/London 9 | script: 10 | - "npm run-script test-travis" 11 | after_script: 12 | - "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js" 13 | - "codeclimate-test-reporter < ./coverage/lcov.info" 14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.codeActionsOnSave": { 4 | "source.organizeImports": "explicit" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Jean-Philippe Monette (http://blogue.jpmonette.net/) -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | See https://github.com/jpmonette/feed/releases. -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## 1. Purpose 4 | 5 | A primary goal of Feed is to be inclusive to the largest number of contributors, with the most varied and diverse backgrounds possible. As such, we are committed to providing a friendly, safe and welcoming environment for all, regardless of gender, sexual orientation, ability, ethnicity, socioeconomic status, and religion (or lack thereof). 6 | 7 | This code of conduct outlines our expectations for all those who participate in our community, as well as the consequences for unacceptable behavior. 8 | 9 | We invite all those who participate in Feed to help us create safe and positive experiences for everyone. 10 | 11 | ## 2. Open Source Citizenship 12 | 13 | A supplemental goal of this Code of Conduct is to increase open source citizenship by encouraging participants to recognize and strengthen the relationships between our actions and their effects on our community. 14 | 15 | Communities mirror the societies in which they exist and positive action is essential to counteract the many forms of inequality and abuses of power that exist in society. 16 | 17 | If you see someone who is making an extra effort to ensure our community is welcoming, friendly, and encourages all participants to contribute to the fullest extent, we want to know. 18 | 19 | ## 3. Expected Behavior 20 | 21 | The following behaviors are expected and requested of all community members: 22 | 23 | * Participate in an authentic and active way. In doing so, you contribute to the health and longevity of this community. 24 | * Exercise consideration and respect in your speech and actions. 25 | * Attempt collaboration before conflict. 26 | * Refrain from demeaning, discriminatory, or harassing behavior and speech. 27 | * Be mindful of your surroundings and of your fellow participants. Alert community leaders if you notice a dangerous situation, someone in distress, or violations of this Code of Conduct, even if they seem inconsequential. 28 | * Remember that community event venues may be shared with members of the public; please be respectful to all patrons of these locations. 29 | 30 | ## 4. Unacceptable Behavior 31 | 32 | The following behaviors are considered harassment and are unacceptable within our community: 33 | 34 | * Violence, threats of violence or violent language directed against another person. 35 | * Sexist, racist, homophobic, transphobic, ableist or otherwise discriminatory jokes and language. 36 | * Posting or displaying sexually explicit or violent material. 37 | * Posting or threatening to post other people’s personally identifying information ("doxing"). 38 | * Personal insults, particularly those related to gender, sexual orientation, race, religion, or disability. 39 | * Inappropriate photography or recording. 40 | * Inappropriate physical contact. You should have someone’s consent before touching them. 41 | * Unwelcome sexual attention. This includes, sexualized comments or jokes; inappropriate touching, groping, and unwelcomed sexual advances. 42 | * Deliberate intimidation, stalking or following (online or in person). 43 | * Advocating for, or encouraging, any of the above behavior. 44 | * Sustained disruption of community events, including talks and presentations. 45 | 46 | ## 5. Consequences of Unacceptable Behavior 47 | 48 | Unacceptable behavior from any community member, including sponsors and those with decision-making authority, will not be tolerated. 49 | 50 | Anyone asked to stop unacceptable behavior is expected to comply immediately. 51 | 52 | If a community member engages in unacceptable behavior, the community organizers may take any action they deem appropriate, up to and including a temporary ban or permanent expulsion from the community without warning (and without refund in the case of a paid event). 53 | 54 | ## 6. Reporting Guidelines 55 | 56 | If you are subject to or witness unacceptable behavior, or have any other concerns, please notify a community organizer as soon as possible. contact@jpmonette.net. 57 | 58 | 59 | 60 | Additionally, community organizers are available to help community members engage with local law enforcement or to otherwise help those experiencing unacceptable behavior feel safe. In the context of in-person events, organizers will also provide escorts as desired by the person experiencing distress. 61 | 62 | ## 7. Addressing Grievances 63 | 64 | If you feel you have been falsely or unfairly accused of violating this Code of Conduct, you should notify Jean-Philippe Monette with a concise description of your grievance. Your grievance will be handled in accordance with our existing governing policies. 65 | 66 | 67 | 68 | ## 8. Scope 69 | 70 | We expect all community participants (contributors, paid or otherwise; sponsors; and other guests) to abide by this Code of Conduct in all community venues–online and in-person–as well as in all one-on-one communications pertaining to community business. 71 | 72 | This code of conduct and its related procedures also applies to unacceptable behavior occurring outside the scope of community activities when such behavior has the potential to adversely affect the safety and well-being of community members. 73 | 74 | ## 9. Contact info 75 | 76 | contact@jpmonette.net 77 | 78 | ## 10. License and attribution 79 | 80 | This Code of Conduct is distributed under a [Creative Commons Attribution-ShareAlike license](http://creativecommons.org/licenses/by-sa/3.0/). 81 | 82 | Portions of text derived from the [Django Code of Conduct](https://www.djangoproject.com/conduct/) and the [Geek Feminism Anti-Harassment Policy](http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy). 83 | 84 | Retrieved on November 22, 2016 from [http://citizencodeofconduct.org/](http://citizencodeofconduct.org/) 85 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are a just a few small guidelines you need to follow. 4 | 5 | ## Submitting a patch 6 | 7 | 1. It's generally best to start by opening a new issue describing the bug or 8 | feature you're intending to fix. Even if you think it's relatively minor, 9 | it's helpful to know what people are working on. Mention in the initial 10 | issue that you are planning to work on that bug or feature so that it can 11 | be assigned to you. 12 | 13 | 2. Considering each syndication client seem to have their own specificities, make 14 | sure you provide enough information about the client in question if you want 15 | to add new elements or update existing ones. Link to their official documentation 16 | and provide examples. 17 | 18 | 3. Create and run tests. Your new addition must be covered by unit tests. 19 | 20 | 4. Follow the normal process of [forking][] the project, and setup a new 21 | branch to work in. 22 | 23 | 5. Do your best to have [well-formed commit messages][] for each change. 24 | This provides consistency throughout the project, and ensures that commit 25 | messages are able to be formatted properly by various git tools. 26 | 27 | 6. Finally, push the commits to your fork and submit a [pull request][]. 28 | 29 | [forking]: https://help.github.com/articles/fork-a-repo 30 | [well-formed commit messages]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html 31 | [squash]: http://git-scm.com/book/en/Git-Tools-Rewriting-History#Squashing-Commits 32 | [pull request]: https://help.github.com/articles/creating-a-pull-request 33 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | Ben McCormick 2 | Jean-Philippe Monette 3 | Pierre Galvez  4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2013, Jean-Philippe Monette 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Feed for Node.js 3 |
4 | npm version License: MIT 5 |

6 |

jpmonette/feed - RSS 2.0, JSON Feed 1.0, and Atom 1.0 generator for Node.js
7 | Making content syndication simple and intuitive!

8 | 9 | --- 10 | 11 | **👩🏻‍💻 Developer Ready**: Quickly generate syndication feeds for your Website. 12 | 13 | **💪🏼 Strongly Typed**: Developed using TypeScript / type-safe. 14 | 15 | **🔒 Tested**: Tests & snapshot for each syndication format to avoid regressions. 16 | 17 | # Getting Started 18 | 19 | ## Installation 20 | 21 | ```bash 22 | $ yarn add feed 23 | ``` 24 | 25 | ## Example 26 | 27 | ```js 28 | import { Feed } from "feed"; 29 | 30 | const feed = new Feed({ 31 | title: "Feed Title", 32 | description: "This is my personal feed!", 33 | id: "http://example.com/", 34 | link: "http://example.com/", 35 | language: "en", // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes 36 | image: "http://example.com/image.png", 37 | favicon: "http://example.com/favicon.ico", 38 | copyright: "All rights reserved 2013, John Doe", 39 | updated: new Date(2013, 6, 14), // optional, default = today 40 | generator: "awesome", // optional, default = 'Feed for Node.js' 41 | feedLinks: { 42 | json: "https://example.com/json", 43 | atom: "https://example.com/atom" 44 | }, 45 | author: { 46 | name: "John Doe", 47 | email: "johndoe@example.com", 48 | link: "https://example.com/johndoe" 49 | } 50 | }); 51 | 52 | posts.forEach(post => { 53 | feed.addItem({ 54 | title: post.title, 55 | id: post.url, 56 | link: post.url, 57 | description: post.description, 58 | content: post.content, 59 | author: [ 60 | { 61 | name: "Jane Doe", 62 | email: "janedoe@example.com", 63 | link: "https://example.com/janedoe" 64 | }, 65 | { 66 | name: "Joe Smith", 67 | email: "joesmith@example.com", 68 | link: "https://example.com/joesmith" 69 | } 70 | ], 71 | contributor: [ 72 | { 73 | name: "Shawn Kemp", 74 | email: "shawnkemp@example.com", 75 | link: "https://example.com/shawnkemp" 76 | }, 77 | { 78 | name: "Reggie Miller", 79 | email: "reggiemiller@example.com", 80 | link: "https://example.com/reggiemiller" 81 | } 82 | ], 83 | date: post.date, 84 | image: post.image 85 | }); 86 | }); 87 | 88 | feed.addCategory("Technologie"); 89 | 90 | feed.addContributor({ 91 | name: "Johan Cruyff", 92 | email: "johancruyff@example.com", 93 | link: "https://example.com/johancruyff" 94 | }); 95 | 96 | console.log(feed.rss2()); 97 | // Output: RSS 2.0 98 | 99 | console.log(feed.atom1()); 100 | // Output: Atom 1.0 101 | 102 | console.log(feed.json1()); 103 | // Output: JSON Feed 1.0 104 | ``` 105 | 106 | ## Migrating from `< 3.0.0` 107 | 108 | If you are migrating from a version older than `3.0.0`, be sure to update your import as we migrated to ES6 named imports. 109 | 110 | If your environment supports the ES6 module syntax, you can `import` as described above: 111 | 112 | ```ts 113 | import { Feed } from "feed"; 114 | ``` 115 | 116 | Otherwise, you can stick with `require()`: 117 | 118 | ```diff 119 | - const Feed = require('feed'); 120 | + const Feed = require('feed').Feed; 121 | ``` 122 | 123 | ## More Information 124 | 125 | - Follow [@jpmonette](https://twitter.com/jpmonette) on Twitter for updates 126 | - Read my personal blog [Blogue de Jean-Philippe Monette](http://blogue.jpmonette.net/) to learn more about what I do! 127 | 128 | ## License 129 | 130 | Copyright (C) 2013, Jean-Philippe Monette 131 | 132 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 133 | 134 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 135 | 136 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 137 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | import { createDefaultEsmPreset } from "ts-jest"; 2 | 3 | export default createDefaultEsmPreset({ 4 | verbose: true, 5 | collectCoverage: true, 6 | transform: { 7 | "^.+\\.tsx?$": "ts-jest", 8 | }, 9 | testEnvironment: "node", 10 | testPathIgnorePatterns: ["/node_modules/", "__tests__/util/"], 11 | testRegex: "(/__tests__/.*\\.spec)\\.(jsx?|tsx?)$", 12 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], 13 | moduleNameMapper: { 14 | "@app/(.*)": "/src/$1", 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "feed", 3 | "version": "5.1.0", 4 | "type": "module", 5 | "description": "Feed is a RSS, Atom and JSON feed generator for Node.js, making content syndication simple and intuitive!", 6 | "homepage": "https://github.com/jpmonette/feed", 7 | "author": "Jean-Philippe Monette ", 8 | "license": "MIT", 9 | "main": "lib/feed.js", 10 | "types": "lib/feed.d.ts", 11 | "files": [ 12 | "!**/*.map", 13 | "lib" 14 | ], 15 | "scripts": { 16 | "build": "tsdown", 17 | "prepublish": "pnpm build", 18 | "test": "jest --silent", 19 | "test-travis": "jest --coverage", 20 | "pretty:check": "prettier src/* --check", 21 | "pretty:fix": "prettier src/* --write" 22 | }, 23 | "keywords": [ 24 | "rss", 25 | "atom", 26 | "feed", 27 | "syndication", 28 | "xml", 29 | "wrapper", 30 | "blog" 31 | ], 32 | "dependencies": { 33 | "xml-js": "^1.6.11" 34 | }, 35 | "devDependencies": { 36 | "@types/jest": "^27.5.1", 37 | "@typescript-eslint/eslint-plugin": "^5.26.0", 38 | "@typescript-eslint/parser": "^5.26.0", 39 | "codeclimate-test-reporter": "^0.5.1", 40 | "coveralls": "^3.1.1", 41 | "eslint": "^8.0.1", 42 | "eslint-config-standard": "^17.0.0", 43 | "eslint-plugin-import": "^2.25.2", 44 | "eslint-plugin-n": "^15.0.0", 45 | "eslint-plugin-promise": "^6.0.0", 46 | "jest": "^28.1.0", 47 | "prettier": "^2.8.8", 48 | "source-map-loader": "^3.0.1", 49 | "ts-jest": "^29.3.4", 50 | "tsdown": "^0.11.2", 51 | "typescript": "^5.8.3" 52 | }, 53 | "engines": { 54 | "node": ">=20", 55 | "pnpm": ">=10" 56 | }, 57 | "bugs": { 58 | "url": "https://github.com/jpmonette/feed/issues" 59 | }, 60 | "repository": { 61 | "type": "git", 62 | "url": "https://github.com/jpmonette/feed.git" 63 | }, 64 | "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39" 65 | } 66 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/atom1.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`atom 1.0 should generate a valid feed 1`] = ` 4 | " 5 | 6 | http://example.com/?link=sanitized&value=4 7 | Feed Title 8 | 2013-07-13T23:00:00.000Z 9 | https://github.com/jpmonette/feed 10 | 11 | John Doe 12 | johndoe@example.com 13 | https://example.com/johndoe?link=sanitized&value=2 14 | 15 | 16 | 17 | 18 | This is my personnal feed! 19 | http://example.com/image.png?link=sanitized&value=6 20 | http://example.com/image.ico?link=sanitized&value=7 21 | All rights reserved 2013, John Doe 22 | 23 | 24 | Johan Cruyff 25 | johancruyff@example.com 26 | https://example.com/johancruyff 27 | 28 | 29 | <![CDATA[Hello World]]> 30 | https://example.com/hello-world?id=this&that=true 31 | 32 | 33 | 34 | 2013-07-13T23:00:00.000Z 35 | 36 | 37 | 38 | Jane Doe 39 | janedoe@example.com 40 | https://example.com/janedoe?link=sanitized&value=2 41 | 42 | 43 | Joe Smith 44 | joesmith@example.com 45 | https://example.com/joesmith 46 | 47 | 48 | Joe Smith, Name Only 49 | 50 | 51 | 52 | 53 | Shawn Kemp 54 | shawnkemp@example.com 55 | https://example.com/shawnkemp 56 | 57 | 58 | Reggie Miller 59 | reggiemiller@example.com 60 | https://example.com/reggiemiller 61 | 62 | 2013-07-10T23:00:00.000Z 63 | 64 | " 65 | `; 66 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/json.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`json 1 should generate a valid feed 1`] = ` 4 | "{ 5 | \\"version\\": \\"https://jsonfeed.org/version/1\\", 6 | \\"title\\": \\"Feed Title\\", 7 | \\"home_page_url\\": \\"http://example.com/?link=sanitized&value=3\\", 8 | \\"feed_url\\": \\"http://example.com/sampleFeed.json?link=sanitized&value=5\\", 9 | \\"description\\": \\"This is my personnal feed!\\", 10 | \\"icon\\": \\"http://example.com/image.png?link=sanitized&value=6\\", 11 | \\"author\\": { 12 | \\"name\\": \\"John Doe\\", 13 | \\"url\\": \\"https://example.com/johndoe?link=sanitized&value=2\\" 14 | }, 15 | \\"_example_extension\\": { 16 | \\"about\\": \\"just an extension example\\", 17 | \\"dummy\\": \\"example\\" 18 | }, 19 | \\"items\\": [ 20 | { 21 | \\"id\\": \\"https://example.com/hello-world?id=this&that=true\\", 22 | \\"content_html\\": \\"Content of my item\\", 23 | \\"url\\": \\"https://example.com/hello-world?link=sanitized&value=2\\", 24 | \\"title\\": \\"Hello World\\", 25 | \\"summary\\": \\"This is an article about Hello World.\\", 26 | \\"image\\": \\"https://example.com/hello-world.jpg\\", 27 | \\"date_modified\\": \\"2013-07-13T23:00:00.000Z\\", 28 | \\"date_published\\": \\"2013-07-10T23:00:00.000Z\\", 29 | \\"author\\": { 30 | \\"name\\": \\"Jane Doe\\", 31 | \\"url\\": \\"https://example.com/janedoe?link=sanitized&value=2\\" 32 | }, 33 | \\"tags\\": [ 34 | \\"Grateful Dead\\", 35 | \\"MSFT\\" 36 | ], 37 | \\"_item_extension_1\\": { 38 | \\"about\\": \\"just an item extension example\\", 39 | \\"dummy1\\": \\"example\\" 40 | }, 41 | \\"_item_extension_2\\": { 42 | \\"about\\": \\"just a second item extension example\\", 43 | \\"dummy1\\": \\"example\\" 44 | } 45 | } 46 | ] 47 | }" 48 | `; 49 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/rss2.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`rss 2.0 Should specify isPermaLink=false when feed item specifies a guid 1`] = ` 4 | " 5 | 6 | 7 | Feed Title 8 | http://example.com/?link=sanitized&value=3 9 | This is my personnal feed! 10 | Sat, 13 Jul 2013 23:00:00 GMT 11 | https://validator.w3.org/feed/docs/rss2.html 12 | https://github.com/jpmonette/feed 13 | en 14 | 60 15 | 16 | Feed Title 17 | http://example.com/image.png?link=sanitized&value=6 18 | http://example.com/?link=sanitized&value=3 19 | 20 | All rights reserved 2013, John Doe 21 | Technology 22 | 23 | 24 | <![CDATA[Hello World]]> 25 | https://example.com/hello-world?link=sanitized&value=2 26 | https://example.com/hello-world?id=this&that=true 27 | Wed, 10 Jul 2013 23:00:00 GMT 28 | 29 | 30 | janedoe@example.com (Jane Doe) 31 | joesmith@example.com (Joe Smith) 32 | Grateful Dead 33 | MSFT 34 | 35 | <_item_extension_1> 36 | just an item extension example 37 | example 38 | 39 | <_item_extension_2> 40 | just a second item extension example 41 | example 42 | 43 | 44 | 45 | <![CDATA[Hello World]]> 46 | https://example.com/hello-world2 47 | 419c523a-28f4-489c-877e-9604be64c001 48 | Wed, 10 Jul 2013 23:00:00 GMT 49 | 50 | 51 | janedoe@example.com (Jane Doe) 52 | joesmith@example.com (Joe Smith) 53 | Grateful Dead 54 | MSFT 55 | 56 | <_item_extension_1> 57 | just an item extension example 58 | example 59 | 60 | <_item_extension_2> 61 | just a second item extension example 62 | example 63 | 64 | 65 | 66 | <![CDATA[Hello World]]> 67 | https://example.com/hello-world2 68 | 419c523a-28f4-489c-877e-9604be64c001 69 | Wed, 10 Jul 2013 23:00:00 GMT 70 | 71 | 72 | janedoe@example.com (Jane Doe) 73 | joesmith@example.com (Joe Smith) 74 | Grateful Dead 75 | MSFT 76 | 77 | <_item_extension_1> 78 | just an item extension example 79 | example 80 | 81 | <_item_extension_2> 82 | just a second item extension example 83 | example 84 | 85 | 86 | 87 | <![CDATA[Hello World]]> 88 | https://example.com/hello-world3 89 | https://example.com/hello-world3 90 | Wed, 10 Jul 2013 23:00:00 GMT 91 | 92 | 93 | janedoe@example.com (Jane Doe) 94 | joesmith@example.com (Joe Smith) 95 | Grateful Dead 96 | MSFT 97 | 98 | <_item_extension_1> 99 | just an item extension example 100 | example 101 | 102 | <_item_extension_2> 103 | just a second item extension example 104 | example 105 | 106 | 107 | 108 | <![CDATA[Hello World]]> 109 | http://example.org/guid 110 | 50e14f43-dd4e-412f-864d-78943ea28d91 111 | Wed, 10 Jul 2013 23:00:00 GMT 112 | 113 | <_example_extension> 114 | just an extension example 115 | example 116 | 117 | 118 | just an extension example 119 | 120 | 121 | " 122 | `; 123 | 124 | exports[`rss 2.0 Should specify isPermaLink=false when feed item specifies a link, but not an id or a guid 1`] = ` 125 | " 126 | 127 | 128 | Feed Title 129 | http://example.com/?link=sanitized&value=3 130 | This is my personnal feed! 131 | Sat, 13 Jul 2013 23:00:00 GMT 132 | https://validator.w3.org/feed/docs/rss2.html 133 | https://github.com/jpmonette/feed 134 | en 135 | 60 136 | 137 | Feed Title 138 | http://example.com/image.png?link=sanitized&value=6 139 | http://example.com/?link=sanitized&value=3 140 | 141 | All rights reserved 2013, John Doe 142 | Technology 143 | 144 | 145 | <![CDATA[Hello World]]> 146 | https://example.com/hello-world?link=sanitized&value=2 147 | https://example.com/hello-world?id=this&that=true 148 | Wed, 10 Jul 2013 23:00:00 GMT 149 | 150 | 151 | janedoe@example.com (Jane Doe) 152 | joesmith@example.com (Joe Smith) 153 | Grateful Dead 154 | MSFT 155 | 156 | <_item_extension_1> 157 | just an item extension example 158 | example 159 | 160 | <_item_extension_2> 161 | just a second item extension example 162 | example 163 | 164 | 165 | 166 | <![CDATA[Hello World]]> 167 | https://example.com/hello-world2 168 | 419c523a-28f4-489c-877e-9604be64c001 169 | Wed, 10 Jul 2013 23:00:00 GMT 170 | 171 | 172 | janedoe@example.com (Jane Doe) 173 | joesmith@example.com (Joe Smith) 174 | Grateful Dead 175 | MSFT 176 | 177 | <_item_extension_1> 178 | just an item extension example 179 | example 180 | 181 | <_item_extension_2> 182 | just a second item extension example 183 | example 184 | 185 | 186 | 187 | <![CDATA[Hello World]]> 188 | https://example.com/hello-world2 189 | 419c523a-28f4-489c-877e-9604be64c001 190 | Wed, 10 Jul 2013 23:00:00 GMT 191 | 192 | 193 | janedoe@example.com (Jane Doe) 194 | joesmith@example.com (Joe Smith) 195 | Grateful Dead 196 | MSFT 197 | 198 | <_item_extension_1> 199 | just an item extension example 200 | example 201 | 202 | <_item_extension_2> 203 | just a second item extension example 204 | example 205 | 206 | 207 | 208 | <![CDATA[Hello World]]> 209 | https://example.com/hello-world3 210 | https://example.com/hello-world3 211 | Wed, 10 Jul 2013 23:00:00 GMT 212 | 213 | 214 | janedoe@example.com (Jane Doe) 215 | joesmith@example.com (Joe Smith) 216 | Grateful Dead 217 | MSFT 218 | 219 | <_item_extension_1> 220 | just an item extension example 221 | example 222 | 223 | <_item_extension_2> 224 | just a second item extension example 225 | example 226 | 227 | 228 | 229 | <![CDATA[Hello World]]> 230 | http://example.org/guid 231 | 50e14f43-dd4e-412f-864d-78943ea28d91 232 | Wed, 10 Jul 2013 23:00:00 GMT 233 | 234 | 235 | <![CDATA[Hello World]]> 236 | http://example.org/id 237 | 67e32b59-3348-4dc3-9645-75c60b6f50cc 238 | Wed, 10 Jul 2013 23:00:00 GMT 239 | 240 | 241 | <![CDATA[Hello World]]> 242 | http://example.org/link 243 | http://example.org/link 244 | Wed, 10 Jul 2013 23:00:00 GMT 245 | 246 | <_example_extension> 247 | just an extension example 248 | example 249 | 250 | 251 | just an extension example 252 | 253 | 254 | " 255 | `; 256 | 257 | exports[`rss 2.0 Should specify isPermaLink=false when feed item specifies an id 1`] = ` 258 | " 259 | 260 | 261 | Feed Title 262 | http://example.com/?link=sanitized&value=3 263 | This is my personnal feed! 264 | Sat, 13 Jul 2013 23:00:00 GMT 265 | https://validator.w3.org/feed/docs/rss2.html 266 | https://github.com/jpmonette/feed 267 | en 268 | 60 269 | 270 | Feed Title 271 | http://example.com/image.png?link=sanitized&value=6 272 | http://example.com/?link=sanitized&value=3 273 | 274 | All rights reserved 2013, John Doe 275 | Technology 276 | 277 | 278 | <![CDATA[Hello World]]> 279 | https://example.com/hello-world?link=sanitized&value=2 280 | https://example.com/hello-world?id=this&that=true 281 | Wed, 10 Jul 2013 23:00:00 GMT 282 | 283 | 284 | janedoe@example.com (Jane Doe) 285 | joesmith@example.com (Joe Smith) 286 | Grateful Dead 287 | MSFT 288 | 289 | <_item_extension_1> 290 | just an item extension example 291 | example 292 | 293 | <_item_extension_2> 294 | just a second item extension example 295 | example 296 | 297 | 298 | 299 | <![CDATA[Hello World]]> 300 | https://example.com/hello-world2 301 | 419c523a-28f4-489c-877e-9604be64c001 302 | Wed, 10 Jul 2013 23:00:00 GMT 303 | 304 | 305 | janedoe@example.com (Jane Doe) 306 | joesmith@example.com (Joe Smith) 307 | Grateful Dead 308 | MSFT 309 | 310 | <_item_extension_1> 311 | just an item extension example 312 | example 313 | 314 | <_item_extension_2> 315 | just a second item extension example 316 | example 317 | 318 | 319 | 320 | <![CDATA[Hello World]]> 321 | https://example.com/hello-world2 322 | 419c523a-28f4-489c-877e-9604be64c001 323 | Wed, 10 Jul 2013 23:00:00 GMT 324 | 325 | 326 | janedoe@example.com (Jane Doe) 327 | joesmith@example.com (Joe Smith) 328 | Grateful Dead 329 | MSFT 330 | 331 | <_item_extension_1> 332 | just an item extension example 333 | example 334 | 335 | <_item_extension_2> 336 | just a second item extension example 337 | example 338 | 339 | 340 | 341 | <![CDATA[Hello World]]> 342 | https://example.com/hello-world3 343 | https://example.com/hello-world3 344 | Wed, 10 Jul 2013 23:00:00 GMT 345 | 346 | 347 | janedoe@example.com (Jane Doe) 348 | joesmith@example.com (Joe Smith) 349 | Grateful Dead 350 | MSFT 351 | 352 | <_item_extension_1> 353 | just an item extension example 354 | example 355 | 356 | <_item_extension_2> 357 | just a second item extension example 358 | example 359 | 360 | 361 | 362 | <![CDATA[Hello World]]> 363 | http://example.org/guid 364 | 50e14f43-dd4e-412f-864d-78943ea28d91 365 | Wed, 10 Jul 2013 23:00:00 GMT 366 | 367 | 368 | <![CDATA[Hello World]]> 369 | http://example.org/id 370 | 67e32b59-3348-4dc3-9645-75c60b6f50cc 371 | Wed, 10 Jul 2013 23:00:00 GMT 372 | 373 | <_example_extension> 374 | just an extension example 375 | example 376 | 377 | 378 | just an extension example 379 | 380 | 381 | " 382 | `; 383 | 384 | exports[`rss 2.0 should generate a valid feed 1`] = ` 385 | " 386 | 387 | 388 | Feed Title 389 | http://example.com/?link=sanitized&value=3 390 | This is my personnal feed! 391 | Sat, 13 Jul 2013 23:00:00 GMT 392 | https://validator.w3.org/feed/docs/rss2.html 393 | https://github.com/jpmonette/feed 394 | en 395 | 60 396 | 397 | Feed Title 398 | http://example.com/image.png?link=sanitized&value=6 399 | http://example.com/?link=sanitized&value=3 400 | 401 | All rights reserved 2013, John Doe 402 | Technology 403 | 404 | 405 | <![CDATA[Hello World]]> 406 | https://example.com/hello-world?link=sanitized&value=2 407 | https://example.com/hello-world?id=this&that=true 408 | Wed, 10 Jul 2013 23:00:00 GMT 409 | 410 | 411 | janedoe@example.com (Jane Doe) 412 | joesmith@example.com (Joe Smith) 413 | Grateful Dead 414 | MSFT 415 | 416 | <_item_extension_1> 417 | just an item extension example 418 | example 419 | 420 | <_item_extension_2> 421 | just a second item extension example 422 | example 423 | 424 | 425 | <_example_extension> 426 | just an extension example 427 | example 428 | 429 | 430 | " 431 | `; 432 | 433 | exports[`rss 2.0 should generate a valid feed with audio 1`] = ` 434 | " 435 | 436 | 437 | Feed Title 438 | http://example.com/?link=sanitized&value=3 439 | This is my personnal feed! 440 | Sat, 13 Jul 2013 23:00:00 GMT 441 | https://validator.w3.org/feed/docs/rss2.html 442 | https://github.com/jpmonette/feed 443 | en 444 | 60 445 | 446 | Feed Title 447 | http://example.com/image.png?link=sanitized&value=6 448 | http://example.com/?link=sanitized&value=3 449 | 450 | All rights reserved 2013, John Doe 451 | Technology 452 | 453 | 454 | <![CDATA[Hello World]]> 455 | https://example.com/hello-world?link=sanitized&value=2 456 | https://example.com/hello-world?id=this&that=true 457 | Wed, 10 Jul 2013 23:00:00 GMT 458 | 459 | 460 | janedoe@example.com (Jane Doe) 461 | joesmith@example.com (Joe Smith) 462 | Grateful Dead 463 | MSFT 464 | 465 | <_item_extension_1> 466 | just an item extension example 467 | example 468 | 469 | <_item_extension_2> 470 | just a second item extension example 471 | example 472 | 473 | 474 | 475 | <![CDATA[Hello World]]> 476 | https://example.com/hello-world2 477 | 419c523a-28f4-489c-877e-9604be64c001 478 | Wed, 10 Jul 2013 23:00:00 GMT 479 | 480 | 481 | janedoe@example.com (Jane Doe) 482 | joesmith@example.com (Joe Smith) 483 | Grateful Dead 484 | MSFT 485 | 486 | <_item_extension_1> 487 | just an item extension example 488 | example 489 | 490 | <_item_extension_2> 491 | just a second item extension example 492 | example 493 | 494 | 495 | 496 | <![CDATA[Hello World]]> 497 | https://example.com/hello-world2 498 | 419c523a-28f4-489c-877e-9604be64c001 499 | Wed, 10 Jul 2013 23:00:00 GMT 500 | 501 | 502 | janedoe@example.com (Jane Doe) 503 | joesmith@example.com (Joe Smith) 504 | Grateful Dead 505 | MSFT 506 | 507 | <_item_extension_1> 508 | just an item extension example 509 | example 510 | 511 | <_item_extension_2> 512 | just a second item extension example 513 | example 514 | 515 | 516 | 517 | <![CDATA[Hello World]]> 518 | https://example.com/hello-world3 519 | https://example.com/hello-world3 520 | Wed, 10 Jul 2013 23:00:00 GMT 521 | 522 | 523 | janedoe@example.com (Jane Doe) 524 | joesmith@example.com (Joe Smith) 525 | Grateful Dead 526 | MSFT 527 | 528 | <_item_extension_1> 529 | just an item extension example 530 | example 531 | 532 | <_item_extension_2> 533 | just a second item extension example 534 | example 535 | 536 | 537 | <_example_extension> 538 | just an extension example 539 | example 540 | 541 | 542 | " 543 | `; 544 | 545 | exports[`rss 2.0 should generate a valid feed with enclosure 1`] = ` 546 | " 547 | 548 | 549 | Feed Title 550 | http://example.com/?link=sanitized&value=3 551 | This is my personnal feed! 552 | Sat, 13 Jul 2013 23:00:00 GMT 553 | https://validator.w3.org/feed/docs/rss2.html 554 | https://github.com/jpmonette/feed 555 | en 556 | 60 557 | 558 | Feed Title 559 | http://example.com/image.png?link=sanitized&value=6 560 | http://example.com/?link=sanitized&value=3 561 | 562 | All rights reserved 2013, John Doe 563 | Technology 564 | 565 | 566 | <![CDATA[Hello World]]> 567 | https://example.com/hello-world?link=sanitized&value=2 568 | https://example.com/hello-world?id=this&that=true 569 | Wed, 10 Jul 2013 23:00:00 GMT 570 | 571 | 572 | janedoe@example.com (Jane Doe) 573 | joesmith@example.com (Joe Smith) 574 | Grateful Dead 575 | MSFT 576 | 577 | <_item_extension_1> 578 | just an item extension example 579 | example 580 | 581 | <_item_extension_2> 582 | just a second item extension example 583 | example 584 | 585 | 586 | 587 | <![CDATA[Hello World]]> 588 | https://example.com/hello-world2 589 | 419c523a-28f4-489c-877e-9604be64c001 590 | Wed, 10 Jul 2013 23:00:00 GMT 591 | 592 | 593 | janedoe@example.com (Jane Doe) 594 | joesmith@example.com (Joe Smith) 595 | Grateful Dead 596 | MSFT 597 | 598 | <_item_extension_1> 599 | just an item extension example 600 | example 601 | 602 | <_item_extension_2> 603 | just a second item extension example 604 | example 605 | 606 | 607 | 608 | <![CDATA[Hello World]]> 609 | https://example.com/hello-world2 610 | 419c523a-28f4-489c-877e-9604be64c001 611 | Wed, 10 Jul 2013 23:00:00 GMT 612 | 613 | 614 | janedoe@example.com (Jane Doe) 615 | joesmith@example.com (Joe Smith) 616 | Grateful Dead 617 | MSFT 618 | 619 | <_item_extension_1> 620 | just an item extension example 621 | example 622 | 623 | <_item_extension_2> 624 | just a second item extension example 625 | example 626 | 627 | 628 | <_example_extension> 629 | just an extension example 630 | example 631 | 632 | 633 | " 634 | `; 635 | 636 | exports[`rss 2.0 should generate a valid feed with image properties 1`] = ` 637 | " 638 | 639 | 640 | Feed Title 641 | http://example.com/?link=sanitized&value=3 642 | This is my personnal feed! 643 | Sat, 13 Jul 2013 23:00:00 GMT 644 | https://validator.w3.org/feed/docs/rss2.html 645 | https://github.com/jpmonette/feed 646 | en 647 | 60 648 | 649 | Feed Title 650 | http://example.com/image.png?link=sanitized&value=6 651 | http://example.com/?link=sanitized&value=3 652 | 653 | All rights reserved 2013, John Doe 654 | Technology 655 | 656 | 657 | <![CDATA[Hello World]]> 658 | https://example.com/hello-world?link=sanitized&value=2 659 | https://example.com/hello-world?id=this&that=true 660 | Wed, 10 Jul 2013 23:00:00 GMT 661 | 662 | 663 | janedoe@example.com (Jane Doe) 664 | joesmith@example.com (Joe Smith) 665 | Grateful Dead 666 | MSFT 667 | 668 | <_item_extension_1> 669 | just an item extension example 670 | example 671 | 672 | <_item_extension_2> 673 | just a second item extension example 674 | example 675 | 676 | 677 | 678 | <![CDATA[Hello World]]> 679 | https://example.com/hello-world2 680 | 419c523a-28f4-489c-877e-9604be64c001 681 | Wed, 10 Jul 2013 23:00:00 GMT 682 | 683 | 684 | janedoe@example.com (Jane Doe) 685 | joesmith@example.com (Joe Smith) 686 | Grateful Dead 687 | MSFT 688 | 689 | <_item_extension_1> 690 | just an item extension example 691 | example 692 | 693 | <_item_extension_2> 694 | just a second item extension example 695 | example 696 | 697 | 698 | <_example_extension> 699 | just an extension example 700 | example 701 | 702 | 703 | " 704 | `; 705 | 706 | exports[`rss 2.0 should generate a valid feed with video 1`] = ` 707 | " 708 | 709 | 710 | Feed Title 711 | http://example.com/ 712 | This is my personnal feed! 713 | Sat, 13 Jul 2013 23:00:00 GMT 714 | https://validator.w3.org/feed/docs/rss2.html 715 | https://github.com/jpmonette/feed 716 | en 717 | 60 718 | 719 | Feed Title 720 | http://example.com/image.png 721 | http://example.com/ 722 | 723 | All rights reserved 2013, John Doe 724 | 725 | 726 | <![CDATA[Hello World]]> 727 | https://example.com/hello-world4 728 | 419c523a-28f4-489c-877e-9604be64c005 729 | Wed, 10 Jul 2013 23:00:00 GMT 730 | 731 | 732 | janedoe@example.com (Jane Doe) 733 | joesmith@example.com (Joe Smith) 734 | Grateful Dead 735 | MSFT 736 | 737 | <_item_extension_1> 738 | just an item extension example 739 | example 740 | 741 | <_item_extension_2> 742 | just a second item extension example 743 | example 744 | 745 | 746 | 747 | " 748 | `; 749 | 750 | exports[`rss 2.0 should generate a valid podcast feed with audio 1`] = ` 751 | " 752 | 753 | 754 | Feed Title 755 | http://example.com/?link=sanitized&value=3 756 | This is my personnal feed! 757 | Sat, 13 Jul 2013 23:00:00 GMT 758 | https://validator.w3.org/feed/docs/rss2.html 759 | https://github.com/jpmonette/feed 760 | en 761 | 60 762 | 763 | Feed Title 764 | http://example.com/image.png?link=sanitized&value=6 765 | http://example.com/?link=sanitized&value=3 766 | 767 | All rights reserved 2013, John Doe 768 | Technology 769 | 770 | 771 | <![CDATA[Hello World]]> 772 | https://example.com/hello-world?link=sanitized&value=2 773 | https://example.com/hello-world?id=this&that=true 774 | Wed, 10 Jul 2013 23:00:00 GMT 775 | 776 | 777 | janedoe@example.com (Jane Doe) 778 | joesmith@example.com (Joe Smith) 779 | Grateful Dead 780 | MSFT 781 | 782 | <_item_extension_1> 783 | just an item extension example 784 | example 785 | 786 | <_item_extension_2> 787 | just a second item extension example 788 | example 789 | 790 | 791 | 792 | <![CDATA[Hello World]]> 793 | https://example.com/hello-world3 794 | https://example.com/hello-world3 795 | Wed, 10 Jul 2013 23:00:00 GMT 796 | 797 | 798 | janedoe@example.com (Jane Doe) 799 | joesmith@example.com (Joe Smith) 800 | Grateful Dead 801 | MSFT 802 | 803 | 50:00 804 | <_item_extension_1> 805 | just an item extension example 806 | example 807 | 808 | <_item_extension_2> 809 | just a second item extension example 810 | example 811 | 812 | 813 | <_example_extension> 814 | just an extension example 815 | example 816 | 817 | johndoe@example.com 818 | 819 | johndoe@example.com 820 | 821 | John Doe 822 | John Doe 823 | 824 | 825 | " 826 | `; 827 | -------------------------------------------------------------------------------- /src/__tests__/atom1.spec.ts: -------------------------------------------------------------------------------- 1 | import { sampleFeed } from "./setup"; 2 | 3 | describe("atom 1.0", () => { 4 | it("should generate a valid feed", () => { 5 | const actual = sampleFeed.atom1(); 6 | expect(actual).toMatchSnapshot(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/__tests__/json.spec.ts: -------------------------------------------------------------------------------- 1 | import { sampleFeed } from "./setup"; 2 | 3 | describe("json 1", () => { 4 | it("should generate a valid feed", () => { 5 | const actual = sampleFeed.json1(); 6 | expect(actual).toMatchSnapshot(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/__tests__/rss2.spec.ts: -------------------------------------------------------------------------------- 1 | import { Feed } from "../feed"; 2 | import { createSampleFeed, published, sampleFeed, updated } from "./setup"; 3 | 4 | describe("rss 2.0", () => { 5 | it("should generate a valid feed", () => { 6 | const actual = sampleFeed.rss2(); 7 | expect(actual).toMatchSnapshot(); 8 | }); 9 | 10 | it("should generate a valid feed with image properties", () => { 11 | sampleFeed.addItem({ 12 | title: "Hello World", 13 | guid: "419c523a-28f4-489c-877e-9604be64c001", 14 | link: "https://example.com/hello-world2", 15 | description: "This is an article about Hello World.", 16 | content: "Content of my item", 17 | author: [ 18 | { 19 | name: "Jane Doe", 20 | email: "janedoe@example.com", 21 | link: "https://example.com/janedoe", 22 | }, 23 | { 24 | name: "Joe Smith", 25 | email: "joesmith@example.com", 26 | link: "https://example.com/joesmith", 27 | }, 28 | ], 29 | extensions: [ 30 | { 31 | name: "_item_extension_1", 32 | objects: { 33 | about: "just an item extension example", 34 | dummy1: "example", 35 | }, 36 | }, 37 | { 38 | name: "_item_extension_2", 39 | objects: { 40 | about: "just a second item extension example", 41 | dummy1: "example", 42 | }, 43 | }, 44 | ], 45 | category: [ 46 | { 47 | name: "Grateful Dead", 48 | }, 49 | { 50 | name: "MSFT", 51 | domain: "http://www.fool.com/cusips", 52 | }, 53 | ], 54 | date: updated, 55 | image: { url: "https://example.com/hello-world.jpg", length: 12665 }, 56 | published, 57 | }); 58 | const actual = sampleFeed.rss2(); 59 | expect(actual).toMatchSnapshot(); 60 | }); 61 | 62 | it("should generate a valid feed with enclosure", () => { 63 | sampleFeed.addItem({ 64 | title: "Hello World", 65 | guid: "419c523a-28f4-489c-877e-9604be64c001", 66 | link: "https://example.com/hello-world2", 67 | description: "This is an article about Hello World.", 68 | content: "Content of my item", 69 | author: [ 70 | { 71 | name: "Jane Doe", 72 | email: "janedoe@example.com", 73 | link: "https://example.com/janedoe", 74 | }, 75 | { 76 | name: "Joe Smith", 77 | email: "joesmith@example.com", 78 | link: "https://example.com/joesmith", 79 | }, 80 | ], 81 | extensions: [ 82 | { 83 | name: "_item_extension_1", 84 | objects: { 85 | about: "just an item extension example", 86 | dummy1: "example", 87 | }, 88 | }, 89 | { 90 | name: "_item_extension_2", 91 | objects: { 92 | about: "just a second item extension example", 93 | dummy1: "example", 94 | }, 95 | }, 96 | ], 97 | category: [ 98 | { 99 | name: "Grateful Dead", 100 | }, 101 | { 102 | name: "MSFT", 103 | domain: "http://www.fool.com/cusips", 104 | }, 105 | ], 106 | date: updated, 107 | enclosure: { url: "https://example.com/hello-world.jpg", length: 12665 }, 108 | published, 109 | }); 110 | const actual = sampleFeed.rss2(); 111 | expect(actual).toMatchSnapshot(); 112 | }); 113 | 114 | it("should generate a valid feed with audio", () => { 115 | sampleFeed.addItem({ 116 | title: "Hello World", 117 | link: "https://example.com/hello-world3", 118 | description: "This is an article about Hello World.", 119 | content: "Content of my item", 120 | author: [ 121 | { 122 | name: "Jane Doe", 123 | email: "janedoe@example.com", 124 | link: "https://example.com/janedoe", 125 | }, 126 | { 127 | name: "Joe Smith", 128 | email: "joesmith@example.com", 129 | link: "https://example.com/joesmith", 130 | }, 131 | ], 132 | extensions: [ 133 | { 134 | name: "_item_extension_1", 135 | objects: { 136 | about: "just an item extension example", 137 | dummy1: "example", 138 | }, 139 | }, 140 | { 141 | name: "_item_extension_2", 142 | objects: { 143 | about: "just a second item extension example", 144 | dummy1: "example", 145 | }, 146 | }, 147 | ], 148 | category: [ 149 | { 150 | name: "Grateful Dead", 151 | }, 152 | { 153 | name: "MSFT", 154 | domain: "http://www.fool.com/cusips", 155 | }, 156 | ], 157 | date: updated, 158 | audio: { url: "https://example.com/hello-world.mp3", length: 12665, type: "audio/mpeg" }, 159 | published, 160 | }); 161 | const actual = sampleFeed.rss2(); 162 | expect(actual).toMatchSnapshot(); 163 | }); 164 | 165 | it("should generate a valid podcast feed with audio", () => { 166 | const podcastFeed = createSampleFeed(); 167 | podcastFeed.options.podcast = true; 168 | 169 | podcastFeed.addItem({ 170 | title: "Hello World", 171 | link: "https://example.com/hello-world3", 172 | description: "This is an article about Hello World.", 173 | content: "Content of my item", 174 | author: [ 175 | { 176 | name: "Jane Doe", 177 | email: "janedoe@example.com", 178 | link: "https://example.com/janedoe", 179 | }, 180 | { 181 | name: "Joe Smith", 182 | email: "joesmith@example.com", 183 | link: "https://example.com/joesmith", 184 | }, 185 | ], 186 | extensions: [ 187 | { 188 | name: "_item_extension_1", 189 | objects: { 190 | about: "just an item extension example", 191 | dummy1: "example", 192 | }, 193 | }, 194 | { 195 | name: "_item_extension_2", 196 | objects: { 197 | about: "just a second item extension example", 198 | dummy1: "example", 199 | }, 200 | }, 201 | ], 202 | category: [ 203 | { 204 | name: "Grateful Dead", 205 | }, 206 | { 207 | name: "MSFT", 208 | domain: "http://www.fool.com/cusips", 209 | }, 210 | ], 211 | date: updated, 212 | audio: { url: "https://example.com/hello-world.mp3", length: 12665, type: "audio/mpeg", duration: 3000 }, 213 | published, 214 | }); 215 | 216 | const actual = podcastFeed.rss2(); 217 | expect(actual).toMatchSnapshot(); 218 | }); 219 | 220 | it("should generate a valid feed with video", () => { 221 | const sampleFeed = new Feed({ 222 | title: "Feed Title", 223 | description: "This is my personnal feed!", 224 | link: "http://example.com/", 225 | id: "http://example.com/", 226 | language: "en", 227 | ttl: 60, 228 | image: "http://example.com/image.png", 229 | copyright: "All rights reserved 2013, John Doe", 230 | hub: "wss://example.com/", 231 | updated, // optional, default = today 232 | 233 | author: { 234 | name: "John Doe", 235 | email: "johndoe@example.com", 236 | link: "https://example.com/johndoe", 237 | }, 238 | }); 239 | sampleFeed.addItem({ 240 | title: "Hello World", 241 | id: "419c523a-28f4-489c-877e-9604be64c005", 242 | link: "https://example.com/hello-world4", 243 | description: "This is an article about Hello World.", 244 | content: "Content of my item", 245 | author: [ 246 | { 247 | name: "Jane Doe", 248 | email: "janedoe@example.com", 249 | link: "https://example.com/janedoe", 250 | }, 251 | { 252 | name: "Joe Smith", 253 | email: "joesmith@example.com", 254 | link: "https://example.com/joesmith", 255 | }, 256 | ], 257 | extensions: [ 258 | { 259 | name: "_item_extension_1", 260 | objects: { 261 | about: "just an item extension example", 262 | dummy1: "example", 263 | }, 264 | }, 265 | { 266 | name: "_item_extension_2", 267 | objects: { 268 | about: "just a second item extension example", 269 | dummy1: "example", 270 | }, 271 | }, 272 | ], 273 | category: [ 274 | { 275 | name: "Grateful Dead", 276 | }, 277 | { 278 | name: "MSFT", 279 | domain: "http://www.fool.com/cusips", 280 | }, 281 | ], 282 | date: updated, 283 | video: "https://example.com/hello-world.mp4", 284 | published, 285 | }); 286 | const actual = sampleFeed.rss2(); 287 | expect(actual).toMatchSnapshot(); 288 | }); 289 | 290 | it("should generate a valid feed with `addExtension`", () => { 291 | sampleFeed.addExtension({ 292 | name: "extension_name", 293 | objects: { 294 | about: "just an extension example", 295 | }, 296 | }); 297 | const actual = sampleFeed.rss2(); 298 | expect(actual).toContain(""); 299 | }); 300 | 301 | it("Should specify isPermaLink=false when feed item specifies a guid", () => { 302 | sampleFeed.addItem({ 303 | title: "Hello World", 304 | guid: "50e14f43-dd4e-412f-864d-78943ea28d91", 305 | link: "http://example.org/guid", 306 | date: published, 307 | }); 308 | const actual = sampleFeed.rss2(); 309 | expect(actual).toMatchSnapshot(); 310 | }); 311 | 312 | it("Should specify isPermaLink=false when feed item specifies an id", () => { 313 | sampleFeed.addItem({ 314 | title: "Hello World", 315 | id: "67e32b59-3348-4dc3-9645-75c60b6f50cc", 316 | link: "http://example.org/id", 317 | date: published, 318 | }); 319 | const actual = sampleFeed.rss2(); 320 | expect(actual).toMatchSnapshot(); 321 | }); 322 | 323 | it("Should specify isPermaLink=false when feed item specifies a link, but not an id or a guid", () => { 324 | sampleFeed.addItem({ 325 | title: "Hello World", 326 | link: "http://example.org/link", 327 | date: published, 328 | }); 329 | const actual = sampleFeed.rss2(); 330 | expect(actual).toMatchSnapshot(); 331 | }); 332 | }); 333 | -------------------------------------------------------------------------------- /src/__tests__/setup.ts: -------------------------------------------------------------------------------- 1 | import { Feed } from "../feed"; 2 | 3 | export const updated = new Date("Sat, 13 Jul 2013 23:00:00 GMT"); 4 | export const published = new Date("Sat, 10 Jul 2013 23:00:00 GMT"); 5 | 6 | export const createSampleFeed = () => { 7 | const feed = new Feed({ 8 | title: "Feed Title", 9 | description: "This is my personnal feed!", 10 | link: "http://example.com/?link=sanitized&value=3", 11 | id: "http://example.com/?link=sanitized&value=4", 12 | feed: "http://example.com/sampleFeed.rss?link=sanitized&value=2", 13 | feedLinks: { 14 | json: "http://example.com/sampleFeed.json?link=sanitized&value=5", 15 | }, 16 | language: "en", 17 | ttl: 60, 18 | image: "http://example.com/image.png?link=sanitized&value=6", 19 | favicon: "http://example.com/image.ico?link=sanitized&value=7", 20 | copyright: "All rights reserved 2013, John Doe", 21 | hub: "wss://example.com/", 22 | updated, // optional, default = today 23 | 24 | author: { 25 | name: "John Doe", 26 | email: "johndoe@example.com", 27 | link: "https://example.com/johndoe?link=sanitized&value=2", 28 | }, 29 | }); 30 | 31 | feed.addCategory("Technology"); 32 | 33 | feed.addContributor({ 34 | name: "Johan Cruyff", 35 | email: "johancruyff@example.com", 36 | link: "https://example.com/johancruyff", 37 | }); 38 | 39 | feed.addItem({ 40 | title: "Hello World", 41 | id: "https://example.com/hello-world?id=this&that=true", 42 | link: "https://example.com/hello-world?link=sanitized&value=2", 43 | description: "This is an article about Hello World.", 44 | content: "Content of my item", 45 | author: [ 46 | { 47 | name: "Jane Doe", 48 | email: "janedoe@example.com", 49 | link: "https://example.com/janedoe?link=sanitized&value=2", 50 | }, 51 | { 52 | name: "Joe Smith", 53 | email: "joesmith@example.com", 54 | link: "https://example.com/joesmith", 55 | }, 56 | { 57 | name: "Joe Smith, Name Only", 58 | }, 59 | ], 60 | contributor: [ 61 | { 62 | name: "Shawn Kemp", 63 | email: "shawnkemp@example.com", 64 | link: "https://example.com/shawnkemp", 65 | }, 66 | { 67 | name: "Reggie Miller", 68 | email: "reggiemiller@example.com", 69 | link: "https://example.com/reggiemiller", 70 | }, 71 | ], 72 | extensions: [ 73 | { 74 | name: "_item_extension_1", 75 | objects: { 76 | about: "just an item extension example", 77 | dummy1: "example", 78 | }, 79 | }, 80 | { 81 | name: "_item_extension_2", 82 | objects: { 83 | about: "just a second item extension example", 84 | dummy1: "example", 85 | }, 86 | }, 87 | ], 88 | category: [ 89 | { 90 | name: "Grateful Dead", 91 | }, 92 | { 93 | name: "MSFT", 94 | domain: "http://www.fool.com/cusips", 95 | }, 96 | ], 97 | date: updated, 98 | image: "https://example.com/hello-world.jpg", 99 | enclosure: { url: "https://example.com/hello-world.jpg", length: 12665, type: "image/jpeg" }, 100 | published, 101 | }); 102 | 103 | feed.addExtension({ 104 | name: "_example_extension", 105 | objects: { 106 | about: "just an extension example", 107 | dummy: "example", 108 | }, 109 | }); 110 | 111 | return feed; 112 | }; 113 | 114 | test.skip("skip", () => {}); 115 | 116 | export const sampleFeed = createSampleFeed(); 117 | -------------------------------------------------------------------------------- /src/__tests__/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { sanitize } from "../utils"; 2 | 3 | describe("Sanitizing", () => { 4 | it("should sanitize & to &", () => { 5 | expect("&").toEqual(sanitize("&")); 6 | }); 7 | 8 | it("should handle multiple &", () => { 9 | expect("https://test.com/?page=1&size=3&length=10").toEqual( 10 | sanitize("https://test.com/?page=1&size=3&length=10") 11 | ); 12 | }); 13 | 14 | it("should handle undefined", () => { 15 | expect(sanitize(undefined)).toBeUndefined(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/atom1.ts: -------------------------------------------------------------------------------- 1 | import * as convert from "xml-js"; 2 | import { generator } from "./config"; 3 | import type { Feed } from "./feed"; 4 | import type { Author, Category, Enclosure, Item } from "./typings"; 5 | import { sanitize } from "./utils"; 6 | 7 | /** 8 | * Returns an Atom feed 9 | * @param ins 10 | */ 11 | export default (ins: Feed) => { 12 | const { options } = ins; 13 | 14 | const base: any = { 15 | _declaration: { _attributes: { version: "1.0", encoding: "utf-8" } }, 16 | feed: { 17 | _attributes: { xmlns: "http://www.w3.org/2005/Atom" }, 18 | id: options.id, 19 | title: options.title, 20 | updated: options.updated ? options.updated.toISOString() : new Date().toISOString(), 21 | generator: sanitize(options.generator || generator), 22 | }, 23 | }; 24 | 25 | if (options.author) { 26 | base.feed.author = formatAuthor(options.author); 27 | } 28 | 29 | base.feed.link = []; 30 | 31 | // link (rel="alternate") 32 | if (options.link) { 33 | base.feed.link.push({ _attributes: { rel: "alternate", href: sanitize(options.link) } }); 34 | } 35 | 36 | // link (rel="self") 37 | const atomLink = options.feed || (options.feedLinks && options.feedLinks.atom); 38 | 39 | if (atomLink) { 40 | base.feed.link.push({ _attributes: { rel: "self", href: sanitize(atomLink) } }); 41 | } 42 | 43 | // link (rel="hub") 44 | if (options.hub) { 45 | base.feed.link.push({ _attributes: { rel: "hub", href: sanitize(options.hub) } }); 46 | } 47 | 48 | /************************************************************************** 49 | * "feed" node: optional elements 50 | *************************************************************************/ 51 | 52 | if (options.description) { 53 | base.feed.subtitle = options.description; 54 | } 55 | 56 | if (options.image) { 57 | base.feed.logo = options.image; 58 | } 59 | 60 | if (options.favicon) { 61 | base.feed.icon = options.favicon; 62 | } 63 | 64 | if (options.copyright) { 65 | base.feed.rights = options.copyright; 66 | } 67 | 68 | base.feed.category = []; 69 | 70 | ins.categories.map((category: string) => { 71 | base.feed.category.push({ _attributes: { term: category } }); 72 | }); 73 | 74 | base.feed.contributor = []; 75 | 76 | ins.contributors.map((contributor: Author) => { 77 | base.feed.contributor.push(formatAuthor(contributor)); 78 | }); 79 | 80 | // icon 81 | 82 | base.feed.entry = []; 83 | 84 | /************************************************************************** 85 | * "entry" nodes 86 | *************************************************************************/ 87 | ins.items.map((item: Item) => { 88 | // 89 | // entry: required elements 90 | // 91 | 92 | const entry: convert.ElementCompact = { 93 | title: { _attributes: { type: "html" }, _cdata: item.title }, 94 | id: sanitize(item.id || item.link), 95 | link: [{ _attributes: { href: sanitize(item.link) } }], 96 | updated: item.date.toISOString(), 97 | }; 98 | 99 | // 100 | // entry: recommended elements 101 | // 102 | if (item.description) { 103 | entry.summary = { 104 | _attributes: { type: "html" }, 105 | _cdata: item.description, 106 | }; 107 | } 108 | 109 | if (item.content) { 110 | entry.content = { 111 | _attributes: { type: "html" }, 112 | _cdata: item.content, 113 | }; 114 | } 115 | 116 | // entry author(s) 117 | if (Array.isArray(item.author)) { 118 | entry.author = []; 119 | 120 | item.author.map((author: Author) => { 121 | entry.author.push(formatAuthor(author)); 122 | }); 123 | } 124 | 125 | // content 126 | 127 | // link - relative link to article 128 | 129 | // 130 | // entry: optional elements 131 | // 132 | 133 | // category 134 | if (Array.isArray(item.category)) { 135 | entry.category = []; 136 | 137 | item.category.map((category: Category) => { 138 | entry.category.push(formatCategory(category)); 139 | }); 140 | } 141 | 142 | /** 143 | * Item Enclosure 144 | * https://validator.w3.org/feed/docs/atom.html#link 145 | */ 146 | if (item.enclosure) { 147 | entry.link.push(formatEnclosure(item.enclosure)); 148 | } 149 | 150 | if (item.image) { 151 | entry.link.push(formatEnclosure(item.image, "image")); 152 | } 153 | 154 | if (item.audio) { 155 | entry.link.push(formatEnclosure(item.audio, "audio")); 156 | } 157 | 158 | if (item.video) { 159 | entry.link.push(formatEnclosure(item.video, "video")); 160 | } 161 | 162 | // contributor 163 | if (item.contributor && Array.isArray(item.contributor)) { 164 | entry.contributor = []; 165 | 166 | item.contributor.map((contributor: Author) => { 167 | entry.contributor.push(formatAuthor(contributor)); 168 | }); 169 | } 170 | 171 | // published 172 | if (item.published) { 173 | entry.published = item.published.toISOString(); 174 | } 175 | 176 | // source 177 | 178 | // rights 179 | if (item.copyright) { 180 | entry.rights = item.copyright; 181 | } 182 | 183 | base.feed.entry.push(entry); 184 | }); 185 | 186 | return convert.js2xml(base, { compact: true, ignoreComment: true, spaces: 4 }); 187 | }; 188 | 189 | /** 190 | * Returns a formatted author 191 | * @param author 192 | */ 193 | const formatAuthor = (author: Author) => { 194 | const { name, email, link } = author; 195 | 196 | const out: { name?: string; email?: string; uri?: string } = { name }; 197 | if (email) { 198 | out.email = email; 199 | } 200 | 201 | if (link) { 202 | out.uri = sanitize(link); 203 | } 204 | 205 | return out; 206 | }; 207 | 208 | /** 209 | * Returns a formated enclosure 210 | * @param enclosure 211 | * @param mimeCategory 212 | */ 213 | const formatEnclosure = (enclosure: string | Enclosure, mimeCategory = "image") => { 214 | if (typeof enclosure === "string") { 215 | const type = new URL(enclosure).pathname.split(".").slice(-1)[0]; 216 | return { _attributes: { rel: "enclosure", href: enclosure, type: `${mimeCategory}/${type}` } }; 217 | } 218 | 219 | const type = new URL(enclosure.url).pathname.split(".").slice(-1)[0]; 220 | return { 221 | _attributes: { 222 | rel: "enclosure", 223 | href: enclosure.url, 224 | title: enclosure.title, 225 | type: `${mimeCategory}/${type}`, 226 | length: enclosure.length, 227 | }, 228 | }; 229 | }; 230 | 231 | /** 232 | * Returns a formatted category 233 | * @param category 234 | */ 235 | const formatCategory = (category: Category) => { 236 | const { name, scheme, term } = category; 237 | 238 | return { 239 | _attributes: { 240 | label: name, 241 | scheme, 242 | term, 243 | }, 244 | }; 245 | }; 246 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | export const generator = "https://github.com/jpmonette/feed"; 2 | -------------------------------------------------------------------------------- /src/feed.ts: -------------------------------------------------------------------------------- 1 | import renderAtom from "./atom1"; 2 | import renderJSON from "./json"; 3 | import renderRSS from "./rss2"; 4 | import type { Author, Category, Enclosure, Extension, FeedOptions, Item } from "./typings"; 5 | 6 | export type { Author, Category, Enclosure, Extension, FeedOptions, Item }; 7 | 8 | /** 9 | * Class used to generate Feeds 10 | */ 11 | export class Feed { 12 | options: FeedOptions; 13 | items: Item[] = []; 14 | categories: string[] = []; 15 | contributors: Author[] = []; 16 | extensions: Extension[] = []; 17 | 18 | constructor(options: FeedOptions) { 19 | this.options = options; 20 | } 21 | 22 | /** 23 | * Add a feed item 24 | * @param item 25 | */ 26 | public addItem = (item: Item) => this.items.push(item); 27 | 28 | /** 29 | * Add a category 30 | * @param category 31 | */ 32 | public addCategory = (category: string) => this.categories.push(category); 33 | 34 | /** 35 | * Add a contributor 36 | * @param contributor 37 | */ 38 | public addContributor = (contributor: Author) => this.contributors.push(contributor); 39 | 40 | /** 41 | * Adds an extension 42 | * @param extension 43 | */ 44 | public addExtension = (extension: Extension) => this.extensions.push(extension); 45 | 46 | /** 47 | * Returns a Atom 1.0 feed 48 | */ 49 | public atom1 = (): string => renderAtom(this); 50 | 51 | /** 52 | * Returns a RSS 2.0 feed 53 | */ 54 | public rss2 = (): string => renderRSS(this); 55 | 56 | /** 57 | * Returns a JSON1 feed 58 | */ 59 | public json1 = (): string => renderJSON(this); 60 | } 61 | -------------------------------------------------------------------------------- /src/json.ts: -------------------------------------------------------------------------------- 1 | import type { Feed } from "./feed"; 2 | import type { Author, Category, Extension, Item } from "./typings"; 3 | 4 | /** 5 | * Returns a JSON feed 6 | * @param ins 7 | */ 8 | export default (ins: Feed) => { 9 | const { options, items, extensions } = ins; 10 | 11 | const feed: any = { 12 | version: "https://jsonfeed.org/version/1", 13 | title: options.title, 14 | }; 15 | 16 | if (options.link) { 17 | feed.home_page_url = options.link; 18 | } 19 | 20 | if (options.feedLinks && options.feedLinks.json) { 21 | feed.feed_url = options.feedLinks.json; 22 | } 23 | 24 | if (options.description) { 25 | feed.description = options.description; 26 | } 27 | 28 | if (options.image) { 29 | feed.icon = options.image; 30 | } 31 | 32 | if (options.author) { 33 | feed.author = {}; 34 | if (options.author.name) { 35 | feed.author.name = options.author.name; 36 | } 37 | if (options.author.link) { 38 | feed.author.url = options.author.link; 39 | } 40 | if (options.author.avatar) { 41 | feed.author.avatar = options.author.avatar; 42 | } 43 | } 44 | 45 | extensions.map((e: Extension) => { 46 | feed[e.name] = e.objects; 47 | }); 48 | 49 | feed.items = items.map((item: Item) => { 50 | const feedItem: any = { 51 | id: item.id, 52 | // json_feed distinguishes between html and text content 53 | // but since we only take a single type, we'll assume HTML 54 | content_html: item.content ?? item.description, 55 | }; 56 | if (item.link) { 57 | feedItem.url = item.link; 58 | } 59 | if (item.title) { 60 | feedItem.title = item.title; 61 | } 62 | if (item.description && item.content) { 63 | feedItem.summary = item.description; 64 | } 65 | 66 | if (item.image) { 67 | feedItem.image = item.image; 68 | } 69 | 70 | if (item.date) { 71 | feedItem.date_modified = item.date.toISOString(); 72 | } 73 | if (item.published) { 74 | feedItem.date_published = item.published.toISOString(); 75 | } 76 | 77 | if (item.author) { 78 | let author: Author | Author[] = item.author; 79 | if (author instanceof Array) { 80 | // json feed only supports 1 author per post 81 | author = author[0]; 82 | } 83 | feedItem.author = {}; 84 | if (author.name) { 85 | feedItem.author.name = author.name; 86 | } 87 | if (author.link) { 88 | feedItem.author.url = author.link; 89 | } 90 | if (author.avatar) { 91 | feedItem.author.avatar = author.avatar; 92 | } 93 | } 94 | 95 | if (Array.isArray(item.category)) { 96 | feedItem.tags = []; 97 | item.category.map((category: Category) => { 98 | if (category.name) { 99 | feedItem.tags.push(category.name); 100 | } 101 | }); 102 | } 103 | 104 | if (item.extensions) { 105 | item.extensions.map((e: Extension) => { 106 | feedItem[e.name] = e.objects; 107 | }); 108 | } 109 | 110 | return feedItem; 111 | }); 112 | 113 | return JSON.stringify(feed, null, 4); 114 | }; 115 | -------------------------------------------------------------------------------- /src/rss2.ts: -------------------------------------------------------------------------------- 1 | import * as convert from "xml-js"; 2 | import { generator } from "./config"; 3 | import type { Feed } from "./feed"; 4 | import type { Author, Category, Enclosure, Extension, Item } from "./typings"; 5 | import { sanitize } from "./utils"; 6 | 7 | /** 8 | * Returns a RSS 2.0 feed 9 | */ 10 | export default (ins: Feed) => { 11 | const { options, extensions } = ins; 12 | let isAtom = false; 13 | let isContent = false; 14 | 15 | const base: any = { 16 | _declaration: { _attributes: { version: "1.0", encoding: "utf-8" } }, 17 | rss: { 18 | _attributes: { version: "2.0" }, 19 | channel: { 20 | title: { _text: options.title }, 21 | link: { _text: sanitize(options.link) }, 22 | description: { _text: options.description }, 23 | lastBuildDate: { _text: options.updated ? options.updated.toUTCString() : new Date().toUTCString() }, 24 | docs: { _text: options.docs ? options.docs : "https://validator.w3.org/feed/docs/rss2.html" }, 25 | generator: { _text: options.generator || generator }, 26 | }, 27 | }, 28 | }; 29 | 30 | /** 31 | * Channel language 32 | * https://validator.w3.org/feed/docs/rss2.html#ltlanguagegtSubelementOfLtchannelgt 33 | */ 34 | if (options.language) { 35 | base.rss.channel.language = { _text: options.language }; 36 | } 37 | 38 | /** 39 | * Channel ttl 40 | * https://validator.w3.org/feed/docs/rss2.html#ltttlgtSubelementOfLtchannelgt 41 | */ 42 | if (options.ttl) { 43 | base.rss.channel.ttl = { _text: options.ttl }; 44 | } 45 | 46 | /** 47 | * Channel Image 48 | * https://validator.w3.org/feed/docs/rss2.html#ltimagegtSubelementOfLtchannelgt 49 | */ 50 | if (options.image) { 51 | base.rss.channel.image = { 52 | title: { _text: options.title }, 53 | url: { _text: options.image }, 54 | link: { _text: sanitize(options.link) }, 55 | }; 56 | } 57 | 58 | /** 59 | * Channel Copyright 60 | * https://validator.w3.org/feed/docs/rss2.html#optionalChannelElements 61 | */ 62 | if (options.copyright) { 63 | base.rss.channel.copyright = { _text: options.copyright }; 64 | } 65 | 66 | /** 67 | * Channel Categories 68 | * https://validator.w3.org/feed/docs/rss2.html#comments 69 | */ 70 | ins.categories.map((category) => { 71 | if (!base.rss.channel.category) { 72 | base.rss.channel.category = []; 73 | } 74 | base.rss.channel.category.push({ _text: category }); 75 | }); 76 | 77 | /** 78 | * Feed URL 79 | * http://validator.w3.org/feed/docs/warning/MissingAtomSelfLink.html 80 | */ 81 | const atomLink = options.feed || (options.feedLinks && options.feedLinks.rss); 82 | if (atomLink) { 83 | isAtom = true; 84 | base.rss.channel["atom:link"] = [ 85 | { 86 | _attributes: { 87 | href: sanitize(atomLink), 88 | rel: "self", 89 | type: "application/rss+xml", 90 | }, 91 | }, 92 | ]; 93 | } 94 | 95 | /** 96 | * Hub for PubSubHubbub 97 | * https://code.google.com/p/pubsubhubbub/ 98 | */ 99 | if (options.hub) { 100 | isAtom = true; 101 | if (!base.rss.channel["atom:link"]) { 102 | base.rss.channel["atom:link"] = []; 103 | } 104 | base.rss.channel["atom:link"] = { 105 | _attributes: { 106 | href: sanitize(options.hub), 107 | rel: "hub", 108 | }, 109 | }; 110 | } 111 | 112 | /** 113 | * Channel Categories 114 | * https://validator.w3.org/feed/docs/rss2.html#hrelementsOfLtitemgt 115 | */ 116 | base.rss.channel.item = []; 117 | 118 | ins.items.map((entry: Item) => { 119 | const item: any = {}; 120 | 121 | if (entry.title) { 122 | item.title = { _cdata: entry.title }; 123 | } 124 | 125 | if (entry.link) { 126 | item.link = { _text: sanitize(entry.link) }; 127 | } 128 | 129 | if (entry.guid) { 130 | item.guid = { _text: entry.guid, _attributes: { isPermaLink: false } }; 131 | } else if (entry.id) { 132 | item.guid = { _text: entry.id, _attributes: { isPermaLink: false } }; 133 | } else if (entry.link) { 134 | item.guid = { _text: sanitize(entry.link), _attributes: { isPermaLink: true } }; 135 | } 136 | 137 | if (entry.date) { 138 | item.pubDate = { _text: entry.date.toUTCString() }; 139 | } 140 | 141 | if (entry.published) { 142 | item.pubDate = { _text: entry.published.toUTCString() }; 143 | } 144 | 145 | if (entry.description) { 146 | item.description = { _cdata: entry.description }; 147 | } 148 | 149 | if (entry.content) { 150 | isContent = true; 151 | item["content:encoded"] = { _cdata: entry.content }; 152 | } 153 | /** 154 | * Item Author 155 | * https://validator.w3.org/feed/docs/rss2.html#ltauthorgtSubelementOfLtitemgt 156 | */ 157 | if (Array.isArray(entry.author)) { 158 | item.author = []; 159 | entry.author.map((author: Author) => { 160 | if (author.email && author.name) { 161 | item.author.push({ _text: author.email + " (" + author.name + ")" }); 162 | } 163 | }); 164 | } 165 | /** 166 | * Item Category 167 | * https://validator.w3.org/feed/docs/rss2.html#ltcategorygtSubelementOfLtitemgt 168 | */ 169 | if (Array.isArray(entry.category)) { 170 | item.category = []; 171 | entry.category.map((category: Category) => { 172 | item.category.push(formatCategory(category)); 173 | }); 174 | } 175 | 176 | /** 177 | * Item Enclosure 178 | * https://validator.w3.org/feed/docs/rss2.html#ltenclosuregtSubelementOfLtitemgt 179 | */ 180 | if (entry.enclosure) { 181 | item.enclosure = formatEnclosure(entry.enclosure); 182 | } 183 | 184 | if (entry.image) { 185 | item.enclosure = formatEnclosure(entry.image, "image"); 186 | } 187 | 188 | if (entry.audio) { 189 | let duration = undefined; 190 | if (options.podcast && typeof entry.audio !== "string" && entry.audio.duration) { 191 | duration = entry.audio.duration; 192 | entry.audio.duration = undefined; 193 | } 194 | item.enclosure = formatEnclosure(entry.audio, "audio"); 195 | 196 | if (duration) { 197 | item["itunes:duration"] = formatDuration(duration); 198 | } 199 | } 200 | 201 | if (entry.video) { 202 | item.enclosure = formatEnclosure(entry.video, "video"); 203 | } 204 | 205 | if (entry.extensions) { 206 | entry.extensions.forEach((extension: Extension) => { 207 | item[extension.name] = extension.objects; 208 | }); 209 | } 210 | 211 | base.rss.channel.item.push(item); 212 | }); 213 | 214 | if (isContent) { 215 | base.rss._attributes["xmlns:dc"] = "http://purl.org/dc/elements/1.1/"; 216 | base.rss._attributes["xmlns:content"] = "http://purl.org/rss/1.0/modules/content/"; 217 | } 218 | 219 | // rss2() support `extensions` 220 | if (extensions) 221 | extensions.map((e: Extension) => { 222 | base.rss.channel[e.name] = e.objects; 223 | }); 224 | 225 | if (isAtom) { 226 | base.rss._attributes["xmlns:atom"] = "http://www.w3.org/2005/Atom"; 227 | } 228 | 229 | /** 230 | * Podcast extensions 231 | * https://support.google.com/podcast-publishers/answer/9889544?hl=en 232 | */ 233 | if (options.podcast) { 234 | base.rss._attributes["xmlns:googleplay"] = "http://www.google.com/schemas/play-podcasts/1.0"; 235 | base.rss._attributes["xmlns:itunes"] = "http://www.itunes.com/dtds/podcast-1.0.dtd"; 236 | if (options.category) { 237 | base.rss.channel["googleplay:category"] = options.category; 238 | base.rss.channel["itunes:category"] = options.category; 239 | } 240 | if (options.author?.email) { 241 | base.rss.channel["googleplay:owner"] = options.author.email; 242 | base.rss.channel["itunes:owner"] = { 243 | "itunes:email": options.author.email, 244 | }; 245 | } 246 | if (options.author?.name) { 247 | base.rss.channel["googleplay:author"] = options.author.name; 248 | base.rss.channel["itunes:author"] = options.author.name; 249 | } 250 | if (options.image) { 251 | base.rss.channel["googleplay:image"] = { 252 | _attributes: { href: sanitize(options.image) }, 253 | }; 254 | } 255 | } 256 | 257 | return convert.js2xml(base, { compact: true, ignoreComment: true, spaces: 4 }); 258 | }; 259 | 260 | /** 261 | * Returns a formated enclosure 262 | * @param enclosure 263 | * @param mimeCategory 264 | */ 265 | const formatEnclosure = (enclosure: string | Enclosure, mimeCategory = "image") => { 266 | if (typeof enclosure === "string") { 267 | const type = new URL(sanitize(enclosure)!).pathname.split(".").slice(-1)[0]; 268 | return { _attributes: { url: enclosure, length: 0, type: `${mimeCategory}/${type}` } }; 269 | } 270 | 271 | const type = new URL(sanitize(enclosure.url)!).pathname.split(".").slice(-1)[0]; 272 | return { _attributes: { length: 0, type: `${mimeCategory}/${type}`, ...enclosure } }; 273 | }; 274 | 275 | /** 276 | * Returns a formated category 277 | * @param category 278 | */ 279 | const formatCategory = (category: Category) => { 280 | const { name, domain } = category; 281 | return { 282 | _text: name, 283 | _attributes: { 284 | domain, 285 | }, 286 | }; 287 | }; 288 | 289 | /** 290 | * Returns a formated duration from seconds 291 | * @param duration 292 | */ 293 | const formatDuration = (duration: number) => { 294 | const seconds = duration % 60; 295 | const totalMinutes = Math.floor(duration / 60); 296 | const minutes = totalMinutes % 60; 297 | const hours = Math.floor(totalMinutes / 60); 298 | const notHours = ("0" + minutes).substr(-2) + ":" + ("0" + seconds).substr(-2); 299 | return hours > 0 ? hours + ":" + notHours : notHours; 300 | }; 301 | -------------------------------------------------------------------------------- /src/typings/index.ts: -------------------------------------------------------------------------------- 1 | export interface Item { 2 | title: string; 3 | id?: string; 4 | link: string; 5 | date: Date; 6 | 7 | description?: string; 8 | content?: string; 9 | category?: Category[]; 10 | 11 | guid?: string; 12 | 13 | image?: string | Enclosure; 14 | audio?: string | Enclosure; 15 | video?: string | Enclosure; 16 | enclosure?: Enclosure; 17 | 18 | author?: Author[]; 19 | contributor?: Author[]; 20 | 21 | published?: Date; 22 | copyright?: string; 23 | 24 | extensions?: Extension[]; 25 | } 26 | 27 | export interface Enclosure { 28 | url: string; 29 | type?: string; 30 | length?: number; 31 | title?: string; 32 | duration?: number; 33 | } 34 | 35 | export interface Author { 36 | name?: string; 37 | email?: string; 38 | link?: string; 39 | avatar?: string; 40 | } 41 | 42 | export interface Category { 43 | name?: string; 44 | domain?: string; 45 | scheme?: string; 46 | term?: string; 47 | } 48 | 49 | export interface FeedOptions { 50 | id: string; 51 | title: string; 52 | updated?: Date; 53 | generator?: string; 54 | language?: string; 55 | ttl?: number; 56 | 57 | feed?: string; 58 | feedLinks?: any; 59 | hub?: string; 60 | docs?: string; 61 | 62 | podcast?: boolean; 63 | category?: string; 64 | 65 | author?: Author; 66 | link?: string; 67 | description?: string; 68 | image?: string; 69 | favicon?: string; 70 | copyright: string; 71 | } 72 | 73 | export interface Extension { 74 | name: string; 75 | objects: any; 76 | } 77 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function sanitize(url: string | undefined): string | undefined { 2 | if (typeof url === "undefined") { 3 | return; 4 | } 5 | return url.replace(/&/g, "&"); 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowUnreachableCode": false, 4 | "allowUnusedLabels": false, 5 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "moduleResolution": "bundler", 8 | "noEmit": true, 9 | "noFallthroughCasesInSwitch": true, 10 | "noImplicitAny": true, 11 | "noImplicitReturns": true, 12 | "strict": true, 13 | "target": "ESNext", 14 | "verbatimModuleSyntax": true, 15 | "esModuleInterop": true, 16 | "isolatedModules": true 17 | }, 18 | "include": ["./src/**/*"], 19 | "exclude": ["node_modules", "lib", "src/__tests__/**/*"] 20 | } 21 | -------------------------------------------------------------------------------- /tsdown.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsdown"; 2 | 3 | export default defineConfig({ 4 | entry: [ 5 | "./src/feed.ts", 6 | ], 7 | outDir: "./lib", 8 | sourcemap: true, 9 | }); 10 | --------------------------------------------------------------------------------