eleventy-plugin-podcaster 2.0.0-alpha.3 → 2.0.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.prettierrc ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "overrides": [
3
+ {
4
+ "files": ["*.jsonc"],
5
+ "options": {
6
+ "parser": "json",
7
+ "trailingComma": "none"
8
+ }
9
+ }
10
+ ]
11
+ }
package/README.md CHANGED
@@ -1,6 +1,9 @@
1
1
  # eleventy-plugin-podcaster 🕚⚡️🎈🐀🎤📲
2
2
 
3
- `eleventy-plugin-podcaster` — or **Podcaster**, as we will call it from now on — is an Eleventy plugin which lets you create a podcast and its accompanying website. **Podcaster** creates the podcast feed that you submit to Apple Podcasts, Spotify or any other podcast directory. And it provides information about your podcast to your Eleventy templates. This means that you can include information about the podcast and its episodes on your podcast's website, creating pages for individual episodes, guests, topics, seasons or anything else at all.
3
+ `eleventy-plugin-podcaster` — or **Podcaster**, as we will call it from now on — is an Eleventy plugin which lets you create a podcast and its accompanying website.
4
+
5
+ - **Podcaster** creates the podcast feed that you submit to Apple Podcasts, Spotify or any other podcast directory.
6
+ - **Podcaster** also provides information about your podcast to your Eleventy templates. This means that you can describe the podcast and its episodes on your podcast website, creating pages for individual episodes, guests, topics, seasons or anything else at all.
4
7
 
5
8
  Plenty of services exist that will host your podcast online — [Spotify][], [Acast][], [Podbean][], [Buzzsprout][], [Blubrry][]. But none of these will allow you to own your podcast's presence on the web, and none of them will give you the freedom to create a site that presents your podcast in a way that reflects its premise, tone and style.
6
9
 
@@ -44,9 +47,9 @@ Detailed and specific information about how to install and use **Podcaster** can
44
47
 
45
48
  1. **Podcaster** is an Eleventy plugin. Create an Eleventy site and install the `eleventy-plugin-podcaster` plugin in the usual way.
46
49
  2. In the data directory, create a `podcast.json` file. This will contain information about your podcast and its site — at the very least, its title, the URL of the site, a description, its language, and its category.
47
- 3. In the input directory, create a `episodeFiles` directory and put your podcast MP3s in there.
48
- 4. In the input directory, create a `episodePosts` directory. You will have a post for each episode, and that post will include information about the episode in its filename and front matter and will have as its content the episode description or show notes.
49
- 5. Create pages on your site for each of your episodes using the posts in the `episodePosts` directory. You can also create index pages, topic pages, guest pages or anything you like, using the information you have supplied to **Podcaster**.
50
+ 3. In the input directory, create an `episode-files` directory and put your podcast MP3s in there.
51
+ 4. In the input directory, create an `episode-posts` directory. Here you will create a post for each episode, which will include information about the episode in its filename and front matter and which whill contain the episode description or show notes.
52
+ 5. Create pages on your site for each of your episodes using the posts in the `episode-posts` directory. And you can also create index pages, topic pages, guest pages or anything you like — all using the information you have supplied to **Podcaster** in your `podcast.json` file and your episode posts.
50
53
  6. Find a host for your site, and a CDN for your podcast episodes.
51
54
 
52
55
  > [!WARNING]
@@ -1,3 +1,5 @@
1
+ import path from 'node:path'
2
+
1
3
  import podcastFeed from './src/podcastFeed.js'
2
4
  import podcastData from './src/podcastData.js'
3
5
  import episodeData from './src/episodeData.js'
@@ -5,11 +7,17 @@ import calculateEpisodeSizeAndDuration from './src/calculateEpisodeSizeAndDurati
5
7
  import calculateEpisodeFilename from './src/calculateEpisodeFilename.js'
6
8
 
7
9
  import readableFilters from './src/readableFilters.js'
10
+ import chapters from './src/chapters.js'
8
11
  import excerpts from './src/excerpts.js'
9
12
  import drafts from './src/drafts.js'
10
13
  import pageTitle from './src/pageTitle.js'
11
14
 
12
15
  export default function (eleventyConfig, options = {}) {
16
+ const episodePostsDirectory = options.episodePostsDirectory ?? 'episode-posts'
17
+ options.episodePostsDirectory = path.join(eleventyConfig.directories.input, episodePostsDirectory)
18
+ const episodeFilesDirectory = options.episodeFilesDirectory ?? 'episode-files'
19
+ options.episodeFilesDirectory = path.join(eleventyConfig.directories.input, episodeFilesDirectory)
20
+
13
21
  eleventyConfig.addPlugin(podcastFeed, options)
14
22
  eleventyConfig.addPlugin(podcastData, options)
15
23
  eleventyConfig.addPlugin(episodeData, options)
@@ -19,6 +27,7 @@ export default function (eleventyConfig, options = {}) {
19
27
  // Filters
20
28
 
21
29
  eleventyConfig.addPlugin(readableFilters, options)
30
+ eleventyConfig.addPlugin(chapters, options)
22
31
 
23
32
  // Optional features
24
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eleventy-plugin-podcaster",
3
- "version": "2.0.0-alpha.3",
3
+ "version": "2.0.0-alpha.5",
4
4
  "description": "An Eleventy plugin that allows you to create a podcast and its accompanying website",
5
5
  "main": "eleventy.config.js",
6
6
  "exports": {
@@ -32,7 +32,6 @@
32
32
  "@11ty/eleventy-plugin-rss": "^2.0.1",
33
33
  "@aws-sdk/client-s3": "^3.862.0",
34
34
  "@tsmx/human-readable": "^2.0.3",
35
- "chalk": "^5.3.0",
36
35
  "dom-serializer": "^2.0.0",
37
36
  "htmlparser2": "^9.1.0",
38
37
  "luxon": "^3.4.4",
@@ -1,7 +1,9 @@
1
+ import isEpisodePost from './isEpisodePost.js'
2
+
1
3
  function findMatchingFilename (episodeData, thisEpisode) {
2
4
  const filenameSeasonAndEpisodePattern =
3
- /^.*?\b[sS](?<seasonNumber>\d+)\s*[eE](?<episodeNumber>\d+)\b.*\.mp3$/
4
- const filenameEpisodePattern = /^.*?\b(?<episodeNumber>\d+)\b.*\.mp3$/
5
+ /^.*?\b[sS](?<seasonNumber>\d+)\s*[eE](?<episodeNumber>\d+)\b.*\.(mp3|m4a)$/
6
+ const filenameEpisodePattern = /^.*?\b(?<episodeNumber>\d+)\b.*\.(mp3|m4a)$/
5
7
  const { seasonNumber, episodeNumber } = thisEpisode
6
8
 
7
9
  for (const file of Object.keys(episodeData)) {
@@ -27,11 +29,11 @@ function findMatchingFilename (episodeData, thisEpisode) {
27
29
  }
28
30
  }
29
31
 
30
- export default function (eleventyConfig, _options) {
32
+ export default function (eleventyConfig, options) {
31
33
  eleventyConfig.addGlobalData('eleventyComputed.episode.filename', () => {
32
34
  return data => {
33
35
  if (data.episode.filename) return data.episode.filename
34
- if (!data.page.inputPath.includes('/episodePosts/')) return
36
+ if (!isEpisodePost(data, options)) return
35
37
 
36
38
  return findMatchingFilename(data.episodeData, data.episode)
37
39
  }
@@ -1,4 +1,5 @@
1
- import { Duration, DateTime } from 'luxon'
1
+ import { DateTime } from 'luxon'
2
+ import readableDuration from './readableDuration.js'
2
3
  import path from 'node:path'
3
4
  import { existsSync } from 'node:fs'
4
5
  import { readdir, stat, writeFile } from 'node:fs/promises'
@@ -6,36 +7,16 @@ import { Writable } from 'node:stream'
6
7
  import { S3Client, ListObjectsCommand, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'
7
8
  import { parseFile as parseFileMetadata, parseBuffer as parseBufferMetadata } from 'music-metadata'
8
9
  import hr from '@tsmx/human-readable'
9
- import chalk from 'chalk'
10
+ import isEpisodePost from './isEpisodePost.js'
10
11
 
11
- const convertSecondsToReadableDuration = seconds =>
12
- Duration.fromMillis(seconds * 1000)
13
- .shiftTo('days', 'hours', 'minutes', 'seconds')
14
- .toHuman()
15
-
16
- const convertReadableDurationToSeconds = duration => {
17
- const durationPattern = /^(?:(?<hours>\d+):)?(?<minutes>\d{1,2}):(?<seconds>\d{2}(?:\.\d+)?)$/
18
-
19
- let match
20
- if (duration?.match) {
21
- match = duration.match(durationPattern)
22
- }
23
-
24
- if (match) {
25
- const hours = isNaN(parseInt(match.groups.hours))
26
- ? 0
27
- : parseInt(match.groups.hours)
28
- const minutes = parseInt(match.groups.minutes)
29
- const seconds = parseFloat(match.groups.seconds)
30
- return hours * 3600 + minutes * 60 + seconds
31
- }
32
- }
12
+ const isAudioFile = episodeFilename => episodeFilename.endsWith('.mp3') ||
13
+ episodeFilename.endsWith('.m4a')
33
14
 
34
15
  async function readEpisodeDataLocally (episodeFilesDirectory) {
35
16
  const episodes = await readdir(episodeFilesDirectory)
36
17
  const episodeData = {}
37
18
  for (const episode of episodes) {
38
- if (!episode.endsWith('.mp3')) continue
19
+ if (!isAudioFile(episode)) continue
39
20
 
40
21
  const episodePath = path.join(episodeFilesDirectory, episode)
41
22
  const episodeSize = (await stat(episodePath)).size
@@ -59,7 +40,7 @@ function calculatePodcastData (episodeData) {
59
40
 
60
41
  function reportPodcastData (podcastData) {
61
42
  const { numberOfEpisodes, totalSize, totalDuration } = podcastData
62
- console.log(chalk.yellow(`${numberOfEpisodes} episodes; ${hr.fromBytes(totalSize)}; ${convertSecondsToReadableDuration(totalDuration)}.`))
43
+ console.log(`\u001b[33m${numberOfEpisodes} episodes; ${hr.fromBytes(totalSize)}; ${readableDuration.convertFromSeconds(totalDuration)}.\u001b[0m`)
63
44
  }
64
45
 
65
46
  async function writePodcastDataLocally (episodeData, podcastData, directories) {
@@ -130,7 +111,7 @@ async function updateEpisodeDataFromS3Bucket (s3Client, s3Bucket) {
130
111
  const list = await s3Client.send(new ListObjectsCommand({ Bucket: s3Bucket }))
131
112
  const result = { ...storedEpisodeData.episodeData }
132
113
  for (const item of list.Contents ?? []) {
133
- if (!item.Key.endsWith('.mp3')) continue
114
+ if (!isAudioFile(item.Key)) continue
134
115
 
135
116
  const { Key: filename, Size: size, LastModified: lastModified } = item
136
117
 
@@ -161,9 +142,10 @@ export default function (eleventyConfig, options = {}) {
161
142
  let firstRun = true
162
143
  eleventyConfig.on('eleventy.before', async ({ directories }) => {
163
144
  if (!firstRun || process.env.SKIP_EPISODE_CALCULATIONS === 'true') return
145
+
164
146
  firstRun = false
165
147
 
166
- const episodeFilesDirectory = path.join(directories.input, 'episodeFiles')
148
+ const episodeFilesDirectory = options.episodeFilesDirectory
167
149
  let episodeData
168
150
  if (existsSync(episodeFilesDirectory)) {
169
151
  episodeData = await readEpisodeDataLocally(episodeFilesDirectory)
@@ -183,7 +165,7 @@ export default function (eleventyConfig, options = {}) {
183
165
  eleventyConfig.addGlobalData('eleventyComputed.episode.size', () => {
184
166
  return data => {
185
167
  if (data.episode.size) return data.episode.size
186
- if (data.page.inputPath.includes('/episodePosts/') && data.episodeData) {
168
+ if (isEpisodePost(data, options) && data.episodeData) {
187
169
  return data.episodeData[data.episode.filename]?.size
188
170
  }
189
171
  }
@@ -192,11 +174,11 @@ export default function (eleventyConfig, options = {}) {
192
174
  eleventyConfig.addGlobalData('eleventyComputed.episode.duration', () => {
193
175
  return data => {
194
176
  if (data.episode.duration) {
195
- const convertedReadableDuration = convertReadableDurationToSeconds(data.episode.duration)
177
+ const convertedReadableDuration = readableDuration.convertToSeconds(data.episode.duration)
196
178
  return convertedReadableDuration ?? data.episode.duration
197
179
  }
198
180
 
199
- if (data.page.inputPath.includes('/episodePosts/') && data.episodeData) {
181
+ if (isEpisodePost(data, options) && data.episodeData) {
200
182
  return data.episodeData[data.episode.filename]?.duration
201
183
  }
202
184
  }
@@ -0,0 +1,13 @@
1
+ import readableDuration from './readableDuration.js'
2
+
3
+ export default (eleventyConfig, options = {}) => {
4
+ eleventyConfig.addFilter('normalizeChaptersData', (data) => {
5
+ const result = data.map(x => {
6
+ if (x.startTime) {
7
+ x.startTime = readableDuration.convertToSeconds(x.startTime) ?? x.startTime
8
+ }
9
+ return x
10
+ })
11
+ return JSON.stringify(result, null, 2)
12
+ })
13
+ }
@@ -0,0 +1,9 @@
1
+ ---
2
+ pagination:
3
+ data: collections.episodePostWithChapters
4
+ size: 1
5
+ alias: episodePost
6
+ permalink: "{{ episodePost.page.url }}/chapters.json"
7
+ ---
8
+
9
+ {{ episodePost.data.episode.chapters | normalizeChaptersData | safe }}
@@ -1,3 +1,5 @@
1
+ import isEpisodePost from './isEpisodePost.js'
2
+
1
3
  export default function (eleventyConfig, options = {}) {
2
4
  const postFilenameSeasonAndEpisodePattern =
3
5
  /^[sS](?<seasonNumber>\d+)[eE](?<episodeNumber>\d+)/i
@@ -7,7 +9,7 @@ export default function (eleventyConfig, options = {}) {
7
9
  return data => {
8
10
  if (data.episode?.seasonNumber) return data.episode.seasonNumber
9
11
 
10
- if (!data.page.inputPath.includes('/episodePosts/')) return
12
+ if (!isEpisodePost(data, options)) return
11
13
 
12
14
  const seasonAndEpisodeMatch = data.page.fileSlug.match(postFilenameSeasonAndEpisodePattern)
13
15
  if (seasonAndEpisodeMatch) {
@@ -20,7 +22,7 @@ export default function (eleventyConfig, options = {}) {
20
22
  return data => {
21
23
  if (data.episode?.episodeNumber) return data.episode.episodeNumber
22
24
 
23
- if (!data.page.inputPath.includes('/episodePosts/')) return
25
+ if (!isEpisodePost(data, options)) return
24
26
 
25
27
  const seasonAndEpisodeMatch = data.page.fileSlug.match(postFilenameSeasonAndEpisodePattern)
26
28
  if (seasonAndEpisodeMatch) {
@@ -49,7 +51,7 @@ export default function (eleventyConfig, options = {}) {
49
51
 
50
52
  eleventyConfig.addGlobalData('eleventyComputed.episode.url', () => {
51
53
  return data => {
52
- if (!data.page.inputPath.includes('/episodePosts/')) return
54
+ if (!isEpisodePost(data, options)) return
53
55
 
54
56
  const episodeUrlBase = data.podcast.episodeUrlBase
55
57
  const filename = data.episode.filename
package/src/excerpts.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import * as htmlparser2 from 'htmlparser2'
2
2
  import render from 'dom-serializer'
3
3
  import markdownIt from 'markdown-it'
4
+ import isEpisodePost from './isEpisodePost.js'
4
5
 
5
6
  export default function (eleventyConfig, options = {}) {
6
7
  eleventyConfig.addGlobalData('eleventyComputed.excerpt', () => {
7
8
  return (data) => {
8
- if (!data.page.inputPath.includes('/episodePosts/')) return
9
+ if (!isEpisodePost(data, options)) return
9
10
 
10
11
  const md = markdownIt({
11
12
  html: true,
@@ -0,0 +1,10 @@
1
+ import path from 'node:path'
2
+
3
+ export default function isEpisodePost (data, options) {
4
+ if (data.page?.inputPath) {
5
+ const importPath = path.normalize(data.page.inputPath)
6
+ return importPath.startsWith(options.episodePostsDirectory)
7
+ } else {
8
+ return false
9
+ }
10
+ }
@@ -1,6 +1,7 @@
1
1
  import rssPlugin from '@11ty/eleventy-plugin-rss'
2
2
  import { readFileSync } from 'node:fs'
3
3
  import path from 'node:path'
4
+ import isEpisodePost from './isEpisodePost.js'
4
5
 
5
6
  export default function (eleventyConfig, options = {}) {
6
7
  if (!('addTemplate' in eleventyConfig)) {
@@ -8,11 +9,18 @@ export default function (eleventyConfig, options = {}) {
8
9
  }
9
10
 
10
11
  const podcastFeedPath = path.join(import.meta.dirname, './podcastFeed.njk')
11
-
12
12
  eleventyConfig.addTemplate('feed.njk', readFileSync(podcastFeedPath), {
13
13
  eleventyExcludeFromCollections: true,
14
14
  eleventyImport: {
15
- collections: ['podcastEpisode']
15
+ collections: ['episodePost']
16
+ }
17
+ })
18
+
19
+ const chaptersPath = path.join(import.meta.dirname, './chapters.njk')
20
+ eleventyConfig.addTemplate('chapters.njk', readFileSync(chaptersPath), {
21
+ eleventyExcludeFromCollections: true,
22
+ eleventyImport: {
23
+ collections: ['episodePostWithChapters']
16
24
  }
17
25
  })
18
26
 
@@ -22,7 +30,23 @@ export default function (eleventyConfig, options = {}) {
22
30
  }
23
31
  })
24
32
 
33
+ eleventyConfig.addCollection('episodePost', (collectionApi) => {
34
+ return collectionApi
35
+ .getAll()
36
+ .filter(item => isEpisodePost(item.data, options))
37
+ // sort order explicit: presence of template data files disrupts it otherwise
38
+ .sort((a, b) => a.date - b.date)
39
+ })
40
+
41
+ // included for backward compatibility, will be deprecated in 3.0
25
42
  eleventyConfig.addCollection('podcastEpisode', (collectionApi) => {
26
- return collectionApi.getFilteredByGlob('**/episodePosts/*')
43
+ return collectionApi
44
+ .getAll()
45
+ .filter(item => isEpisodePost(item.data, options))
46
+ .sort((a, b) => a.date - b.date)
47
+ })
48
+
49
+ eleventyConfig.addCollection('episodePostWithChapters', (collectionApi) => {
50
+ return collectionApi.getAll().filter(item => isEpisodePost(item.data, options) && item.data.episode?.chapters)
27
51
  })
28
52
  }
@@ -17,7 +17,7 @@ eleventyAllowMissingExtension: true
17
17
  <description>{{ podcast.description }}</description>
18
18
  <language>{{ podcast.language }}</language>
19
19
  <copyright>{{ podcast.copyrightNotice }}</copyright>
20
- <pubDate>{{ collections.podcastEpisode | getNewestCollectionItemDate | dateToRfc3339 }}</pubDate>
20
+ <pubDate>{{ collections.episodePost | getNewestCollectionItemDate | dateToRfc3339 }}</pubDate>
21
21
  <lastBuildDate>{{ podcast.feedLastBuildDate }}</lastBuildDate>
22
22
  <itunes:image href="{{ podcast.imagePath | htmlBaseUrl(siteUrl) }}"></itunes:image>
23
23
  {%- if podcast.subcategory %}
@@ -47,7 +47,7 @@ eleventyAllowMissingExtension: true
47
47
  </itunes:owner>
48
48
  {%- endif %}
49
49
 
50
- {% for post in collections.podcastEpisode | reverse %}
50
+ {% for post in collections.episodePost | reverse %}
51
51
  <item>
52
52
  <title>{{ post.data.episode.title or post.data.title }}</title>
53
53
  {% if post.data.episode.itunesTitle %}
@@ -60,6 +60,10 @@ eleventyAllowMissingExtension: true
60
60
  <guid isPermaLink="true">{{ post.url | htmlBaseUrl(siteUrl) }}</guid>
61
61
  {% endif -%}
62
62
  <pubDate>{{ post.date | dateToRfc3339 }}</pubDate>
63
+ {%- if post.data.episode.chapters %}
64
+ <podcast:chapters url="{{ [post.url, 'chapters.json'] | join('/') | htmlBaseUrl(siteUrl) }}"
65
+ type="application/json+chapters" />
66
+ {% endif -%}
63
67
  {% if podcast.episodeDescriptionTemplate %}
64
68
  {%- set episodeDescription -%}
65
69
  {% include podcast.episodeDescriptionTemplate %}
@@ -0,0 +1,26 @@
1
+ import { Duration } from 'luxon'
2
+
3
+ export default {
4
+ convertFromSeconds (seconds) {
5
+ return Duration.fromMillis(seconds * 1000)
6
+ .shiftTo('days', 'hours', 'minutes', 'seconds')
7
+ .toHuman()
8
+ },
9
+ convertToSeconds (duration) {
10
+ const durationPattern = /^(?:(?<hours>\d+):)?(?<minutes>\d{1,2}):(?<seconds>\d{2}(?:\.\d+)?)$/
11
+
12
+ let match
13
+ if (duration?.match) {
14
+ match = duration.match(durationPattern)
15
+ }
16
+
17
+ if (match) {
18
+ const hours = isNaN(parseInt(match.groups.hours))
19
+ ? 0
20
+ : parseInt(match.groups.hours)
21
+ const minutes = parseInt(match.groups.minutes)
22
+ const seconds = parseFloat(match.groups.seconds)
23
+ return hours * 3600 + minutes * 60 + seconds
24
+ }
25
+ }
26
+ }