├── .gitignore ├── .size-snapshot.json ├── CHANGELOG.md ├── README.md ├── package.json ├── src ├── buildFeed.ts ├── feed.ts ├── formatTime.ts ├── index.ts ├── rss2.ts └── types │ ├── Author.ts │ ├── Episode.ts │ └── index.ts ├── test └── blah.test.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .rts2_cache_cjs 5 | .rts2_cache_es 6 | .rts2_cache_umd 7 | dist 8 | -------------------------------------------------------------------------------- /.size-snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "/Users/swyx/Work/podcats2/podcats/dist/podcats.cjs.development.js": { 3 | "bundled": 14041, 4 | "minified": 5109, 5 | "gzipped": 2226 6 | }, 7 | "/Users/swyx/Work/podcats2/podcats/dist/podcats.umd.development.js": { 8 | "bundled": 15040, 9 | "minified": 4921, 10 | "gzipped": 2211 11 | }, 12 | "/Users/swyx/Work/podcats2/podcats/dist/podcats.cjs.production.js": { 13 | "bundled": 14041, 14 | "minified": 5109, 15 | "gzipped": 2226 16 | }, 17 | "/Users/swyx/Work/podcats2/podcats/dist/podcats.umd.production.js": { 18 | "bundled": 15040, 19 | "minified": 4921, 20 | "gzipped": 2211 21 | }, 22 | "/Users/swyx/Work/podcats2/podcats/dist/podcats.es.production.js": { 23 | "bundled": 13837, 24 | "minified": 4943, 25 | "gzipped": 2162, 26 | "treeshaked": { 27 | "rollup": { 28 | "code": 192, 29 | "import_statements": 12 30 | }, 31 | "webpack": { 32 | "code": 1356 33 | } 34 | } 35 | }, 36 | "/Users/jared/workspace/github/jaredpalmer/podcats/dist/podcats.umd.development.js": { 37 | "bundled": 14660, 38 | "minified": 4751, 39 | "gzipped": 2127 40 | }, 41 | "/Users/jared/workspace/github/jaredpalmer/podcats/dist/podcats.cjs.development.js": { 42 | "bundled": 13681, 43 | "minified": 4918, 44 | "gzipped": 2133 45 | }, 46 | "/Users/jared/workspace/github/jaredpalmer/podcats/dist/podcats.umd.production.js": { 47 | "bundled": 14660, 48 | "minified": 4751, 49 | "gzipped": 2127 50 | }, 51 | "/Users/jared/workspace/github/jaredpalmer/podcats/dist/podcats.cjs.production.js": { 52 | "bundled": 13681, 53 | "minified": 4918, 54 | "gzipped": 2133 55 | }, 56 | "/Users/jared/workspace/github/jaredpalmer/podcats/dist/podcats.es.production.js": { 57 | "bundled": 13477, 58 | "minified": 4752, 59 | "gzipped": 2069, 60 | "treeshaked": { 61 | "rollup": { 62 | "code": 192, 63 | "import_statements": 12 64 | }, 65 | "webpack": { 66 | "code": 1356 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). 9 | 10 | ## [v0.1.13](https://github.com/sw-yx/podcats/compare/v0.1.12...v0.1.13) - 2019-05-22 11 | 12 | Burning one because `np` doesn't publish properly with yarn. 13 | 14 | ## [v0.1.12](https://github.com/sw-yx/podcats/compare/v0.1.11...v0.1.12) - 2019-05-22 15 | 16 | ### Commits 17 | 18 | - Added repository info to package.json [`5d6951b`](https://github.com/sw-yx/podcats/commit/5d6951b469c94fcdf9ef56598c186515baf07f43) 19 | 20 | ## [v0.1.11](https://github.com/sw-yx/podcats/compare/v0.1.10...v0.1.11) - 2019-05-22 21 | 22 | ### Merged 23 | 24 | - Ran prettier, removed failing test, updated deps [`#6`](https://github.com/sw-yx/podcats/pull/6) 25 | - Fixed weird bug with invariant function [`#5`](https://github.com/sw-yx/podcats/pull/5) 26 | 27 | ### Commits 28 | 29 | - Undid accidental prettier changes [`c79dfff`](https://github.com/sw-yx/podcats/commit/c79dfff0a2553a23f38b11d35882a6728ec4c721) 30 | 31 | ## [v0.1.10](https://github.com/sw-yx/podcats/compare/v0.1.9...v0.1.10) - 2019-05-21 32 | 33 | ### Merged 34 | 35 | - Changed podtrac option to generic decorateURL option [`#4`](https://github.com/sw-yx/podcats/pull/4) 36 | 37 | ### Commits 38 | 39 | - add changelog [`58bd1d2`](https://github.com/sw-yx/podcats/commit/58bd1d2132b7736efe7c96f4a5eb8df492ade6b9) 40 | 41 | ## [v0.1.9](https://github.com/sw-yx/podcats/compare/v0.1.8...v0.1.9) - 2019-03-21 42 | 43 | ### Merged 44 | 45 | - Allowed podtrac prefixing of mp3 urls [`#3`](https://github.com/sw-yx/podcats/pull/3) 46 | 47 | ### Commits 48 | 49 | - snapshot [`308dbc6`](https://github.com/sw-yx/podcats/commit/308dbc6cd27a23a35314c00fcb2b931222022d66) 50 | - snapshot [`a569750`](https://github.com/sw-yx/podcats/commit/a5697506ea4969a696210d88a9bdd063048288cb) 51 | 52 | ## [v0.1.8](https://github.com/sw-yx/podcats/compare/v0.1.7...v0.1.8) - 2019-02-04 53 | 54 | ### Commits 55 | 56 | - fix slug screwup [`2303c09`](https://github.com/sw-yx/podcats/commit/2303c095178b9ba56bc4dc8aa23d6684ee6075a7) 57 | 58 | ## [v0.1.7](https://github.com/sw-yx/podcats/compare/v0.1.6...v0.1.7) - 2019-01-31 59 | 60 | ### Merged 61 | 62 | - Bump tsdx and ensure build occurs before publish [`#2`](https://github.com/sw-yx/podcats/pull/2) 63 | 64 | ### Commits 65 | 66 | - readme [`af6f9c3`](https://github.com/sw-yx/podcats/commit/af6f9c333295fb174c5f3636f9bfd750d52c7a6d) 67 | 68 | ## [v0.1.6](https://github.com/sw-yx/podcats/compare/v0.1.5...v0.1.6) - 2019-01-31 69 | 70 | ### Commits 71 | 72 | - main still wrong [`bb1b149`](https://github.com/sw-yx/podcats/commit/bb1b149ba9b1a26e7d89aea6ebf8f8e09922f599) 73 | 74 | ## v0.1.5 - 2019-01-31 75 | 76 | ### Commits 77 | 78 | - init [`3b59617`](https://github.com/sw-yx/podcats/commit/3b596173f6c99969e03f99740ab5066b658c2501) 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Podcats 2 | 3 | Make Podcast feeds with Typescript. 😺 4 | 5 | ![image](https://user-images.githubusercontent.com/6764957/51956942-ac830600-23ed-11e9-8576-20afa4e7ea7b.png) 6 | 7 | See this in use at: https://github.com/sw-yx/react-static-podcast-hosting 8 | 9 | ## install 10 | 11 | ``` 12 | yarn add -D podcats 13 | ``` 14 | 15 | ## important assumptions 16 | 17 | this library assumes that you have a unique markdown file pointed to every podcast episode that supplies all the metadata for the rss feed. 18 | 19 | The markdown file has dual purpose - its frontmatter and body content is used for both generating your podcast's static site (which of course you dont have to use if you really dont want to), AND to write your podcast RSS (including show notes!). 20 | 21 |
22 | Expected Markdown + mp3 format 23 | 24 | 25 | Markdown content is in `/content/week0.md` 26 | 27 | ``` 28 | --- 29 | title: YOUR TITLE HERE 30 | episode: 0 31 | date: 2019-01-06 32 | mp3URL: episodes/week0.mp3 33 | description: the first episode 34 | --- 35 | 36 | YOUR SHOW NOTES/BLOGPOST HERE 37 | ``` 38 | 39 | and the mp3 should be in a folder that would correspond to the `mp3URL` path, e.g. `/public/episodes/week0.mp3` 40 | 41 | Again, see https://github.com/sw-yx/react-static-podcast-hosting for live deployed example. 42 | 43 |
44 | 45 | ## Public APIs 46 | 47 | **grabContents** 48 | 49 | pass it an array of paths to your markdown files (see the assumptions above). No path resolution is done for you so be sure to do your own as demonstrated in the example. 50 | 51 | ```ts 52 | import { grabContents } from 'podcats'; 53 | 54 | const myURL = 'https://yourpodcastsitehere.netlify.com'; 55 | const contentFolder = 'content'; // my markdown content is hosted at './content' 56 | const filenames = fs.readdirSync(contentFolder).reverse(); 57 | const filepaths: string[] = filenames.map(file => 58 | path.join(process.cwd(), contentFolder, file) 59 | ); 60 | const contents = grabContents(filepaths, myURL); 61 | ``` 62 | 63 | **buildFeed** 64 | 65 | > ⚠️ For now it requires the result of `contents` from `grabContents()` above 66 | 67 | pass in a whole lot of configs (examples below), and get back a promise which returns a `Feed` object. call its `rss2()` method to output a string to write to a file (or respond in your Express server if you still do that sort of thing) 68 | 69 | ```ts 70 | import { buildFeed, Author, FeedOptions, ITunesChannelFields } from 'podcats'; 71 | 72 | const myURL = 'https://yourpodcastsitehere.netlify.com'; 73 | const author: Author = { 74 | name: 'REACTSTATICPODCAST_AUTHOR_NAME', 75 | email: 'REACTSTATICPODCAST_AUTHOR_EMAIL@foo.com', 76 | link: 'https://REACTSTATICPODCAST_AUTHOR_LINK.com' 77 | }; 78 | const feedOptions: FeedOptions = { 79 | // blog feed options 80 | title: 'React Static Podcast', 81 | description: 82 | 'a podcast feed and blog generator in React and hosted on Netlify', 83 | link: myURL, 84 | id: myURL, 85 | copyright: 'copyright REACTSTATICPODCAST_YOURNAMEHERE', 86 | feedLinks: { 87 | atom: safeJoin(myURL, 'atom.xml'), 88 | json: safeJoin(myURL, 'feed.json'), 89 | rss: safeJoin(myURL, 'rss') 90 | }, 91 | author 92 | }; 93 | const iTunesChannelFields: ITunesChannelFields = { 94 | // itunes options 95 | summary: 'REACTSTATICPODCAST_SUMMARY_HERE', 96 | author: author.name, 97 | keywords: ['Technology'], 98 | categories: [ 99 | { cat: 'Technology' }, 100 | { cat: 'Technology', child: 'Tech News' } 101 | ], 102 | image: 'https://placekitten.com/g/1400/1400', // TODO: itunes cover art. you should customise this! 103 | explicit: false, 104 | owner: author, 105 | type: 'episodic' 106 | }; 107 | 108 | // usage example inside async function 109 | async () => { 110 | let feed = await buildFeed( 111 | contents, 112 | myURL, 113 | author, 114 | feedOptions, 115 | iTunesChannelFields 116 | ); 117 | writeToFile('/public/rss/index.xml', feed.rss2()); 118 | }; 119 | ``` 120 | 121 | ## Exported Types 122 | 123 | Many types have comments annotations so that they should pop up inline in your IDE. However they aren't complete and can always be better. happy to take PR's... 124 | 125 | ```ts 126 | export type Episode = { 127 | frontmatter: EpisodeFrontMatter; 128 | body: string; 129 | }; 130 | export type EpisodeFrontMatter = { 131 | title: string; 132 | mp3URL: string; 133 | date: string; 134 | description: string; 135 | episodeType?: 'full' | 'trailer' | 'bonus'; 136 | episode?: number; 137 | season?: number; 138 | slug?: string; 139 | }; 140 | export type Author = { 141 | name: string; 142 | email: string; 143 | link: string; 144 | }; 145 | export type ITunesChannelFields = { 146 | block?: boolean; 147 | summary: string; 148 | author: string; 149 | keywords: string[]; 150 | categories: ITunesCategory[]; 151 | image: string; 152 | explicit: boolean; 153 | owner: ITunesOwner; 154 | subtitle?: string; 155 | type: 'episodic' | 'serial'; 156 | }; 157 | ``` 158 | 159 | ## TSDX Bootstrap 160 | 161 | This project was bootstrapped with [TSDX](https://github.com/jaredpalmer/tsdx) v0.3.0. This is beta software, don't rely on it yet. 162 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "podcats", 3 | "version": "0.1.13", 4 | "main": "dist/index.js", 5 | "umd:main": "dist/podcats.umd.production.js", 6 | "module": "dist/podcats.es.production.js", 7 | "typings": "dist/index.d.ts", 8 | "files": [ 9 | "dist" 10 | ], 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/sw-yx/podcats" 14 | }, 15 | "scripts": { 16 | "start": "tsdx watch", 17 | "build": "tsdx build", 18 | "prepare": "tsdx build", 19 | "test": "tsdx test", 20 | "version": "auto-changelog -p --template keepachangelog && git add CHANGELOG.md", 21 | "prepublishOnly": "git push && git push --tags && gh-release" 22 | }, 23 | "husky": { 24 | "hooks": { 25 | "pre-commit": "pretty-quick --staged" 26 | } 27 | }, 28 | "prettier": { 29 | "printWidth": 100, 30 | "semi": false, 31 | "singleQuote": true, 32 | "trailingComma": "es5" 33 | }, 34 | "devDependencies": { 35 | "@types/jest": "^23.3.13", 36 | "@types/xml": "^1.0.2", 37 | "auto-changelog": "^1.13.0", 38 | "gh-release": "^3.5.0", 39 | "husky": "^2.3.0", 40 | "prettier": "^1.17.1", 41 | "pretty-quick": "^1.11.0", 42 | "tsdx": "^0.5.11", 43 | "typescript": "^3.2.4", 44 | "xml": "^1.0.1" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/buildFeed.ts: -------------------------------------------------------------------------------- 1 | import { Feed } from './feed' 2 | const fs = require('fs') 3 | const path = require('path') 4 | const markdownIt = require('markdown-it') 5 | const frontmatter = require('front-matter') 6 | const { parse } = require('date-fns') 7 | const mp3Duration = require('mp3-duration') 8 | import { EpisodeFrontMatter, Author, FeedOptions, ITunesChannelFields } from './types' 9 | 10 | //markdownIt is a markdown parser that takes my raw md files and 11 | //translates them into HTML that we can use in the feed 12 | const md = markdownIt({ 13 | html: true, 14 | linkify: true, 15 | typographer: true, 16 | }) 17 | 18 | // synchronously grab contents - a separate process because buildFeed needs to be async 19 | export const grabContents = (filepaths: string[], myURL: string) => { 20 | return filepaths.map(filepath => { 21 | let { attributes, body } = frontmatter(fs.readFileSync(filepath, 'utf-8')) as { 22 | attributes: EpisodeFrontMatter 23 | body: string 24 | } 25 | attributes.slug = filepath 26 | .split('/') 27 | .slice(-1)[0] 28 | .split('.')[0] // todo: slugify 29 | // handle local links 30 | body = md.render(body) 31 | body = body.replace(/src="\//g, `src="${myURL}/`) 32 | const mp3path = path.join(process.cwd(), 'public', attributes.mp3URL) as string 33 | return { 34 | frontmatter: attributes, 35 | body, 36 | mp3path, 37 | filepath, 38 | } 39 | }) 40 | } 41 | 42 | // build feed is our main function to build a `Feed` object which we 43 | // can then serialize into various formats 44 | // USER: Customize to your own details 45 | export const buildFeed = async ( 46 | contents: ReturnType, 47 | myURL: string, 48 | author: Author, 49 | feedOptions: FeedOptions, 50 | iTunesChannelFields: ITunesChannelFields 51 | ) => { 52 | let feed = new Feed(feedOptions, iTunesChannelFields) 53 | feed.addContributor(author) 54 | 55 | await Promise.all( 56 | contents.map(async ({ frontmatter: fm, body, mp3path, filepath }) => { 57 | let decoratedMp3URL = feedOptions.decorateURL 58 | ? feedOptions.decorateURL(safeJoin(myURL, fm.mp3URL)) 59 | : safeJoin(myURL, fm.mp3URL) 60 | feed.addItem({ 61 | title: fm.title, 62 | id: safeJoin(myURL, filepath), 63 | link: decoratedMp3URL, 64 | date: parse(fm.date), 65 | content: body, 66 | author: [author], 67 | description: body, 68 | itunes: { 69 | // image: // up to you to configure but per-episode image is possible 70 | duration: await mp3Duration(mp3path, (err: any) => err && console.error(err.message)), 71 | // explicit: false, // optional 72 | // keywords: string[] // per-episode keywords possible 73 | subtitle: fm.description, 74 | episodeType: fm.episodeType || 'full', 75 | episode: fm.episode, 76 | season: fm.season, 77 | contentEncoded: body, 78 | mp3URL: decoratedMp3URL, 79 | enclosureLength: fs.statSync(mp3path).size, // size in bytes 80 | }, 81 | }) 82 | return { 83 | frontmatter: fm, 84 | body, 85 | } 86 | }) 87 | ) 88 | 89 | return feed 90 | } 91 | 92 | function safeJoin(a: string, b: string) { 93 | /** strip starting/leading slashes and only use our own */ 94 | let a1 = a.slice(-1) === '/' ? a.slice(0, a.length - 1) : a 95 | let b1 = b.slice(0) === '/' ? b.slice(1) : b 96 | return `${a1}/${b1}` 97 | } 98 | -------------------------------------------------------------------------------- /src/feed.ts: -------------------------------------------------------------------------------- 1 | // adapted from https://github.com/jpmonette/feed 2 | 3 | import { 4 | FeedOptions, 5 | Item, 6 | Author, 7 | Extension, 8 | // ITunesCategory, 9 | // ITunesOwner, 10 | ITunesChannelFields, 11 | } from './types' 12 | // import renderAtom from './atom1' 13 | // import renderJSON from './json' 14 | import renderRSS from './rss2' 15 | 16 | export class Feed { 17 | options: FeedOptions 18 | IToptions: ITunesChannelFields 19 | items: Item[] = [] 20 | categories: string[] = [] 21 | contributors: Author[] = [] 22 | extensions: Extension[] = [] 23 | 24 | constructor(options: FeedOptions, IToptions: ITunesChannelFields) { 25 | this.options = options 26 | this.IToptions = IToptions 27 | } 28 | 29 | public addItem = (item: Item) => this.items.push(item) 30 | 31 | public addCategory = (category: string) => this.categories.push(category) 32 | 33 | public addContributor = (contributor: Author) => this.contributors.push(contributor) 34 | 35 | public addExtension = (extension: Extension) => this.extensions.push(extension) 36 | 37 | // /** 38 | // * Returns a Atom 1.0 feed 39 | // */ 40 | // public atom1 = (): string => renderAtom(this) 41 | 42 | /** 43 | * Returns a RSS 2.0 feed 44 | */ 45 | public rss2 = (): string => renderRSS(this) 46 | 47 | // /** 48 | // * Returns a JSON1 feed 49 | // */ 50 | // public json1 = (): string => renderJSON(this) 51 | } 52 | -------------------------------------------------------------------------------- /src/formatTime.ts: -------------------------------------------------------------------------------- 1 | export function formatTime(timeInSeconds: number) { 2 | const hours = Math.floor(timeInSeconds / (60 * 60)) 3 | timeInSeconds -= hours * 60 * 60 4 | const minutes = Math.floor(timeInSeconds / 60) 5 | timeInSeconds -= minutes * 60 6 | 7 | // left pad number with 0 8 | const leftPad = (num: number) => `${num}`.padStart(2, '0') 9 | const str = 10 | (hours ? `${leftPad(hours)}:` : '') + 11 | // (minutes ? `${leftPad(minutes)}:` : '00') + 12 | `${leftPad(minutes)}:` + 13 | leftPad(Math.round(timeInSeconds)) 14 | return str 15 | } 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './buildFeed' 2 | export * from './types' 3 | export * from './formatTime' 4 | -------------------------------------------------------------------------------- /src/rss2.ts: -------------------------------------------------------------------------------- 1 | // adapted from https://github.com/jpmonette/feed 2 | import { Item, Author } from './types' 3 | import xml from 'xml' 4 | import { Feed } from './feed' 5 | import { formatTime } from './formatTime' 6 | 7 | const DOCTYPE = '\n' 8 | const CDATA = (foo: string) => ({ 9 | _cdata: foo, 10 | }) 11 | export default (ins: Feed) => { 12 | const { options, IToptions } = ins 13 | let isAtom = false 14 | let isContent = false 15 | 16 | type ITField = { 17 | [x: string]: string | object | number 18 | } 19 | const makeITunesField = ( 20 | field: string, 21 | content: string | object | number, 22 | text?: string, 23 | href?: string 24 | // child?: ITField, 25 | ) => { 26 | if (!(content || text || href)) return undefined 27 | const record: ITField = { ['itunes:' + field]: content } 28 | record._attr = { href, text } 29 | return record 30 | } 31 | 32 | const categories = IToptions.categories.map((category: { cat: string; child?: string }) => { 33 | let finalItem 34 | if (category.child) { 35 | finalItem = [ 36 | { 37 | _attr: { 38 | text: category.cat, 39 | }, 40 | }, 41 | { 42 | 'itunes:category': { 43 | _attr: { 44 | text: category.child, 45 | }, 46 | }, 47 | }, 48 | ] 49 | } else { 50 | finalItem = { 51 | _attr: { 52 | text: category.cat, 53 | }, 54 | } 55 | } 56 | return { 57 | 'itunes:category': finalItem, 58 | } 59 | }) 60 | // console.log({ categories }) 61 | const ITchannel: any = [ 62 | makeITunesField('summary', IToptions.summary), 63 | makeITunesField('author', IToptions.author), 64 | makeITunesField('keywords', IToptions.keywords.join(',')), 65 | ...categories, 66 | { 'itunes:image': { _attr: { href: IToptions.image } } }, 67 | makeITunesField('explicit', IToptions.explicit ? 'yes' : 'clean'), // TODO: check strings 68 | { 69 | 'itunes:owner': [ 70 | makeITunesField('name', CDATA(IToptions.owner.name)), 71 | makeITunesField('email', IToptions.owner.email), 72 | ], 73 | }, 74 | // subtitle 75 | makeITunesField('type', IToptions.type), 76 | ] 77 | const channel: any = [ 78 | // // SWYX: disabled for duplication 79 | // { 80 | // 'atom:link': { 81 | // _attr: { 82 | // href: myInvariant( 83 | // options.feedLinks, 84 | // 'rss', 85 | // 'missing in your feed channel config options', 86 | // ), 87 | // rel: 'self', 88 | // type: 'application/rss+xml', 89 | // }, 90 | // }, 91 | // }, 92 | { title: options.title }, 93 | { link: options.link }, 94 | { language: 'en' }, 95 | { description: options.description }, 96 | { managingEditor: `${options.author.email} (${options.author.email})` }, 97 | { 98 | pubDate: options.updated // TODO: use actual last pub date 99 | ? options.updated.toUTCString() 100 | : new Date().toUTCString(), 101 | }, 102 | , 103 | { 104 | lastBuildDate: options.updated ? options.updated.toUTCString() : new Date().toUTCString(), 105 | }, 106 | { docs: options.link }, 107 | { 108 | generator: options.generator || 'https://github.com/sw-yx/react-static-typescript-starter', 109 | }, 110 | ...ITchannel, 111 | ] 112 | 113 | const rss: any[] = [{ _attr: { version: '2.0' } }, { channel }] 114 | 115 | /** 116 | * Channel Image 117 | * http://cyber.law.harvard.edu/rss/rss.html#ltimagegtSubelementOfLtchannelgt 118 | */ 119 | if (options.image) { 120 | channel.push({ 121 | image: [{ title: options.title }, { url: options.image }, { link: options.link }], 122 | }) 123 | } 124 | 125 | /** 126 | * Channel Copyright 127 | * http://cyber.law.harvard.edu/rss/rss.html#optionalChannelElements 128 | */ 129 | if (options.copyright) { 130 | channel.push({ copyright: options.copyright }) 131 | } 132 | 133 | /** 134 | * Channel Categories 135 | * http://cyber.law.harvard.edu/rss/rss.html#comments 136 | */ 137 | ins.categories.forEach(category => { 138 | channel.push({ category }) 139 | }) 140 | 141 | /** 142 | * Feed URL 143 | * http://validator.w3.org/feed/docs/warning/MissingAtomSelfLink.html 144 | */ 145 | const atomLink = options.feed || (options.feedLinks && options.feedLinks.atom) 146 | if (atomLink) { 147 | isAtom = true 148 | 149 | channel.push({ 150 | 'atom:link': { 151 | _attr: { 152 | href: atomLink, 153 | rel: 'self', 154 | type: 'application/rss+xml', 155 | }, 156 | }, 157 | }) 158 | } 159 | 160 | /** 161 | * Hub for PubSubHubbub 162 | * https://code.google.com/p/pubsubhubbub/ 163 | */ 164 | if (options.hub) { 165 | isAtom = true 166 | channel.push({ 167 | 'atom:link': { 168 | _attr: { 169 | href: options.hub, 170 | rel: 'hub', 171 | }, 172 | }, 173 | }) 174 | } 175 | 176 | /** 177 | * Channel Categories 178 | * http://cyber.law.harvard.edu/rss/rss.html#hrelementsOfLtitemgt 179 | */ 180 | ins.items.forEach((entry: Item) => { 181 | let item: any[] = [] 182 | 183 | if (entry.title) { 184 | item.push({ title: CDATA(entry.title) }) 185 | } 186 | if (entry.link) { 187 | item.push({ link: CDATA(entry.link) }) 188 | // item.push({ link: entry.link }) 189 | } 190 | if (entry.guid) { 191 | item.push({ guid: entry.guid }) 192 | } else if (entry.link) { 193 | item.push({ guid: entry.link }) 194 | } 195 | if (entry.date) { 196 | item.push({ pubDate: entry.date.toUTCString() }) 197 | } 198 | if (entry.description) { 199 | item.push({ description: CDATA(entry.description) }) 200 | } 201 | if (entry.content) { 202 | isContent = true 203 | item.push({ 'content:encoded': CDATA(entry.content) }) 204 | } 205 | /** 206 | * Item Author 207 | * http://cyber.law.harvard.edu/rss/rss.html#ltauthorgtSubelementOfLtitemgt 208 | */ 209 | if (Array.isArray(entry.author)) { 210 | entry.author.map((author: Author) => { 211 | if (author.email && author.name) { 212 | item.push({ author: author.email + ' (' + author.name + ')' }) 213 | } 214 | }) 215 | } 216 | 217 | if (entry.image) { 218 | item.push({ enclosure: [{ _attr: { url: entry.image } }] }) 219 | } 220 | 221 | const { itunes } = entry 222 | if (itunes) { 223 | // // SWYX: TEMPORARY DISABLE BECAUSE DUPLICATE 224 | // item.push( 225 | // makeITunesField( 226 | // 'image', 227 | // '', 228 | // undefined, 229 | // itunes.image || IToptions.image, 230 | // ), 231 | // ) 232 | 233 | // // SWYX: TEMPORARY DISABLE BECAUSE DUPLICATE 234 | // item.push({ 235 | // guid: { 236 | // _attr: { 237 | // isPermaLink: 'false', 238 | // }, 239 | // _cdata: myInvariant(itunes, 'mp3URL'), 240 | // }, 241 | // }) 242 | item.push({ 243 | enclosure: { 244 | _attr: { 245 | length: myInvariant(itunes, 'enclosureLength'), 246 | type: 'audio/mpeg', 247 | url: myInvariant(itunes, 'mp3URL'), 248 | }, 249 | }, 250 | }) 251 | // item.push( 252 | // makeITunesField('image', '', undefined, itunes.image || options.image), 253 | // ) 254 | item.push(makeITunesField('duration', formatTime(itunes.duration))) 255 | item.push(makeITunesField('explicit', itunes.explicit ? 'yes' : 'no')) 256 | if (itunes.keywords && itunes.keywords.length) 257 | item.push(makeITunesField('keywords', itunes.keywords.join(','))) 258 | item.push(makeITunesField('subtitle', CDATA(myInvariant(itunes, 'subtitle')))) 259 | item.push(makeITunesField('episodeType', itunes.episodeType)) 260 | if (itunes.episode) item.push(makeITunesField('episode', itunes.episode)) 261 | if (itunes.season) item.push(makeITunesField('season', itunes.season)) 262 | // // SWYX: temporary disable for duplication 263 | // item.push({ 'content:encoded': CDATA(itunes.contentEncoded) }) 264 | } 265 | channel.push({ item }) 266 | }) 267 | 268 | if (isContent) { 269 | rss[0]._attr['xmlns:content'] = 'http://purl.org/rss/1.0/modules/content/' 270 | } 271 | 272 | if (isAtom) { 273 | rss[0]._attr['xmlns:atom'] = 'http://www.w3.org/2005/Atom' 274 | } 275 | 276 | // rest 277 | rss[0]._attr['xmlns:cc'] = 'http://web.resource.org/cc/' 278 | rss[0]._attr['xmlns:itunes'] = 'http://www.itunes.com/dtds/podcast-1.0.dtd' 279 | rss[0]._attr['xmlns:media'] = 'http://search.yahoo.com/mrss/' 280 | rss[0]._attr['xmlns:rdf'] = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#' 281 | 282 | return DOCTYPE + xml([{ rss }], true) 283 | } 284 | 285 | /** 286 | * If this function is called invariant, it won't be called, as some 287 | * sort of Typescript version overrides it. Blew my mind debugging. 288 | * -@erikras, May 22, 2019 289 | */ 290 | function myInvariant(obj: { [index: string]: any }, key: string, msg?: string) { 291 | if (!obj[key]) { 292 | const errmsg = key + (msg || ' is missing from your frontmatter') 293 | console.error(errmsg + '\n ---- \n error found in', obj) 294 | throw new Error(errmsg) 295 | } 296 | return obj[key] 297 | } 298 | -------------------------------------------------------------------------------- /src/types/Author.ts: -------------------------------------------------------------------------------- 1 | export type Author = { 2 | name: string 3 | email: string 4 | /** 5 | * This tag contains the link to your website and will be displayed next to your Podcast cover art. 6 | * 7 | * https://feedforall.com/itune-tutorial-tags.htm#link 8 | */ 9 | link: string 10 | } 11 | -------------------------------------------------------------------------------- /src/types/Episode.ts: -------------------------------------------------------------------------------- 1 | export type Episode = { 2 | frontmatter: EpisodeFrontMatter 3 | body: string 4 | } 5 | export type EpisodeFrontMatter = { 6 | title: string 7 | mp3URL: string 8 | date: string 9 | description: string 10 | episodeType?: 'full' | 'trailer' | 'bonus' 11 | episode?: number 12 | season?: number 13 | slug?: string 14 | } 15 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Episode' 2 | import { Author } from './Author' 3 | export { Author } 4 | 5 | // from https://github.com/jpmonette/feed 6 | export interface Item { 7 | /** 8 | * (episode/item level): only the episode title—no episode number, season number, or show title. This can be used with any show and episode type. 9 | * 10 | * new 2017 tag: https://theaudacitytopodcast.com/how-to-start-using-the-new-itunes-podcast-tags-for-ios-11-tap316/ 11 | */ 12 | title: string 13 | id?: string 14 | /** 15 | * This tag contains the link to your website and will be displayed next to your Podcast cover art. 16 | * 17 | * https://feedforall.com/itune-tutorial-tags.htm#link 18 | */ 19 | link: string 20 | date: Date 21 | description?: string 22 | content?: string 23 | guid?: string 24 | image?: string 25 | author?: Author[] 26 | contributor?: Author[] 27 | published?: Date 28 | 29 | /** 30 | * This tag contains copyright information about your Podcast. 31 | * The tag is free text and can include dates, for example: Apple Computer 2005. 32 | * You do not need to include the copyright symbol in the tag, it will automatically be displayed in iTunes. 33 | * 34 | * https://feedforall.com/itune-tutorial-tags.htm#copyright 35 | */ 36 | copyright?: string 37 | extensions?: Extension[] 38 | itunes?: ITunesItem 39 | [index: string]: any 40 | } 41 | 42 | export interface ITunesItem { 43 | mp3URL: string 44 | enclosureLength: number 45 | /** 46 | * Use this inside an element to prevent that episode from appearing in the iTunes Podcast directory. Use this inside a element to prevent the entire podcast from appearing in the iTunes Podcast directory. 47 | * 48 | * https://feedforall.com/itune-tutorial-tags.htm#block 49 | */ 50 | block?: boolean 51 | /** 52 | * This tag specifies the artwork for the Channel and Item(s). This artwork can be larger than the maximum allowed by RSS. Details on the size recommendations are in the section below. 53 | * Preferred size: 54 | * 300 pixels x 300 pixels at 72 dpi 55 | * Minimum size: 56 | * 170 pixels x 170 pixels square at 72 dpi 57 | * Format: 58 | * JPG, PNG, uncompressed 59 | * 60 | * https://feedforall.com/itune-tutorial-tags.htm#image 61 | */ 62 | image?: string 63 | /** 64 | * This tag is for informational purposes only and will allow users to know the duration prior to download. 65 | * The tag is formatted: HH:MM:SS 66 | * This tag is applicable to the Item element only. 67 | * 68 | * https://feedforall.com/itune-tutorial-tags.htm#duration 69 | */ 70 | duration: number 71 | explicit?: boolean 72 | /** 73 | * This tag allows users to search on text keywords. 74 | * Limited to 255 characters or less, plain text, no HTML, words must be separated by spaces. 75 | * This tag is applicable to the Item element only. 76 | * 77 | * https://feedforall.com/itune-tutorial-tags.htm#keywords 78 | */ 79 | keywords?: string[] 80 | subtitle: string 81 | /** 82 | * (episode/item level): “full” for normal episodes; “trailer” to promote an upcoming show, season, or episode; or “bonus” for extra content related to a show, season, or episode. 83 | * 84 | * new 2017 tag: https://theaudacitytopodcast.com/how-to-start-using-the-new-itunes-podcast-tags-for-ios-11-tap316/ 85 | */ 86 | episodeType: 'full' | 'trailer' | 'bonus' 87 | /** 88 | * (episode/item level): any number to indicate the current episode number, which can be relative to the entire show (like “316”), or relative to the current season (like “5”). This can be used with any show and episode type. 89 | * 90 | * new 2017 tag: https://theaudacitytopodcast.com/how-to-start-using-the-new-itunes-podcast-tags-for-ios-11-tap316/ 91 | */ 92 | episode?: number 93 | /** 94 | * (episode/item level): any number to indicate the season in which this episode belongs. This can be used with any show and episode type. 95 | * 96 | * new 2017 tag: https://theaudacitytopodcast.com/how-to-start-using-the-new-itunes-podcast-tags-for-ios-11-tap316/ 97 | */ 98 | 99 | season?: number 100 | /** 101 | * (episode/item level): this updated (but not new) tag is for your full show notes. It will display below the title and summary. 102 | * 103 | * new 2017 tag: https://theaudacitytopodcast.com/how-to-start-using-the-new-itunes-podcast-tags-for-ios-11-tap316/ 104 | * 105 | */ 106 | contentEncoded?: string 107 | } 108 | 109 | export interface FeedOptions { 110 | id: string 111 | title: string 112 | updated?: Date 113 | generator?: string 114 | 115 | feed?: string 116 | feedLinks?: any 117 | hub?: string 118 | decorateURL?: (url: string) => string 119 | 120 | author: Author 121 | 122 | /** 123 | * This tag contains the link to your website and will be displayed next to your Podcast cover art. 124 | * 125 | * https://feedforall.com/itune-tutorial-tags.htm#link 126 | */ 127 | link?: string 128 | description?: string 129 | image?: string 130 | favicon?: string 131 | /** 132 | * This tag contains copyright information about your Podcast. 133 | * The tag is free text and can include dates, for example: Apple Computer 2005. 134 | * You do not need to include the copyright symbol in the tag, it will automatically be displayed in iTunes. 135 | * 136 | * https://feedforall.com/itune-tutorial-tags.htm#copyright 137 | */ 138 | copyright: string 139 | } 140 | 141 | export interface Feed { 142 | options: FeedOptions 143 | items: Item[] 144 | categories: string[] 145 | contributors: Author[] 146 | extensions: Extension[] 147 | } 148 | 149 | export interface Extension { 150 | name: string 151 | objects: string 152 | } 153 | 154 | export type ITunesCategory = { 155 | cat: string 156 | child?: string 157 | } 158 | /** 159 | * This tag contains the e-mail address that will be used to contact the owner of the Podcast for communication specifically about their Podcast on iTunes. 160 | * It will not be publicly displayed on iTunes. 161 | * This tag is applicable to the Channel element only. 162 | * 163 | * https://feedforall.com/itune-tutorial-tags.htm#owner 164 | */ 165 | export type ITunesOwner = { 166 | name: string 167 | email: string 168 | } 169 | export type ITunesChannelFields = { 170 | /** 171 | * Use this inside an element to prevent that episode from appearing in the iTunes Podcast directory. Use this inside a element to prevent the entire podcast from appearing in the iTunes Podcast directory. 172 | * 173 | * https://feedforall.com/itune-tutorial-tags.htm#block 174 | */ 175 | block?: boolean 176 | /** 177 | * At the Channel level, this tag is a long description that will appear next to your Podcast cover art when a user selects your Podcast. 178 | * At the Item level, this tag is a long description that will be displayed in an expanded window when users click on an episode. 179 | * Limited to 4000 characters or less, plain text, no HTML 180 | * 181 | * 2017 update: (episode/item level): this updated (but not new) tag is best for a short description of your episode. It will display above the full show notes. 182 | * 183 | * https://feedforall.com/itune-tutorial-tags.htm#summary 184 | */ 185 | summary: string 186 | /** 187 | * At the Channel level this tag contains the name of the person or company that is most widely attributed to publishing the Podcast and will be displayed immediately underneath the title of the Podcast. 188 | * If applicable, at the item level, this tag can contain information about the person(s) featured on a specific episode. 189 | * 190 | * https://feedforall.com/itune-tutorial-tags.htm#author 191 | */ 192 | author: string 193 | /** 194 | * This tag allows users to search on text keywords. 195 | * Limited to 255 characters or less, plain text, no HTML, words must be separated by spaces. 196 | * This tag is applicable to the Item element only? (check this) 197 | * 198 | * https://feedforall.com/itune-tutorial-tags.htm#keywords 199 | */ 200 | keywords: string[] 201 | /** https://feedforall.com/itune-tutorial-tags.htm#category */ 202 | categories: ITunesCategory[] 203 | /** 204 | * This tag specifies the artwork for the Channel and Item(s). This artwork can be larger than the maximum allowed by RSS. Details on the size recommendations are in the section below. 205 | * Preferred size: 206 | * 300 pixels x 300 pixels at 72 dpi 207 | * Minimum size: 208 | * 170 pixels x 170 pixels square at 72 dpi 209 | * Format: 210 | * JPG, PNG, uncompressed 211 | * 212 | * https://feedforall.com/itune-tutorial-tags.htm#image 213 | */ 214 | image: string 215 | /** https://feedforall.com/itune-tutorial-tags.htm#explicit */ 216 | explicit: boolean 217 | owner: ITunesOwner 218 | /** 219 | * At the Channel level, this tag is a short description that provides general information about the Podcast. It will appear next to your Podcast as users browse through listings of Podcasts. 220 | * At the Item level, this tag is a short description that provides specific information for each episode. 221 | * Limited to 255 characters or less, plain text, no HTML 222 | * 223 | * https://feedforall.com/itune-tutorial-tags.htm#subtitle 224 | * */ 225 | subtitle?: string 226 | /** 227 | * (show/channel level): “episodic” for non-chronological episodes that will behave as they have for years and download the latest episode, or “serial” for chronological episodes that should be consumed oldest to newest. 228 | * 229 | * new 2017 tag: https://theaudacitytopodcast.com/how-to-start-using-the-new-itunes-podcast-tags-for-ios-11-tap316/ 230 | */ 231 | type: 'episodic' | 'serial' 232 | } 233 | -------------------------------------------------------------------------------- /test/blah.test.ts: -------------------------------------------------------------------------------- 1 | describe('fuck', () => { 2 | it('works', () => { 3 | // lol, no tests 4 | }) 5 | }) 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "ESNext", 5 | "lib": ["dom", "esnext"], 6 | "declaration": true, 7 | "sourceMap": true, 8 | "rootDir": "./", 9 | "strict": true, 10 | "noImplicitAny": true, 11 | "strictNullChecks": true, 12 | "strictFunctionTypes": true, 13 | "strictPropertyInitialization": true, 14 | "noImplicitThis": true, 15 | "alwaysStrict": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "noImplicitReturns": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "moduleResolution": "node", 21 | "baseUrl": "./", 22 | "paths": { 23 | "*": ["src/*", "node_modules/*"] 24 | }, 25 | "jsx": "react", 26 | "esModuleInterop": true 27 | }, 28 | "include": ["src", "types"], 29 | } --------------------------------------------------------------------------------