diff --git a/README.md b/README.md
index 00bd2d0a..486b6f04 100644
--- a/README.md
+++ b/README.md
@@ -63,11 +63,12 @@ Feedsmith aims to fully support all major feed formats and namespaces in complet
| [RDF](https://feedsmith.dev/reference/feeds/rdf) | 0.9, 1.0 | ✅ | 📋 |
| [JSON Feed](https://feedsmith.dev/reference/feeds/json) | 1.0, 1.1 | ✅ | ✅ |
-### Other
+### Related Formats
| Format | Versions | Parse | Generate |
|--------|----------|-------|----------|
-| [OPML](https://feedsmith.dev/reference/other/opml) | 1.0, 2.0 | ✅ | ✅ |
+| [OPML](https://feedsmith.dev/reference/opml) | 1.0, 2.0 | ✅ | ✅ |
+| [Podcast Chapters](https://feedsmith.dev/reference/related/podcast-chapters) | 1.2 | ✅ | ✅ |
### Feed Namespaces
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 3033cf6f..ecca7ff1 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -145,8 +145,12 @@ export default defineConfig({
],
},
{
- text: 'OPML',
- link: '/reference/opml',
+ text: 'Related Formats',
+ collapsed: true,
+ items: [
+ { text: 'OPML', link: '/reference/opml' },
+ { text: 'Podcast Chapters', link: '/reference/related/podcast-chapters' },
+ ],
},
{
text: 'TypeScript',
diff --git a/docs/index.md b/docs/index.md
index 296e741c..f0a809f3 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -52,11 +52,12 @@ Feedsmith aims to fully support all major feed formats and namespaces in complet
| [RDF](/reference/feeds/rdf) | 0.9, 1.0 | ✅ | 📋 |
| [JSON Feed](/reference/feeds/json-feed) | 1.0, 1.1 | ✅ | ✅ |
-### Other
+### Related Formats
| Format | Versions | Parse | Generate |
|--------|----------|-------|----------|
| [OPML](/reference/opml) | 1.0, 2.0 | ✅ | ✅ |
+| [Podcast Chapters](/reference/related/podcast-chapters) | 1.2 | ✅ | ✅ |
### Feed Namespaces
diff --git a/docs/reference/feeds/json-feed.md b/docs/reference/feeds/json-feed.md
index bb885e5e..b6214d79 100644
--- a/docs/reference/feeds/json-feed.md
+++ b/docs/reference/feeds/json-feed.md
@@ -43,7 +43,7 @@ const jsonFeed = parseJsonFeed(jsonContent, { maxItems: 10 })
| Parameter | Type | Description |
|-----------|------|-------------|
-| `content` | `string` | The JSON Feed content to parse |
+| `content` | `unknown` | The JSON Feed content to parse (string or object) |
| `options` | `object` | Optional parsing settings |
#### Options
diff --git a/docs/reference/related/podcast-chapters.md b/docs/reference/related/podcast-chapters.md
new file mode 100644
index 00000000..e833de12
--- /dev/null
+++ b/docs/reference/related/podcast-chapters.md
@@ -0,0 +1,126 @@
+---
+title: "Reference: Podcast Chapters"
+---
+
+# Podcast Chapters Reference
+
+Podcast Chapters is a JSON format for podcast chapter markers, part of the [Podcasting 2.0 specification](/reference/namespaces/podcast). It enables podcasters to embed chapter metadata including timestamps, titles, images, links, and location data.
+
+
+
+## Functions
+
+### `parseChapters()`
+
+Parses Podcast Chapters content and returns a typed chapters object.
+
+```typescript
+import { parseChapters } from 'feedsmith'
+
+const chapters = parseChapters(jsonContent)
+// Returns: object with all fields optional except required ones
+```
+
+#### Parameters
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `content` | `unknown` | The Podcast Chapters JSON content to parse (string or object) |
+
+#### Returns
+`object` - Parsed Podcast Chapters with all fields optional
+
+### `generateChapters()`
+
+Generates Podcast Chapters object from chapters data. The `version` field is automatically set to `1.2.0`.
+
+```typescript
+import { generateChapters } from 'feedsmith'
+
+const chapters = generateChapters({
+ chapters: [
+ { startTime: 0, title: 'Introduction' },
+ { startTime: 60, title: 'Main Content' },
+ ],
+ title: 'Episode Chapters',
+})
+// Returns: { version: '1.2.0', chapters: [...], title: 'Episode Chapters' }
+```
+
+#### Parameters
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `data` | `object` | Podcast Chapters data to generate |
+| `options` | `object` | Optional generation settings |
+
+#### Options
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `strict` | `boolean` | `false` | Enable strict mode for spec-required field validation, see [Strict Mode](/generating/strict-mode) |
+
+#### Returns
+`object` - Generated Podcast Chapters
+
+### `detectChapters()`
+
+Detects if the provided content is a Podcast Chapters document.
+
+#### Parameters
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `content` | `unknown` | The content to check (string or object) |
+
+#### Returns
+`boolean` - `true` if content appears to be Podcast Chapters format
+
+#### Example
+```typescript
+import { detectChapters } from 'feedsmith'
+
+const isChapters = detectChapters(jsonContent)
+```
+
+## Types
+
+All Podcast Chapters types are available under the `Chapters` namespace:
+
+```typescript
+import type { Chapters } from 'feedsmith'
+
+// Access any type from the definitions below
+type Document = Chapters.Document
+type Chapter = Chapters.Chapter
+type Location = Chapters.Location
+// … see type definitions below for all available types
+```
+
+See the [TypeScript guide](/reference/typescript) for usage examples.
+
+### Type Definitions
+
+> [!INFO]
+> For details on type parameters (`TStrict`) and `Requirable` markers, see [TypeScript Reference](/reference/typescript#tdate).
+
+<<< @/../src/related/chapters/common/types.ts#reference
+
+## Related
+
+- **[Podcast Namespace](/reference/namespaces/podcast)** - The Podcasting 2.0 namespace that references Podcast Chapters
diff --git a/src/common/config.ts b/src/common/config.ts
index 1a1858b5..14b762e0 100644
--- a/src/common/config.ts
+++ b/src/common/config.ts
@@ -129,6 +129,7 @@ export const locales = {
invalidInputAtom: 'Invalid input Atom',
invalidInputRss: 'Invalid input RSS',
invalidInputJson: 'Invalid input JSON',
+ invalidInputChapters: 'Invalid input Podcast Chapters',
}
export const namespaceUris = {
diff --git a/src/common/utils.test.ts b/src/common/utils.test.ts
index 2841270f..13b63262 100644
--- a/src/common/utils.test.ts
+++ b/src/common/utils.test.ts
@@ -3,6 +3,7 @@ import { type XMLBuilder, XMLParser } from 'fast-xml-parser'
import { namespacePrefixes, namespaceUris } from './config.js'
import type { ParseUtilExact } from './types.js'
import {
+ createCaseInsensitiveGetter,
createNamespaceNormalizator,
detectNamespaces,
generateBoolean,
@@ -1724,6 +1725,126 @@ describe('parseSingularOf', () => {
})
})
+describe('createCaseInsensitiveGetter', () => {
+ it('should retrieve value using case-insensitive key lookup', () => {
+ const value = {
+ Title: 'Example Title',
+ AUTHOR: 'John Doe',
+ content: 'Some content here',
+ }
+ const get = createCaseInsensitiveGetter(value)
+
+ expect(get('title')).toBe('Example Title')
+ expect(get('author')).toBe('John Doe')
+ expect(get('CONTENT')).toBe('Some content here')
+ })
+
+ it('should preserve the original value types', () => {
+ const value = {
+ Number: 42,
+ Boolean: true,
+ Object: { key: 'value' },
+ Array: [1, 2, 3],
+ Null: null,
+ }
+ const get = createCaseInsensitiveGetter(value)
+
+ expect(get('number')).toBe(42)
+ expect(get('boolean')).toBe(true)
+ expect(get('object')).toEqual({ key: 'value' })
+ expect(get('array')).toEqual([1, 2, 3])
+ expect(get('null')).toBeNull()
+ })
+
+ it('should handle keys that differ only in case', () => {
+ const value = {
+ key: 'lowercase value',
+ KEY: 'uppercase value',
+ }
+ const get = createCaseInsensitiveGetter(value)
+
+ expect(get('key')).toBe('lowercase value')
+ expect(get('KEY')).toBe('uppercase value')
+ })
+
+ it('should handle non-string key lookups by coercing to string', () => {
+ const value = {
+ '123': 'numeric key',
+ true: 'boolean key',
+ }
+ const get = createCaseInsensitiveGetter(value)
+
+ expect(get('123')).toBe('numeric key')
+ expect(get('TRUE')).toBe('boolean key')
+ })
+
+ it('should handle special characters in keys', () => {
+ const value = {
+ 'Special-Key': 'with dash',
+ Special_Key: 'with underscore',
+ 'Special.Key': 'with dot',
+ }
+ const get = createCaseInsensitiveGetter(value)
+
+ expect(get('special-key')).toBe('with dash')
+ expect(get('SPECIAL_KEY')).toBe('with underscore')
+ expect(get('special.key')).toBe('with dot')
+ })
+
+ it('should handle Unicode characters correctly', () => {
+ const value = {
+ CaféItem: 'coffee',
+ RÉSUMÉ: 'document',
+ }
+ const get = createCaseInsensitiveGetter(value)
+
+ expect(get('caféitem')).toBe('coffee')
+ expect(get('résumé')).toBe('document')
+ })
+
+ it('should handle multiple lookups on the same getter', () => {
+ const value = {
+ First: 'first value',
+ Second: 'second value',
+ Third: 'third value',
+ }
+ const get = createCaseInsensitiveGetter(value)
+
+ expect(get('first')).toBe('first value')
+ expect(get('SECOND')).toBe('second value')
+ expect(get('THiRd')).toBe('third value')
+ })
+
+ it('should handle undefined values in the object', () => {
+ const value = {
+ DefinedKey: 'defined value',
+ UndefinedKey: undefined,
+ }
+ const get = createCaseInsensitiveGetter(value)
+
+ expect(get('definedkey')).toBe('defined value')
+ expect(get('undefinedkey')).toBeUndefined()
+ // Make sure we can distinguish between non-existent keys and keys with undefined values.
+ expect('UndefinedKey' in value).toBe(true)
+ })
+
+ it('should return undefined for non-existent keys', () => {
+ const value = {
+ ExistingKey: 'value',
+ }
+ const get = createCaseInsensitiveGetter(value)
+
+ expect(get('nonexistentkey')).toBeUndefined()
+ })
+
+ it('should handle empty objects', () => {
+ const value = {}
+ const get = createCaseInsensitiveGetter(value)
+
+ expect(get('anykey')).toBeUndefined()
+ })
+})
+
describe('parseArray', () => {
it('should handle arrays', () => {
const value1 = [] as Array
diff --git a/src/common/utils.ts b/src/common/utils.ts
index c2ad944b..ab1a2a53 100644
--- a/src/common/utils.ts
+++ b/src/common/utils.ts
@@ -331,6 +331,22 @@ export const parseSingularOf = (value: Unreliable, parse: ParseUtilExact):
return parse(parseSingular(value))
}
+export const createCaseInsensitiveGetter = (value: Record) => {
+ return (requestedKey: string) => {
+ if (requestedKey in value) {
+ return value[requestedKey]
+ }
+
+ const lowerKey = requestedKey.toLowerCase()
+
+ for (const key in value) {
+ if (key.toLowerCase() === lowerKey) {
+ return value[key]
+ }
+ }
+ }
+}
+
export const parseCsvOf = (
value: Unreliable,
parse: ParseUtilExact,
diff --git a/src/feeds/json/detect/index.ts b/src/feeds/json/detect/index.ts
index 63a65a1b..7ad0e055 100644
--- a/src/feeds/json/detect/index.ts
+++ b/src/feeds/json/detect/index.ts
@@ -1,5 +1,9 @@
-import { isNonEmptyString, isObject, parseJsonObject } from '../../../common/utils.js'
-import { createCaseInsensitiveGetter } from '../parse/utils.js'
+import {
+ createCaseInsensitiveGetter,
+ isNonEmptyString,
+ isObject,
+ parseJsonObject,
+} from '../../../common/utils.js'
export const detect = (value: unknown): value is object => {
const json = parseJsonObject(value)
diff --git a/src/feeds/json/parse/utils.test.ts b/src/feeds/json/parse/utils.test.ts
index f6bca3df..381adcf8 100644
--- a/src/feeds/json/parse/utils.test.ts
+++ b/src/feeds/json/parse/utils.test.ts
@@ -1,6 +1,5 @@
import { describe, expect, it } from 'bun:test'
import {
- createCaseInsensitiveGetter,
parseAttachment,
parseAuthor,
parseFeed,
@@ -9,126 +8,6 @@ import {
retrieveAuthors,
} from './utils.js'
-describe('createCaseInsensitiveGetter', () => {
- it('should retrieve value using case-insensitive key lookup', () => {
- const value = {
- Title: 'Example Title',
- AUTHOR: 'John Doe',
- content: 'Some content here',
- }
- const get = createCaseInsensitiveGetter(value)
-
- expect(get('title')).toBe('Example Title')
- expect(get('author')).toBe('John Doe')
- expect(get('CONTENT')).toBe('Some content here')
- })
-
- it('should preserve the original value types', () => {
- const value = {
- Number: 42,
- Boolean: true,
- Object: { key: 'value' },
- Array: [1, 2, 3],
- Null: null,
- }
- const get = createCaseInsensitiveGetter(value)
-
- expect(get('number')).toBe(42)
- expect(get('boolean')).toBe(true)
- expect(get('object')).toEqual({ key: 'value' })
- expect(get('array')).toEqual([1, 2, 3])
- expect(get('null')).toBeNull()
- })
-
- it('should handle keys that differ only in case', () => {
- const value = {
- key: 'lowercase value',
- KEY: 'uppercase value',
- }
- const get = createCaseInsensitiveGetter(value)
-
- expect(get('key')).toBe('lowercase value')
- expect(get('KEY')).toBe('uppercase value')
- })
-
- it('should handle non-string key lookups by coercing to string', () => {
- const value = {
- '123': 'numeric key',
- true: 'boolean key',
- }
- const get = createCaseInsensitiveGetter(value)
-
- expect(get('123')).toBe('numeric key')
- expect(get('TRUE')).toBe('boolean key')
- })
-
- it('should handle special characters in keys', () => {
- const value = {
- 'Special-Key': 'with dash',
- Special_Key: 'with underscore',
- 'Special.Key': 'with dot',
- }
- const get = createCaseInsensitiveGetter(value)
-
- expect(get('special-key')).toBe('with dash')
- expect(get('SPECIAL_KEY')).toBe('with underscore')
- expect(get('special.key')).toBe('with dot')
- })
-
- it('should handle Unicode characters correctly', () => {
- const value = {
- CaféItem: 'coffee',
- RÉSUMÉ: 'document',
- }
- const get = createCaseInsensitiveGetter(value)
-
- expect(get('caféitem')).toBe('coffee')
- expect(get('résumé')).toBe('document')
- })
-
- it('should handle multiple lookups on the same getter', () => {
- const value = {
- First: 'first value',
- Second: 'second value',
- Third: 'third value',
- }
- const get = createCaseInsensitiveGetter(value)
-
- expect(get('first')).toBe('first value')
- expect(get('SECOND')).toBe('second value')
- expect(get('THiRd')).toBe('third value')
- })
-
- it('should handle undefined values in the object', () => {
- const value = {
- DefinedKey: 'defined value',
- UndefinedKey: undefined,
- }
- const get = createCaseInsensitiveGetter(value)
-
- expect(get('definedkey')).toBe('defined value')
- expect(get('undefinedkey')).toBeUndefined()
- // Make sure we can distinguish between non-existent keys and keys with undefined values.
- expect('UndefinedKey' in value).toBe(true)
- })
-
- it('should return undefined for non-existent keys', () => {
- const value = {
- ExistingKey: 'value',
- }
- const get = createCaseInsensitiveGetter(value)
-
- expect(get('nonexistentkey')).toBeUndefined()
- })
-
- it('should handle empty objects', () => {
- const value = {}
- const get = createCaseInsensitiveGetter(value)
-
- expect(get('anykey')).toBeUndefined()
- })
-})
-
describe('parseAuthor', () => {
const expectedFull = { name: 'John', url: 'link', avatar: '123' }
diff --git a/src/index.ts b/src/index.ts
index b93b06f9..f889da64 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -52,3 +52,7 @@ export type { YtNs } from './namespaces/yt/common/types.js'
export type { Opml } from './opml/common/types.js'
export { generate as generateOpml } from './opml/generate/index.js'
export { parse as parseOpml } from './opml/parse/index.js'
+export type { Chapters } from './related/chapters/common/types.js'
+export { detect as detectChapters } from './related/chapters/detect/index.js'
+export { generate as generateChapters } from './related/chapters/generate/index.js'
+export { parse as parseChapters } from './related/chapters/parse/index.js'
diff --git a/src/related/chapters/common/types.ts b/src/related/chapters/common/types.ts
new file mode 100644
index 00000000..ef634446
--- /dev/null
+++ b/src/related/chapters/common/types.ts
@@ -0,0 +1,40 @@
+import type { Requirable, Strict } from '../../../common/types.js'
+
+// #region reference
+export namespace Chapters {
+ export type Location = Strict<
+ {
+ name: Requirable // Required in spec.
+ geo: Requirable // Required in spec.
+ osm?: string
+ },
+ TStrict
+ >
+
+ export type Chapter = Strict<
+ {
+ startTime: Requirable // Required in spec.
+ title?: string
+ img?: string
+ url?: string
+ toc?: boolean
+ endTime?: number
+ location?: Location
+ },
+ TStrict
+ >
+
+ export type Document = Strict<
+ {
+ chapters: Requirable>> // Required in spec.
+ author?: string
+ title?: string
+ podcastName?: string
+ description?: string
+ fileName?: string
+ waypoints?: boolean
+ },
+ TStrict
+ >
+}
+// #endregion reference
diff --git a/src/related/chapters/detect/index.test.ts b/src/related/chapters/detect/index.test.ts
new file mode 100644
index 00000000..28abfb3f
--- /dev/null
+++ b/src/related/chapters/detect/index.test.ts
@@ -0,0 +1,205 @@
+import { describe, expect, it } from 'bun:test'
+import { detect } from './index.js'
+
+describe('detect', () => {
+ it('should detect chapters with version string', () => {
+ const value = {
+ version: '1.2.0',
+ chapters: [{ startTime: 0 }],
+ }
+
+ expect(detect(value)).toBe(true)
+ })
+
+ it('should detect chapters with version 1.0.0', () => {
+ const value = {
+ version: '1.0.0',
+ chapters: [{ startTime: 0 }],
+ }
+
+ expect(detect(value)).toBe(true)
+ })
+
+ it('should detect chapters with version 1.1.0', () => {
+ const value = {
+ version: '1.1.0',
+ chapters: [{ startTime: 0 }],
+ }
+
+ expect(detect(value)).toBe(true)
+ })
+
+ it('should detect chapters without version but with valid chapters array', () => {
+ const value = {
+ chapters: [{ startTime: 0, title: 'Introduction' }],
+ }
+
+ expect(detect(value)).toBe(true)
+ })
+
+ it('should detect chapters from JSON string', () => {
+ const value = JSON.stringify({
+ version: '1.2.0',
+ chapters: [{ startTime: 0 }],
+ })
+
+ expect(detect(value)).toBe(true)
+ })
+
+ it('should detect chapters from JSON string with whitespace', () => {
+ const json = JSON.stringify({
+ version: '1.2.0',
+ chapters: [{ startTime: 0 }],
+ })
+ const value = ` ${json} `
+
+ expect(detect(value)).toBe(true)
+ })
+
+ it('should not detect empty object', () => {
+ const value = {}
+
+ expect(detect(value)).toBe(false)
+ })
+
+ it('should not detect object with empty chapters array', () => {
+ const value = {
+ chapters: [],
+ }
+
+ expect(detect(value)).toBe(false)
+ })
+
+ it('should not detect chapters without startTime', () => {
+ const value = {
+ chapters: [{ title: 'Introduction' }],
+ }
+
+ expect(detect(value)).toBe(false)
+ })
+
+ it('should not detect chapters with non-number startTime', () => {
+ const value = {
+ chapters: [{ startTime: '0' }],
+ }
+
+ expect(detect(value)).toBe(false)
+ })
+
+ it('should not detect JSON Feed', () => {
+ const value = {
+ version: 'https://jsonfeed.org/version/1.1',
+ title: 'My Feed',
+ items: [],
+ }
+
+ expect(detect(value)).toBe(false)
+ })
+
+ it('should not detect invalid JSON string', () => {
+ const value = 'not valid json'
+
+ expect(detect(value)).toBe(false)
+ })
+
+ it('should not detect null', () => {
+ expect(detect(null)).toBe(false)
+ })
+
+ it('should not detect undefined', () => {
+ expect(detect(undefined)).toBe(false)
+ })
+
+ it('should not detect number', () => {
+ expect(detect(123)).toBe(false)
+ })
+
+ it('should not detect array', () => {
+ expect(detect([])).toBe(false)
+ })
+
+ it('should not detect empty string', () => {
+ expect(detect('')).toBe(false)
+ })
+
+ it('should return false for generic object', () => {
+ const genericObj = {
+ name: 'John',
+ age: 30,
+ city: 'New York',
+ }
+
+ expect(detect(genericObj)).toBe(false)
+ })
+
+ it('should return false for object with only title', () => {
+ const value = {
+ title: 'Just a title',
+ }
+
+ expect(detect(value)).toBe(false)
+ })
+
+ it('should return false for JSON string with non-chapters structure', () => {
+ const json = JSON.stringify({
+ title: 'Just a title',
+ description: 'Some description',
+ })
+
+ expect(detect(json)).toBe(false)
+ })
+
+ it('should return false for object with wrong version format', () => {
+ const value = {
+ version: '2.0',
+ chapters: [{ startTime: 0 }],
+ }
+
+ expect(detect(value)).toBe(false)
+ })
+})
+
+describe('case insensitive detection', () => {
+ it('should detect chapters with uppercase VERSION', () => {
+ const value = {
+ VERSION: '1.2.0',
+ chapters: [{ startTime: 0 }],
+ }
+
+ expect(detect(value)).toBe(true)
+ })
+
+ it('should detect chapters with uppercase CHAPTERS', () => {
+ const value = {
+ CHAPTERS: [{ startTime: 0 }],
+ }
+
+ expect(detect(value)).toBe(true)
+ })
+
+ it('should detect chapters with uppercase STARTTIME', () => {
+ const value = {
+ chapters: [{ STARTTIME: 0 }],
+ }
+
+ expect(detect(value)).toBe(true)
+ })
+
+ it('should detect chapters with mixed case properties', () => {
+ const value = {
+ Version: '1.2.0',
+ Chapters: [{ StartTime: 0 }],
+ }
+
+ expect(detect(value)).toBe(true)
+ })
+
+ it('should detect chapters with all uppercase properties', () => {
+ const value = {
+ VERSION: '1.2.0',
+ CHAPTERS: [{ STARTTIME: 0, TITLE: 'Test' }],
+ }
+
+ expect(detect(value)).toBe(true)
+ })
+})
diff --git a/src/related/chapters/detect/index.ts b/src/related/chapters/detect/index.ts
new file mode 100644
index 00000000..fff3eb94
--- /dev/null
+++ b/src/related/chapters/detect/index.ts
@@ -0,0 +1,35 @@
+import {
+ createCaseInsensitiveGetter,
+ isNonEmptyString,
+ isObject,
+ parseJsonObject,
+} from '../../../common/utils.js'
+
+export const detect = (value: unknown): value is object => {
+ const json = parseJsonObject(value)
+
+ if (!isObject(json)) {
+ return false
+ }
+
+ const get = createCaseInsensitiveGetter(json)
+ const version = get('version')
+ const chapters = get('chapters')
+
+ // If version is present, it must start with '1.'
+ if (isNonEmptyString(version)) {
+ return version.startsWith('1.')
+ }
+
+ // If no version, check for valid chapters structure
+ if (Array.isArray(chapters) && chapters.length > 0) {
+ const firstChapter = chapters[0]
+
+ if (isObject(firstChapter)) {
+ const getChapter = createCaseInsensitiveGetter(firstChapter)
+ return typeof getChapter('startTime') === 'number'
+ }
+ }
+
+ return false
+}
diff --git a/src/related/chapters/generate/index.test.ts b/src/related/chapters/generate/index.test.ts
new file mode 100644
index 00000000..a440e36a
--- /dev/null
+++ b/src/related/chapters/generate/index.test.ts
@@ -0,0 +1,238 @@
+import { describe, expect, it } from 'bun:test'
+import { generate } from './index.js'
+
+describe('generate', () => {
+ it('should generate chapters document', () => {
+ const value = {
+ chapters: [
+ {
+ startTime: 0,
+ title: 'Introduction',
+ },
+ {
+ startTime: 60,
+ title: 'Main Content',
+ },
+ ],
+ title: 'Episode Chapters',
+ }
+ const expected = {
+ version: '1.2.0',
+ chapters: [
+ {
+ startTime: 0,
+ title: 'Introduction',
+ },
+ {
+ startTime: 60,
+ title: 'Main Content',
+ },
+ ],
+ title: 'Episode Chapters',
+ }
+
+ expect(generate(value)).toEqual(expected)
+ })
+
+ it('should generate chapters with all properties', () => {
+ const value = {
+ chapters: [
+ {
+ startTime: 0,
+ title: 'At the Eiffel Tower',
+ img: 'https://example.com/eiffel.jpg',
+ url: 'https://example.com/chapter1',
+ toc: true,
+ endTime: 120,
+ location: {
+ name: 'Eiffel Tower',
+ geo: 'geo:48.8584,2.2945',
+ osm: 'W5013364',
+ },
+ },
+ ],
+ author: 'John Doe',
+ title: 'Travel Podcast Chapters',
+ podcastName: 'World Travels',
+ description: 'Chapter markers for Paris episode',
+ fileName: 'paris-chapters.json',
+ waypoints: true,
+ }
+ const expected = {
+ version: '1.2.0',
+ chapters: [
+ {
+ startTime: 0,
+ title: 'At the Eiffel Tower',
+ img: 'https://example.com/eiffel.jpg',
+ url: 'https://example.com/chapter1',
+ toc: true,
+ endTime: 120,
+ location: {
+ name: 'Eiffel Tower',
+ geo: 'geo:48.8584,2.2945',
+ osm: 'W5013364',
+ },
+ },
+ ],
+ author: 'John Doe',
+ title: 'Travel Podcast Chapters',
+ podcastName: 'World Travels',
+ description: 'Chapter markers for Paris episode',
+ fileName: 'paris-chapters.json',
+ waypoints: true,
+ }
+
+ expect(generate(value)).toEqual(expected)
+ })
+
+ it('should generate minimal chapters document', () => {
+ const value = {
+ chapters: [{ startTime: 0 }],
+ }
+ const expected = {
+ version: '1.2.0',
+ chapters: [{ startTime: 0 }],
+ }
+
+ expect(generate(value)).toEqual(expected)
+ })
+
+ it('should generate chapters with floating point times', () => {
+ const value = {
+ chapters: [{ startTime: 10.5, endTime: 20.75 }],
+ }
+ const expected = {
+ version: '1.2.0',
+ chapters: [{ startTime: 10.5, endTime: 20.75 }],
+ }
+
+ expect(generate(value)).toEqual(expected)
+ })
+
+ it('should generate document with empty object', () => {
+ const value = {}
+ const expected = {
+ version: '1.2.0',
+ }
+
+ expect(generate(value)).toEqual(expected)
+ })
+
+ it('should generate document with missing chapters (lenient)', () => {
+ const value = {
+ title: 'No chapters',
+ }
+ const expected = {
+ version: '1.2.0',
+ title: 'No chapters',
+ }
+
+ expect(generate(value)).toEqual(expected)
+ })
+
+ it('should generate document with empty chapters array (lenient)', () => {
+ const value = {
+ chapters: [],
+ }
+ const expected = {
+ version: '1.2.0',
+ }
+
+ expect(generate(value)).toEqual(expected)
+ })
+})
+
+describe('strict mode', () => {
+ it('should require chapters in strict mode', () => {
+ // @ts-expect-error: Testing missing required chapters field.
+ generate({ title: 'Test' }, { strict: true })
+ })
+
+ it('should accept document with all required fields in strict mode', () => {
+ generate({ chapters: [{ startTime: 0 }] }, { strict: true })
+ })
+
+ it('should require chapter startTime in strict mode', () => {
+ generate(
+ {
+ // @ts-expect-error: Testing missing required startTime field.
+ chapters: [{ title: 'Hello' }],
+ },
+ { strict: true },
+ )
+ })
+
+ it('should require location name and geo in strict mode', () => {
+ generate(
+ {
+ chapters: [
+ {
+ startTime: 0,
+ // @ts-expect-error: Testing missing required geo field.
+ location: { name: 'Test' },
+ },
+ ],
+ },
+ { strict: true },
+ )
+ })
+
+ it('should accept location with all required fields in strict mode', () => {
+ generate(
+ {
+ chapters: [
+ {
+ startTime: 0,
+ location: { name: 'Eiffel Tower', geo: 'geo:48.8584,2.2945' },
+ },
+ ],
+ },
+ { strict: true },
+ )
+ })
+
+ it('should accept partial document in lenient mode', () => {
+ generate({ title: 'Test' })
+ })
+})
+
+describe('generate edge cases', () => {
+ it('should accept partial documents', () => {
+ const value = {
+ title: 'Test Chapters',
+ }
+ const expected = {
+ version: '1.2.0',
+ title: 'Test Chapters',
+ }
+
+ expect(generate(value)).toEqual(expected)
+ })
+
+ it('should handle zero startTime', () => {
+ const value = {
+ chapters: [{ startTime: 0 }],
+ }
+ const expected = {
+ version: '1.2.0',
+ chapters: [{ startTime: 0 }],
+ }
+
+ expect(generate(value)).toEqual(expected)
+ })
+
+ it('should handle boolean waypoints', () => {
+ const value = {
+ chapters: [{ startTime: 0 }],
+ waypoints: true,
+ }
+ const expected = {
+ version: '1.2.0',
+ chapters: [{ startTime: 0 }],
+ waypoints: true,
+ }
+
+ expect(generate(value)).toEqual(expected)
+ })
+})
diff --git a/src/related/chapters/generate/index.ts b/src/related/chapters/generate/index.ts
new file mode 100644
index 00000000..47d6df62
--- /dev/null
+++ b/src/related/chapters/generate/index.ts
@@ -0,0 +1,7 @@
+import type { GenerateMainJson } from '../../../common/types.js'
+import type { Chapters } from '../common/types.js'
+import { generateDocument } from './utils.js'
+
+export const generate: GenerateMainJson> = (value) => {
+ return generateDocument(value as Chapters.Document)
+}
diff --git a/src/related/chapters/generate/utils.test.ts b/src/related/chapters/generate/utils.test.ts
new file mode 100644
index 00000000..652dcc45
--- /dev/null
+++ b/src/related/chapters/generate/utils.test.ts
@@ -0,0 +1,359 @@
+import { describe, expect, it } from 'bun:test'
+import { generateChapter, generateDocument, generateLocation } from './utils.js'
+
+describe('generateLocation', () => {
+ it('should generate location with all properties', () => {
+ const value = {
+ name: 'Eiffel Tower',
+ geo: 'geo:48.8584,2.2945',
+ osm: 'W5013364',
+ }
+ const expected = {
+ name: 'Eiffel Tower',
+ geo: 'geo:48.8584,2.2945',
+ osm: 'W5013364',
+ }
+
+ expect(generateLocation(value)).toEqual(expected)
+ })
+
+ it('should generate location with only required properties', () => {
+ const value = {
+ name: 'Central Park',
+ geo: 'geo:40.7829,-73.9654',
+ }
+ const expected = {
+ name: 'Central Park',
+ geo: 'geo:40.7829,-73.9654',
+ }
+
+ expect(generateLocation(value)).toEqual(expected)
+ })
+
+ it('should handle empty strings', () => {
+ const value = {
+ name: 'Location',
+ geo: 'geo:0,0',
+ osm: '',
+ }
+ const expected = {
+ name: 'Location',
+ geo: 'geo:0,0',
+ }
+
+ expect(generateLocation(value)).toEqual(expected)
+ })
+
+ it('should handle whitespace-only strings', () => {
+ const value = {
+ name: ' ',
+ geo: ' ',
+ }
+
+ expect(generateLocation(value)).toBeUndefined()
+ })
+
+ it('should handle empty object', () => {
+ expect(generateLocation({})).toBeUndefined()
+ })
+
+ it('should handle non-object inputs', () => {
+ // @ts-expect-error: This is for testing purposes.
+ expect(generateLocation('string')).toBeUndefined()
+ // @ts-expect-error: This is for testing purposes.
+ expect(generateLocation(123)).toBeUndefined()
+ expect(generateLocation(undefined)).toBeUndefined()
+ // @ts-expect-error: This is for testing purposes.
+ expect(generateLocation(null)).toBeUndefined()
+ })
+})
+
+describe('generateChapter', () => {
+ it('should generate chapter with all properties', () => {
+ const value = {
+ startTime: 0,
+ title: 'Introduction',
+ img: 'https://example.com/intro.jpg',
+ url: 'https://example.com/intro',
+ toc: true,
+ endTime: 60,
+ location: {
+ name: 'Studio',
+ geo: 'geo:40.7128,-74.0060',
+ osm: 'R175905',
+ },
+ }
+ const expected = {
+ startTime: 0,
+ title: 'Introduction',
+ img: 'https://example.com/intro.jpg',
+ url: 'https://example.com/intro',
+ toc: true,
+ endTime: 60,
+ location: {
+ name: 'Studio',
+ geo: 'geo:40.7128,-74.0060',
+ osm: 'R175905',
+ },
+ }
+
+ expect(generateChapter(value)).toEqual(expected)
+ })
+
+ it('should generate chapter with only required startTime', () => {
+ const value = {
+ startTime: 120,
+ }
+ const expected = {
+ startTime: 120,
+ }
+
+ expect(generateChapter(value)).toEqual(expected)
+ })
+
+ it('should generate chapter with toc set to false', () => {
+ const value = {
+ startTime: 0,
+ title: 'Hidden Chapter',
+ toc: false,
+ }
+ const expected = {
+ startTime: 0,
+ title: 'Hidden Chapter',
+ toc: false,
+ }
+
+ expect(generateChapter(value)).toEqual(expected)
+ })
+
+ it('should handle floating point times', () => {
+ const value = {
+ startTime: 10.5,
+ endTime: 20.75,
+ }
+ const expected = {
+ startTime: 10.5,
+ endTime: 20.75,
+ }
+
+ expect(generateChapter(value)).toEqual(expected)
+ })
+
+ it('should handle startTime of zero', () => {
+ const value = {
+ startTime: 0,
+ }
+ const expected = {
+ startTime: 0,
+ }
+
+ expect(generateChapter(value)).toEqual(expected)
+ })
+
+ it('should handle empty strings', () => {
+ const value = {
+ startTime: 0,
+ title: '',
+ img: '',
+ url: '',
+ }
+ const expected = {
+ startTime: 0,
+ }
+
+ expect(generateChapter(value)).toEqual(expected)
+ })
+
+ it('should handle whitespace-only strings', () => {
+ const value = {
+ startTime: 0,
+ title: ' ',
+ }
+ const expected = {
+ startTime: 0,
+ }
+
+ expect(generateChapter(value)).toEqual(expected)
+ })
+
+ it('should handle empty object', () => {
+ expect(generateChapter({})).toBeUndefined()
+ })
+
+ it('should handle non-object inputs', () => {
+ // @ts-expect-error: This is for testing purposes.
+ expect(generateChapter('string')).toBeUndefined()
+ // @ts-expect-error: This is for testing purposes.
+ expect(generateChapter(123)).toBeUndefined()
+ expect(generateChapter(undefined)).toBeUndefined()
+ // @ts-expect-error: This is for testing purposes.
+ expect(generateChapter(null)).toBeUndefined()
+ })
+})
+
+describe('generateDocument', () => {
+ it('should generate document with all properties', () => {
+ const value = {
+ chapters: [
+ {
+ startTime: 0,
+ title: 'Introduction',
+ img: 'https://example.com/intro.jpg',
+ url: 'https://example.com/intro',
+ toc: true,
+ endTime: 60,
+ },
+ {
+ startTime: 60,
+ title: 'Main Content',
+ toc: true,
+ },
+ ],
+ author: 'John Doe',
+ title: 'Episode Chapters',
+ podcastName: 'My Podcast',
+ description: 'Chapter markers for episode 1',
+ fileName: 'episode1-chapters.json',
+ waypoints: false,
+ }
+ const expected = {
+ version: '1.2.0',
+ chapters: [
+ {
+ startTime: 0,
+ title: 'Introduction',
+ img: 'https://example.com/intro.jpg',
+ url: 'https://example.com/intro',
+ toc: true,
+ endTime: 60,
+ },
+ {
+ startTime: 60,
+ title: 'Main Content',
+ toc: true,
+ },
+ ],
+ author: 'John Doe',
+ title: 'Episode Chapters',
+ podcastName: 'My Podcast',
+ description: 'Chapter markers for episode 1',
+ fileName: 'episode1-chapters.json',
+ waypoints: false,
+ }
+
+ expect(generateDocument(value)).toEqual(expected)
+ })
+
+ it('should generate document with only chapters', () => {
+ const value = {
+ chapters: [{ startTime: 0 }, { startTime: 30 }],
+ }
+ const expected = {
+ version: '1.2.0',
+ chapters: [{ startTime: 0 }, { startTime: 30 }],
+ }
+
+ expect(generateDocument(value)).toEqual(expected)
+ })
+
+ it('should generate document with chapters containing locations', () => {
+ const value = {
+ chapters: [
+ {
+ startTime: 0,
+ title: 'At the Eiffel Tower',
+ location: {
+ name: 'Eiffel Tower',
+ geo: 'geo:48.8584,2.2945',
+ osm: 'W5013364',
+ },
+ },
+ ],
+ }
+ const expected = {
+ version: '1.2.0',
+ chapters: [
+ {
+ startTime: 0,
+ title: 'At the Eiffel Tower',
+ location: {
+ name: 'Eiffel Tower',
+ geo: 'geo:48.8584,2.2945',
+ osm: 'W5013364',
+ },
+ },
+ ],
+ }
+
+ expect(generateDocument(value)).toEqual(expected)
+ })
+
+ it('should generate document with waypoints set to true', () => {
+ const value = {
+ chapters: [{ startTime: 0 }],
+ waypoints: true,
+ }
+ const expected = {
+ version: '1.2.0',
+ chapters: [{ startTime: 0 }],
+ waypoints: true,
+ }
+
+ expect(generateDocument(value)).toEqual(expected)
+ })
+
+ it('should handle empty chapters array', () => {
+ const value = {
+ chapters: [],
+ }
+ const expected = {
+ version: '1.2.0',
+ }
+
+ expect(generateDocument(value)).toEqual(expected)
+ })
+
+ it('should handle empty strings', () => {
+ const value = {
+ chapters: [{ startTime: 0 }],
+ author: '',
+ title: '',
+ }
+ const expected = {
+ version: '1.2.0',
+ chapters: [{ startTime: 0 }],
+ }
+
+ expect(generateDocument(value)).toEqual(expected)
+ })
+
+ it('should handle whitespace-only strings', () => {
+ const value = {
+ chapters: [],
+ author: ' ',
+ }
+ const expected = {
+ version: '1.2.0',
+ }
+
+ expect(generateDocument(value)).toEqual(expected)
+ })
+
+ it('should handle empty object', () => {
+ const expected = {
+ version: '1.2.0',
+ }
+
+ expect(generateDocument({})).toEqual(expected)
+ })
+
+ it('should handle non-object inputs', () => {
+ // @ts-expect-error: This is for testing purposes.
+ expect(generateDocument('string')).toBeUndefined()
+ // @ts-expect-error: This is for testing purposes.
+ expect(generateDocument(123)).toBeUndefined()
+ expect(generateDocument(undefined)).toBeUndefined()
+ // @ts-expect-error: This is for testing purposes.
+ expect(generateDocument(null)).toBeUndefined()
+ })
+})
diff --git a/src/related/chapters/generate/utils.ts b/src/related/chapters/generate/utils.ts
new file mode 100644
index 00000000..b70c7d63
--- /dev/null
+++ b/src/related/chapters/generate/utils.ts
@@ -0,0 +1,61 @@
+import type { GenerateUtil } from '../../../common/types.js'
+import {
+ generateBoolean,
+ generateNumber,
+ generatePlainString,
+ isObject,
+ trimArray,
+ trimObject,
+} from '../../../common/utils.js'
+import type { Chapters } from '../common/types.js'
+
+export const generateLocation: GenerateUtil = (location) => {
+ if (!isObject(location)) {
+ return
+ }
+
+ const value = {
+ name: generatePlainString(location.name),
+ geo: generatePlainString(location.geo),
+ osm: generatePlainString(location.osm),
+ }
+
+ return trimObject(value)
+}
+
+export const generateChapter: GenerateUtil = (chapter) => {
+ if (!isObject(chapter)) {
+ return
+ }
+
+ const value = {
+ startTime: generateNumber(chapter.startTime),
+ title: generatePlainString(chapter.title),
+ img: generatePlainString(chapter.img),
+ url: generatePlainString(chapter.url),
+ toc: generateBoolean(chapter.toc),
+ endTime: generateNumber(chapter.endTime),
+ location: generateLocation(chapter.location),
+ }
+
+ return trimObject(value)
+}
+
+export const generateDocument: GenerateUtil = (document) => {
+ if (!isObject(document)) {
+ return
+ }
+
+ const value = {
+ version: '1.2.0',
+ chapters: trimArray(document.chapters, generateChapter),
+ author: generatePlainString(document.author),
+ title: generatePlainString(document.title),
+ podcastName: generatePlainString(document.podcastName),
+ description: generatePlainString(document.description),
+ fileName: generatePlainString(document.fileName),
+ waypoints: generateBoolean(document.waypoints),
+ }
+
+ return trimObject(value)
+}
diff --git a/src/related/chapters/parse/index.test.ts b/src/related/chapters/parse/index.test.ts
new file mode 100644
index 00000000..5b232f80
--- /dev/null
+++ b/src/related/chapters/parse/index.test.ts
@@ -0,0 +1,224 @@
+import { describe, expect, it } from 'bun:test'
+import { locales } from '../../../common/config.js'
+import { parse } from './index.js'
+
+describe('parse', () => {
+ it('should parse valid JSON chapters string', () => {
+ const value = JSON.stringify({
+ version: '1.2.0',
+ chapters: [
+ {
+ startTime: 0,
+ title: 'Introduction',
+ },
+ {
+ startTime: 60,
+ title: 'Main Content',
+ },
+ ],
+ title: 'Episode Chapters',
+ })
+ const expected = {
+ chapters: [
+ {
+ startTime: 0,
+ title: 'Introduction',
+ },
+ {
+ startTime: 60,
+ title: 'Main Content',
+ },
+ ],
+ title: 'Episode Chapters',
+ }
+
+ expect(parse(value)).toEqual(expected)
+ })
+
+ it('should parse already-parsed object', () => {
+ const value = {
+ version: '1.2.0',
+ chapters: [
+ {
+ startTime: 0,
+ title: 'Introduction',
+ },
+ ],
+ title: 'Episode Chapters',
+ }
+ const expected = {
+ chapters: [
+ {
+ startTime: 0,
+ title: 'Introduction',
+ },
+ ],
+ title: 'Episode Chapters',
+ }
+
+ expect(parse(value)).toEqual(expected)
+ })
+
+ it('should parse chapters with all properties', () => {
+ const value = JSON.stringify({
+ version: '1.2.0',
+ chapters: [
+ {
+ startTime: 0,
+ title: 'At the Eiffel Tower',
+ img: 'https://example.com/eiffel.jpg',
+ url: 'https://example.com/chapter1',
+ toc: true,
+ endTime: 120,
+ location: {
+ name: 'Eiffel Tower',
+ geo: 'geo:48.8584,2.2945',
+ osm: 'W5013364',
+ },
+ },
+ ],
+ author: 'John Doe',
+ title: 'Travel Podcast Chapters',
+ podcastName: 'World Travels',
+ description: 'Chapter markers for Paris episode',
+ fileName: 'paris-chapters.json',
+ waypoints: true,
+ })
+ const expected = {
+ chapters: [
+ {
+ startTime: 0,
+ title: 'At the Eiffel Tower',
+ img: 'https://example.com/eiffel.jpg',
+ url: 'https://example.com/chapter1',
+ toc: true,
+ endTime: 120,
+ location: {
+ name: 'Eiffel Tower',
+ geo: 'geo:48.8584,2.2945',
+ osm: 'W5013364',
+ },
+ },
+ ],
+ author: 'John Doe',
+ title: 'Travel Podcast Chapters',
+ podcastName: 'World Travels',
+ description: 'Chapter markers for Paris episode',
+ fileName: 'paris-chapters.json',
+ waypoints: true,
+ }
+
+ expect(parse(value)).toEqual(expected)
+ })
+
+ it('should parse minimal valid chapters document', () => {
+ const value = JSON.stringify({
+ version: '1.2.0',
+ chapters: [{ startTime: 0 }],
+ })
+ const expected = {
+ chapters: [{ startTime: 0 }],
+ }
+
+ expect(parse(value)).toEqual(expected)
+ })
+
+ it('should parse chapters from string with leading whitespace', () => {
+ const json = JSON.stringify({
+ version: '1.2.0',
+ chapters: [{ startTime: 0, title: 'Test' }],
+ })
+ const value = ` ${json}`
+ const expected = {
+ chapters: [{ startTime: 0, title: 'Test' }],
+ }
+
+ expect(parse(value)).toEqual(expected)
+ })
+
+ it('should parse chapters from string with trailing whitespace', () => {
+ const json = JSON.stringify({
+ version: '1.2.0',
+ chapters: [{ startTime: 0, title: 'Test' }],
+ })
+ const value = `${json} `
+ const expected = {
+ chapters: [{ startTime: 0, title: 'Test' }],
+ }
+
+ expect(parse(value)).toEqual(expected)
+ })
+
+ it('should parse chapters from string with whitespace on both ends', () => {
+ const json = JSON.stringify({
+ version: '1.2.0',
+ chapters: [{ startTime: 0, title: 'Test' }],
+ })
+ const value = ` ${json} `
+ const expected = {
+ chapters: [{ startTime: 0, title: 'Test' }],
+ }
+
+ expect(parse(value)).toEqual(expected)
+ })
+
+ it('should handle malformed JSON string', () => {
+ const value = '{"version":"1.2.0","chapters":[{"startTime":0'
+
+ expect(() => parse(value)).toThrowError(locales.invalidInputChapters)
+ })
+
+ it('should parse document with missing chapters (lenient)', () => {
+ const value = JSON.stringify({
+ version: '1.2.0',
+ title: 'No chapters',
+ })
+ const expected = {
+ title: 'No chapters',
+ }
+
+ expect(parse(value)).toEqual(expected)
+ })
+
+ it('should throw error for empty object', () => {
+ expect(() => parse({})).toThrowError(locales.invalidInputChapters)
+ })
+
+ it('should throw error for empty chapters array', () => {
+ expect(() => parse({ chapters: [] })).toThrowError(locales.invalidInputChapters)
+ })
+
+ it('should throw error for invalid string', () => {
+ expect(() => parse('not valid json')).toThrowError(locales.invalidInputChapters)
+ })
+
+ it('should throw error for null', () => {
+ expect(() => parse(null)).toThrowError(locales.invalidInputChapters)
+ })
+
+ it('should throw error for undefined', () => {
+ expect(() => parse(undefined)).toThrowError(locales.invalidInputChapters)
+ })
+
+ it('should throw error for array', () => {
+ expect(() => parse([])).toThrowError(locales.invalidInputChapters)
+ })
+
+ it('should throw error for number', () => {
+ expect(() => parse(123)).toThrowError(locales.invalidInputChapters)
+ })
+
+ it('should handle case insensitive fields', () => {
+ const value = {
+ VERSION: '1.2.0',
+ CHAPTERS: [{ STARTTIME: 0, TITLE: 'Introduction' }],
+ AUTHOR: 'John Doe',
+ }
+ const expected = {
+ chapters: [{ startTime: 0, title: 'Introduction' }],
+ author: 'John Doe',
+ }
+
+ expect(parse(value)).toEqual(expected)
+ })
+})
diff --git a/src/related/chapters/parse/index.ts b/src/related/chapters/parse/index.ts
new file mode 100644
index 00000000..bf962a1c
--- /dev/null
+++ b/src/related/chapters/parse/index.ts
@@ -0,0 +1,21 @@
+import { locales } from '../../../common/config.js'
+import { parseJsonObject } from '../../../common/utils.js'
+import { detectChapters } from '../../../index.js'
+import type { Chapters } from '../common/types.js'
+import { parseDocument } from './utils.js'
+
+export const parse = (value: unknown): Chapters.Document => {
+ const json = parseJsonObject(value)
+
+ if (!detectChapters(json)) {
+ throw new Error(locales.invalidInputChapters)
+ }
+
+ const parsed = parseDocument(json)
+
+ if (!parsed) {
+ throw new Error(locales.invalidInputChapters)
+ }
+
+ return parsed
+}
diff --git a/src/related/chapters/parse/utils.test.ts b/src/related/chapters/parse/utils.test.ts
new file mode 100644
index 00000000..271957c6
--- /dev/null
+++ b/src/related/chapters/parse/utils.test.ts
@@ -0,0 +1,445 @@
+import { describe, expect, it } from 'bun:test'
+import { parseChapter, parseDocument, parseLocation } from './utils.js'
+
+describe('parseLocation', () => {
+ it('should parse location with all properties', () => {
+ const value = {
+ name: 'Eiffel Tower',
+ geo: 'geo:48.8584,2.2945',
+ osm: 'W5013364',
+ }
+ const expected = {
+ name: 'Eiffel Tower',
+ geo: 'geo:48.8584,2.2945',
+ osm: 'W5013364',
+ }
+
+ expect(parseLocation(value)).toEqual(expected)
+ })
+
+ it('should parse location with only required properties', () => {
+ const value = {
+ name: 'Central Park',
+ geo: 'geo:40.7829,-73.9654',
+ }
+ const expected = {
+ name: 'Central Park',
+ geo: 'geo:40.7829,-73.9654',
+ }
+
+ expect(parseLocation(value)).toEqual(expected)
+ })
+
+ it('should handle empty strings', () => {
+ const value = {
+ name: 'Location',
+ geo: 'geo:0,0',
+ osm: '',
+ }
+ const expected = {
+ name: 'Location',
+ geo: 'geo:0,0',
+ }
+
+ expect(parseLocation(value)).toEqual(expected)
+ })
+
+ it('should handle whitespace-only strings', () => {
+ const value = {
+ name: ' ',
+ geo: ' ',
+ }
+
+ expect(parseLocation(value)).toBeUndefined()
+ })
+
+ it('should return undefined for empty object', () => {
+ const value = {}
+
+ expect(parseLocation(value)).toBeUndefined()
+ })
+
+ it('should return undefined for non-object inputs', () => {
+ expect(parseLocation(null)).toBeUndefined()
+ expect(parseLocation(undefined)).toBeUndefined()
+ expect(parseLocation('string')).toBeUndefined()
+ expect(parseLocation(123)).toBeUndefined()
+ })
+})
+
+describe('parseChapter', () => {
+ it('should parse chapter with all properties', () => {
+ const value = {
+ startTime: 0,
+ title: 'Introduction',
+ img: 'https://example.com/intro.jpg',
+ url: 'https://example.com/intro',
+ toc: true,
+ endTime: 60,
+ location: {
+ name: 'Studio',
+ geo: 'geo:40.7128,-74.0060',
+ osm: 'R175905',
+ },
+ }
+ const expected = {
+ startTime: 0,
+ title: 'Introduction',
+ img: 'https://example.com/intro.jpg',
+ url: 'https://example.com/intro',
+ toc: true,
+ endTime: 60,
+ location: {
+ name: 'Studio',
+ geo: 'geo:40.7128,-74.0060',
+ osm: 'R175905',
+ },
+ }
+
+ expect(parseChapter(value)).toEqual(expected)
+ })
+
+ it('should parse chapter with only required startTime', () => {
+ const value = {
+ startTime: 120,
+ }
+ const expected = {
+ startTime: 120,
+ }
+
+ expect(parseChapter(value)).toEqual(expected)
+ })
+
+ it('should parse chapter with toc set to false', () => {
+ const value = {
+ startTime: 0,
+ title: 'Hidden Chapter',
+ toc: false,
+ }
+ const expected = {
+ startTime: 0,
+ title: 'Hidden Chapter',
+ toc: false,
+ }
+
+ expect(parseChapter(value)).toEqual(expected)
+ })
+
+ it('should handle startTime as string number', () => {
+ const value = {
+ startTime: '90.5',
+ }
+ const expected = {
+ startTime: 90.5,
+ }
+
+ expect(parseChapter(value)).toEqual(expected)
+ })
+
+ it('should handle floating point times', () => {
+ const value = {
+ startTime: 10.5,
+ endTime: 20.75,
+ }
+ const expected = {
+ startTime: 10.5,
+ endTime: 20.75,
+ }
+
+ expect(parseChapter(value)).toEqual(expected)
+ })
+
+ it('should handle empty strings', () => {
+ const value = {
+ startTime: 0,
+ title: '',
+ img: '',
+ url: '',
+ }
+ const expected = {
+ startTime: 0,
+ }
+
+ expect(parseChapter(value)).toEqual(expected)
+ })
+
+ it('should handle whitespace-only strings', () => {
+ const value = {
+ startTime: 0,
+ title: ' ',
+ }
+ const expected = {
+ startTime: 0,
+ }
+
+ expect(parseChapter(value)).toEqual(expected)
+ })
+
+ it('should return undefined for empty object', () => {
+ const value = {}
+
+ expect(parseChapter(value)).toBeUndefined()
+ })
+
+ it('should return undefined for non-object inputs', () => {
+ expect(parseChapter(null)).toBeUndefined()
+ expect(parseChapter(undefined)).toBeUndefined()
+ expect(parseChapter('string')).toBeUndefined()
+ expect(parseChapter(123)).toBeUndefined()
+ })
+})
+
+describe('parseDocument', () => {
+ const expectedFull = {
+ chapters: [
+ {
+ startTime: 0,
+ title: 'Introduction',
+ img: 'https://example.com/intro.jpg',
+ url: 'https://example.com/intro',
+ toc: true,
+ endTime: 60,
+ },
+ {
+ startTime: 60,
+ title: 'Main Content',
+ toc: true,
+ },
+ ],
+ author: 'John Doe',
+ title: 'Episode Chapters',
+ podcastName: 'My Podcast',
+ description: 'Chapter markers for episode 1',
+ fileName: 'episode1-chapters.json',
+ waypoints: false,
+ }
+
+ it('should parse document with all properties', () => {
+ const value = {
+ version: '1.2.0',
+ chapters: [
+ {
+ startTime: 0,
+ title: 'Introduction',
+ img: 'https://example.com/intro.jpg',
+ url: 'https://example.com/intro',
+ toc: true,
+ endTime: 60,
+ },
+ {
+ startTime: 60,
+ title: 'Main Content',
+ toc: true,
+ },
+ ],
+ author: 'John Doe',
+ title: 'Episode Chapters',
+ podcastName: 'My Podcast',
+ description: 'Chapter markers for episode 1',
+ fileName: 'episode1-chapters.json',
+ waypoints: false,
+ }
+
+ expect(parseDocument(value)).toEqual(expectedFull)
+ })
+
+ it('should parse document with only chapters', () => {
+ const value = {
+ version: '1.2.0',
+ chapters: [{ startTime: 0 }, { startTime: 30 }],
+ }
+ const expected = {
+ chapters: [{ startTime: 0 }, { startTime: 30 }],
+ }
+
+ expect(parseDocument(value)).toEqual(expected)
+ })
+
+ it('should parse document with chapters containing locations', () => {
+ const value = {
+ version: '1.2.0',
+ chapters: [
+ {
+ startTime: 0,
+ title: 'At the Eiffel Tower',
+ location: {
+ name: 'Eiffel Tower',
+ geo: 'geo:48.8584,2.2945',
+ osm: 'W5013364',
+ },
+ },
+ ],
+ }
+ const expected = {
+ chapters: [
+ {
+ startTime: 0,
+ title: 'At the Eiffel Tower',
+ location: {
+ name: 'Eiffel Tower',
+ geo: 'geo:48.8584,2.2945',
+ osm: 'W5013364',
+ },
+ },
+ ],
+ }
+
+ expect(parseDocument(value)).toEqual(expected)
+ })
+
+ it('should parse document with waypoints set to true', () => {
+ const value = {
+ version: '1.2.0',
+ chapters: [{ startTime: 0 }],
+ waypoints: true,
+ }
+ const expected = {
+ chapters: [{ startTime: 0 }],
+ waypoints: true,
+ }
+
+ expect(parseDocument(value)).toEqual(expected)
+ })
+
+ it('should handle coercible values', () => {
+ const value = {
+ version: 1.2,
+ chapters: [{ startTime: '0' }],
+ }
+ const expected = {
+ chapters: [{ startTime: 0 }],
+ }
+
+ expect(parseDocument(value)).toEqual(expected)
+ })
+
+ it('should handle empty chapters array', () => {
+ const value = {
+ version: '1.2.0',
+ chapters: [],
+ }
+
+ expect(parseDocument(value)).toBeUndefined()
+ })
+
+ it('should handle empty strings', () => {
+ const value = {
+ version: '1.2.0',
+ chapters: [{ startTime: 0 }],
+ author: '',
+ title: '',
+ }
+ const expected = {
+ chapters: [{ startTime: 0 }],
+ }
+
+ expect(parseDocument(value)).toEqual(expected)
+ })
+
+ it('should handle whitespace-only strings', () => {
+ const value = {
+ version: ' ',
+ chapters: [],
+ }
+
+ expect(parseDocument(value)).toBeUndefined()
+ })
+
+ it('should return undefined for empty object', () => {
+ const value = {}
+
+ expect(parseDocument(value)).toBeUndefined()
+ })
+
+ it('should return undefined for non-object inputs', () => {
+ expect(parseDocument(null)).toBeUndefined()
+ expect(parseDocument(undefined)).toBeUndefined()
+ expect(parseDocument('string')).toBeUndefined()
+ expect(parseDocument(123)).toBeUndefined()
+ })
+})
+
+describe('case insensitive parsing', () => {
+ it('should parse document with uppercase property names', () => {
+ const value = {
+ VERSION: '1.2.0',
+ CHAPTERS: [{ STARTTIME: 0, TITLE: 'Introduction' }],
+ AUTHOR: 'John Doe',
+ TITLE: 'Episode Chapters',
+ }
+ const expected = {
+ chapters: [{ startTime: 0, title: 'Introduction' }],
+ author: 'John Doe',
+ title: 'Episode Chapters',
+ }
+
+ expect(parseDocument(value)).toEqual(expected)
+ })
+
+ it('should parse document with mixed case property names', () => {
+ const value = {
+ Version: '1.2.0',
+ Chapters: [{ StartTime: 0, Title: 'Introduction' }],
+ Author: 'John Doe',
+ PodcastName: 'My Podcast',
+ }
+ const expected = {
+ chapters: [{ startTime: 0, title: 'Introduction' }],
+ author: 'John Doe',
+ podcastName: 'My Podcast',
+ }
+
+ expect(parseDocument(value)).toEqual(expected)
+ })
+
+ it('should parse chapter with uppercase property names', () => {
+ const value = {
+ STARTTIME: 0,
+ TITLE: 'Introduction',
+ IMG: 'https://example.com/intro.jpg',
+ URL: 'https://example.com/intro',
+ TOC: true,
+ ENDTIME: 60,
+ }
+ const expected = {
+ startTime: 0,
+ title: 'Introduction',
+ img: 'https://example.com/intro.jpg',
+ url: 'https://example.com/intro',
+ toc: true,
+ endTime: 60,
+ }
+
+ expect(parseChapter(value)).toEqual(expected)
+ })
+
+ it('should parse location with uppercase property names', () => {
+ const value = {
+ NAME: 'Eiffel Tower',
+ GEO: 'geo:48.8584,2.2945',
+ OSM: 'W5013364',
+ }
+ const expected = {
+ name: 'Eiffel Tower',
+ geo: 'geo:48.8584,2.2945',
+ osm: 'W5013364',
+ }
+
+ expect(parseLocation(value)).toEqual(expected)
+ })
+
+ it('should prefer exact case match over case-insensitive match', () => {
+ const value = {
+ startTime: 0,
+ STARTTIME: 999,
+ title: 'Correct',
+ TITLE: 'Wrong',
+ }
+ const expected = {
+ startTime: 0,
+ title: 'Correct',
+ }
+
+ expect(parseChapter(value)).toEqual(expected)
+ })
+})
diff --git a/src/related/chapters/parse/utils.ts b/src/related/chapters/parse/utils.ts
new file mode 100644
index 00000000..e2d2705e
--- /dev/null
+++ b/src/related/chapters/parse/utils.ts
@@ -0,0 +1,80 @@
+import type { ParseUtilPartial } from '../../../common/types.js'
+import {
+ isObject,
+ parseArrayOf,
+ parseBoolean,
+ parseNumber,
+ parseSingularOf,
+ parseString,
+ trimObject,
+} from '../../../common/utils.js'
+import type { Chapters } from '../common/types.js'
+
+const createCaseInsensitiveGetter = (value: Record) => {
+ return (requestedKey: string) => {
+ if (requestedKey in value) {
+ return value[requestedKey]
+ }
+
+ const lowerKey = requestedKey.toLowerCase()
+
+ for (const key in value) {
+ if (key.toLowerCase() === lowerKey) {
+ return value[key]
+ }
+ }
+ }
+}
+
+export const parseLocation: ParseUtilPartial = (value) => {
+ if (!isObject(value)) {
+ return
+ }
+
+ const get = createCaseInsensitiveGetter(value)
+ const location = {
+ name: parseSingularOf(get('name'), parseString),
+ geo: parseSingularOf(get('geo'), parseString),
+ osm: parseSingularOf(get('osm'), parseString),
+ }
+
+ return trimObject(location)
+}
+
+export const parseChapter: ParseUtilPartial = (value) => {
+ if (!isObject(value)) {
+ return
+ }
+
+ const get = createCaseInsensitiveGetter(value)
+ const chapter = {
+ startTime: parseSingularOf(get('startTime'), parseNumber),
+ title: parseSingularOf(get('title'), parseString),
+ img: parseSingularOf(get('img'), parseString),
+ url: parseSingularOf(get('url'), parseString),
+ toc: parseSingularOf(get('toc'), parseBoolean),
+ endTime: parseSingularOf(get('endTime'), parseNumber),
+ location: parseSingularOf(get('location'), parseLocation),
+ }
+
+ return trimObject(chapter)
+}
+
+export const parseDocument: ParseUtilPartial = (value) => {
+ if (!isObject(value)) {
+ return
+ }
+
+ const get = createCaseInsensitiveGetter(value)
+ const document = {
+ chapters: parseArrayOf(get('chapters'), parseChapter),
+ author: parseSingularOf(get('author'), parseString),
+ title: parseSingularOf(get('title'), parseString),
+ podcastName: parseSingularOf(get('podcastName'), parseString),
+ description: parseSingularOf(get('description'), parseString),
+ fileName: parseSingularOf(get('fileName'), parseString),
+ waypoints: parseSingularOf(get('waypoints'), parseBoolean),
+ }
+
+ return trimObject(document)
+}