├── .editorconfig ├── .github └── workflows │ ├── bb.yml │ └── main.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── index.js ├── lib ├── atom.js ├── rss.js ├── types.js └── util.js ├── license ├── package.json ├── readme.md ├── test.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/workflows/bb.yml: -------------------------------------------------------------------------------- 1 | name: bb 2 | on: 3 | issues: 4 | types: [opened, reopened, edited, closed, labeled, unlabeled] 5 | pull_request_target: 6 | types: [opened, reopened, edited, closed, labeled, unlabeled] 7 | jobs: 8 | main: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: unifiedjs/beep-boop-beta@main 12 | with: 13 | repo-token: ${{secrets.GITHUB_TOKEN}} 14 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | on: 3 | - pull_request 4 | - push 5 | jobs: 6 | main: 7 | name: ${{matrix.node}} 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version: ${{matrix.node}} 14 | - run: npm install 15 | - run: npm test 16 | - uses: codecov/codecov-action@v3 17 | strategy: 18 | matrix: 19 | node: 20 | - lts/gallium 21 | - node 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.d.ts 3 | *.log 4 | coverage/ 5 | node_modules/ 6 | yarn.lock 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-scripts=true 2 | package-lock=false 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | *.json 3 | *.md 4 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('./lib/types.js').Author} Author 3 | * @typedef {import('./lib/types.js').Channel} Channel 4 | * @typedef {import('./lib/types.js').Enclosure} Enclosure 5 | * @typedef {import('./lib/types.js').Entry} Entry 6 | */ 7 | 8 | export {rss} from './lib/rss.js' 9 | export {atom} from './lib/atom.js' 10 | -------------------------------------------------------------------------------- /lib/atom.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('xast').Element} Element 3 | * @typedef {import('xast').Root} Root 4 | * @typedef {import('./types.js').Author} Author 5 | * @typedef {import('./types.js').Channel} Channel 6 | * @typedef {import('./types.js').Entry} Entry 7 | */ 8 | 9 | import {bcp47Normalize as normalize} from 'bcp-47-normalize' 10 | import {u} from 'unist-builder' 11 | import {x} from 'xastscript' 12 | import {toAuthor, toDate} from './util.js' 13 | 14 | /** 15 | * Build an Atom feed. 16 | * 17 | * Same API as `rss` otherwise. 18 | * 19 | * @param {Channel} channel 20 | * Data on the feed (the group of items). 21 | * @param {Array | null | undefined} [data] 22 | * List of entries (optional). 23 | * @returns {Root} 24 | * Atom feed. 25 | */ 26 | export function atom(channel, data) { 27 | const now = new Date() 28 | /** @type {Channel} */ 29 | const meta = channel || {title: undefined, url: undefined} 30 | 31 | if (meta.title === null || meta.title === undefined) { 32 | throw new Error('Expected `channel.title` to be set') 33 | } 34 | 35 | if (meta.url === null || meta.url === undefined) { 36 | throw new Error('Expected `channel.url` to be set') 37 | } 38 | 39 | const url = new URL(meta.url).href 40 | const items = [ 41 | x('title', String(meta.title)), 42 | x('subtitle', String(meta.description || '') || undefined), 43 | // `rel: 'alternate'` is the default. 44 | x('link', url), 45 | x('id', url), 46 | // @ts-expect-error `toGTMString` is exactly what we need. 47 | x('updated', now.toGMTString()) 48 | ] 49 | 50 | if (meta.feedUrl) { 51 | items.push( 52 | x('link', { 53 | href: new URL(meta.feedUrl).href, 54 | rel: 'self', 55 | type: 'application/atom+xml' 56 | }) 57 | ) 58 | } 59 | 60 | if (meta.author) { 61 | const author = toAuthor(meta.author) 62 | items.push( 63 | x('rights', '© ' + now.getUTCFullYear() + ' ' + author.name), 64 | createAuthor(author) 65 | ) 66 | } 67 | 68 | if (meta.tags) { 69 | let index = -1 70 | while (++index < meta.tags.length) { 71 | items.push(x('category', {term: String(meta.tags[index])})) 72 | } 73 | } 74 | 75 | if (data) { 76 | let index = -1 77 | 78 | while (++index < data.length) { 79 | const datum = data[index] 80 | /** @type {Array} */ 81 | const children = [] 82 | 83 | if (!datum.title && !datum.description && !datum.descriptionHtml) { 84 | throw new Error( 85 | 'Expected either `title` or `description` to be set in entry `' + 86 | index + 87 | '`' 88 | ) 89 | } 90 | 91 | if (datum.title) children.push(x('title', String(datum.title))) 92 | 93 | if (datum.author) { 94 | children.push(createAuthor(toAuthor(datum.author))) 95 | } else if (!meta.author) { 96 | throw new Error( 97 | 'Expected `author` to be set in entry `' + 98 | index + 99 | '` or in the channel' 100 | ) 101 | } 102 | 103 | if (datum.url) { 104 | const url = new URL(datum.url).href 105 | children.push(x('link', {href: url}), x('id', url)) 106 | } 107 | 108 | if (datum.published !== null && datum.published !== undefined) { 109 | children.push(x('published', toDate(datum.published).toISOString())) 110 | } 111 | 112 | if (datum.modified !== null && datum.modified !== undefined) { 113 | children.push(x('updated', toDate(datum.modified).toISOString())) 114 | } 115 | 116 | if (datum.tags) { 117 | let offset = -1 118 | while (++offset < datum.tags.length) { 119 | children.push(x('category', {term: String(datum.tags[offset])})) 120 | } 121 | } 122 | 123 | const enclosure = datum.enclosure 124 | 125 | if (enclosure) { 126 | if (!enclosure.url) { 127 | throw new Error( 128 | 'Expected either `enclosure.url` to be set in entry `' + index + '`' 129 | ) 130 | } 131 | 132 | if (!enclosure.size) { 133 | throw new Error( 134 | 'Expected either `enclosure.size` to be set in entry `' + 135 | index + 136 | '`' 137 | ) 138 | } 139 | 140 | if (!enclosure.type) { 141 | throw new Error( 142 | 'Expected either `enclosure.type` to be set in entry `' + 143 | index + 144 | '`' 145 | ) 146 | } 147 | 148 | // Can’t use `xastscript` because of `length` 149 | children.push( 150 | x('link', { 151 | rel: 'enclosure', 152 | href: new URL(enclosure.url).href, 153 | length: String(enclosure.size), 154 | type: enclosure.type 155 | }) 156 | ) 157 | } 158 | 159 | if (datum.descriptionHtml || datum.description) { 160 | children.push( 161 | x( 162 | 'content', 163 | // `type: "text"` is the default. 164 | {type: datum.descriptionHtml ? 'html' : undefined}, 165 | String(datum.descriptionHtml || datum.description) 166 | ) 167 | ) 168 | } 169 | 170 | items.push(x('entry', children)) 171 | } 172 | } 173 | 174 | return u('root', [ 175 | u('instruction', {name: 'xml'}, 'version="1.0" encoding="utf-8"'), 176 | x( 177 | 'feed', 178 | { 179 | xmlns: 'http://www.w3.org/2005/Atom', 180 | 'xml:lang': meta.lang ? normalize(meta.lang) : undefined 181 | }, 182 | items 183 | ) 184 | ]) 185 | } 186 | 187 | /** 188 | * @param {Author} value 189 | * @returns {Element} 190 | */ 191 | function createAuthor(value) { 192 | return x('author', [ 193 | x('name', String(value.name)), 194 | value.email ? x('email', String(value.email)) : undefined, 195 | value.url ? x('uri', new URL(value.url).href) : undefined 196 | ]) 197 | } 198 | -------------------------------------------------------------------------------- /lib/rss.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('xast').Element} Element 3 | * @typedef {import('xast').Root} Root 4 | * @typedef {import('./types.js').Channel} Channel 5 | * @typedef {import('./types.js').Entry} Entry 6 | */ 7 | 8 | import {bcp47Normalize as normalize} from 'bcp-47-normalize' 9 | import {u} from 'unist-builder' 10 | import {x} from 'xastscript' 11 | import {toAuthor, toDate} from './util.js' 12 | 13 | /** 14 | * Build an RSS feed. 15 | * 16 | * Same API as `atom` otherwise. 17 | * 18 | * @param {Channel} channel 19 | * Data on the feed (the group of items). 20 | * @param {Array | null | undefined} [data] 21 | * List of entries (optional). 22 | * @returns {Root} 23 | * RSS feed. 24 | */ 25 | export function rss(channel, data) { 26 | const now = new Date() 27 | /** @type {Channel} */ 28 | const meta = channel || {title: undefined, url: undefined} 29 | /** @type {boolean} */ 30 | let atom = false 31 | 32 | if (meta.title === null || meta.title === undefined) { 33 | throw new Error('Expected `channel.title` to be set') 34 | } 35 | 36 | if (meta.url === null || meta.url === undefined) { 37 | throw new Error('Expected `channel.url` to be set') 38 | } 39 | 40 | const items = [ 41 | x('title', String(meta.title)), 42 | x('description', String(meta.description || '') || undefined), 43 | x('link', new URL(meta.url).href), 44 | // @ts-expect-error `toGTMString` is exactly what we need. 45 | x('lastBuildDate', now.toGMTString()), 46 | x('dc:date', now.toISOString()) 47 | ] 48 | 49 | if (meta.feedUrl) { 50 | atom = true 51 | items.push( 52 | x('atom:link', { 53 | href: new URL(meta.feedUrl).href, 54 | rel: 'self', 55 | type: 'application/rss+xml' 56 | }) 57 | ) 58 | } 59 | 60 | if (meta.lang) { 61 | const lang = normalize(meta.lang) 62 | items.push(x('language', lang), x('dc:language', lang)) 63 | } 64 | 65 | if (meta.author) { 66 | const copy = '© ' + now.getUTCFullYear() + ' ' + meta.author 67 | items.push(x('copyright', copy), x('dc:rights', copy)) 68 | } 69 | 70 | if (meta.tags) { 71 | let index = -1 72 | while (++index < meta.tags.length) { 73 | items.push(x('category', String(meta.tags[index]))) 74 | } 75 | } 76 | 77 | if (data) { 78 | let index = -1 79 | while (++index < data.length) { 80 | const datum = data[index] 81 | /** @type {Array} */ 82 | const children = [] 83 | 84 | if (!datum.title && !datum.description && !datum.descriptionHtml) { 85 | throw new Error( 86 | 'Expected either `title` or `description` to be set in entry `' + 87 | index + 88 | '`' 89 | ) 90 | } 91 | 92 | if (datum.title) children.push(x('title', String(datum.title))) 93 | 94 | if (datum.author) { 95 | const author = toAuthor(datum.author) 96 | children.push(x('dc:creator', author.name)) 97 | 98 | if (author.email) { 99 | children.push(x('author', author.email + ' (' + author.name + ')')) 100 | } 101 | } 102 | 103 | if (datum.url) { 104 | const url = new URL(datum.url).href 105 | children.push( 106 | x('link', url), 107 | // Do not treat it as a URL, just an opaque identifier. 108 | // `` is already used by readers for the URL. 109 | // Now, the value we have here is a URL, but we can’t know if it’s 110 | // “permanent”, so, set `false`. 111 | x('guid', {isPermaLink: 'false'}, url) 112 | ) 113 | } 114 | 115 | if (datum.published !== null && datum.published !== undefined) { 116 | children.push( 117 | // @ts-expect-error `toGTMString` is exactly what we need. 118 | x('pubDate', toDate(datum.published).toGMTString()), 119 | x('dc:date', toDate(datum.published).toISOString()) 120 | ) 121 | } 122 | 123 | if (datum.modified !== null && datum.modified !== undefined) { 124 | children.push(x('dc:modified', toDate(datum.modified).toISOString())) 125 | } 126 | 127 | if (datum.tags) { 128 | let offset = -1 129 | while (++offset < datum.tags.length) { 130 | children.push(x('category', String(datum.tags[offset]))) 131 | } 132 | } 133 | 134 | const enclosure = datum.enclosure 135 | 136 | if (enclosure) { 137 | if (!enclosure.url) { 138 | throw new Error( 139 | 'Expected either `enclosure.url` to be set in entry `' + index + '`' 140 | ) 141 | } 142 | 143 | if (!enclosure.size) { 144 | throw new Error( 145 | 'Expected either `enclosure.size` to be set in entry `' + 146 | index + 147 | '`' 148 | ) 149 | } 150 | 151 | if (!enclosure.type) { 152 | throw new Error( 153 | 'Expected either `enclosure.type` to be set in entry `' + 154 | index + 155 | '`' 156 | ) 157 | } 158 | 159 | // Can’t use `xastscript` because of `length`. 160 | children.push( 161 | x('enclosure', { 162 | url: new URL(enclosure.url).href, 163 | length: String(enclosure.size), 164 | type: enclosure.type 165 | }) 166 | ) 167 | } 168 | 169 | if (datum.descriptionHtml || datum.description) { 170 | children.push( 171 | x('description', String(datum.descriptionHtml || datum.description)) 172 | ) 173 | } 174 | 175 | items.push(x('item', children)) 176 | } 177 | } 178 | 179 | return u('root', [ 180 | u('instruction', {name: 'xml'}, 'version="1.0" encoding="utf-8"'), 181 | x( 182 | 'rss', 183 | { 184 | version: '2.0', 185 | 'xmlns:dc': 'http://purl.org/dc/elements/1.1/', 186 | 'xmlns:atom': atom ? 'http://www.w3.org/2005/Atom' : undefined 187 | }, 188 | x('channel', items) 189 | ) 190 | ]) 191 | } 192 | -------------------------------------------------------------------------------- /lib/types.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef Author 3 | * Author object 4 | * @property {string} name 5 | * Name (example: `'Acme, Inc.'` or `'Jane Doe'`). 6 | * @property {string | null | undefined} [email] 7 | * Email address (example: `john@example.org`, optional). 8 | * @property {string | null | undefined} [url] 9 | * URL to author (example: `'https://example.org/john'`, optional). 10 | * 11 | * `url` is used in `atom`, not in `rss`. 12 | * 13 | * @typedef Enclosure 14 | * Media. 15 | * @property {string} url 16 | * Full URL to the resource (example: 17 | * `'http://dallas.example.com/joebob_050689.mp3'`). 18 | * @property {number} size 19 | * Resource size in bytes (example: `24986239`). 20 | * @property {string} type 21 | * Mime type of the resource (example: `'audio/mpeg'`). 22 | * 23 | * @typedef Channel 24 | * Data on the feed (the group of items). 25 | * @property {string} title 26 | * Title of the channel (required, example: `Zimbabwe | The Guardian`). 27 | * @property {string} url 28 | * Full URL to the site (required, example: 29 | * `'https://www.theguardian.com/world/zimbabwe'`). 30 | * @property {string | null | undefined} [feedUrl] 31 | * Full URL to this channel (example: 32 | * `'https://www.adweek.com/feed/'`, optional). 33 | * 34 | * Make sure to pass different ones to `rss` and `atom`! 35 | * 36 | * You *should* define this. 37 | * @property {string | null | undefined} [description] 38 | * Short description of the channel (example: `Album Reviews`, optional). 39 | * 40 | * You *should* define this. 41 | * @property {string | null | undefined} [lang] 42 | * BCP 47 language tag representing the language of the whole channel (example: 43 | * `'fr-BE'`, optional). 44 | * 45 | * You *should* define this. 46 | * @property {Author | string | null | undefined} [author] 47 | * Optional author of the whole channel (optional). 48 | * 49 | * Either `string`, in which case it’s as passing `{name: string}`. 50 | * Or an author object. 51 | * @property {Array | null | undefined} [tags] 52 | * Categories of the channel (example: `['JavaScript', 'React']`, optional). 53 | * 54 | * @typedef Entry 55 | * Data on a single item. 56 | * @property {string | null | undefined} [title] 57 | * Title of the item (example: `'Playboi Carti: Whole Lotta Red'`, optional). 58 | * 59 | * Either `title`, `description`, or `descriptionHtml` must be set. 60 | * @property {string | null | undefined} [description] 61 | * Either the whole post or an excerpt of it (example: `'Lorem'`, optional). 62 | * 63 | * Should be plain text. 64 | * `descriptionHtml` is preferred over plain text `description`. 65 | * 66 | * Either `title`, `description`, or `descriptionHtml` must be set. 67 | * @property {string | null | undefined} [descriptionHtml] 68 | * Either the whole post or an excerpt of it (example: `'

Lorem

'`, 69 | * optional). 70 | * 71 | * Should be serialized HTML. 72 | * `descriptionHtml` is preferred over plain text `description`. 73 | * 74 | * Either `title`, `description`, or `descriptionHtml` must be set. 75 | * @property {Author | string | null | undefined} [author] 76 | * Entry version of `channel.author` (optional). 77 | * 78 | * You *should* define this. 79 | * 80 | * For `atom`, it is required to either set `channel.author` or set `author` 81 | * on all entries. 82 | * @property {string | null | undefined} [url] 83 | * Full URL of this entry on the *site* (example: 84 | * `'https://pitchfork.com/reviews/albums/roberta-flack-first-take'`, 85 | * optional). 86 | * @property {Date | number | string | null | undefined} [published] 87 | * When the entry was first published (optional). 88 | * @property {Date | number | string | null | undefined} [modified] 89 | * When the entry was last modified (optional). 90 | * @property {Array | null | undefined} [tags] 91 | * Categories of the entry (example: `['laravel', 'debugging']`, optional). 92 | * @property {Enclosure | null | undefined} [enclosure] 93 | * Attached media. 94 | */ 95 | 96 | export {} 97 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('./types.js').Author} Author 3 | */ 4 | 5 | /** 6 | * Create an author object. 7 | * 8 | * @param {Author | string} value 9 | * Author or string. 10 | * @returns {Author} 11 | * Valid author. 12 | */ 13 | export function toAuthor(value) { 14 | if (typeof value === 'string') { 15 | return {name: value} 16 | } 17 | 18 | if (!value.name) { 19 | throw new Error('Expected `author.name` to be set') 20 | } 21 | 22 | return value 23 | } 24 | 25 | /** 26 | * Create a date object. 27 | * 28 | * @param {Date | string | number} value 29 | * Serialized date, numeric date, actual date. 30 | * @returns {Date} 31 | * Valid date. 32 | */ 33 | export function toDate(value) { 34 | /* c8 ignore next */ 35 | return typeof value === 'object' ? value : new Date(value) 36 | } 37 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2021 Titus Wormer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xast-util-feed", 3 | "version": "2.0.0", 4 | "description": "xast utility to build feeds (rss, atom)", 5 | "license": "MIT", 6 | "keywords": [ 7 | "unist", 8 | "xast", 9 | "xast-util", 10 | "util", 11 | "utility", 12 | "xml", 13 | "feed", 14 | "rss", 15 | "atom", 16 | "syndicate" 17 | ], 18 | "repository": "syntax-tree/xast-util-feed", 19 | "bugs": "https://github.com/syntax-tree/xast-util-feed/issues", 20 | "funding": { 21 | "type": "opencollective", 22 | "url": "https://opencollective.com/unified" 23 | }, 24 | "author": "Titus Wormer (https://wooorm.com)", 25 | "contributors": [ 26 | "Titus Wormer (https://wooorm.com)" 27 | ], 28 | "sideEffects": false, 29 | "type": "module", 30 | "exports": "./index.js", 31 | "files": [ 32 | "lib/", 33 | "index.d.ts", 34 | "index.js" 35 | ], 36 | "dependencies": { 37 | "@types/xast": "^2.0.0", 38 | "bcp-47-normalize": "^2.0.0", 39 | "unist-builder": "^4.0.0", 40 | "xastscript": "^4.0.0" 41 | }, 42 | "devDependencies": { 43 | "@types/node": "^20.0.0", 44 | "c8": "^8.0.0", 45 | "prettier": "^3.0.0", 46 | "remark-cli": "^11.0.0", 47 | "remark-preset-wooorm": "^9.0.0", 48 | "type-coverage": "^2.0.0", 49 | "typescript": "^5.0.0", 50 | "xast-util-to-xml": "^4.0.0", 51 | "xo": "^0.55.0" 52 | }, 53 | "scripts": { 54 | "prepack": "npm run build && npm run format", 55 | "build": "tsc --build --clean && tsc --build && type-coverage", 56 | "format": "remark . -qfo && prettier . -w --log-level warn && xo --fix", 57 | "test-api": "node --conditions development test.js", 58 | "test-coverage": "c8 --100 --reporter lcov npm run test-api", 59 | "test": "npm run build && npm run format && npm run test-coverage" 60 | }, 61 | "prettier": { 62 | "bracketSpacing": false, 63 | "semi": false, 64 | "singleQuote": true, 65 | "tabWidth": 2, 66 | "trailingComma": "none", 67 | "useTabs": false 68 | }, 69 | "remarkConfig": { 70 | "plugins": [ 71 | "remark-preset-wooorm" 72 | ] 73 | }, 74 | "typeCoverage": { 75 | "atLeast": 100, 76 | "detail": true, 77 | "ignoreCatch": true, 78 | "strict": true 79 | }, 80 | "xo": { 81 | "prettier": true, 82 | "rules": { 83 | "complexity": "off", 84 | "unicorn/explicit-length-check": "off" 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # xast-util-feed 2 | 3 | [![Build][build-badge]][build] 4 | [![Coverage][coverage-badge]][coverage] 5 | [![Downloads][downloads-badge]][downloads] 6 | [![Size][size-badge]][size] 7 | [![Sponsors][sponsors-badge]][collective] 8 | [![Backers][backers-badge]][collective] 9 | [![Chat][chat-badge]][chat] 10 | 11 | [xast][] utility to build (web) feeds ([RSS][], [Atom][]). 12 | 13 | ## Contents 14 | 15 | * [What is this?](#what-is-this) 16 | * [When should I use this?](#when-should-i-use-this) 17 | * [Install](#install) 18 | * [Use](#use) 19 | * [API](#api) 20 | * [`atom(channel, data)`](#atomchannel-data) 21 | * [`rss(channel, data)`](#rsschannel-data) 22 | * [`Author`](#author) 23 | * [`Channel`](#channel) 24 | * [`Enclosure`](#enclosure) 25 | * [`Entry`](#entry) 26 | * [Types](#types) 27 | * [Compatibility](#compatibility) 28 | * [Security](#security) 29 | * [Related](#related) 30 | * [Contribute](#contribute) 31 | * [License](#license) 32 | 33 | ## What is this? 34 | 35 | This package generates RSS or Atom feeds from data. 36 | 37 | ## When should I use this? 38 | 39 | This package helps you add feeds to your site. 40 | It focusses on a small set of widely used and supported parts of feeds. 41 | It has a few good options instead of overwhelming with hundreds of things to 42 | configure. 43 | If you do need more things, well: this utility gives you a syntax tree, which 44 | you can change. 45 | 46 | It’s good to use this package to build several feeds and to only include recent 47 | posts (often 15-20 items are included in a channel). 48 | You should make a channel for all your posts; when relevant, separate channels 49 | per language as well; and potentially, channels per post type (such as separate 50 | ones for blog posts, notes, and images). 51 | 52 | Just using either RSS or Atom is probably fine: no need to do both. 53 | 54 | ## Install 55 | 56 | This package is [ESM only][esm]. 57 | In Node.js (version 16+), install with [npm][]: 58 | 59 | ```sh 60 | npm install xast-util-feed 61 | ``` 62 | 63 | In Deno with [`esm.sh`][esmsh]: 64 | 65 | ```js 66 | import {atom, rss} from 'https://esm.sh/xast-util-feed@2' 67 | ``` 68 | 69 | In browsers with [`esm.sh`][esmsh]: 70 | 71 | ```html 72 | 75 | ``` 76 | 77 | ## Use 78 | 79 | ```js 80 | import {atom, rss} from 'xast-util-feed' 81 | import {toXml} from 'xast-util-to-xml' 82 | 83 | const channel = { 84 | title: 'NYT > Top Stories', 85 | url: 'https://www.nytimes.com', 86 | feedUrl: 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml', 87 | lang: 'en', 88 | author: 'The New York Times Company' 89 | } 90 | 91 | const data = [ 92 | { 93 | title: 'Senate Balances Impeachment Trial With an Incoming President', 94 | url: 95 | 'https://www.nytimes.com/2021/01/14/us/politics/impeachment-senate-trial-trump.html', 96 | descriptionHtml: '

Senate leaders etc etc etc.

', 97 | author: 'Nicholas Fandos and Catie Edmondson', 98 | published: 'Fri, 15 Jan 2021 01:18:49 +0000', 99 | tags: ['Senate', 'Murkowski, Lisa', 'Trump, Donald J'] 100 | } 101 | ] 102 | 103 | console.log(toXml(rss(channel, data))) 104 | console.log(toXml(atom(channel, data))) 105 | ``` 106 | 107 | Yields (pretty printed): 108 | 109 | ```xml 110 | 111 | 112 | 113 | NYT > Top Stories 114 | 115 | https://www.nytimes.com/ 116 | Sun, 17 Jan 2021 09:00:54 GMT 117 | 2021-01-17T09:00:54.781Z 118 | 119 | en 120 | en 121 | © 2021 The New York Times Company 122 | © 2021 The New York Times Company 123 | 124 | Senate Balances Impeachment Trial With an Incoming President 125 | Nicholas Fandos and Catie Edmondson 126 | https://www.nytimes.com/2021/01/14/us/politics/impeachment-senate-trial-trump.html 127 | https://www.nytimes.com/2021/01/14/us/politics/impeachment-senate-trial-trump.html 128 | Fri, 15 Jan 2021 01:18:49 GMT 129 | 2021-01-15T01:18:49.000Z 130 | Senate 131 | Murkowski, Lisa 132 | Trump, Donald J 133 | <p>Senate leaders etc etc etc.</p> 134 | 135 | 136 | 137 | ``` 138 | 139 | ```xml 140 | 141 | 142 | NYT > Top Stories 143 | 144 | https://www.nytimes.com/ 145 | https://www.nytimes.com/ 146 | Sun, 17 Jan 2021 09:00:54 GMT 147 | 148 | © 2021 The New York Times Company 149 | 150 | The New York Times Company 151 | 152 | 153 | 154 | 155 | 156 | Senate Balances Impeachment Trial With an Incoming President 157 | 158 | Nicholas Fandos and Catie Edmondson 159 | 160 | 161 | https://www.nytimes.com/2021/01/14/us/politics/impeachment-senate-trial-trump.html 162 | 2021-01-15T01:18:49.000Z 163 | <p>Senate leaders etc etc etc.</p> 164 | 165 | 166 | ``` 167 | 168 | ## API 169 | 170 | This package exports the identifiers [`atom`][api-atom] and [`rss`][api-rss]. 171 | There is no default export. 172 | 173 | ### `atom(channel, data)` 174 | 175 | Build an [Atom][] feed. 176 | 177 | ###### Parameters 178 | 179 | * `channel` ([`Channel`][api-channel]) 180 | — data on the feed (the group of items) 181 | * `data` ([`Array`][api-entry], optional) 182 | — list of entries 183 | 184 | ###### Returns 185 | 186 | Atom feed ([`Root`][root]). 187 | 188 | ### `rss(channel, data)` 189 | 190 | Build an [RSS][] feed. 191 | 192 | ###### Parameters 193 | 194 | * `channel` ([`Channel`][api-channel]) 195 | — data on the feed (the group of items) 196 | * `data` ([`Array`][api-entry], optional) 197 | — list of entries 198 | 199 | ###### Returns 200 | 201 | RSS feed ([`Root`][root]). 202 | 203 | ### `Author` 204 | 205 | Author object (TypeScript type). 206 | 207 | ##### Fields 208 | 209 | ###### `name` 210 | 211 | Name (`string`, **required**, example: `'Acme, Inc.'` or `'Jane Doe'`). 212 | 213 | ###### `email` 214 | 215 | Email address (`string`, optional, ,example: `john@example.org`) 216 | 217 | ###### `url` 218 | 219 | URL to author (`string`, optional, example: `'https://example.org/john'`). 220 | 221 | `url` is used in `atom`, not in `rss`. 222 | 223 | ### `Channel` 224 | 225 | Data on the feed (the group of items) (TypeScript type). 226 | 227 | ##### Fields 228 | 229 | ###### `title` 230 | 231 | Title of the channel (`string`, **required**, example: `Zimbabwe | The 232 | Guardian`). 233 | 234 | ###### `url` 235 | 236 | Full URL to the *site* (`string`, **required**, example: 237 | `'https://www.theguardian.com/world/zimbabwe'`). 238 | 239 | ###### `feedUrl` 240 | 241 | Full URL to this channel (`string?`, example: `'https://www.adweek.com/feed/'`). 242 | 243 | Make sure to pass different ones to `rss` and `atom` when you build both! 244 | 245 | You *should* define this. 246 | 247 | ###### `description` 248 | 249 | Short description of the channel (`string?`, example: `Album Reviews`). 250 | 251 | You *should* define this. 252 | 253 | ###### `lang` 254 | 255 | [BCP 47][bcp47] language tag representing the language of the whole channel 256 | (`string?`, example: `'fr-BE'`). 257 | 258 | You *should* define this. 259 | 260 | ###### `author` 261 | 262 | Optional author of the whole channel (`string` or [`Author`][api-author]). 263 | 264 | Either `string`, in which case it’s as passing `{name: string}`. 265 | Or an author object. 266 | 267 | ###### `tags` 268 | 269 | Categories of the channel (`Array?`, example: `['JavaScript', 270 | 'React']`). 271 | 272 | ### `Enclosure` 273 | 274 | Media (TypeScript type). 275 | 276 | ##### Fields 277 | 278 | ###### `url` 279 | 280 | Full URL to the resource (`string`, **required**, example: 281 | `'http://dallas.example.com/joebob_050689.mp3'`). 282 | 283 | ###### `size` 284 | 285 | Resource size in bytes (`number`, **required**, example: `24986239`). 286 | 287 | ###### `type` 288 | 289 | Mime type of the resource (`string`, **required**, example: `'audio/mpeg'`). 290 | 291 | ### `Entry` 292 | 293 | Data on a single item (TypeScript type). 294 | 295 | ##### Fields 296 | 297 | ###### `title` 298 | 299 | Title of the item (`string?`, example: `'Playboi Carti: Whole Lotta Red'`). 300 | 301 | Either `title`, `description`, or `descriptionHtml` must be set. 302 | 303 | ###### `description` 304 | 305 | Either the whole post or an excerpt of it (`string?`, example: `'Lorem'`). 306 | 307 | Should be plain text. 308 | `descriptionHtml` is preferred over plain text `description`. 309 | 310 | Either `title`, `description`, or `descriptionHtml` must be set. 311 | 312 | ###### `descriptionHtml` 313 | 314 | Either the whole post or an excerpt of it (`string?`, example: `'

Lorem

'`). 315 | 316 | Should be serialized HTML. 317 | `descriptionHtml` is preferred over plain text `description`. 318 | 319 | Either `title`, `description`, or `descriptionHtml` must be set. 320 | 321 | ###### `author` 322 | 323 | Entry version of `channel.author`. 324 | 325 | You *should* define this. 326 | 327 | For `atom`, it is required to either set `channel.author` or set `author` on all 328 | entries. 329 | 330 | ###### `url` 331 | 332 | Full URL of this entry on the *site* (`string?`, example: 333 | `'https://pitchfork.com/reviews/albums/roberta-flack-first-take'`). 334 | 335 | ###### `published` 336 | 337 | When the entry was first published (`Date` or value for `new Date(x)`, 338 | optional). 339 | 340 | ###### `modified` 341 | 342 | When the entry was last modified (`Date` or value for `new Date(x)`, optional). 343 | 344 | ###### `tags` 345 | 346 | Categories of the entry (`Array?`, example: `['laravel', 347 | 'debugging']`). 348 | 349 | ###### `enclosure` 350 | 351 | Attached media ([`Enclosure?`][api-enclosure]). 352 | 353 | ## Types 354 | 355 | This package is fully typed with [TypeScript][]. 356 | It exports the additional types [`Author`][api-author], 357 | [`Channel`][api-channel], 358 | [`Enclosure`][api-enclosure], and 359 | [`Entry`][api-entry]. 360 | 361 | ## Compatibility 362 | 363 | Projects maintained by the unified collective are compatible with maintained 364 | versions of Node.js. 365 | 366 | When we cut a new major release, we drop support for unmaintained versions of 367 | Node. 368 | This means we try to keep the current release line, `xast-util-feed@^2`, 369 | compatible with Node.js 16. 370 | 371 | ## Security 372 | 373 | XML can be a dangerous language: don’t trust user-provided data. 374 | 375 | ## Related 376 | 377 | * [`xast-util-to-xml`](https://github.com/syntax-tree/xast-util-to-xml) 378 | — serialize xast to XML 379 | * [`xast-util-sitemap`](https://github.com/syntax-tree/xast-util-sitemap) 380 | — build a sitemap 381 | * [`xastscript`](https://github.com/syntax-tree/xastscript) 382 | — create xast trees 383 | 384 | ## Contribute 385 | 386 | See [`contributing.md`][contributing] in [`syntax-tree/.github`][health] for 387 | ways to get started. 388 | See [`support.md`][support] for ways to get help. 389 | 390 | This project has a [code of conduct][coc]. 391 | By interacting with this repository, organization, or community you agree to 392 | abide by its terms. 393 | 394 | ## License 395 | 396 | [MIT][license] © [Titus Wormer][wooorm] 397 | 398 | 399 | 400 | [build-badge]: https://github.com/syntax-tree/xast-util-feed/workflows/main/badge.svg 401 | 402 | [build]: https://github.com/syntax-tree/xast-util-feed/actions 403 | 404 | [coverage-badge]: https://img.shields.io/codecov/c/github/syntax-tree/xast-util-feed.svg 405 | 406 | [coverage]: https://codecov.io/github/syntax-tree/xast-util-feed 407 | 408 | [downloads-badge]: https://img.shields.io/npm/dm/xast-util-feed.svg 409 | 410 | [downloads]: https://www.npmjs.com/package/xast-util-feed 411 | 412 | [size-badge]: https://img.shields.io/badge/dynamic/json?label=minzipped%20size&query=$.size.compressedSize&url=https://deno.bundlejs.com/?q=xast-util-feed 413 | 414 | [size]: https://bundlejs.com/?q=xast-util-feed 415 | 416 | [sponsors-badge]: https://opencollective.com/unified/sponsors/badge.svg 417 | 418 | [backers-badge]: https://opencollective.com/unified/backers/badge.svg 419 | 420 | [collective]: https://opencollective.com/unified 421 | 422 | [chat-badge]: https://img.shields.io/badge/chat-discussions-success.svg 423 | 424 | [chat]: https://github.com/syntax-tree/unist/discussions 425 | 426 | [npm]: https://docs.npmjs.com/cli/install 427 | 428 | [esm]: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c 429 | 430 | [esmsh]: https://esm.sh 431 | 432 | [typescript]: https://www.typescriptlang.org 433 | 434 | [license]: license 435 | 436 | [wooorm]: https://wooorm.com 437 | 438 | [health]: https://github.com/syntax-tree/.github 439 | 440 | [contributing]: https://github.com/syntax-tree/.github/blob/main/contributing.md 441 | 442 | [support]: https://github.com/syntax-tree/.github/blob/main/support.md 443 | 444 | [coc]: https://github.com/syntax-tree/.github/blob/main/code-of-conduct.md 445 | 446 | [xast]: https://github.com/syntax-tree/xast 447 | 448 | [root]: https://github.com/syntax-tree/xast#root 449 | 450 | [rss]: https://www.rssboard.org/rss-specification 451 | 452 | [atom]: https://tools.ietf.org/html/rfc4287 453 | 454 | [bcp47]: https://github.com/wooorm/bcp-47 455 | 456 | [api-atom]: #atomchannel-data 457 | 458 | [api-rss]: #rsschannel-data 459 | 460 | [api-author]: #author 461 | 462 | [api-channel]: #channel 463 | 464 | [api-enclosure]: #enclosure 465 | 466 | [api-entry]: #entry 467 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict' 2 | import test from 'node:test' 3 | import {atom, rss} from 'xast-util-feed' 4 | 5 | // Hack so the tests don’t need updating everytime… 6 | const ODate = global.Date 7 | 8 | // Note: this isn’t reset. 9 | // @ts-expect-error: the other fields on `Date` are not used here. 10 | global.Date = wrapperDate 11 | 12 | /** 13 | * @param {string | number} value 14 | * @returns {Date} 15 | */ 16 | function wrapperDate(value) { 17 | return new ODate(value || 1_234_567_890_123) 18 | } 19 | 20 | test('core', async function (t) { 21 | await t.test('should expose the public api', async function () { 22 | assert.deepEqual(Object.keys(await import('xast-util-feed')).sort(), [ 23 | 'atom', 24 | 'rss' 25 | ]) 26 | }) 27 | }) 28 | 29 | test('rss', async function (t) { 30 | await t.test('should throw when w/o `channel`', async function () { 31 | assert.throws(function () { 32 | // @ts-expect-error: check how the runtime handles missing `channel`. 33 | rss() 34 | }, /Expected `channel.title` to be set/) 35 | }) 36 | 37 | await t.test('should throw when w/o `url`', async function () { 38 | assert.throws(function () { 39 | // @ts-expect-error: check how the runtime handles missing `channel.url`. 40 | rss({title: 'a'}) 41 | }, /Expected `channel.url` to be set/) 42 | }) 43 | 44 | await t.test('should throw on incorrect `url`', async function () { 45 | assert.throws(function () { 46 | rss({title: 'a', url: 'b'}) 47 | }, /Invalid URL/) 48 | }) 49 | 50 | await t.test('should support `title` and `url`', async function () { 51 | assert.deepEqual(rss({title: 'a', url: 'https://example.com'}), { 52 | type: 'root', 53 | children: [ 54 | { 55 | type: 'instruction', 56 | name: 'xml', 57 | value: 'version="1.0" encoding="utf-8"' 58 | }, 59 | { 60 | type: 'element', 61 | name: 'rss', 62 | attributes: { 63 | version: '2.0', 64 | 'xmlns:dc': 'http://purl.org/dc/elements/1.1/' 65 | }, 66 | children: [ 67 | { 68 | type: 'element', 69 | name: 'channel', 70 | attributes: {}, 71 | children: [ 72 | { 73 | type: 'element', 74 | name: 'title', 75 | attributes: {}, 76 | children: [{type: 'text', value: 'a'}] 77 | }, 78 | { 79 | type: 'element', 80 | name: 'description', 81 | attributes: {}, 82 | children: [] 83 | }, 84 | { 85 | type: 'element', 86 | name: 'link', 87 | attributes: {}, 88 | children: [{type: 'text', value: 'https://example.com/'}] 89 | }, 90 | { 91 | type: 'element', 92 | name: 'lastBuildDate', 93 | attributes: {}, 94 | children: [ 95 | {type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'} 96 | ] 97 | }, 98 | { 99 | type: 'element', 100 | name: 'dc:date', 101 | attributes: {}, 102 | children: [{type: 'text', value: '2009-02-13T23:31:30.123Z'}] 103 | } 104 | ] 105 | } 106 | ] 107 | } 108 | ] 109 | }) 110 | }) 111 | 112 | await t.test('should support `feedUrl` (for `atom:link`)', async function () { 113 | assert.deepEqual( 114 | rss({ 115 | title: 'a', 116 | url: 'https://example.com', 117 | feedUrl: 'https://example.com/rss' 118 | }).children[1], 119 | { 120 | type: 'element', 121 | name: 'rss', 122 | attributes: { 123 | version: '2.0', 124 | 'xmlns:dc': 'http://purl.org/dc/elements/1.1/', 125 | 'xmlns:atom': 'http://www.w3.org/2005/Atom' 126 | }, 127 | children: [ 128 | { 129 | type: 'element', 130 | name: 'channel', 131 | attributes: {}, 132 | children: [ 133 | { 134 | type: 'element', 135 | name: 'title', 136 | attributes: {}, 137 | children: [{type: 'text', value: 'a'}] 138 | }, 139 | { 140 | type: 'element', 141 | name: 'description', 142 | attributes: {}, 143 | children: [] 144 | }, 145 | { 146 | type: 'element', 147 | name: 'link', 148 | attributes: {}, 149 | children: [{type: 'text', value: 'https://example.com/'}] 150 | }, 151 | { 152 | type: 'element', 153 | name: 'lastBuildDate', 154 | attributes: {}, 155 | children: [ 156 | {type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'} 157 | ] 158 | }, 159 | { 160 | type: 'element', 161 | name: 'dc:date', 162 | attributes: {}, 163 | children: [{type: 'text', value: '2009-02-13T23:31:30.123Z'}] 164 | }, 165 | { 166 | type: 'element', 167 | name: 'atom:link', 168 | attributes: { 169 | href: 'https://example.com/rss', 170 | rel: 'self', 171 | type: 'application/rss+xml' 172 | }, 173 | children: [] 174 | } 175 | ] 176 | } 177 | ] 178 | } 179 | ) 180 | }) 181 | 182 | await t.test('should support `description`', async function () { 183 | assert.deepEqual( 184 | rss({title: 'a', url: 'https://example.com', description: 'b'}) 185 | .children[1], 186 | { 187 | type: 'element', 188 | name: 'rss', 189 | attributes: { 190 | version: '2.0', 191 | 'xmlns:dc': 'http://purl.org/dc/elements/1.1/' 192 | }, 193 | children: [ 194 | { 195 | type: 'element', 196 | name: 'channel', 197 | attributes: {}, 198 | children: [ 199 | { 200 | type: 'element', 201 | name: 'title', 202 | attributes: {}, 203 | children: [{type: 'text', value: 'a'}] 204 | }, 205 | { 206 | type: 'element', 207 | name: 'description', 208 | attributes: {}, 209 | children: [{type: 'text', value: 'b'}] 210 | }, 211 | { 212 | type: 'element', 213 | name: 'link', 214 | attributes: {}, 215 | children: [{type: 'text', value: 'https://example.com/'}] 216 | }, 217 | { 218 | type: 'element', 219 | name: 'lastBuildDate', 220 | attributes: {}, 221 | children: [ 222 | {type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'} 223 | ] 224 | }, 225 | { 226 | type: 'element', 227 | name: 'dc:date', 228 | attributes: {}, 229 | children: [{type: 'text', value: '2009-02-13T23:31:30.123Z'}] 230 | } 231 | ] 232 | } 233 | ] 234 | } 235 | ) 236 | }) 237 | 238 | await t.test('should support `lang`', async function () { 239 | assert.deepEqual( 240 | rss({title: 'a', url: 'https://example.com', lang: 'nl-NL'}).children[1], 241 | { 242 | type: 'element', 243 | name: 'rss', 244 | attributes: { 245 | version: '2.0', 246 | 'xmlns:dc': 'http://purl.org/dc/elements/1.1/' 247 | }, 248 | children: [ 249 | { 250 | type: 'element', 251 | name: 'channel', 252 | attributes: {}, 253 | children: [ 254 | { 255 | type: 'element', 256 | name: 'title', 257 | attributes: {}, 258 | children: [{type: 'text', value: 'a'}] 259 | }, 260 | { 261 | type: 'element', 262 | name: 'description', 263 | attributes: {}, 264 | children: [] 265 | }, 266 | { 267 | type: 'element', 268 | name: 'link', 269 | attributes: {}, 270 | children: [{type: 'text', value: 'https://example.com/'}] 271 | }, 272 | { 273 | type: 'element', 274 | name: 'lastBuildDate', 275 | attributes: {}, 276 | children: [ 277 | {type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'} 278 | ] 279 | }, 280 | { 281 | type: 'element', 282 | name: 'dc:date', 283 | attributes: {}, 284 | children: [{type: 'text', value: '2009-02-13T23:31:30.123Z'}] 285 | }, 286 | { 287 | type: 'element', 288 | name: 'language', 289 | attributes: {}, 290 | children: [{type: 'text', value: 'nl'}] 291 | }, 292 | { 293 | type: 'element', 294 | name: 'dc:language', 295 | attributes: {}, 296 | children: [{type: 'text', value: 'nl'}] 297 | } 298 | ] 299 | } 300 | ] 301 | } 302 | ) 303 | }) 304 | 305 | await t.test('should support `author` (for copyright)', async function () { 306 | assert.deepEqual( 307 | rss({title: 'a', url: 'https://example.com', author: 'b'}).children[1], 308 | { 309 | type: 'element', 310 | name: 'rss', 311 | attributes: { 312 | version: '2.0', 313 | 'xmlns:dc': 'http://purl.org/dc/elements/1.1/' 314 | }, 315 | children: [ 316 | { 317 | type: 'element', 318 | name: 'channel', 319 | attributes: {}, 320 | children: [ 321 | { 322 | type: 'element', 323 | name: 'title', 324 | attributes: {}, 325 | children: [{type: 'text', value: 'a'}] 326 | }, 327 | { 328 | type: 'element', 329 | name: 'description', 330 | attributes: {}, 331 | children: [] 332 | }, 333 | { 334 | type: 'element', 335 | name: 'link', 336 | attributes: {}, 337 | children: [{type: 'text', value: 'https://example.com/'}] 338 | }, 339 | { 340 | type: 'element', 341 | name: 'lastBuildDate', 342 | attributes: {}, 343 | children: [ 344 | {type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'} 345 | ] 346 | }, 347 | { 348 | type: 'element', 349 | name: 'dc:date', 350 | attributes: {}, 351 | children: [{type: 'text', value: '2009-02-13T23:31:30.123Z'}] 352 | }, 353 | { 354 | type: 'element', 355 | name: 'copyright', 356 | attributes: {}, 357 | children: [{type: 'text', value: '© 2009 b'}] 358 | }, 359 | { 360 | type: 'element', 361 | name: 'dc:rights', 362 | attributes: {}, 363 | children: [{type: 'text', value: '© 2009 b'}] 364 | } 365 | ] 366 | } 367 | ] 368 | } 369 | ) 370 | }) 371 | 372 | await t.test('should support `tags`', async function () { 373 | assert.deepEqual( 374 | rss({title: 'a', url: 'https://example.com', tags: ['b', 'c']}) 375 | .children[1], 376 | { 377 | type: 'element', 378 | name: 'rss', 379 | attributes: { 380 | version: '2.0', 381 | 'xmlns:dc': 'http://purl.org/dc/elements/1.1/' 382 | }, 383 | children: [ 384 | { 385 | type: 'element', 386 | name: 'channel', 387 | attributes: {}, 388 | children: [ 389 | { 390 | type: 'element', 391 | name: 'title', 392 | attributes: {}, 393 | children: [{type: 'text', value: 'a'}] 394 | }, 395 | { 396 | type: 'element', 397 | name: 'description', 398 | attributes: {}, 399 | children: [] 400 | }, 401 | { 402 | type: 'element', 403 | name: 'link', 404 | attributes: {}, 405 | children: [{type: 'text', value: 'https://example.com/'}] 406 | }, 407 | { 408 | type: 'element', 409 | name: 'lastBuildDate', 410 | attributes: {}, 411 | children: [ 412 | {type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'} 413 | ] 414 | }, 415 | { 416 | type: 'element', 417 | name: 'dc:date', 418 | attributes: {}, 419 | children: [{type: 'text', value: '2009-02-13T23:31:30.123Z'}] 420 | }, 421 | { 422 | type: 'element', 423 | name: 'category', 424 | attributes: {}, 425 | children: [{type: 'text', value: 'b'}] 426 | }, 427 | { 428 | type: 'element', 429 | name: 'category', 430 | attributes: {}, 431 | children: [{type: 'text', value: 'c'}] 432 | } 433 | ] 434 | } 435 | ] 436 | } 437 | ) 438 | }) 439 | 440 | await t.test( 441 | 'should throw when entry w/o `title` or `description`', 442 | async function () { 443 | assert.throws(function () { 444 | rss({title: 'a', url: 'https://example.com'}, [{}]) 445 | }, /Expected either `title` or `description` to be set in entry `0`/) 446 | } 447 | ) 448 | 449 | await t.test('should support an item w/ `title`', async function () { 450 | assert.deepEqual( 451 | rss({title: 'a', url: 'https://example.com'}, [{title: 'b'}]).children[1], 452 | { 453 | type: 'element', 454 | name: 'rss', 455 | attributes: { 456 | version: '2.0', 457 | 'xmlns:dc': 'http://purl.org/dc/elements/1.1/' 458 | }, 459 | children: [ 460 | { 461 | type: 'element', 462 | name: 'channel', 463 | attributes: {}, 464 | children: [ 465 | { 466 | type: 'element', 467 | name: 'title', 468 | attributes: {}, 469 | children: [{type: 'text', value: 'a'}] 470 | }, 471 | { 472 | type: 'element', 473 | name: 'description', 474 | attributes: {}, 475 | children: [] 476 | }, 477 | { 478 | type: 'element', 479 | name: 'link', 480 | attributes: {}, 481 | children: [{type: 'text', value: 'https://example.com/'}] 482 | }, 483 | { 484 | type: 'element', 485 | name: 'lastBuildDate', 486 | attributes: {}, 487 | children: [ 488 | {type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'} 489 | ] 490 | }, 491 | { 492 | type: 'element', 493 | name: 'dc:date', 494 | attributes: {}, 495 | children: [{type: 'text', value: '2009-02-13T23:31:30.123Z'}] 496 | }, 497 | { 498 | type: 'element', 499 | name: 'item', 500 | attributes: {}, 501 | children: [ 502 | { 503 | type: 'element', 504 | name: 'title', 505 | attributes: {}, 506 | children: [{type: 'text', value: 'b'}] 507 | } 508 | ] 509 | } 510 | ] 511 | } 512 | ] 513 | } 514 | ) 515 | }) 516 | 517 | await t.test('should support an item w/ `description`', async function () { 518 | assert.deepEqual( 519 | rss({title: 'a', url: 'https://example.com'}, [{description: 'b'}]) 520 | .children[1], 521 | { 522 | type: 'element', 523 | name: 'rss', 524 | attributes: { 525 | version: '2.0', 526 | 'xmlns:dc': 'http://purl.org/dc/elements/1.1/' 527 | }, 528 | children: [ 529 | { 530 | type: 'element', 531 | name: 'channel', 532 | attributes: {}, 533 | children: [ 534 | { 535 | type: 'element', 536 | name: 'title', 537 | attributes: {}, 538 | children: [{type: 'text', value: 'a'}] 539 | }, 540 | { 541 | type: 'element', 542 | name: 'description', 543 | attributes: {}, 544 | children: [] 545 | }, 546 | { 547 | type: 'element', 548 | name: 'link', 549 | attributes: {}, 550 | children: [{type: 'text', value: 'https://example.com/'}] 551 | }, 552 | { 553 | type: 'element', 554 | name: 'lastBuildDate', 555 | attributes: {}, 556 | children: [ 557 | {type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'} 558 | ] 559 | }, 560 | { 561 | type: 'element', 562 | name: 'dc:date', 563 | attributes: {}, 564 | children: [{type: 'text', value: '2009-02-13T23:31:30.123Z'}] 565 | }, 566 | { 567 | type: 'element', 568 | name: 'item', 569 | attributes: {}, 570 | children: [ 571 | { 572 | type: 'element', 573 | name: 'description', 574 | attributes: {}, 575 | children: [{type: 'text', value: 'b'}] 576 | } 577 | ] 578 | } 579 | ] 580 | } 581 | ] 582 | } 583 | ) 584 | }) 585 | 586 | await t.test( 587 | 'should support an item w/ `descriptionHtml`', 588 | async function () { 589 | assert.deepEqual( 590 | rss({title: 'a', url: 'https://example.com'}, [ 591 | {descriptionHtml: '

b

'} 592 | ]).children[1], 593 | { 594 | type: 'element', 595 | name: 'rss', 596 | attributes: { 597 | version: '2.0', 598 | 'xmlns:dc': 'http://purl.org/dc/elements/1.1/' 599 | }, 600 | children: [ 601 | { 602 | type: 'element', 603 | name: 'channel', 604 | attributes: {}, 605 | children: [ 606 | { 607 | type: 'element', 608 | name: 'title', 609 | attributes: {}, 610 | children: [{type: 'text', value: 'a'}] 611 | }, 612 | { 613 | type: 'element', 614 | name: 'description', 615 | attributes: {}, 616 | children: [] 617 | }, 618 | { 619 | type: 'element', 620 | name: 'link', 621 | attributes: {}, 622 | children: [{type: 'text', value: 'https://example.com/'}] 623 | }, 624 | { 625 | type: 'element', 626 | name: 'lastBuildDate', 627 | attributes: {}, 628 | children: [ 629 | {type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'} 630 | ] 631 | }, 632 | { 633 | type: 'element', 634 | name: 'dc:date', 635 | attributes: {}, 636 | children: [{type: 'text', value: '2009-02-13T23:31:30.123Z'}] 637 | }, 638 | { 639 | type: 'element', 640 | name: 'item', 641 | attributes: {}, 642 | children: [ 643 | { 644 | type: 'element', 645 | name: 'description', 646 | attributes: {}, 647 | children: [{type: 'text', value: '

b

'}] 648 | } 649 | ] 650 | } 651 | ] 652 | } 653 | ] 654 | } 655 | ) 656 | } 657 | ) 658 | 659 | await t.test( 660 | 'should prefer `descriptionHtml` over `description`', 661 | async function () { 662 | assert.deepEqual( 663 | rss({title: 'a', url: 'https://example.com'}, [ 664 | {description: 'b', descriptionHtml: '

b

'} 665 | ]).children[1], 666 | { 667 | type: 'element', 668 | name: 'rss', 669 | attributes: { 670 | version: '2.0', 671 | 'xmlns:dc': 'http://purl.org/dc/elements/1.1/' 672 | }, 673 | children: [ 674 | { 675 | type: 'element', 676 | name: 'channel', 677 | attributes: {}, 678 | children: [ 679 | { 680 | type: 'element', 681 | name: 'title', 682 | attributes: {}, 683 | children: [{type: 'text', value: 'a'}] 684 | }, 685 | { 686 | type: 'element', 687 | name: 'description', 688 | attributes: {}, 689 | children: [] 690 | }, 691 | { 692 | type: 'element', 693 | name: 'link', 694 | attributes: {}, 695 | children: [{type: 'text', value: 'https://example.com/'}] 696 | }, 697 | { 698 | type: 'element', 699 | name: 'lastBuildDate', 700 | attributes: {}, 701 | children: [ 702 | {type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'} 703 | ] 704 | }, 705 | { 706 | type: 'element', 707 | name: 'dc:date', 708 | attributes: {}, 709 | children: [{type: 'text', value: '2009-02-13T23:31:30.123Z'}] 710 | }, 711 | { 712 | type: 'element', 713 | name: 'item', 714 | attributes: {}, 715 | children: [ 716 | { 717 | type: 'element', 718 | name: 'description', 719 | attributes: {}, 720 | children: [{type: 'text', value: '

b

'}] 721 | } 722 | ] 723 | } 724 | ] 725 | } 726 | ] 727 | } 728 | ) 729 | } 730 | ) 731 | 732 | await t.test( 733 | 'should support an item w/ `author` (`dc:creator`)', 734 | async function () { 735 | assert.deepEqual( 736 | rss({title: 'a', url: 'https://example.com'}, [ 737 | {title: 'b', author: 'c'} 738 | ]).children[1], 739 | { 740 | type: 'element', 741 | name: 'rss', 742 | attributes: { 743 | version: '2.0', 744 | 'xmlns:dc': 'http://purl.org/dc/elements/1.1/' 745 | }, 746 | children: [ 747 | { 748 | type: 'element', 749 | name: 'channel', 750 | attributes: {}, 751 | children: [ 752 | { 753 | type: 'element', 754 | name: 'title', 755 | attributes: {}, 756 | children: [{type: 'text', value: 'a'}] 757 | }, 758 | { 759 | type: 'element', 760 | name: 'description', 761 | attributes: {}, 762 | children: [] 763 | }, 764 | { 765 | type: 'element', 766 | name: 'link', 767 | attributes: {}, 768 | children: [{type: 'text', value: 'https://example.com/'}] 769 | }, 770 | { 771 | type: 'element', 772 | name: 'lastBuildDate', 773 | attributes: {}, 774 | children: [ 775 | {type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'} 776 | ] 777 | }, 778 | { 779 | type: 'element', 780 | name: 'dc:date', 781 | attributes: {}, 782 | children: [{type: 'text', value: '2009-02-13T23:31:30.123Z'}] 783 | }, 784 | { 785 | type: 'element', 786 | name: 'item', 787 | attributes: {}, 788 | children: [ 789 | { 790 | type: 'element', 791 | name: 'title', 792 | attributes: {}, 793 | children: [{type: 'text', value: 'b'}] 794 | }, 795 | { 796 | type: 'element', 797 | name: 'dc:creator', 798 | attributes: {}, 799 | children: [{type: 'text', value: 'c'}] 800 | } 801 | ] 802 | } 803 | ] 804 | } 805 | ] 806 | } 807 | ) 808 | } 809 | ) 810 | 811 | await t.test('should throw on author w/o `name`', async function () { 812 | assert.throws(function () { 813 | rss({title: 'a', url: 'https://example.com'}, [ 814 | { 815 | title: 'b', 816 | // @ts-expect-error: check how the runtime handles missing `author.name`. 817 | author: {} 818 | } 819 | ]) 820 | }, /Expected `author.name` to be set/) 821 | }) 822 | 823 | await t.test( 824 | 'should support an item w/ `author` as object (`author`)', 825 | async function () { 826 | assert.deepEqual( 827 | rss({title: 'a', url: 'https://example.com'}, [ 828 | {title: 'b', author: {name: 'c'}} 829 | ]).children[1], 830 | { 831 | type: 'element', 832 | name: 'rss', 833 | attributes: { 834 | version: '2.0', 835 | 'xmlns:dc': 'http://purl.org/dc/elements/1.1/' 836 | }, 837 | children: [ 838 | { 839 | type: 'element', 840 | name: 'channel', 841 | attributes: {}, 842 | children: [ 843 | { 844 | type: 'element', 845 | name: 'title', 846 | attributes: {}, 847 | children: [{type: 'text', value: 'a'}] 848 | }, 849 | { 850 | type: 'element', 851 | name: 'description', 852 | attributes: {}, 853 | children: [] 854 | }, 855 | { 856 | type: 'element', 857 | name: 'link', 858 | attributes: {}, 859 | children: [{type: 'text', value: 'https://example.com/'}] 860 | }, 861 | { 862 | type: 'element', 863 | name: 'lastBuildDate', 864 | attributes: {}, 865 | children: [ 866 | {type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'} 867 | ] 868 | }, 869 | { 870 | type: 'element', 871 | name: 'dc:date', 872 | attributes: {}, 873 | children: [{type: 'text', value: '2009-02-13T23:31:30.123Z'}] 874 | }, 875 | { 876 | type: 'element', 877 | name: 'item', 878 | attributes: {}, 879 | children: [ 880 | { 881 | type: 'element', 882 | name: 'title', 883 | attributes: {}, 884 | children: [{type: 'text', value: 'b'}] 885 | }, 886 | { 887 | type: 'element', 888 | name: 'dc:creator', 889 | attributes: {}, 890 | children: [{type: 'text', value: 'c'}] 891 | } 892 | ] 893 | } 894 | ] 895 | } 896 | ] 897 | } 898 | ) 899 | } 900 | ) 901 | 902 | await t.test( 903 | 'should support an item w/ `author` as object w/ `email` (`author`)', 904 | async function () { 905 | assert.deepEqual( 906 | rss({title: 'a', url: 'https://example.com'}, [ 907 | {title: 'b', author: {name: 'c', email: 'd'}} 908 | ]).children[1], 909 | { 910 | type: 'element', 911 | name: 'rss', 912 | attributes: { 913 | version: '2.0', 914 | 'xmlns:dc': 'http://purl.org/dc/elements/1.1/' 915 | }, 916 | children: [ 917 | { 918 | type: 'element', 919 | name: 'channel', 920 | attributes: {}, 921 | children: [ 922 | { 923 | type: 'element', 924 | name: 'title', 925 | attributes: {}, 926 | children: [{type: 'text', value: 'a'}] 927 | }, 928 | { 929 | type: 'element', 930 | name: 'description', 931 | attributes: {}, 932 | children: [] 933 | }, 934 | { 935 | type: 'element', 936 | name: 'link', 937 | attributes: {}, 938 | children: [{type: 'text', value: 'https://example.com/'}] 939 | }, 940 | { 941 | type: 'element', 942 | name: 'lastBuildDate', 943 | attributes: {}, 944 | children: [ 945 | {type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'} 946 | ] 947 | }, 948 | { 949 | type: 'element', 950 | name: 'dc:date', 951 | attributes: {}, 952 | children: [{type: 'text', value: '2009-02-13T23:31:30.123Z'}] 953 | }, 954 | { 955 | type: 'element', 956 | name: 'item', 957 | attributes: {}, 958 | children: [ 959 | { 960 | type: 'element', 961 | name: 'title', 962 | attributes: {}, 963 | children: [{type: 'text', value: 'b'}] 964 | }, 965 | { 966 | type: 'element', 967 | name: 'dc:creator', 968 | attributes: {}, 969 | children: [{type: 'text', value: 'c'}] 970 | }, 971 | { 972 | type: 'element', 973 | name: 'author', 974 | attributes: {}, 975 | children: [{type: 'text', value: 'd (c)'}] 976 | } 977 | ] 978 | } 979 | ] 980 | } 981 | ] 982 | } 983 | ) 984 | } 985 | ) 986 | 987 | await t.test( 988 | 'should support an item w/ `link` (`link` and `guid`)', 989 | async function () { 990 | assert.deepEqual( 991 | rss({title: 'a', url: 'https://example.com'}, [ 992 | {title: 'b', url: 'https://example.com/b.html'} 993 | ]).children[1], 994 | { 995 | type: 'element', 996 | name: 'rss', 997 | attributes: { 998 | version: '2.0', 999 | 'xmlns:dc': 'http://purl.org/dc/elements/1.1/' 1000 | }, 1001 | children: [ 1002 | { 1003 | type: 'element', 1004 | name: 'channel', 1005 | attributes: {}, 1006 | children: [ 1007 | { 1008 | type: 'element', 1009 | name: 'title', 1010 | attributes: {}, 1011 | children: [{type: 'text', value: 'a'}] 1012 | }, 1013 | { 1014 | type: 'element', 1015 | name: 'description', 1016 | attributes: {}, 1017 | children: [] 1018 | }, 1019 | { 1020 | type: 'element', 1021 | name: 'link', 1022 | attributes: {}, 1023 | children: [{type: 'text', value: 'https://example.com/'}] 1024 | }, 1025 | { 1026 | type: 'element', 1027 | name: 'lastBuildDate', 1028 | attributes: {}, 1029 | children: [ 1030 | {type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'} 1031 | ] 1032 | }, 1033 | { 1034 | type: 'element', 1035 | name: 'dc:date', 1036 | attributes: {}, 1037 | children: [{type: 'text', value: '2009-02-13T23:31:30.123Z'}] 1038 | }, 1039 | { 1040 | type: 'element', 1041 | name: 'item', 1042 | attributes: {}, 1043 | children: [ 1044 | { 1045 | type: 'element', 1046 | name: 'title', 1047 | attributes: {}, 1048 | children: [{type: 'text', value: 'b'}] 1049 | }, 1050 | { 1051 | type: 'element', 1052 | name: 'link', 1053 | attributes: {}, 1054 | children: [ 1055 | {type: 'text', value: 'https://example.com/b.html'} 1056 | ] 1057 | }, 1058 | { 1059 | type: 'element', 1060 | name: 'guid', 1061 | attributes: {isPermaLink: 'false'}, 1062 | children: [ 1063 | {type: 'text', value: 'https://example.com/b.html'} 1064 | ] 1065 | } 1066 | ] 1067 | } 1068 | ] 1069 | } 1070 | ] 1071 | } 1072 | ) 1073 | } 1074 | ) 1075 | 1076 | await t.test('should support an item w/ `tags`', async function () { 1077 | assert.deepEqual( 1078 | rss({title: 'a', url: 'https://example.com'}, [ 1079 | {title: 'b', tags: ['a', 'b']} 1080 | ]).children[1], 1081 | { 1082 | type: 'element', 1083 | name: 'rss', 1084 | attributes: { 1085 | version: '2.0', 1086 | 'xmlns:dc': 'http://purl.org/dc/elements/1.1/' 1087 | }, 1088 | children: [ 1089 | { 1090 | type: 'element', 1091 | name: 'channel', 1092 | attributes: {}, 1093 | children: [ 1094 | { 1095 | type: 'element', 1096 | name: 'title', 1097 | attributes: {}, 1098 | children: [{type: 'text', value: 'a'}] 1099 | }, 1100 | { 1101 | type: 'element', 1102 | name: 'description', 1103 | attributes: {}, 1104 | children: [] 1105 | }, 1106 | { 1107 | type: 'element', 1108 | name: 'link', 1109 | attributes: {}, 1110 | children: [{type: 'text', value: 'https://example.com/'}] 1111 | }, 1112 | { 1113 | type: 'element', 1114 | name: 'lastBuildDate', 1115 | attributes: {}, 1116 | children: [ 1117 | {type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'} 1118 | ] 1119 | }, 1120 | { 1121 | type: 'element', 1122 | name: 'dc:date', 1123 | attributes: {}, 1124 | children: [{type: 'text', value: '2009-02-13T23:31:30.123Z'}] 1125 | }, 1126 | { 1127 | type: 'element', 1128 | name: 'item', 1129 | attributes: {}, 1130 | children: [ 1131 | { 1132 | type: 'element', 1133 | name: 'title', 1134 | attributes: {}, 1135 | children: [{type: 'text', value: 'b'}] 1136 | }, 1137 | { 1138 | type: 'element', 1139 | name: 'category', 1140 | attributes: {}, 1141 | children: [{type: 'text', value: 'a'}] 1142 | }, 1143 | { 1144 | type: 'element', 1145 | name: 'category', 1146 | attributes: {}, 1147 | children: [{type: 'text', value: 'b'}] 1148 | } 1149 | ] 1150 | } 1151 | ] 1152 | } 1153 | ] 1154 | } 1155 | ) 1156 | }) 1157 | 1158 | await t.test('should support an item w/ `published`', async function () { 1159 | assert.deepEqual( 1160 | rss({title: 'a', url: 'https://example.com'}, [ 1161 | {title: 'b', published: 1_231_111_111_111} 1162 | ]).children[1], 1163 | { 1164 | type: 'element', 1165 | name: 'rss', 1166 | attributes: { 1167 | version: '2.0', 1168 | 'xmlns:dc': 'http://purl.org/dc/elements/1.1/' 1169 | }, 1170 | children: [ 1171 | { 1172 | type: 'element', 1173 | name: 'channel', 1174 | attributes: {}, 1175 | children: [ 1176 | { 1177 | type: 'element', 1178 | name: 'title', 1179 | attributes: {}, 1180 | children: [{type: 'text', value: 'a'}] 1181 | }, 1182 | { 1183 | type: 'element', 1184 | name: 'description', 1185 | attributes: {}, 1186 | children: [] 1187 | }, 1188 | { 1189 | type: 'element', 1190 | name: 'link', 1191 | attributes: {}, 1192 | children: [{type: 'text', value: 'https://example.com/'}] 1193 | }, 1194 | { 1195 | type: 'element', 1196 | name: 'lastBuildDate', 1197 | attributes: {}, 1198 | children: [ 1199 | {type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'} 1200 | ] 1201 | }, 1202 | { 1203 | type: 'element', 1204 | name: 'dc:date', 1205 | attributes: {}, 1206 | children: [{type: 'text', value: '2009-02-13T23:31:30.123Z'}] 1207 | }, 1208 | { 1209 | type: 'element', 1210 | name: 'item', 1211 | attributes: {}, 1212 | children: [ 1213 | { 1214 | type: 'element', 1215 | name: 'title', 1216 | attributes: {}, 1217 | children: [{type: 'text', value: 'b'}] 1218 | }, 1219 | { 1220 | type: 'element', 1221 | name: 'pubDate', 1222 | attributes: {}, 1223 | children: [ 1224 | {type: 'text', value: 'Sun, 04 Jan 2009 23:18:31 GMT'} 1225 | ] 1226 | }, 1227 | { 1228 | type: 'element', 1229 | name: 'dc:date', 1230 | attributes: {}, 1231 | children: [ 1232 | {type: 'text', value: '2009-01-04T23:18:31.111Z'} 1233 | ] 1234 | } 1235 | ] 1236 | } 1237 | ] 1238 | } 1239 | ] 1240 | } 1241 | ) 1242 | }) 1243 | 1244 | await t.test('should support an item w/ `modified`', async function () { 1245 | assert.deepEqual( 1246 | rss({title: 'a', url: 'https://example.com'}, [ 1247 | {title: 'b', modified: 1_231_111_111_111} 1248 | ]).children[1], 1249 | { 1250 | type: 'element', 1251 | name: 'rss', 1252 | attributes: { 1253 | version: '2.0', 1254 | 'xmlns:dc': 'http://purl.org/dc/elements/1.1/' 1255 | }, 1256 | children: [ 1257 | { 1258 | type: 'element', 1259 | name: 'channel', 1260 | attributes: {}, 1261 | children: [ 1262 | { 1263 | type: 'element', 1264 | name: 'title', 1265 | attributes: {}, 1266 | children: [{type: 'text', value: 'a'}] 1267 | }, 1268 | { 1269 | type: 'element', 1270 | name: 'description', 1271 | attributes: {}, 1272 | children: [] 1273 | }, 1274 | { 1275 | type: 'element', 1276 | name: 'link', 1277 | attributes: {}, 1278 | children: [{type: 'text', value: 'https://example.com/'}] 1279 | }, 1280 | { 1281 | type: 'element', 1282 | name: 'lastBuildDate', 1283 | attributes: {}, 1284 | children: [ 1285 | {type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'} 1286 | ] 1287 | }, 1288 | { 1289 | type: 'element', 1290 | name: 'dc:date', 1291 | attributes: {}, 1292 | children: [{type: 'text', value: '2009-02-13T23:31:30.123Z'}] 1293 | }, 1294 | { 1295 | type: 'element', 1296 | name: 'item', 1297 | attributes: {}, 1298 | children: [ 1299 | { 1300 | type: 'element', 1301 | name: 'title', 1302 | attributes: {}, 1303 | children: [{type: 'text', value: 'b'}] 1304 | }, 1305 | { 1306 | type: 'element', 1307 | name: 'dc:modified', 1308 | attributes: {}, 1309 | children: [ 1310 | {type: 'text', value: '2009-01-04T23:18:31.111Z'} 1311 | ] 1312 | } 1313 | ] 1314 | } 1315 | ] 1316 | } 1317 | ] 1318 | } 1319 | ) 1320 | }) 1321 | 1322 | await t.test('should throw on enclosure w/o `url`', async function () { 1323 | assert.throws(function () { 1324 | rss({title: 'a', url: 'https://example.com'}, [ 1325 | { 1326 | title: 'b', 1327 | // @ts-expect-error: check how the runtime handles missing `enclosure.url`. 1328 | enclosure: {} 1329 | } 1330 | ]) 1331 | }, /Expected either `enclosure.url` to be set in entry `0`/) 1332 | }) 1333 | 1334 | await t.test('should throw on enclosure w/o `size`', async function () { 1335 | assert.throws(function () { 1336 | rss({title: 'a', url: 'https://example.com'}, [ 1337 | { 1338 | title: 'b', 1339 | // @ts-expect-error: check how the runtime handles missing `enclosure.size`. 1340 | enclosure: {url: 'c'} 1341 | } 1342 | ]) 1343 | }, /Expected either `enclosure.size` to be set in entry `0`/) 1344 | }) 1345 | 1346 | await t.test('should throw on enclosure w/o `type`', async function () { 1347 | assert.throws(function () { 1348 | rss({title: 'a', url: 'https://example.com'}, [ 1349 | { 1350 | title: 'b', 1351 | // @ts-expect-error: check how the runtime handles missing `enclosure.type`. 1352 | enclosure: {url: 'c', size: 1} 1353 | } 1354 | ]) 1355 | }, /Expected either `enclosure.type` to be set in entry `0`/) 1356 | }) 1357 | 1358 | await t.test( 1359 | 'should throw on incorrect `url` in enclosure', 1360 | async function () { 1361 | assert.throws(function () { 1362 | rss({title: 'a', url: 'https://example.com'}, [ 1363 | {title: 'b', enclosure: {url: 'c', size: 1, type: 'd'}} 1364 | ]) 1365 | }, /Invalid URL/) 1366 | } 1367 | ) 1368 | 1369 | await t.test('should support an item w/ `enclosure`', async function () { 1370 | assert.deepEqual( 1371 | rss({title: 'a', url: 'https://example.com'}, [ 1372 | { 1373 | title: 'b', 1374 | enclosure: { 1375 | url: 'https://example.com/123.png', 1376 | size: 1, 1377 | type: 'image/png' 1378 | } 1379 | } 1380 | ]).children[1], 1381 | { 1382 | type: 'element', 1383 | name: 'rss', 1384 | attributes: { 1385 | version: '2.0', 1386 | 'xmlns:dc': 'http://purl.org/dc/elements/1.1/' 1387 | }, 1388 | children: [ 1389 | { 1390 | type: 'element', 1391 | name: 'channel', 1392 | attributes: {}, 1393 | children: [ 1394 | { 1395 | type: 'element', 1396 | name: 'title', 1397 | attributes: {}, 1398 | children: [{type: 'text', value: 'a'}] 1399 | }, 1400 | { 1401 | type: 'element', 1402 | name: 'description', 1403 | attributes: {}, 1404 | children: [] 1405 | }, 1406 | { 1407 | type: 'element', 1408 | name: 'link', 1409 | attributes: {}, 1410 | children: [{type: 'text', value: 'https://example.com/'}] 1411 | }, 1412 | { 1413 | type: 'element', 1414 | name: 'lastBuildDate', 1415 | attributes: {}, 1416 | children: [ 1417 | {type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'} 1418 | ] 1419 | }, 1420 | { 1421 | type: 'element', 1422 | name: 'dc:date', 1423 | attributes: {}, 1424 | children: [{type: 'text', value: '2009-02-13T23:31:30.123Z'}] 1425 | }, 1426 | { 1427 | type: 'element', 1428 | name: 'item', 1429 | attributes: {}, 1430 | children: [ 1431 | { 1432 | type: 'element', 1433 | name: 'title', 1434 | attributes: {}, 1435 | children: [{type: 'text', value: 'b'}] 1436 | }, 1437 | { 1438 | type: 'element', 1439 | name: 'enclosure', 1440 | attributes: { 1441 | url: 'https://example.com/123.png', 1442 | length: '1', 1443 | type: 'image/png' 1444 | }, 1445 | children: [] 1446 | } 1447 | ] 1448 | } 1449 | ] 1450 | } 1451 | ] 1452 | } 1453 | ) 1454 | }) 1455 | }) 1456 | 1457 | test('atom', async function (t) { 1458 | await t.test('should throw when w/o `title`', async function () { 1459 | assert.throws(function () { 1460 | // @ts-expect-error: check how the runtime handles missing `channel`. 1461 | atom() 1462 | }, /Expected `channel.title` to be set/) 1463 | }) 1464 | 1465 | await t.test('should throw when w/o `url`', async function () { 1466 | assert.throws(function () { 1467 | // @ts-expect-error: check how the runtime handles missing `channel.url`. 1468 | atom({title: 'a'}) 1469 | }, /Expected `channel.url` to be set/) 1470 | }) 1471 | 1472 | await t.test('should throw on incorrect `url`', async function () { 1473 | assert.throws(function () { 1474 | atom({title: 'a', url: 'b'}) 1475 | }, /Invalid URL/) 1476 | }) 1477 | 1478 | await t.test('should support `title` and `url`', async function () { 1479 | assert.deepEqual(atom({title: 'a', url: 'https://example.com'}), { 1480 | type: 'root', 1481 | children: [ 1482 | { 1483 | type: 'instruction', 1484 | name: 'xml', 1485 | value: 'version="1.0" encoding="utf-8"' 1486 | }, 1487 | { 1488 | type: 'element', 1489 | name: 'feed', 1490 | attributes: {xmlns: 'http://www.w3.org/2005/Atom'}, 1491 | children: [ 1492 | { 1493 | type: 'element', 1494 | name: 'title', 1495 | attributes: {}, 1496 | children: [{type: 'text', value: 'a'}] 1497 | }, 1498 | {type: 'element', name: 'subtitle', attributes: {}, children: []}, 1499 | { 1500 | type: 'element', 1501 | name: 'link', 1502 | attributes: {}, 1503 | children: [{type: 'text', value: 'https://example.com/'}] 1504 | }, 1505 | { 1506 | type: 'element', 1507 | name: 'id', 1508 | attributes: {}, 1509 | children: [{type: 'text', value: 'https://example.com/'}] 1510 | }, 1511 | { 1512 | type: 'element', 1513 | name: 'updated', 1514 | attributes: {}, 1515 | children: [{type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'}] 1516 | } 1517 | ] 1518 | } 1519 | ] 1520 | }) 1521 | }) 1522 | 1523 | await t.test( 1524 | 'should support `feedUrl` (for `link[rel=self]`)', 1525 | async function () { 1526 | assert.deepEqual( 1527 | atom({ 1528 | title: 'a', 1529 | url: 'https://example.com', 1530 | feedUrl: 'https://example.com/atom' 1531 | }).children[1], 1532 | { 1533 | type: 'element', 1534 | name: 'feed', 1535 | attributes: {xmlns: 'http://www.w3.org/2005/Atom'}, 1536 | children: [ 1537 | { 1538 | type: 'element', 1539 | name: 'title', 1540 | attributes: {}, 1541 | children: [{type: 'text', value: 'a'}] 1542 | }, 1543 | {type: 'element', name: 'subtitle', attributes: {}, children: []}, 1544 | { 1545 | type: 'element', 1546 | name: 'link', 1547 | attributes: {}, 1548 | children: [{type: 'text', value: 'https://example.com/'}] 1549 | }, 1550 | { 1551 | type: 'element', 1552 | name: 'id', 1553 | attributes: {}, 1554 | children: [{type: 'text', value: 'https://example.com/'}] 1555 | }, 1556 | { 1557 | type: 'element', 1558 | name: 'updated', 1559 | attributes: {}, 1560 | children: [{type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'}] 1561 | }, 1562 | { 1563 | type: 'element', 1564 | name: 'link', 1565 | attributes: { 1566 | href: 'https://example.com/atom', 1567 | rel: 'self', 1568 | type: 'application/atom+xml' 1569 | }, 1570 | children: [] 1571 | } 1572 | ] 1573 | } 1574 | ) 1575 | } 1576 | ) 1577 | 1578 | await t.test('should support `description`', async function () { 1579 | assert.deepEqual( 1580 | atom({title: 'a', url: 'https://example.com', description: 'b'}) 1581 | .children[1], 1582 | { 1583 | type: 'element', 1584 | name: 'feed', 1585 | attributes: {xmlns: 'http://www.w3.org/2005/Atom'}, 1586 | children: [ 1587 | { 1588 | type: 'element', 1589 | name: 'title', 1590 | attributes: {}, 1591 | children: [{type: 'text', value: 'a'}] 1592 | }, 1593 | { 1594 | type: 'element', 1595 | name: 'subtitle', 1596 | attributes: {}, 1597 | children: [{type: 'text', value: 'b'}] 1598 | }, 1599 | { 1600 | type: 'element', 1601 | name: 'link', 1602 | attributes: {}, 1603 | children: [{type: 'text', value: 'https://example.com/'}] 1604 | }, 1605 | { 1606 | type: 'element', 1607 | name: 'id', 1608 | attributes: {}, 1609 | children: [{type: 'text', value: 'https://example.com/'}] 1610 | }, 1611 | { 1612 | type: 'element', 1613 | name: 'updated', 1614 | attributes: {}, 1615 | children: [{type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'}] 1616 | } 1617 | ] 1618 | } 1619 | ) 1620 | }) 1621 | 1622 | await t.test('should support `lang`', async function () { 1623 | assert.deepEqual( 1624 | atom({title: 'a', url: 'https://example.com', lang: 'nl-NL'}).children[1], 1625 | { 1626 | type: 'element', 1627 | name: 'feed', 1628 | attributes: {xmlns: 'http://www.w3.org/2005/Atom', 'xml:lang': 'nl'}, 1629 | children: [ 1630 | { 1631 | type: 'element', 1632 | name: 'title', 1633 | attributes: {}, 1634 | children: [{type: 'text', value: 'a'}] 1635 | }, 1636 | {type: 'element', name: 'subtitle', attributes: {}, children: []}, 1637 | { 1638 | type: 'element', 1639 | name: 'link', 1640 | attributes: {}, 1641 | children: [{type: 'text', value: 'https://example.com/'}] 1642 | }, 1643 | { 1644 | type: 'element', 1645 | name: 'id', 1646 | attributes: {}, 1647 | children: [{type: 'text', value: 'https://example.com/'}] 1648 | }, 1649 | { 1650 | type: 'element', 1651 | name: 'updated', 1652 | attributes: {}, 1653 | children: [{type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'}] 1654 | } 1655 | ] 1656 | } 1657 | ) 1658 | }) 1659 | 1660 | await t.test('should support `author`', async function () { 1661 | assert.deepEqual( 1662 | atom({title: 'a', url: 'https://example.com', author: 'b'}).children[1], 1663 | { 1664 | type: 'element', 1665 | name: 'feed', 1666 | attributes: {xmlns: 'http://www.w3.org/2005/Atom'}, 1667 | children: [ 1668 | { 1669 | type: 'element', 1670 | name: 'title', 1671 | attributes: {}, 1672 | children: [{type: 'text', value: 'a'}] 1673 | }, 1674 | {type: 'element', name: 'subtitle', attributes: {}, children: []}, 1675 | { 1676 | type: 'element', 1677 | name: 'link', 1678 | attributes: {}, 1679 | children: [{type: 'text', value: 'https://example.com/'}] 1680 | }, 1681 | { 1682 | type: 'element', 1683 | name: 'id', 1684 | attributes: {}, 1685 | children: [{type: 'text', value: 'https://example.com/'}] 1686 | }, 1687 | { 1688 | type: 'element', 1689 | name: 'updated', 1690 | attributes: {}, 1691 | children: [{type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'}] 1692 | }, 1693 | { 1694 | type: 'element', 1695 | name: 'rights', 1696 | attributes: {}, 1697 | children: [{type: 'text', value: '© 2009 b'}] 1698 | }, 1699 | { 1700 | type: 'element', 1701 | name: 'author', 1702 | attributes: {}, 1703 | children: [ 1704 | { 1705 | type: 'element', 1706 | name: 'name', 1707 | attributes: {}, 1708 | children: [{type: 'text', value: 'b'}] 1709 | } 1710 | ] 1711 | } 1712 | ] 1713 | } 1714 | ) 1715 | }) 1716 | 1717 | await t.test('should support `tags`', async function () { 1718 | assert.deepEqual( 1719 | atom({title: 'a', url: 'https://example.com', tags: ['b', 'c']}) 1720 | .children[1], 1721 | { 1722 | type: 'element', 1723 | name: 'feed', 1724 | attributes: {xmlns: 'http://www.w3.org/2005/Atom'}, 1725 | children: [ 1726 | { 1727 | type: 'element', 1728 | name: 'title', 1729 | attributes: {}, 1730 | children: [{type: 'text', value: 'a'}] 1731 | }, 1732 | {type: 'element', name: 'subtitle', attributes: {}, children: []}, 1733 | { 1734 | type: 'element', 1735 | name: 'link', 1736 | attributes: {}, 1737 | children: [{type: 'text', value: 'https://example.com/'}] 1738 | }, 1739 | { 1740 | type: 'element', 1741 | name: 'id', 1742 | attributes: {}, 1743 | children: [{type: 'text', value: 'https://example.com/'}] 1744 | }, 1745 | { 1746 | type: 'element', 1747 | name: 'updated', 1748 | attributes: {}, 1749 | children: [{type: 'text', value: 'Fri, 13 Feb 2009 23:31:30 GMT'}] 1750 | }, 1751 | { 1752 | type: 'element', 1753 | name: 'category', 1754 | attributes: {term: 'b'}, 1755 | children: [] 1756 | }, 1757 | { 1758 | type: 'element', 1759 | name: 'category', 1760 | attributes: {term: 'c'}, 1761 | children: [] 1762 | } 1763 | ] 1764 | } 1765 | ) 1766 | }) 1767 | 1768 | await t.test( 1769 | 'should throw when entry w/o `title` or `description`', 1770 | async function () { 1771 | assert.throws(function () { 1772 | atom({title: 'a', url: 'https://example.com'}, [{}]) 1773 | }, /Expected either `title` or `description` to be set in entry `0`/) 1774 | } 1775 | ) 1776 | 1777 | await t.test( 1778 | 'should throw w/ entry (and channel) w/o `author`', 1779 | async function () { 1780 | assert.throws(function () { 1781 | atom({title: 'a', url: 'https://example.com'}, [{title: 'b'}]) 1782 | }, /Expected `author` to be set in entry `0` or in the channel/) 1783 | } 1784 | ) 1785 | 1786 | await t.test('should support an item w/ `title`', async function () { 1787 | const root = atom({title: 'a', author: 'b', url: 'https://example.com'}, [ 1788 | {title: 'c'} 1789 | ]) 1790 | const element = root.children[1] 1791 | assert(element.type === 'element') 1792 | const entry = element.children.pop() 1793 | 1794 | assert.deepEqual(entry, { 1795 | type: 'element', 1796 | name: 'entry', 1797 | attributes: {}, 1798 | children: [ 1799 | { 1800 | type: 'element', 1801 | name: 'title', 1802 | attributes: {}, 1803 | children: [{type: 'text', value: 'c'}] 1804 | } 1805 | ] 1806 | }) 1807 | }) 1808 | 1809 | await t.test('should support an item w/ `description`', async function () { 1810 | const root = atom({title: 'a', author: 'b', url: 'https://example.com'}, [ 1811 | {description: 'c'} 1812 | ]) 1813 | const element = root.children[1] 1814 | assert(element.type === 'element') 1815 | const entry = element.children.pop() 1816 | 1817 | assert.deepEqual(entry, { 1818 | type: 'element', 1819 | name: 'entry', 1820 | attributes: {}, 1821 | children: [ 1822 | { 1823 | type: 'element', 1824 | name: 'content', 1825 | attributes: {}, 1826 | children: [{type: 'text', value: 'c'}] 1827 | } 1828 | ] 1829 | }) 1830 | }) 1831 | 1832 | await t.test( 1833 | 'should support an item w/ `descriptionHtml`', 1834 | async function () { 1835 | const root = atom({title: 'a', author: 'b', url: 'https://example.com'}, [ 1836 | {descriptionHtml: '

c

'} 1837 | ]) 1838 | const element = root.children[1] 1839 | assert(element.type === 'element') 1840 | const entry = element.children.pop() 1841 | 1842 | assert.deepEqual(entry, { 1843 | type: 'element', 1844 | name: 'entry', 1845 | attributes: {}, 1846 | children: [ 1847 | { 1848 | type: 'element', 1849 | name: 'content', 1850 | attributes: {type: 'html'}, 1851 | children: [{type: 'text', value: '

c

'}] 1852 | } 1853 | ] 1854 | }) 1855 | } 1856 | ) 1857 | 1858 | await t.test( 1859 | 'should prefer `descriptionHtml` over `description`', 1860 | async function () { 1861 | const root = atom({title: 'a', author: 'b', url: 'https://example.com'}, [ 1862 | {description: 'c', descriptionHtml: '

c

'} 1863 | ]) 1864 | const element = root.children[1] 1865 | assert(element.type === 'element') 1866 | const entry = element.children.pop() 1867 | 1868 | assert.deepEqual(entry, { 1869 | type: 'element', 1870 | name: 'entry', 1871 | attributes: {}, 1872 | children: [ 1873 | { 1874 | type: 'element', 1875 | name: 'content', 1876 | attributes: {type: 'html'}, 1877 | children: [{type: 'text', value: '

c

'}] 1878 | } 1879 | ] 1880 | }) 1881 | } 1882 | ) 1883 | 1884 | await t.test('should support an item w/ `author`', async function () { 1885 | const root = atom({title: 'a', author: 'b', url: 'https://example.com'}, [ 1886 | {title: 'c', author: 'd'} 1887 | ]) 1888 | const element = root.children[1] 1889 | assert(element.type === 'element') 1890 | const entry = element.children.pop() 1891 | 1892 | assert.deepEqual(entry, { 1893 | type: 'element', 1894 | name: 'entry', 1895 | attributes: {}, 1896 | children: [ 1897 | { 1898 | type: 'element', 1899 | name: 'title', 1900 | attributes: {}, 1901 | children: [{type: 'text', value: 'c'}] 1902 | }, 1903 | { 1904 | type: 'element', 1905 | name: 'author', 1906 | attributes: {}, 1907 | children: [ 1908 | { 1909 | type: 'element', 1910 | name: 'name', 1911 | attributes: {}, 1912 | children: [{type: 'text', value: 'd'}] 1913 | } 1914 | ] 1915 | } 1916 | ] 1917 | }) 1918 | }) 1919 | 1920 | await t.test('should throw on author w/o `name`', async function () { 1921 | assert.throws(function () { 1922 | atom({title: 'a', url: 'https://example.com'}, [ 1923 | { 1924 | title: 'b', 1925 | // @ts-expect-error: check how the runtime handles missing `author.name`. 1926 | author: {} 1927 | } 1928 | ]) 1929 | }, /Expected `author.name` to be set/) 1930 | }) 1931 | 1932 | await t.test( 1933 | 'should support an item w/ `author` as object', 1934 | async function () { 1935 | const root = atom({title: 'a', url: 'https://example.com'}, [ 1936 | {title: 'b', author: {name: 'c'}} 1937 | ]) 1938 | const element = root.children[1] 1939 | assert(element.type === 'element') 1940 | const entry = element.children.pop() 1941 | 1942 | assert.deepEqual(entry, { 1943 | type: 'element', 1944 | name: 'entry', 1945 | attributes: {}, 1946 | children: [ 1947 | { 1948 | type: 'element', 1949 | name: 'title', 1950 | attributes: {}, 1951 | children: [{type: 'text', value: 'b'}] 1952 | }, 1953 | { 1954 | type: 'element', 1955 | name: 'author', 1956 | attributes: {}, 1957 | children: [ 1958 | { 1959 | type: 'element', 1960 | name: 'name', 1961 | attributes: {}, 1962 | children: [{type: 'text', value: 'c'}] 1963 | } 1964 | ] 1965 | } 1966 | ] 1967 | }) 1968 | } 1969 | ) 1970 | 1971 | await t.test( 1972 | 'should support an item w/ `author` as object w/ `email`', 1973 | async function () { 1974 | const root = atom({title: 'a', url: 'https://example.com'}, [ 1975 | {title: 'b', author: {name: 'c', email: 'd'}} 1976 | ]) 1977 | const element = root.children[1] 1978 | assert(element.type === 'element') 1979 | const entry = element.children.pop() 1980 | 1981 | assert.deepEqual(entry, { 1982 | type: 'element', 1983 | name: 'entry', 1984 | attributes: {}, 1985 | children: [ 1986 | { 1987 | type: 'element', 1988 | name: 'title', 1989 | attributes: {}, 1990 | children: [{type: 'text', value: 'b'}] 1991 | }, 1992 | { 1993 | type: 'element', 1994 | name: 'author', 1995 | attributes: {}, 1996 | children: [ 1997 | { 1998 | type: 'element', 1999 | name: 'name', 2000 | attributes: {}, 2001 | children: [{type: 'text', value: 'c'}] 2002 | }, 2003 | { 2004 | type: 'element', 2005 | name: 'email', 2006 | attributes: {}, 2007 | children: [{type: 'text', value: 'd'}] 2008 | } 2009 | ] 2010 | } 2011 | ] 2012 | }) 2013 | } 2014 | ) 2015 | 2016 | await t.test('should throw on author w/ incorrect `url`', async function () { 2017 | assert.throws(function () { 2018 | atom({title: 'a', url: 'https://example.com'}, [ 2019 | {title: 'b', author: {name: 'c', url: 'd'}} 2020 | ]) 2021 | }, /Invalid URL/) 2022 | }) 2023 | 2024 | await t.test( 2025 | 'should support an item w/ `author` as object w/ `url`', 2026 | async function () { 2027 | const root = atom({title: 'a', url: 'https://example.com'}, [ 2028 | {title: 'b', author: {name: 'c', url: 'https://example.org'}} 2029 | ]) 2030 | const element = root.children[1] 2031 | assert(element.type === 'element') 2032 | const entry = element.children.pop() 2033 | 2034 | assert.deepEqual(entry, { 2035 | type: 'element', 2036 | name: 'entry', 2037 | attributes: {}, 2038 | children: [ 2039 | { 2040 | type: 'element', 2041 | name: 'title', 2042 | attributes: {}, 2043 | children: [{type: 'text', value: 'b'}] 2044 | }, 2045 | { 2046 | type: 'element', 2047 | name: 'author', 2048 | attributes: {}, 2049 | children: [ 2050 | { 2051 | type: 'element', 2052 | name: 'name', 2053 | attributes: {}, 2054 | children: [{type: 'text', value: 'c'}] 2055 | }, 2056 | { 2057 | type: 'element', 2058 | name: 'uri', 2059 | attributes: {}, 2060 | children: [{type: 'text', value: 'https://example.org/'}] 2061 | } 2062 | ] 2063 | } 2064 | ] 2065 | }) 2066 | } 2067 | ) 2068 | 2069 | await t.test('should support an item w/ `link`', async function () { 2070 | const root = atom({title: 'a', author: 'b', url: 'https://example.com'}, [ 2071 | {title: 'c', url: 'https://example.com/b.html'} 2072 | ]) 2073 | const element = root.children[1] 2074 | assert(element.type === 'element') 2075 | const entry = element.children.pop() 2076 | 2077 | assert.deepEqual(entry, { 2078 | type: 'element', 2079 | name: 'entry', 2080 | attributes: {}, 2081 | children: [ 2082 | { 2083 | type: 'element', 2084 | name: 'title', 2085 | attributes: {}, 2086 | children: [{type: 'text', value: 'c'}] 2087 | }, 2088 | { 2089 | type: 'element', 2090 | name: 'link', 2091 | attributes: {href: 'https://example.com/b.html'}, 2092 | children: [] 2093 | }, 2094 | { 2095 | type: 'element', 2096 | name: 'id', 2097 | attributes: {}, 2098 | children: [{type: 'text', value: 'https://example.com/b.html'}] 2099 | } 2100 | ] 2101 | }) 2102 | }) 2103 | 2104 | await t.test('should support an item w/ `tags`', async function () { 2105 | const root = atom({title: 'a', author: 'b', url: 'https://example.com'}, [ 2106 | {title: 'c', tags: ['x', 'y']} 2107 | ]) 2108 | const element = root.children[1] 2109 | assert(element.type === 'element') 2110 | const entry = element.children.pop() 2111 | 2112 | assert.deepEqual(entry, { 2113 | type: 'element', 2114 | name: 'entry', 2115 | attributes: {}, 2116 | children: [ 2117 | { 2118 | type: 'element', 2119 | name: 'title', 2120 | attributes: {}, 2121 | children: [{type: 'text', value: 'c'}] 2122 | }, 2123 | { 2124 | type: 'element', 2125 | name: 'category', 2126 | attributes: {term: 'x'}, 2127 | children: [] 2128 | }, 2129 | { 2130 | type: 'element', 2131 | name: 'category', 2132 | attributes: {term: 'y'}, 2133 | children: [] 2134 | } 2135 | ] 2136 | }) 2137 | }) 2138 | 2139 | await t.test('should support an item w/ `published`', async function () { 2140 | const root = atom({title: 'a', author: 'b', url: 'https://example.com'}, [ 2141 | {title: 'c', published: 1_231_111_111_111} 2142 | ]) 2143 | const element = root.children[1] 2144 | assert(element.type === 'element') 2145 | const entry = element.children.pop() 2146 | 2147 | assert.deepEqual(entry, { 2148 | type: 'element', 2149 | name: 'entry', 2150 | attributes: {}, 2151 | children: [ 2152 | { 2153 | type: 'element', 2154 | name: 'title', 2155 | attributes: {}, 2156 | children: [{type: 'text', value: 'c'}] 2157 | }, 2158 | { 2159 | type: 'element', 2160 | name: 'published', 2161 | attributes: {}, 2162 | children: [{type: 'text', value: '2009-01-04T23:18:31.111Z'}] 2163 | } 2164 | ] 2165 | }) 2166 | }) 2167 | 2168 | await t.test('should support an item w/ `modified`', async function () { 2169 | const root = atom({title: 'a', author: 'b', url: 'https://example.com'}, [ 2170 | {title: 'c', modified: 1_231_111_111_111} 2171 | ]) 2172 | const element = root.children[1] 2173 | assert(element.type === 'element') 2174 | const entry = element.children.pop() 2175 | 2176 | assert.deepEqual(entry, { 2177 | type: 'element', 2178 | name: 'entry', 2179 | attributes: {}, 2180 | children: [ 2181 | { 2182 | type: 'element', 2183 | name: 'title', 2184 | attributes: {}, 2185 | children: [{type: 'text', value: 'c'}] 2186 | }, 2187 | { 2188 | type: 'element', 2189 | name: 'updated', 2190 | attributes: {}, 2191 | children: [{type: 'text', value: '2009-01-04T23:18:31.111Z'}] 2192 | } 2193 | ] 2194 | }) 2195 | }) 2196 | 2197 | await t.test('should throw on enclosure w/o `url`', async function () { 2198 | assert.throws(function () { 2199 | atom({title: 'a', author: 'b', url: 'https://example.com'}, [ 2200 | { 2201 | title: 'c', 2202 | // @ts-expect-error: check how the runtime handles missing `enclosure.url`. 2203 | enclosure: {} 2204 | } 2205 | ]) 2206 | }, /Expected either `enclosure.url` to be set in entry `0`/) 2207 | }) 2208 | 2209 | await t.test('should throw on enclosure w/o `size`', async function () { 2210 | assert.throws(function () { 2211 | atom({title: 'a', author: 'b', url: 'https://example.com'}, [ 2212 | { 2213 | title: 'c', 2214 | // @ts-expect-error: check how the runtime handles missing `enclosure.size`. 2215 | enclosure: {url: 'd'} 2216 | } 2217 | ]) 2218 | }, /Expected either `enclosure.size` to be set in entry `0`/) 2219 | }) 2220 | 2221 | await t.test('should throw on enclosure w/o `type`', async function () { 2222 | assert.throws(function () { 2223 | atom({title: 'a', author: 'b', url: 'https://example.com'}, [ 2224 | { 2225 | title: 'c', 2226 | // @ts-expect-error: check how the runtime handles missing `enclosure.type`. 2227 | enclosure: {url: 'd', size: 1} 2228 | } 2229 | ]) 2230 | }, /Expected either `enclosure.type` to be set in entry `0`/) 2231 | }) 2232 | 2233 | await t.test( 2234 | 'should throw on incorrect `url` in enclosure', 2235 | async function () { 2236 | assert.throws(function () { 2237 | atom({title: 'a', author: 'b', url: 'https://example.com'}, [ 2238 | {title: 'c', enclosure: {url: 'd', size: 1, type: 'e'}} 2239 | ]) 2240 | }, /Invalid URL/) 2241 | } 2242 | ) 2243 | 2244 | await t.test('should support an item w/ `enclosure`', async function () { 2245 | const root = atom({title: 'a', author: 'b', url: 'https://example.com'}, [ 2246 | { 2247 | title: 'c', 2248 | enclosure: { 2249 | url: 'https://example.com/123.png', 2250 | size: 1, 2251 | type: 'image/png' 2252 | } 2253 | } 2254 | ]) 2255 | const element = root.children[1] 2256 | assert(element.type === 'element') 2257 | const entry = element.children.pop() 2258 | 2259 | assert.deepEqual(entry, { 2260 | type: 'element', 2261 | name: 'entry', 2262 | attributes: {}, 2263 | children: [ 2264 | { 2265 | type: 'element', 2266 | name: 'title', 2267 | attributes: {}, 2268 | children: [{type: 'text', value: 'c'}] 2269 | }, 2270 | { 2271 | type: 'element', 2272 | name: 'link', 2273 | attributes: { 2274 | rel: 'enclosure', 2275 | href: 'https://example.com/123.png', 2276 | length: '1', 2277 | type: 'image/png' 2278 | }, 2279 | children: [] 2280 | } 2281 | ] 2282 | }) 2283 | }) 2284 | }) 2285 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "checkJs": true, 4 | "customConditions": ["development"], 5 | "declaration": true, 6 | "emitDeclarationOnly": true, 7 | "exactOptionalPropertyTypes": true, 8 | "lib": ["es2022"], 9 | "module": "node16", 10 | "strict": true, 11 | "target": "es2022" 12 | }, 13 | "exclude": ["coverage/", "node_modules/"], 14 | "include": ["**/*.js"] 15 | } 16 | --------------------------------------------------------------------------------