eleventy-plugin-podcaster 2.0.1 → 2.1.0
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/CLAUDE.md +54 -0
- package/TODO.md +24 -0
- package/jsconfig.json +5 -0
- package/package.json +4 -5
- package/src/calculateEpisodeSizeAndDuration.js +2 -3
- package/src/podcastData.js +2 -4
- package/src/podcastFeed.js +3 -1
- package/src/podcastFeed.njk +2 -0
- package/src/readableDuration.js +24 -5
- package/src/readableFilters.js +9 -14
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm test # run all tests (AVA)
|
|
9
|
+
npx ava test/someTestFile.js # run a single test file
|
|
10
|
+
npx eslint src/ # lint source files
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
There is no build step — the plugin is used directly from source.
|
|
14
|
+
|
|
15
|
+
## Architecture
|
|
16
|
+
|
|
17
|
+
`eleventy.config.js` is the plugin entry point. It registers 8 sub-plugins and 3 optional features:
|
|
18
|
+
|
|
19
|
+
**Core sub-plugins (always active):**
|
|
20
|
+
|
|
21
|
+
- `podcastFeed.js` — RSS feed generation; creates `episodePost` and `episodePostWithChapters` collections via virtual templates
|
|
22
|
+
- `podcastData.js` — Computes podcast-level `eleventyComputed` fields: `feedPath`, `imagePath`, `episodeUrlBase`, `copyrightNotice`
|
|
23
|
+
- `episodeData.js` — Parses `seasonNumber`/`episodeNumber` from filenames; constructs episode `url` and `permalink`
|
|
24
|
+
- `calculateEpisodeSizeAndDuration.js` — Reads audio metadata from local files or S3; caches results; uses `music-metadata`
|
|
25
|
+
- `calculateEpisodeFilename.js` — Matches episode posts to audio files by season/episode number
|
|
26
|
+
- `readableFilters.js` — Adds `readableDate`, `readableDuration`, `readableSize` filters
|
|
27
|
+
- `chapters.js` — Filter for normalizing chapter data to JSON-LD; template in `src/chapters.njk`
|
|
28
|
+
|
|
29
|
+
**Optional features (opt-in via plugin options):**
|
|
30
|
+
|
|
31
|
+
- `drafts.js` — Filters draft posts; controlled by `INCLUDE_DRAFTS` env var or `ELEVENTY_RUN_MODE`
|
|
32
|
+
- `excerpts.js` — Auto-generates episode excerpts from front matter, HTML comments, or first paragraph
|
|
33
|
+
- `pageTitle.js` — Computes `pageTitle` with configurable separator
|
|
34
|
+
|
|
35
|
+
**Options:** `handleDrafts`, `handleExcerpts`, `handlePageTitles` (individual flags), or `optionalFeatures: true` (enables all three). `episodePostsDirectory` and `episodeFilesDirectory` override the default directory names.
|
|
36
|
+
|
|
37
|
+
## Tests
|
|
38
|
+
|
|
39
|
+
Tests use AVA and are configured in `ava.config.js`. Each test file typically builds a fixture site using Eleventy's programmatic API and asserts on the output.
|
|
40
|
+
|
|
41
|
+
Fixture sites live in `fixtures/` — each is a minimal Eleventy site with `eleventy.config.js`, `_data/podcast.json`, episode markdown files in `episode-posts/`, and audio files in `episode-files/`.
|
|
42
|
+
|
|
43
|
+
Key environment variables used in tests:
|
|
44
|
+
|
|
45
|
+
- `SKIP_EPISODE_CALCULATIONS` — skip audio metadata calculation (speeds up tests that don't need it)
|
|
46
|
+
- `INCLUDE_DRAFTS` — controls draft inclusion
|
|
47
|
+
- `ELEVENTY_RUN_MODE` — `'build'` or `'serve'`
|
|
48
|
+
|
|
49
|
+
## Conventions
|
|
50
|
+
|
|
51
|
+
- All source files are ES modules (`"type": "module"` in package.json)
|
|
52
|
+
- Computed episode/podcast fields are set via Eleventy's `eleventyComputed` pattern
|
|
53
|
+
- No Luxon — duration logic uses native `Intl` / `Date`; see `src/readableDuration.js`
|
|
54
|
+
- Commit messages: short past-tense verb phrases, no body, no co-author lines (e.g. `Removed Luxon dependency`)
|
package/TODO.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# TODO
|
|
2
|
+
|
|
3
|
+
## Warnings and errors for missing data
|
|
4
|
+
|
|
5
|
+
Add consistent warnings and errors when required data is missing. This should be opt-in via a plugin option to avoid a breaking change.
|
|
6
|
+
|
|
7
|
+
Behaviour:
|
|
8
|
+
|
|
9
|
+
- In development (`ELEVENTY_RUN_MODE === 'serve'`): log warnings but continue the build
|
|
10
|
+
- In production: throw errors and stop the build
|
|
11
|
+
|
|
12
|
+
Cases to handle:
|
|
13
|
+
|
|
14
|
+
- Missing `podcast.json` or required fields within it
|
|
15
|
+
- Episode post without a matching audio file
|
|
16
|
+
- Malformed episode numbering in filenames
|
|
17
|
+
- Incomplete S3 configuration (some keys but not others)
|
|
18
|
+
- Missing audio file when calculating duration/size
|
|
19
|
+
|
|
20
|
+
Implementation notes:
|
|
21
|
+
|
|
22
|
+
- Use a consistent prefix like `[podcaster]` so messages are identifiable
|
|
23
|
+
- New plugin option (e.g., `strictMode` or `validateData`) to enable this
|
|
24
|
+
- The draft system may already cover some cases (e.g., incomplete episodes marked as drafts)
|
package/jsconfig.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eleventy-plugin-podcaster",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
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": {
|
|
@@ -29,20 +29,19 @@
|
|
|
29
29
|
"license": "ISC",
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@11ty/eleventy": "^3.0.0",
|
|
32
|
-
"@11ty/eleventy-plugin-rss": "^
|
|
32
|
+
"@11ty/eleventy-plugin-rss": "^3.0.0",
|
|
33
33
|
"@aws-sdk/client-s3": "^3.862.0",
|
|
34
34
|
"@tsmx/human-readable": "^2.0.3",
|
|
35
35
|
"dom-serializer": "^2.0.0",
|
|
36
36
|
"htmlparser2": "^9.1.0",
|
|
37
|
-
"luxon": "^3.4.4",
|
|
38
37
|
"markdown-it": "^14.1.0",
|
|
39
38
|
"music-metadata": "^11.7.1"
|
|
40
39
|
},
|
|
41
40
|
"devDependencies": {
|
|
42
41
|
"ava": "^6.1.3",
|
|
43
42
|
"dotenv": "^17.2.1",
|
|
44
|
-
"fast-xml-parser": "^
|
|
45
|
-
"mock-aws-s3-v3": "^6.
|
|
43
|
+
"fast-xml-parser": "^5.5.9",
|
|
44
|
+
"mock-aws-s3-v3": "^6.1.12",
|
|
46
45
|
"neostandard": "^0.11.4"
|
|
47
46
|
}
|
|
48
47
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { DateTime } from 'luxon'
|
|
2
1
|
import readableDuration from './readableDuration.js'
|
|
3
2
|
import path from 'node:path'
|
|
4
3
|
import { existsSync, readFileSync } from 'node:fs'
|
|
@@ -96,7 +95,7 @@ async function getCachedEpisodeDataFromS3Bucket (s3Storage, s3Bucket) {
|
|
|
96
95
|
async function calculateEpisodeDataFromS3Bucket (s3Storage, s3Bucket) {
|
|
97
96
|
const cachedEpisodeData = await getCachedEpisodeDataFromS3Bucket(s3Storage, s3Bucket)
|
|
98
97
|
const cachedEpisodeDataLastModifiedDate = (cachedEpisodeData.lastModified)
|
|
99
|
-
?
|
|
98
|
+
? new Date(cachedEpisodeData.lastModified)
|
|
100
99
|
: null
|
|
101
100
|
|
|
102
101
|
console.log(`Reading episode data from S3 bucket ${s3Bucket}`)
|
|
@@ -111,7 +110,7 @@ async function calculateEpisodeDataFromS3Bucket (s3Storage, s3Bucket) {
|
|
|
111
110
|
!('size' in result[filename]) ||
|
|
112
111
|
!('duration' in result[filename]) ||
|
|
113
112
|
!cachedEpisodeDataLastModifiedDate ||
|
|
114
|
-
cachedEpisodeDataLastModifiedDate <
|
|
113
|
+
cachedEpisodeDataLastModifiedDate < new Date(lastModified)) {
|
|
115
114
|
const { buffer } = await getObjectFromS3Bucket(s3Storage, s3Bucket, filename)
|
|
116
115
|
const metadata = await parseBufferMetadata(buffer, null, { duration: true })
|
|
117
116
|
const duration = metadata.format.duration
|
package/src/podcastData.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { DateTime } from 'luxon'
|
|
2
|
-
|
|
3
1
|
export default function (eleventyConfig) {
|
|
4
2
|
eleventyConfig.addGlobalData('eleventyComputed.podcast.feedPath', () => {
|
|
5
3
|
return data => data.podcast.feedPath || '/feed/podcast.xml'
|
|
@@ -23,7 +21,7 @@ export default function (eleventyConfig) {
|
|
|
23
21
|
})
|
|
24
22
|
|
|
25
23
|
function constructCopyrightNotice (data) {
|
|
26
|
-
const thisYear =
|
|
24
|
+
const thisYear = new Date().getFullYear()
|
|
27
25
|
let yearRange
|
|
28
26
|
if (!data.podcast.startingYear || data.podcast.startingYear === thisYear) {
|
|
29
27
|
yearRange = thisYear
|
|
@@ -40,5 +38,5 @@ export default function (eleventyConfig) {
|
|
|
40
38
|
eleventyConfig.addGlobalData('eleventyComputed.copyrightNotice', () => {
|
|
41
39
|
return constructCopyrightNotice
|
|
42
40
|
})
|
|
43
|
-
eleventyConfig.addGlobalData('podcast.feedLastBuildDate',
|
|
41
|
+
eleventyConfig.addGlobalData('podcast.feedLastBuildDate', new Date().toUTCString().replace('GMT', '+0000'))
|
|
44
42
|
}
|
package/src/podcastFeed.js
CHANGED
|
@@ -13,7 +13,9 @@ export default function (eleventyConfig, options = {}) {
|
|
|
13
13
|
eleventyExcludeFromCollections: true,
|
|
14
14
|
eleventyImport: {
|
|
15
15
|
collections: ['episodePost']
|
|
16
|
-
}
|
|
16
|
+
},
|
|
17
|
+
script: options?.feedScript,
|
|
18
|
+
stylesheet: options?.feedStylesheet
|
|
17
19
|
})
|
|
18
20
|
|
|
19
21
|
const chaptersPath = path.join(import.meta.dirname, './chapters.njk')
|
package/src/podcastFeed.njk
CHANGED
|
@@ -5,12 +5,14 @@ eleventyAllowMissingExtension: true
|
|
|
5
5
|
---
|
|
6
6
|
{%- set siteUrl %}{{ podcast.siteUrl or site.url }}{% endset -%}
|
|
7
7
|
<?xml version="1.0" encoding="utf-8"?>
|
|
8
|
+
{% if stylesheet %}<?xml-stylesheet type="text/xsl" href="{{ stylesheet }}" ?>{% endif %}
|
|
8
9
|
<rss version="2.0"
|
|
9
10
|
xmlns:atom="http://www.w3.org/2005/Atom"
|
|
10
11
|
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
|
|
11
12
|
xmlns:content="http://purl.org/rss/1.0/modules/content/"
|
|
12
13
|
xmlns:podcast="https://podcastindex.org/namespace/1.0">
|
|
13
14
|
<channel>
|
|
15
|
+
{% if script %}<script src="{{ script }}" xmlns="http://www.w3.org/1999/xhtml"></script>{% endif %}
|
|
14
16
|
<title>{{ podcast.title }}</title>
|
|
15
17
|
<link>{{ siteUrl }}</link>
|
|
16
18
|
<atom:link href="{{ permalink | htmlBaseUrl(siteUrl) }}" rel="self" type="application/rss+xml" />
|
package/src/readableDuration.js
CHANGED
|
@@ -1,10 +1,29 @@
|
|
|
1
|
-
|
|
1
|
+
function pluralise (value, unit) {
|
|
2
|
+
return `${value} ${unit}${value === 1 ? '' : 's'}`
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function toComponents (seconds) {
|
|
6
|
+
return {
|
|
7
|
+
d: Math.floor(seconds / 86400),
|
|
8
|
+
h: Math.floor((seconds % 86400) / 3600),
|
|
9
|
+
m: Math.floor((seconds % 3600) / 60),
|
|
10
|
+
s: Math.round((seconds % 60) * 1000) / 1000
|
|
11
|
+
}
|
|
12
|
+
}
|
|
2
13
|
|
|
3
14
|
export default {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
15
|
+
longFormat (seconds) {
|
|
16
|
+
const { d, h, m, s } = toComponents(seconds)
|
|
17
|
+
return [pluralise(d, 'day'), pluralise(h, 'hour'), pluralise(m, 'minute'), pluralise(s, 'second')].join(', ')
|
|
18
|
+
},
|
|
19
|
+
shortFormat (seconds) {
|
|
20
|
+
const h = Math.floor(seconds / 3600)
|
|
21
|
+
const m = Math.floor((seconds % 3600) / 60)
|
|
22
|
+
const s = Math.floor(seconds % 60)
|
|
23
|
+
if (h === 0) {
|
|
24
|
+
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
|
25
|
+
}
|
|
26
|
+
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
|
8
27
|
},
|
|
9
28
|
convertToSeconds (duration) {
|
|
10
29
|
const durationPattern = /^(?:(?<hours>\d+):)?(?<minutes>\d{1,2}):(?<seconds>\d{2}(?:\.\d+)?)$/
|
package/src/readableFilters.js
CHANGED
|
@@ -1,29 +1,24 @@
|
|
|
1
|
-
import { DateTime, Duration } from 'luxon'
|
|
2
1
|
import hr from '@tsmx/human-readable'
|
|
2
|
+
import readableDuration from './readableDuration.js'
|
|
3
3
|
|
|
4
4
|
export default function (eleventyConfig, options = {}) {
|
|
5
5
|
eleventyConfig.addFilter('readableDate', function (date) {
|
|
6
6
|
const readableDateLocale = options.readableDateLocale ?? 'en-AU'
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
return new Date(date).toLocaleDateString(readableDateLocale, {
|
|
8
|
+
weekday: 'long',
|
|
9
|
+
year: 'numeric',
|
|
10
|
+
month: 'long',
|
|
11
|
+
day: 'numeric',
|
|
12
|
+
timeZone: 'UTC'
|
|
12
13
|
})
|
|
13
|
-
return result.setLocale(readableDateLocale).toLocaleString(DateTime.DATE_HUGE)
|
|
14
14
|
})
|
|
15
15
|
|
|
16
16
|
eleventyConfig.addFilter('readableDuration', (seconds, length) => {
|
|
17
17
|
if (!seconds) return '0:00'
|
|
18
18
|
if (length === 'long') {
|
|
19
|
-
return
|
|
20
|
-
.shiftTo('days', 'hours', 'minutes', 'seconds')
|
|
21
|
-
.toHuman()
|
|
22
|
-
} else if (seconds < 60 * 60) {
|
|
23
|
-
return Duration.fromMillis(seconds * 1000).toFormat('mm:ss')
|
|
24
|
-
} else {
|
|
25
|
-
return Duration.fromMillis(seconds * 1000).toFormat('h:mm:ss')
|
|
19
|
+
return readableDuration.longFormat(seconds)
|
|
26
20
|
}
|
|
21
|
+
return readableDuration.shortFormat(seconds)
|
|
27
22
|
})
|
|
28
23
|
|
|
29
24
|
eleventyConfig.addFilter('readableSize', (bytes, fixedPrecision = 1) =>
|