├── .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 | 
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 | }
--------------------------------------------------------------------------------