diff --git a/README.md b/README.md index 18ecf3da..00bd2d0a 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,6 @@ Feedsmith offers universal and format‑specific parsers that maintain the origi --- -> [!IMPORTANT] -> **Feedsmith 3.x is in final stages of development.** Check out the [v3.x guide](https://v3.feedsmith.dev/migration/v2-to-v3) to explore new features and learn how to upgrade. Install it with: -> -> `npm install feedsmith@next` - ---- - ## Features ### Core @@ -53,7 +46,7 @@ Feedsmith offers universal and format‑specific parsers that maintain the origi ## Supported Formats -Feedsmith aims to fully support all major feed formats and namespaces in complete alignment with their specifications. +Feedsmith aims to fully support all major feed formats and namespaces in complete alignment with their specs. ✅ Available   ·   @@ -112,6 +105,7 @@ Feedsmith aims to fully support all major feed formats and namespaces in complet | [W3C Basic Geo](https://feedsmith.dev/reference/namespaces/geo) | `` | RSS, Atom | ✅ | ✅ | | [GeoRSS Simple](https://feedsmith.dev/reference/namespaces/georss) | `` | RSS, Atom, RDF | ✅ | ✅ | | [RDF](https://feedsmith.dev/reference/namespaces/rdf) | `` | RDF | ✅ | ✅ | +| [XML](https://feedsmith.dev/reference/namespaces/xml) | `` | RSS, Atom, RDF | ✅ | ✅ | ## Quick Start @@ -231,7 +225,7 @@ try { Feedsmith provides comprehensive TypeScript types for all feed formats: ```typescript -import type { Rss, Atom, Json, Opml } from 'feedsmith/types' +import type { Rss, Atom, Json, Opml } from 'feedsmith' // Access all types for a format type Feed = Rss.Feed diff --git a/benchmarks/javascript/bun.lock b/benchmarks/javascript/bun.lock index a7504fcd..c82cddf7 100644 --- a/benchmarks/javascript/bun.lock +++ b/benchmarks/javascript/bun.lock @@ -11,7 +11,7 @@ "benchmark": "^2.1.4", "feedme": "^2.0.2", "feedparser": "^2.3.1", - "feedsmith": "2.9.3", + "feedsmith": "2.9.4", "node-opml-parser": "^1.0.0", "opml": "^0.5.8", "opml-generator": "^1.1.1", @@ -19,7 +19,7 @@ "opmlparser": "^0.8.0", "podcast-feed-parser": "^1.0.4", "rss-parser": "^3.13.0", - "tinybench": "^6.0.0", + "tinybench": "^6.0.1", }, "devDependencies": { "@types/benchmark": "^2.1.5", @@ -122,7 +122,7 @@ "feedparser": ["feedparser@2.3.1", "", { "dependencies": { "addressparser": "^1.0.1", "array-indexofobject": "~0.0.1", "lodash.assign": "^4.2.0", "lodash.get": "^4.4.2", "lodash.has": "^4.5.2", "lodash.uniq": "^4.5.0", "mri": "^1.1.5", "readable-stream": "^2.3.7", "sax": ">=1.2.4 <1.4.4" }, "bin": { "feedparser": "bin/feedparser.js" } }, "sha512-YTeYlA9+7UVj1tHMs4L27JypeghmA3wjE3ZUWuv/N9L3vDxuYe+5fOM14MxkCAyNzMtCHaQ+1KoQAHJfJoy0Vw=="], - "feedsmith": ["feedsmith@2.9.3", "", { "dependencies": { "entities": "^7.0.1", "fast-xml-parser": "~5.7.1" } }, "sha512-H2Dj/gax2p61HszgUdhORg4Wtpfz9wu6w6fhloEWovcx2xF9+QzzNJvHisn4Vr2yoozjQpH75pq2OLf9/JY8Gg=="], + "feedsmith": ["feedsmith@2.9.4", "", { "dependencies": { "entities": "^7.0.1", "fast-xml-parser": "~5.7.2" } }, "sha512-mjBxjSQ52mZQQZmSwSwB4e+Ig6KHadjUG9rwNOb6t6s6F1mO/qFrijBi5TKjtjExfi6/eJ6v30TsaPgcYlQeNA=="], "follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="], @@ -246,7 +246,7 @@ "strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="], - "tinybench": ["tinybench@6.0.0", "", {}, "sha512-BWlWpVbbZXaYjRV0twGLNQO00Zj4HA/sjLOQP2IvzQqGwRGp+2kh7UU3ijyJ3ywFRogYDRbiHDMrUOfaMnN56g=="], + "tinybench": ["tinybench@6.0.1", "", {}, "sha512-cMdWsxmysdg8mNWf1pujiWl3TW0cU6m8QuNw55QlnP3I6N96Grb0wnu5N0syHIu3LbiVZCNqlfWzWDq84HZphA=="], "tough-cookie": ["tough-cookie@2.5.0", "", { "dependencies": { "psl": "^1.1.28", "punycode": "^2.1.1" } }, "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g=="], @@ -286,7 +286,7 @@ "@ulisesgascon/rss-feed-parser/fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="], - "feedsmith/fast-xml-parser": ["fast-xml-parser@5.7.1", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA=="], + "feedsmith/fast-xml-parser": ["fast-xml-parser@5.7.2", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w=="], "node-opml-parser/sax": ["sax@1.1.5", "", {}, "sha512-z19WXQiOz8RBu3zDpOE9541RgB7Q5NecZ7SAgU3yUvqhMNvG4hTChbrutzpDyDSHmeHouR5Rqk4eup1o6C6dxQ=="], diff --git a/benchmarks/javascript/package.json b/benchmarks/javascript/package.json index 7dec5417..dc97b43c 100644 --- a/benchmarks/javascript/package.json +++ b/benchmarks/javascript/package.json @@ -9,7 +9,7 @@ "benchmark": "^2.1.4", "feedme": "^2.0.2", "feedparser": "^2.3.1", - "feedsmith": "2.9.3", + "feedsmith": "2.9.4", "node-opml-parser": "^1.0.0", "opml": "^0.5.8", "opml-generator": "^1.1.1", @@ -17,7 +17,7 @@ "opmlparser": "^0.8.0", "podcast-feed-parser": "^1.0.4", "rss-parser": "^3.13.0", - "tinybench": "^6.0.0" + "tinybench": "^6.0.1" }, "devDependencies": { "@types/benchmark": "^2.1.5", diff --git a/compatibility/bundler/esm/index.ts b/compatibility/bundler/esm/index.ts index c600c46b..ed0f94dc 100644 --- a/compatibility/bundler/esm/index.ts +++ b/compatibility/bundler/esm/index.ts @@ -1,6 +1,5 @@ -import type { RssFeed } from 'feedsmith' +import type { Rss } from 'feedsmith' import { generateRssFeed, parseFeed } from 'feedsmith' -import type { Rss } from 'feedsmith/types' const rssXml = ` @@ -20,11 +19,4 @@ const feedData: Rss.Feed = { items: [], } -const _legacyFeedData: RssFeed = { - title: 'Legacy Type', - link: 'https://example.com', - description: 'Legacy description', - items: [], -} - const _generatedRss = generateRssFeed(feedData) diff --git a/compatibility/explicit-modules/cjs-package/index.cts b/compatibility/explicit-modules/cjs-package/index.cts index c600c46b..ed0f94dc 100644 --- a/compatibility/explicit-modules/cjs-package/index.cts +++ b/compatibility/explicit-modules/cjs-package/index.cts @@ -1,6 +1,5 @@ -import type { RssFeed } from 'feedsmith' +import type { Rss } from 'feedsmith' import { generateRssFeed, parseFeed } from 'feedsmith' -import type { Rss } from 'feedsmith/types' const rssXml = ` @@ -20,11 +19,4 @@ const feedData: Rss.Feed = { items: [], } -const _legacyFeedData: RssFeed = { - title: 'Legacy Type', - link: 'https://example.com', - description: 'Legacy description', - items: [], -} - const _generatedRss = generateRssFeed(feedData) diff --git a/compatibility/explicit-modules/cjs-package/index.mts b/compatibility/explicit-modules/cjs-package/index.mts index c600c46b..ed0f94dc 100644 --- a/compatibility/explicit-modules/cjs-package/index.mts +++ b/compatibility/explicit-modules/cjs-package/index.mts @@ -1,6 +1,5 @@ -import type { RssFeed } from 'feedsmith' +import type { Rss } from 'feedsmith' import { generateRssFeed, parseFeed } from 'feedsmith' -import type { Rss } from 'feedsmith/types' const rssXml = ` @@ -20,11 +19,4 @@ const feedData: Rss.Feed = { items: [], } -const _legacyFeedData: RssFeed = { - title: 'Legacy Type', - link: 'https://example.com', - description: 'Legacy description', - items: [], -} - const _generatedRss = generateRssFeed(feedData) diff --git a/compatibility/explicit-modules/esm-package/index.cts b/compatibility/explicit-modules/esm-package/index.cts index c600c46b..ed0f94dc 100644 --- a/compatibility/explicit-modules/esm-package/index.cts +++ b/compatibility/explicit-modules/esm-package/index.cts @@ -1,6 +1,5 @@ -import type { RssFeed } from 'feedsmith' +import type { Rss } from 'feedsmith' import { generateRssFeed, parseFeed } from 'feedsmith' -import type { Rss } from 'feedsmith/types' const rssXml = ` @@ -20,11 +19,4 @@ const feedData: Rss.Feed = { items: [], } -const _legacyFeedData: RssFeed = { - title: 'Legacy Type', - link: 'https://example.com', - description: 'Legacy description', - items: [], -} - const _generatedRss = generateRssFeed(feedData) diff --git a/compatibility/explicit-modules/esm-package/index.mts b/compatibility/explicit-modules/esm-package/index.mts index c600c46b..ed0f94dc 100644 --- a/compatibility/explicit-modules/esm-package/index.mts +++ b/compatibility/explicit-modules/esm-package/index.mts @@ -1,6 +1,5 @@ -import type { RssFeed } from 'feedsmith' +import type { Rss } from 'feedsmith' import { generateRssFeed, parseFeed } from 'feedsmith' -import type { Rss } from 'feedsmith/types' const rssXml = ` @@ -20,11 +19,4 @@ const feedData: Rss.Feed = { items: [], } -const _legacyFeedData: RssFeed = { - title: 'Legacy Type', - link: 'https://example.com', - description: 'Legacy description', - items: [], -} - const _generatedRss = generateRssFeed(feedData) diff --git a/compatibility/explicit-modules/mixed-package/index.cts b/compatibility/explicit-modules/mixed-package/index.cts index c600c46b..ed0f94dc 100644 --- a/compatibility/explicit-modules/mixed-package/index.cts +++ b/compatibility/explicit-modules/mixed-package/index.cts @@ -1,6 +1,5 @@ -import type { RssFeed } from 'feedsmith' +import type { Rss } from 'feedsmith' import { generateRssFeed, parseFeed } from 'feedsmith' -import type { Rss } from 'feedsmith/types' const rssXml = ` @@ -20,11 +19,4 @@ const feedData: Rss.Feed = { items: [], } -const _legacyFeedData: RssFeed = { - title: 'Legacy Type', - link: 'https://example.com', - description: 'Legacy description', - items: [], -} - const _generatedRss = generateRssFeed(feedData) diff --git a/compatibility/explicit-modules/mixed-package/index.mts b/compatibility/explicit-modules/mixed-package/index.mts index c600c46b..ed0f94dc 100644 --- a/compatibility/explicit-modules/mixed-package/index.mts +++ b/compatibility/explicit-modules/mixed-package/index.mts @@ -1,6 +1,5 @@ -import type { RssFeed } from 'feedsmith' +import type { Rss } from 'feedsmith' import { generateRssFeed, parseFeed } from 'feedsmith' -import type { Rss } from 'feedsmith/types' const rssXml = ` @@ -20,11 +19,4 @@ const feedData: Rss.Feed = { items: [], } -const _legacyFeedData: RssFeed = { - title: 'Legacy Type', - link: 'https://example.com', - description: 'Legacy description', - items: [], -} - const _generatedRss = generateRssFeed(feedData) diff --git a/compatibility/explicit-modules/mixed-package/index.ts b/compatibility/explicit-modules/mixed-package/index.ts index c600c46b..ed0f94dc 100644 --- a/compatibility/explicit-modules/mixed-package/index.ts +++ b/compatibility/explicit-modules/mixed-package/index.ts @@ -1,6 +1,5 @@ -import type { RssFeed } from 'feedsmith' +import type { Rss } from 'feedsmith' import { generateRssFeed, parseFeed } from 'feedsmith' -import type { Rss } from 'feedsmith/types' const rssXml = ` @@ -20,11 +19,4 @@ const feedData: Rss.Feed = { items: [], } -const _legacyFeedData: RssFeed = { - title: 'Legacy Type', - link: 'https://example.com', - description: 'Legacy description', - items: [], -} - const _generatedRss = generateRssFeed(feedData) diff --git a/compatibility/typescript/legacy-cjs/index.ts b/compatibility/typescript/legacy-cjs/index.ts index d092e738..12836654 100644 --- a/compatibility/typescript/legacy-cjs/index.ts +++ b/compatibility/typescript/legacy-cjs/index.ts @@ -1,8 +1,7 @@ // biome-ignore lint/style/noCommonJs: This file tests CJS compatibility. const { generateRssFeed, parseFeed } = require('feedsmith') -import type { RssFeed } from 'feedsmith' -import type { Rss } from 'feedsmith/types' +import type { Rss } from 'feedsmith' const rssXml = ` @@ -22,11 +21,4 @@ const feedData: Rss.Feed = { items: [], } -const _legacyFeedData: RssFeed = { - title: 'Legacy Type', - link: 'https://example.com', - description: 'Legacy description', - items: [], -} - const _generatedRss = generateRssFeed(feedData) diff --git a/compatibility/typescript/modern-cjs/index.ts b/compatibility/typescript/modern-cjs/index.ts index c600c46b..ed0f94dc 100644 --- a/compatibility/typescript/modern-cjs/index.ts +++ b/compatibility/typescript/modern-cjs/index.ts @@ -1,6 +1,5 @@ -import type { RssFeed } from 'feedsmith' +import type { Rss } from 'feedsmith' import { generateRssFeed, parseFeed } from 'feedsmith' -import type { Rss } from 'feedsmith/types' const rssXml = ` @@ -20,11 +19,4 @@ const feedData: Rss.Feed = { items: [], } -const _legacyFeedData: RssFeed = { - title: 'Legacy Type', - link: 'https://example.com', - description: 'Legacy description', - items: [], -} - const _generatedRss = generateRssFeed(feedData) diff --git a/compatibility/typescript/modern-esm/index.ts b/compatibility/typescript/modern-esm/index.ts index c600c46b..ed0f94dc 100644 --- a/compatibility/typescript/modern-esm/index.ts +++ b/compatibility/typescript/modern-esm/index.ts @@ -1,6 +1,5 @@ -import type { RssFeed } from 'feedsmith' +import type { Rss } from 'feedsmith' import { generateRssFeed, parseFeed } from 'feedsmith' -import type { Rss } from 'feedsmith/types' const rssXml = ` @@ -20,11 +19,4 @@ const feedData: Rss.Feed = { items: [], } -const _legacyFeedData: RssFeed = { - title: 'Legacy Type', - link: 'https://example.com', - description: 'Legacy description', - items: [], -} - const _generatedRss = generateRssFeed(feedData) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 8b7aeba7..730c8eae 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -55,8 +55,8 @@ export default defineConfig({ { text: 'Parsing', link: '/parsing' }, { text: 'Generating', link: '/generating' }, { - text: 'v2.x', - items: [{ text: 'v3.x (Next)', link: 'https://v3.feedsmith.dev' }], + text: 'v3.0 (Next)', + items: [{ text: 'v2.0', link: 'https://feedsmith.dev' }], }, ], sidebar: [ @@ -75,6 +75,7 @@ export default defineConfig({ { text: 'Namespaces', link: '/parsing/namespaces' }, { text: 'Dates', link: '/parsing/dates' }, { text: 'Detecting', link: '/parsing/detecting' }, + { text: 'Errors', link: '/parsing/errors' }, { text: 'Examples', link: '/parsing/examples' }, ], }, @@ -83,7 +84,8 @@ export default defineConfig({ items: [ { text: 'Overview', link: '/generating' }, { text: 'Styling', link: '/generating/styling' }, - { text: 'Lenient Mode', link: '/generating/lenient-mode' }, + { text: 'Strict Mode', link: '/generating/strict-mode' }, + { text: 'Errors', link: '/generating/errors' }, { text: 'Examples', link: '/generating/examples' }, ], }, @@ -140,6 +142,7 @@ export default defineConfig({ { text: 'W3C Basic Geo', link: '/reference/namespaces/geo' }, { text: 'GeoRSS Simple', link: '/reference/namespaces/georss' }, { text: 'RDF', link: '/reference/namespaces/rdf' }, + { text: 'XML', link: '/reference/namespaces/xml' }, ], }, { @@ -154,7 +157,10 @@ export default defineConfig({ }, { text: 'Migration', - items: [{ text: 'From 1.x to 2.x', link: '/migration/v1-to-v2' }], + items: [ + { text: 'From 2.x to 3.x', link: '/migration/v2-to-v3' }, + { text: 'From 1.x to 2.x', link: '/migration/v1-to-v2' }, + ], }, ], search: { diff --git a/docs/generating.md b/docs/generating.md index 89b47843..2ce9dff6 100644 --- a/docs/generating.md +++ b/docs/generating.md @@ -4,11 +4,7 @@ title: Generating Feeds # Generating Feeds -Create RSS, Atom, JSON Feed, and OPML files with full namespace support. - -## Overview - -Feed generation is straightforward - provide the feed data and get back a properly formatted string: +Create RSS, Atom, JSON Feed, and OPML files with full namespace support. Just provide the feed data and get back a properly formatted string: ```typescript import { diff --git a/docs/generating/errors.md b/docs/generating/errors.md new file mode 100644 index 00000000..b7326495 --- /dev/null +++ b/docs/generating/errors.md @@ -0,0 +1,37 @@ +# Error Handling + +Feedsmith provides a dedicated error type for generation failures. + +## GenerateError + +Thrown when feed generation fails due to invalid input. Applies to all generators: `generateRssFeed`, `generateAtomFeed`, `generateJsonFeed`, and `generateOpml`. + +```typescript +import { generateRssFeed, GenerateError } from 'feedsmith' + +try { + generateRssFeed({}) +} catch (error) { + if (error instanceof GenerateError) { + console.log(error.message) // "Invalid input RSS" + } +} +``` + +## Error Hierarchy + +All error classes extend the built-in `Error`, so `instanceof Error` checks work as expected. This can be useful for catching any generation error alongside other errors in a single handler: + +```typescript +import { generateJsonFeed, GenerateError } from 'feedsmith' + +try { + generateJsonFeed(data) +} catch (error) { + if (error instanceof GenerateError) { + // Feedsmith-specific generation failure. + } else if (error instanceof Error) { + // Any other error. + } +} +``` diff --git a/docs/generating/examples.md b/docs/generating/examples.md index 18c15519..0f48c478 100644 --- a/docs/generating/examples.md +++ b/docs/generating/examples.md @@ -32,7 +32,7 @@ const rssFeed = generateRssFeed({ description: 'Learn the basics of TypeScript and why you should use it', pubDate: new Date('2024-01-15T10:00:00Z'), guid: 'https://myblog.com/posts/intro-to-typescript', - authors: ['john@myblog.com (John Doe)'], + authors: [{ email: 'john@myblog.com', name: 'John Doe' }], categories: [{ name: 'TypeScript' }, { name: 'Programming' }] } ] @@ -97,11 +97,10 @@ Generates (showing first lines): Build type-safe RSS feeds using the exported types: ```typescript -import type { Rss } from 'feedsmith/types' -import { generateRssFeed } from 'feedsmith' +import { type RssFeed, generateRssFeed } from 'feedsmith' // Define items with full type safety -const items: Array> = [{ +const items: Array> = [{ title: 'New Episode', description: 'Episode description', enclosures: [{ @@ -112,7 +111,7 @@ const items: Array> = [{ }] // Build the feed -const feed: Rss.Feed = { +const feed: RssFeed.Feed = { title: 'My Podcast', link: 'https://example.com', description: 'A podcast', @@ -131,7 +130,7 @@ import { generateAtomFeed } from 'feedsmith' const atomFeed = generateAtomFeed({ id: 'https://myblog.com/feed', - title: 'My Tech Blog', + title: { value: 'My Tech Blog' }, updated: new Date('2024-01-15T12:00:00Z'), links: [ { href: 'https://myblog.com/feed.xml', rel: 'self' }, @@ -140,9 +139,9 @@ const atomFeed = generateAtomFeed({ entries: [ { id: 'https://myblog.com/posts/1', - title: 'Introduction to TypeScript', + title: { value: 'Introduction to TypeScript' }, updated: new Date('2024-01-15T10:00:00Z'), - content: '

Learn the basics of TypeScript and why you should use it

', + content: { value: '

Learn the basics of TypeScript and why you should use it

', type: 'html' }, links: [{ href: 'https://myblog.com/posts/intro-to-typescript' }], categories: [{ term: 'typescript', label: 'TypeScript' }] } @@ -165,7 +164,9 @@ Generates: Introduction to TypeScript 2024-01-15T10:00:00.000Z - Learn the basics of TypeScript and why you should use it + + Learn the basics of TypeScript and why you should use it

]]> +
diff --git a/docs/generating/lenient-mode.md b/docs/generating/lenient-mode.md deleted file mode 100644 index d537199b..00000000 --- a/docs/generating/lenient-mode.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -title: "Generating Feeds: Lenient Mode" ---- - -# Lenient Mode - -By default, the generate functions enforce all fields that are required by the feed specifications and expect `Date` objects for date fields. However, when working with parsed feeds or building feeds incrementally, you may need more flexibility. This is where **lenient mode** comes in. - -## What is Lenient Mode? - -Lenient mode allows you to: -- Generate feeds **without spec-required fields** (all fields become optional) -- Use **string dates** instead of Date objects -- Pass through **invalid date strings** as-is - -This is particularly useful when working with the _parse → modify → generate_ workflow, as the parse functions return objects where all fields are optional and dates are strings. - -## Basic Usage - -Add `{ lenient: true }` as the second parameter to any generate function: - -```typescript -import { generateRssFeed, parseRssFeed } from 'feedsmith' - -// Parse returns an object with all optional fields and string dates -const parsedFeed = parseRssFeed(xmlString) - -// Generate with lenient mode accepts the parsed output directly -const regeneratedXml = generateRssFeed(parsedFeed, { lenient: true }) -``` - -## Strict vs Lenient Mode - -### Strict Mode (default) - -```typescript -// Requires spec-mandated fields with Date objects for dates -const feed = { - title: 'My Blog', // Required by RSS spec - link: 'https://example.com', // Required by RSS spec - description: 'A blog about things', // Required by RSS spec - pubDate: new Date('2024-01-01'), // Optional, but must be Date if provided - items: [ - { - title: 'Post 1', // At least title or description required - link: 'https://example.com/post1', - description: 'First post', - pubDate: new Date('2024-01-02') // Optional, but must be Date if provided - } - ] -} - -const xml = generateRssFeed(feed) -``` - -### Lenient Mode - -```typescript -// All fields become optional, accepts string dates -const partialFeed = { - title: 'My Blog', - // link and description not required in lenient mode - pubDate: '2024-01-01T00:00:00Z', // String date accepted - items: [ - { - title: 'Post 1', - // No other fields required - pubDate: 'Mon, 01 Jan 2024 12:00:00 GMT' // RFC822 string accepted - } - ] -} - -const xml = generateRssFeed(partialFeed, { lenient: true }) -``` - -## Invalid Date Handling - -In lenient mode, invalid date strings are preserved as-is instead of stripping them from the output: - -```typescript -const feedWithInvalidDates = { - title: 'Feed with Custom Dates', - pubDate: 'Yesterday at 3pm', // Invalid but preserved - items: [ - { - title: 'Post', - pubDate: 'Coming soon' // Invalid but preserved - } - ] -} - -const xml = generateRssFeed(feedWithInvalidDates, { lenient: true }) -// Output will contain: Yesterday at 3pm -``` - -## When to Use Lenient Mode - -### Use lenient mode -- Processing feeds from external sources (_parse → modify → generate_ workflow) -- Building feeds incrementally where not all data is available initially -- Working with legacy feeds that don't strictly follow specifications -- Migrating or transforming feeds between different systems - -### Use strict mode (default) -- Creating new feeds from scratch with complete data -- You want TypeScript to enforce all spec-required fields -- Date formatting consistency is critical diff --git a/docs/generating/strict-mode.md b/docs/generating/strict-mode.md new file mode 100644 index 00000000..3483aa41 --- /dev/null +++ b/docs/generating/strict-mode.md @@ -0,0 +1,120 @@ +--- +title: "Generating Feeds: Strict Mode" +--- + +# Strict Mode + +By default, Feedsmith is lenient — all fields are optional to accommodate real-world feeds that may not follow specifications exactly. When strict mode is enabled, TypeScript will enforce fields that are required by the specification. This validation happens at compile time only, not at runtime. + +```typescript +import { generateRssFeed } from 'feedsmith' + +// Lenient mode (default) - compiles fine even with missing required fields +const rss = generateRssFeed({ title: 'My Feed' }) + +// Strict mode - TypeScript error if required fields are missing +const rss = generateRssFeed( + { + title: 'My Feed', + link: 'https://example.com', + description: 'A complete feed' + }, + { strict: true } +) +``` + +## Enabling Strict Mode + +Pass `strict: true` in the options to enable compile-time validation: + +```typescript +import { + generateRssFeed, + generateAtomFeed, + generateJsonFeed, + generateOpml +} from 'feedsmith' + +// RSS with strict mode +const rss = generateRssFeed(feedData, { strict: true }) + +// Atom with strict mode +const atom = generateAtomFeed(feedData, { strict: true }) + +// JSON Feed with strict mode +const json = generateJsonFeed(feedData, { strict: true }) + +// OPML with strict mode +const opml = generateOpml(opmlData, { strict: true }) +``` + +## Date Objects Required + +In strict mode, date fields must be JavaScript `Date` objects, not strings: + +```typescript +// Lenient mode - strings work fine +generateRssFeed({ + title: 'Feed', + link: 'https://example.com', + description: 'Description', + pubDate: '2024-01-01T00:00:00Z' // String dates allowed +}) + +// Strict mode - must use Date objects +generateRssFeed( + { + title: 'Feed', + link: 'https://example.com', + description: 'Description', + pubDate: new Date('2024-01-01') // Date object required + }, + { strict: true } +) +``` + +## Required Fields + +See the reference documentation for required fields in strict mode: + +- [RSS Reference](/reference/feeds/rss#type-definitions) +- [Atom Reference](/reference/feeds/atom#type-definitions) +- [JSON Feed Reference](/reference/feeds/json-feed#type-definitions) +- [OPML Reference](/reference/opml#type-definitions) + +Some namespaces also have required fields for their nested types. Look for `Requirable<...>` in type definitions to identify fields that become required in strict mode. + +## Combining with Other Options + +Strict mode can be combined with other generation options: + +```typescript +generateRssFeed( + { + title: 'My Feed', + link: 'https://example.com', + description: 'A complete feed', + items: [] + }, + { + strict: true, + stylesheets: [{ type: 'text/xsl', href: '/feed.xsl' }] + } +) +``` + +## When to Use Strict Mode + +**Use strict mode when:** +- Building new feeds from scratch where you control all data +- You want TypeScript to catch missing required fields +- Following specifications precisely matters for your use case + +**Use lenient mode (default) when:** +- Processing feeds from external sources +- Migrating existing feeds that may be incomplete + +## Related + +- **[Generating Feeds](/generating)** - Overview of feed generation +- **[TypeScript Guide](/reference/typescript)** - Working with Feedsmith types diff --git a/docs/generating/styling.md b/docs/generating/styling.md index 0a86022b..948d8789 100644 --- a/docs/generating/styling.md +++ b/docs/generating/styling.md @@ -2,12 +2,10 @@ title: "Generating Feeds: Styling" --- -# Styling +# Styling Feeds XML-based feeds (RSS, Atom) and OPML files support stylesheets to provide custom styling and transformations in browsers and feed readers. -## Overview - Stylesheets allow you to: - **Transform feed appearance** in browsers and feed readers diff --git a/docs/index.md b/docs/index.md index ae4db6a0..296e741c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,11 +8,6 @@ Fast, all‑in‑one JavaScript feed parser and generator for RSS, Atom, RDF, an Feedsmith offers universal and format‑specific parsers that maintain the original feed structure in a clean, object-oriented format while intelligently normalizing legacy elements. Access all feed data without compromising simplicity. -> [!IMPORTANT] -> **Feedsmith 3.x is in final stages of development.** Check out the [v3.x guide](https://v3.feedsmith.dev/migration/v2-to-v3) to explore new features and learn how to upgrade. Install it with: -> -> `npm install feedsmith@next` - ## Features ### Core diff --git a/docs/migration/v2-to-v3.md b/docs/migration/v2-to-v3.md new file mode 100644 index 00000000..345335e7 --- /dev/null +++ b/docs/migration/v2-to-v3.md @@ -0,0 +1,559 @@ +--- +title: Migrating from 2.x to 3.x +--- + +# Migrating from 2.x to 3.x + +This guide covers all breaking changes when upgrading from Feedsmith 2.x to 3.x. Each breaking change is detailed with specific upgrade steps and examples. + +> [!IMPORTANT] +> Version 3.x inverts the default behavior: feeds are now lenient by default (all fields optional), with strict mode available as an opt-in via `{ strict: true }`. + +## Installation + +Update your package to the latest 3.x version: + +```bash +npm install feedsmith@latest +``` + +## Migration Checklist + +Use this checklist to ensure a complete migration: + +- Remove `{ lenient: true }` from all generate function calls +- Add `{ strict: true }` where you need compile-time validation of required fields +- Update type parameters if using strict types directly (add `true` as last parameter) +- Remove `DeepPartial` from imports +- Change `feedsmith/types` imports to `feedsmith` +- Update Atom text fields (`title`, `subtitle`, `rights`, `summary`): read `.value` instead of plain string, generate with `{ value: '...' }` instead of plain string +- Update Atom `entry.content` usage: read `content?.value` instead of `content`, generate with `{ value: '...' }` instead of plain string +- Update RSS person fields (`managingEditor`, `webMaster`, `authors`): read `.email`/`.name` instead of plain string, generate with `{ email: '...', name: '...' }` instead of plain string +- Replace Media namespace deprecated field (`group` → `groups`) +- Replace Podcast namespace deprecated fields (`location` → `locations`, `value` → `values`, `chats` → `chat`) +- Replace Dublin Core singular fields with plural arrays (e.g., `title` → `titles`) +- Replace Dublin Core Terms singular fields with plural arrays (e.g., `title` → `titles`) +- Rename type imports: `Rss` → `RssFeed`, `Atom` → `AtomFeed`, `Json` → `JsonFeed`, `Rdf` → `RdfFeed` +- Test feed generation to ensure output is correct +- Update error handling to use `DetectError`, `MalformedError`, `ParseError`, and `GenerateError` instead of generic `Error` + +## Breaking Changes + +### Strict Mode Now Opt-In + +In version 2.x, generate functions enforced spec-required fields by default and to make all fields optional, it required passing `{ lenient: true }`. In 3.x, this is inverted: all fields are optional by default and `{ strict: true }` enables compile-time validation of spec-required fields. + +#### Before (2.x) +```typescript +import { generateRssFeed } from 'feedsmith' + +// Strict mode (default) - required fields and Date objects +const xml = generateRssFeed({ + title: 'My Blog', + description: 'A blog about things', + pubDate: new Date('2024-01-01'), +}) + +// Lenient mode - all optional, string dates accepted +const xml = generateRssFeed({ + title: 'My Blog', + pubDate: '2024-01-01T00:00:00Z', +}, { lenient: true }) +``` + +#### After (3.x) +```typescript +import { generateRssFeed } from 'feedsmith' + +// Lenient mode (default) - all optional, string dates accepted +const xml = generateRssFeed({ + title: 'My Blog', + pubDate: '2024-01-01T00:00:00Z', +}) + +// Strict mode - required fields and Date objects +const xml = generateRssFeed({ + title: 'My Blog', + description: 'A blog about things', + pubDate: new Date('2024-01-01'), +}, { strict: true }) +``` + +#### Migration Steps +1. Remove `{ lenient: true }` from all generate function calls (it's now the default) +2. Add `{ strict: true }` if you want to preserve v2's default strict behavior + +### All Type Fields Now Optional by Default + +Related to the above, previously required fields in type definitions are now optional by default. Pass `true` as the strict type parameter if you need compile-time enforcement. + +#### Before (2.x) +```typescript +import type { Atom } from 'feedsmith/types' + +// TypeScript enforced required fields +const entry: Atom.Entry = { + id: 'https://example.com/post/1', + title: 'Post Title', + updated: new Date('2024-01-01'), +} +``` + +#### After (3.x) +```typescript +import type { Atom } from 'feedsmith' + +// All fields optional by default +const entry: Atom.Entry = { + title: 'Post Title', +} + +// Pass `true` for compile-time enforcement +const strictEntry: Atom.Entry = { + id: 'https://example.com/post/1', + title: 'Post Title', + updated: new Date('2024-01-01'), +} +``` + +#### Migration Steps +1. If you relied on TypeScript to enforce required fields, add `true` as the last type parameter +2. Alternatively, add runtime validation for required fields + +### `DeepPartial` Type Removed + +The `DeepPartial` utility type has been removed. Since all type fields are now optional by default, this type is no longer needed. + +#### Before (2.x) +```typescript +import type { DeepPartial, Rss } from 'feedsmith/types' + +const processFeed = (feed: DeepPartial>) => { + console.log(feed.title) +} +``` + +#### After (3.x) +```typescript +import type { Rss } from 'feedsmith' + +// All fields already optional - DeepPartial not needed +const processFeed = (feed: Rss.Feed) => { + console.log(feed.title) +} +``` + +#### Migration Steps +1. Remove `DeepPartial` from your imports +2. Use base types directly (`Rss.Feed`, `Atom.Feed`, etc.) + +### Types Entry Point Removed + +The `feedsmith/types` entry point has been removed. All types are now exported from the main `feedsmith` entry point. Additionally, deprecated type aliases (`RssFeed`, `AtomFeed`, `JsonFeed`, `RdfFeed`, `Opml`) have been removed. + +#### Before (2.x) +```typescript +import type { Rss } from 'feedsmith/types' +import { parseRssFeed } from 'feedsmith' +``` + +#### After (3.x) +```typescript +import { type Rss, parseRssFeed } from 'feedsmith' +``` + +#### Migration Steps +1. Change `feedsmith/types` imports to `feedsmith` +2. Replace deprecated type aliases: `RssFeed` → `Rss.Feed`, `AtomFeed` → `Atom.Feed`, etc. + +### Atom text fields changed from string to object + +The `title`, `subtitle`, `rights`, and `summary` fields on Atom feeds and entries were previously flattened to strings. This meant any additional attributes like `type` (indicating whether the text is plain text, HTML, or XHTML) and XML namespace declarations were lost during parsing. In the new version, they use the `Atom.Text` object that preserves these attributes, properly representing the [Atom text construct](https://www.rfc-editor.org/rfc/rfc4287#section-3.1). + +The affected fields are: +- **Feed**: `title`, `subtitle`, `rights` +- **Entry**: `title`, `summary`, `rights` +- **Source** (in entry): `title`, `subtitle`, `rights` + +```xml +My <em>Blog</em> +``` + +#### Before (2.x) +```typescript +// Parsing +const feed = parseAtomFeed(xml) +const title = feed.title // string +const subtitle = feed.subtitle // string +const rights = feed.entries?.[0]?.rights // string + +// Generating +const xml = generateAtomFeed({ + title: 'My Blog', + subtitle: 'A blog about things', +}) +``` + +#### After (3.x) +```typescript +// Parsing +const feed = parseAtomFeed(xml) +const title = feed.title?.value // string (text content) +const titleType = feed.title?.type // e.g. 'html', 'xhtml', 'text' +const subtitle = feed.subtitle?.value // string +const rights = feed.entries?.[0]?.rights?.value // string + +// Generating +const xml = generateAtomFeed({ + title: { value: 'My Blog' }, + subtitle: { value: 'A blog about things', type: 'text' }, +}) +``` + +#### Migration Steps +1. Replace reads with `.value` (e.g., `feed.title` → `feed.title?.value`) +2. Update generate calls: `title: 'text'` → `title: { value: 'text' }` +3. Optionally use `type` for richer text metadata (`'text'`, `'html'`, `'xhtml'`) + +### Atom Entry `content` changed from string to object + +The `content` field on Atom entries was previously flattened to a string. This meant, any additional attributes like `type` (indicating content type), `src` (remote content URI), and XML namespace declarations were lost during parsing. In the new version, it is replaced with the `Atom.Content` object that preserves these attributes, properly representing the [Atom content construct](https://www.rfc-editor.org/rfc/rfc4287#section-4.1.3). + +```xml + + Text + +``` + +#### Before (2.x) +```typescript +// Parsing +const feed = parseAtomFeed(xml) +const content = feed.entries?.[0]?.content // string + +// Generating +const xml = generateAtomFeed({ + entries: [{ content: '

Hello

' }], +}) +``` + +#### After (3.x) +```typescript +// Parsing +const feed = parseAtomFeed(xml) +const content = feed.entries?.[0]?.content?.value // string (text content) +const type = feed.entries?.[0]?.content?.type // e.g. 'html', 'xhtml', 'text' +const src = feed.entries?.[0]?.content?.src // remote content URI + +// Generating +const xml = generateAtomFeed({ + entries: [{ content: { value: '

Hello

', type: 'html' } }], +}) +``` + +#### Migration Steps +1. Replace `entry.content` reads with `entry.content?.value` +2. Update generate calls: `content: 'text'` → `content: { value: 'text' }` +3. Optionally use `type` and `src` for richer content metadata + +### RSS Person Fields Changed from Strings to Objects + +The `managingEditor`, `webMaster`, and `authors` fields on RSS feeds and items were previously plain strings (e.g., `'editor@example.com (Editor Name)'`). In the new version, they use the `Rss.Person` object that preserves structured data, properly representing the [RSS person construct](https://www.rssboard.org/rss-specification#ltauthorgtSubelementOfLtitemgt). + +The affected fields are: +- **Feed**: `managingEditor`, `webMaster` +- **Item**: `authors` + +```xml +editor@example.com (Editor Name) +john@example.com (John Doe) +``` + +#### Before (2.x) +```typescript +// Parsing +const feed = parseRssFeed(xml) +const editor = feed.managingEditor // 'editor@example.com (Editor Name)' +const author = feed.items?.[0]?.authors?.[0] // 'john@example.com (John Doe)' + +// Generating +const xml = generateRssFeed({ + managingEditor: 'editor@example.com (Editor Name)', + items: [{ authors: ['john@example.com (John Doe)'] }], +}) +``` + +#### After (3.x) +```typescript +// Parsing +const feed = parseRssFeed(xml) +const editor = feed.managingEditor // { email: 'editor@example.com', name: 'Editor Name' } +const author = feed.items?.[0]?.authors?.[0] // { email: 'john@example.com', name: 'John Doe' } + +// Generating +const xml = generateRssFeed({ + managingEditor: { email: 'editor@example.com', name: 'Editor Name' }, + items: [{ authors: [{ email: 'john@example.com', name: 'John Doe' }] }], +}) +``` + +> [!NOTE] +> The `link` property on `Rss.Person` is parse-only — it is extracted from URLs found in the person string but is not included in generated XML output, as RSS spec does not define a standard way to encode links in person fields. + +#### Migration Steps +1. Replace string reads with object property access (e.g., `feed.managingEditor` → `feed.managingEditor?.email`) +2. Update generate calls: `'email (Name)'` → `{ email: 'email', name: 'Name' }` + +### Media Namespace: Deprecated Field Removed + +The deprecated `group` field has been removed to align with the [Media RSS specification](https://www.rssboard.org/media-rss): +- `group` → `groups` (spec allows multiple `media:group` elements) + +#### Before (2.x) +```typescript +const feed = parseRssFeed(xml) +const group = feed.media?.group +``` + +#### After (3.x) +```typescript +const feed = parseRssFeed(xml) +const group = feed.media?.groups?.[0] +``` + +#### Migration Steps +1. Replace `group` with `groups` +2. Access the first element: `media.group` → `media.groups?.[0]` + +### Podcast Namespace: Deprecated Fields Removed + +Deprecated fields have been removed to align with the [Podcasting 2.0 specification](https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md): + +- `location` → `locations` (spec allows multiple `podcast:location` elements) +- `value` → `values` (spec allows multiple `podcast:value` elements) +- `chats` → `chat` (spec allows only one `podcast:chat` element) + +#### Before (2.x) +```typescript +const feed = parseRssFeed(xml) +const location = feed.items?.[0]?.podcast?.location +const value = feed.items?.[0]?.podcast?.value +const chat = feed.items?.[0]?.podcast?.chats?.[0] +``` + +#### After (3.x) +```typescript +const feed = parseRssFeed(xml) +const location = feed.items?.[0]?.podcast?.locations?.[0] +const value = feed.items?.[0]?.podcast?.values?.[0] +const chat = feed.items?.[0]?.podcast?.chat +``` + +#### Migration Steps +1. Replace `location` with `locations` (wrap in array) +2. Replace `value` with `values` (wrap in array) +3. Replace `chats` with `chat` (use `chats[0]` if you had multiple) + +### Dublin Core Namespace: Singular Fields Removed + +Deprecated singular fields have been removed to align with the [Dublin Core specification](https://www.dublincore.org/specifications/dublin-core/dces/) where all elements are repeatable: + +- `title` → `titles` +- `creator` → `creators` +- `subject` → `subjects` +- `description` → `descriptions` +- `publisher` → `publishers` +- `contributor` → `contributors` +- `date` → `dates` +- `type` → `types` +- `format` → `formats` +- `identifier` → `identifiers` +- `source` → `sources` +- `language` → `languages` +- `relation` → `relations` +- `coverage` (now array) +- `rights` (now array) + +#### Before (2.x) +```typescript +const feed = parseRssFeed(xml) +const title = feed.dc?.title +const creator = feed.dc?.creator +const coverage = feed.dc?.coverage +``` + +#### After (3.x) +```typescript +const feed = parseRssFeed(xml) +const title = feed.dc?.titles?.[0] +const creator = feed.dc?.creators?.[0] +const coverage = feed.dc?.coverage?.[0] +``` + +#### Migration Steps +1. Replace singular fields with plural equivalents (e.g., `dc.title` → `dc.titles?.[0]`) +2. Fields that kept their name (`coverage`, `rights`) are now arrays: `dc.coverage` → `dc.coverage?.[0]` + +### Dublin Core Terms Namespace: Singular Fields Removed + +Deprecated singular fields have been removed to align with the [Dublin Core Terms specification](https://www.dublincore.org/specifications/dublin-core/dcmi-terms/) where all elements are repeatable: + +- `title` → `titles` +- `creator` → `creators` +- `subject` → `subjects` +- `description` → `descriptions` +- `publisher` → `publishers` +- `contributor` → `contributors` +- `date` → `dates` +- `type` → `types` +- `format` → `formats` +- `identifier` → `identifiers` +- `source` → `sources` +- `language` → `languages` +- `relation` → `relations` +- `abstract` → `abstracts` +- `audience` → `audiences` +- `alternative` → `alternatives` +- `educationLevel` → `educationLevels` +- `extent` → `extents` +- `hasFormat` → `hasFormats` +- `hasPart` → `hasParts` +- `hasVersion` → `hasVersions` +- `instructionalMethod` → `instructionalMethods` +- `license` → `licenses` +- `mediator` → `mediators` +- `medium` → `mediums` +- `provenance` → `provenances` +- `rightsHolder` → `rightsHolders` +- `spatial` → `spatials` +- `temporal` → `temporals` +- `accrualMethod` → `accrualMethods` +- `accrualPeriodicity` → `accrualPeriodicities` +- `accrualPolicy` → `accrualPolicies` +- `bibliographicCitation` → `bibliographicCitations` +- Plus 21 fields that kept their name but are now arrays (e.g., `created`, `modified`, `issued`, `valid`) + +#### Before (2.x) +```typescript +const feed = parseRssFeed(xml) +const title = feed.dcterms?.title +const creator = feed.dcterms?.creator +const created = feed.dcterms?.created +``` + +#### After (3.x) +```typescript +const feed = parseRssFeed(xml) +const title = feed.dcterms?.titles?.[0] +const creator = feed.dcterms?.creators?.[0] +const created = feed.dcterms?.created?.[0] +``` + +#### Migration Steps +1. Replace singular fields with plural equivalents (e.g., `dcterms.title` → `dcterms.titles?.[0]`) +2. Fields that kept their name (e.g., `created`, `modified`) are now arrays: `dcterms.created` → `dcterms.created?.[0]` + +## New Features + +### Improved Error Handling + +Feedsmith now throws dedicated error types for different failure scenarios: + +- `DetectError` — thrown when input doesn't match the expected feed format +- `MalformedError` — thrown when content is malformed (e.g., invalid XML) +- `ParseError` — thrown when content parsed but produced an invalid result +- `GenerateError` — thrown when feed generation fails due to invalid input + +See [Parsing Errors](/parsing/errors) and [Generating Errors](/generating/errors) for more details. + +### Namespace Type Exports + +All namespace types are now exported directly from the main package: + +```typescript +import type { ItunesNs, DcNs, MediaNs, PodcastNs } from 'feedsmith' + +const category: ItunesNs.Category = { + text: 'Technology' +} + +const transcript: PodcastNs.Transcript = { + url: 'https://example.com/transcript.srt', + type: 'application/srt' +} +``` + +See [Working with TypeScript](/reference/typescript#importing-namespace-types) for the more information and usage examples. + +### Utility Type Exports + +Common utility types `DateLike` and `XmlStylesheet` are now exported from the main package: + +```typescript +import type { RssFeed, DateLike, XmlStylesheet } from 'feedsmith' + +type RssMetadata = Omit, 'items'> + +const stylesheet: XmlStylesheet = { + type: 'text/xsl', + href: '/feed.xsl', +} +``` + +### Format Type Namespaces Renamed + +The four format-level type namespaces have been renamed to align with their parse function names: + +| Before (2.x) | After (3.x) | +|---|---| +| `Rss` | `RssFeed` | +| `Atom` | `AtomFeed` | +| `Json` | `JsonFeed` | +| `Rdf` | `RdfFeed` | + +```typescript +// Before (2.x) +import type { Rss, Atom } from 'feedsmith/types' +const feed: Rss.Feed = parseRssFeed(xml) +const entry: Atom.Entry = entries[0] + +// After (3.x) +import type { RssFeed, AtomFeed } from 'feedsmith' +const feed: RssFeed.Feed = parseRssFeed(xml) +const entry: AtomFeed.Entry = entries[0] +``` + +Nested type access works identically: `RssFeed.Item`, `AtomFeed.Link`, `JsonFeed.Author`, `RdfFeed.Image`, etc. The shape of each namespace is unchanged — only the outer name is different. + +The previous names (`Rss`, `Atom`, `Json`, `Rdf`) remain available as deprecated aliases and will be removed in 4.x. + +### `parseFeed` Return Type Now Exported as `AnyFeed` + +The universal `parseFeed` function now has a publicly exported return type, `AnyFeed`, so you can annotate the result directly: + +```typescript +import { parseFeed, type AnyFeed } from 'feedsmith' + +const result: AnyFeed = parseFeed(content) +const { format, feed } = result +``` + +The function itself, its options, and its return shape are unchanged. + +### Custom Date Parsing + +Parse functions now accept a `parseDateFn` option to convert date strings into any format. All date fields across feeds, items, and namespaces are passed through the provided function. See [Parsing Dates](/parsing/dates) for details and examples. + +```typescript +import { parseRssFeed } from 'feedsmith' + +const feed = parseRssFeed(xml, { + parseDateFn: (raw) => new Date(raw), +}) + +feed.pubDate // Date +``` + +### XML Namespace Support + +RSS, Atom, and RDF feeds now support the [XML namespace](/reference/namespaces/xml) (`xml:*` attributes). The `xml` property is available on both feed and item levels, providing access to `xml:lang`, `xml:base`, `xml:space`, and `xml:id` attributes. diff --git a/docs/parsing.md b/docs/parsing.md index b5690f73..35c2aab4 100644 --- a/docs/parsing.md +++ b/docs/parsing.md @@ -41,7 +41,7 @@ The universal parser: - Automatically detects the feed format using format detection functions - Returns an object with `format` and `feed` properties - Supports RSS, Atom, RDF, and JSON Feed formats -- Throws an error for unrecognized or invalid feeds +- Throws `DetectError`, `MalformedError`, or `ParseError` for invalid feeds > [!IMPORTANT] > The universal parser uses detection functions to identify the feed format. While these work well for most feeds, they might not perfectly detect all valid feeds, especially those with non-standard structures. If you know the feed format in advance, using a dedicated parser is more reliable. @@ -89,26 +89,8 @@ opml.body?.outlines ## Error Handling -If the feed is unrecognized or invalid, an `Error` will be thrown with a descriptive message. - -```typescript -import { parseFeed, parseJsonFeed } from 'feedsmith' - -try { - const universalFeed = parseFeed('') -} catch (error) { - // Error: Unrecognized feed format -} - -try { - const jsonFeed = parseJsonFeed('{}') -} catch (error) { - // Error: Invalid feed format -} -``` +Parsing functions throw `DetectError` when the input doesn't match the expected format, `MalformedError` when the content is malformed, or `ParseError` when the parsed result is invalid. See [Error Handling](/parsing/errors) for details. ## Returned Values -The parsing functions return JavaScript objects representing the feed in its original structure. - -For detailed examples of input and output for each feed format, see the [Parsing Examples](/parsing/examples) page. +The parsing functions return JavaScript objects representing the feed in its original structure. See [Parsing Examples](/parsing/examples) page for detailed examples of input and output for each feed format. diff --git a/docs/parsing/dates.md b/docs/parsing/dates.md index c110a810..cb01dddc 100644 --- a/docs/parsing/dates.md +++ b/docs/parsing/dates.md @@ -1,21 +1,60 @@ --- -title: "Parsing Feeds: Handling Dates" +title: "Parsing Feeds: Parsing Dates" --- -# Handling Dates +# Parsing Dates -Dates in feeds do not always follow a format defined in the specifications, or even any consistent format. Instead of attempting to parse all of them and risking errors, Feedsmith returns dates in their original string form. This method allows for the use of a preferred date parsing library, custom function, or the `Date` object directly. +Dates in feeds are notoriously unreliable — wrong formats, missing timezones, localized strings, inconsistencies within the same feed. Rather than shipping a built-in parser that handles some cases and silently fails on others, Feedsmith returns date strings as-is and lets you bring your own parsing logic via the `parseDateFn` option. This way you can use whichever date library (or the native `Date` constructor) fits your needs and handle edge cases on your terms. -### Common Issues +The function receives the raw (trimmed) date string and its return value replaces it in the result. -- **RSS**: Should use RFC 2822 format, but many feeds use incorrect formats -- **Atom**: ISO 8601/RFC 3339 format, generally more consistent but still varies -- **Real-world problems**: - - Missing timezone information - - Invalid day/month combinations - - Inconsistent formatting within the same feed - - Localized date strings - - Custom date formats +### Using `Date` constructor -> [!NOTE] -> Automatic date parsing may be implemented in a future version of Feedsmith, with an option to preserve string behavior for backward compatibility. +```typescript +import { parseFeed } from 'feedsmith' + +const { feed } = parseFeed(xml, { + parseDateFn: (raw) => new Date(raw), +}) + +feed.pubDate // Date +``` + +### Using a date library + +```typescript +import { parseRssFeed } from 'feedsmith' +import { parse } from 'date-fns' + +const feed = parseRssFeed(xml, { + parseDateFn: (raw) => { + return parse(raw, 'EEE, dd MMM yyyy HH:mm:ss xx', new Date()) + }, +}) + +feed.pubDate // Date +``` + +### Type safety with `TDate` + +All parse functions accept a generic `TDate` parameter that defaults to `string`. When `parseDateFn` is provided, `TDate` is inferred from its return type, so all date fields in the result are typed accordingly: + +```typescript +import { parseAtomFeed } from 'feedsmith' + +// Without parseDateFn — dates are strings. +const feed = parseAtomFeed(xml) +feed.updated // string | undefined + +// With parseDateFn — dates match the return type. +const feed = parseAtomFeed(xml, { + parseDateFn: (raw) => new Date(raw), +}) +feed.updated // Date | undefined +``` + +### Error handling + +Errors thrown by `parseDateFn` are **not caught** — they propagate to the caller. You should wrap your logic in a try/catch if it might throw. This is intentional — silently swallowing errors would hide invalid dates, and you're better off deciding how to handle them yourself. + +If a date string is empty or whitespace-only, `parseDateFn` is not called and the field is omitted from the result. diff --git a/docs/parsing/detecting.md b/docs/parsing/detecting.md index b38ba3df..175152c3 100644 --- a/docs/parsing/detecting.md +++ b/docs/parsing/detecting.md @@ -2,7 +2,7 @@ title: "Parsing Feeds: Detecting Format" --- -# Detecting Format +# Detecting Feed Format You can quickly detect the feed format without parsing it. diff --git a/docs/parsing/errors.md b/docs/parsing/errors.md new file mode 100644 index 00000000..6dc7ff5a --- /dev/null +++ b/docs/parsing/errors.md @@ -0,0 +1,95 @@ +# Error Handling + +Feedsmith provides dedicated error types for different failure scenarios during parsing. + +## Error Types + +### DetectError + +Thrown when the input doesn't match the expected feed format. This happens before any parsing is attempted. + +```typescript +import { parseRssFeed, DetectError } from 'feedsmith' + +try { + parseRssFeed('not rss') +} catch (error) { + if (error instanceof DetectError) { + console.log(error.message) // "Invalid feed format" + } +} +``` + +### MalformedError + +Thrown when the content is malformed and the underlying parser fails (e.g., invalid XML). + +```typescript +import { parseRssFeed, MalformedError } from 'feedsmith' + +try { + parseRssFeed('Test</title</channel></rss>') +} catch (error) { + if (error instanceof MalformedError) { + console.log(error.message) // "Invalid feed format" + } +} +``` + +### ParseError + +Thrown when the content is syntactically valid but produces an empty or invalid result. + +```typescript +import { parseRssFeed, ParseError } from 'feedsmith' + +try { + parseRssFeed('<rss version="2.0"></rss>') +} catch (error) { + if (error instanceof ParseError) { + console.log(error.message) // "Invalid feed format" + } +} +``` + +## Universal Parser + +The universal `parseFeed` function throws the same error types: + +```typescript +import { parseFeed, DetectError, MalformedError, ParseError } from 'feedsmith' + +try { + parseFeed('<not-a-feed></not-a-feed>') +} catch (error) { + if (error instanceof DetectError) { + // Unrecognized feed format. + } else if (error instanceof MalformedError) { + // Malformed XML. + } else if (error instanceof ParseError) { + // Valid XML but invalid feed structure. + } +} +``` + +## Error Hierarchy + +All error classes extend the built-in `Error`, so `instanceof Error` checks work as expected. This can be useful for catching any parsing error alongside other errors in a single handler: + +```typescript +import { parseFeed, DetectError, MalformedError, ParseError } from 'feedsmith' + +try { + parseFeed(input) +} catch (error) { + if (error instanceof DetectError) { + // Unrecognized feed format. + } else if (error instanceof MalformedError) { + // Malformed XML. + } else if (error instanceof ParseError) { + // Valid XML but invalid feed structure. + } else if (error instanceof Error) { + // Any other error. + } +} +``` diff --git a/docs/parsing/examples.md b/docs/parsing/examples.md index d32c8eff..ae04a862 100644 --- a/docs/parsing/examples.md +++ b/docs/parsing/examples.md @@ -125,6 +125,7 @@ const rssFeed = parseRssFeed(` <title>First item title http://example.org/item/1 Some description of the first item. + john@example.org (John Doe) http://example.org/comments/1 http://example.org/guid/1 @@ -144,7 +145,7 @@ Returns: "link": "http://example.org/", "description": "For documentation only", "language": "en", - "webMaster": "webmaster@example.org", + "webMaster": { "email": "webmaster@example.org" }, "pubDate": "Sat, 19 Mar 1988 07:15:00 GMT", "lastBuildDate": "Sat, 19 Mar 1988 07:15:00 GMT", "categories": [{ "name": "Examples2", "domain": "http://www.example.com/cusips" }], @@ -179,6 +180,7 @@ Returns: "title": "First item title", "link": "http://example.org/item/1", "description": "Some description of the first item.", + "authors": [{ "email": "john@example.org", "name": "John Doe" }], "comments": "http://example.org/comments/1", "enclosures": [ { diff --git a/docs/quick-start.md b/docs/quick-start.md index 66f0275f..ee537f75 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -127,36 +127,43 @@ console.log(rss) // Complete RSS XML ## Error Handling -If the feed is unrecognized or invalid, an `Error` will be thrown with a descriptive message. +Feedsmith throws dedicated error types for different failure scenarios: -```typescript -import { parseFeed, parseJsonFeed } from 'feedsmith' +- `DetectError` — input doesn't match the expected feed format +- `MalformedError` — content is malformed (e.g., invalid XML) +- `ParseError` — content parsed but produced an invalid result +- `GenerateError` — feed generation failed due to invalid input -try { - const universalFeed = parseFeed('') -} catch (error) { - // Error: Unrecognized feed format -} +```typescript +import { parseRssFeed, DetectError, MalformedError, ParseError } from 'feedsmith' try { - const jsonFeed = parseJsonFeed('{}') + parseRssFeed(input) } catch (error) { - // Error: Invalid feed format + if (error instanceof DetectError) { + // Not RSS format + } else if (error instanceof MalformedError) { + // Malformed XML + } else if (error instanceof ParseError) { + // Valid XML but invalid feed structure + } } ``` +See [Parsing Errors](/parsing/errors) and [Generating Errors](/generating/errors) for more details. + ## TypeScript Types Feedsmith provides comprehensive TypeScript types for all feed formats: ```typescript -import type { Rss, Atom, Json, Opml } from 'feedsmith/types' +import type { RssFeed, AtomFeed, JsonFeed, Opml } from 'feedsmith' // Access all types for a format -type Feed = Rss.Feed -type Item = Rss.Item -type Category = Rss.Category -type Enclosure = Rss.Enclosure +type Feed = RssFeed.Feed +type Item = RssFeed.Item +type Category = RssFeed.Category +type Enclosure = RssFeed.Enclosure ``` Each format exports its complete type system, including nested types and namespace types. See the [TypeScript guide](/reference/typescript) for details. diff --git a/docs/reference/feeds/atom.md b/docs/reference/feeds/atom.md index c43ac3fc..56f902fb 100644 --- a/docs/reference/feeds/atom.md +++ b/docs/reference/feeds/atom.md @@ -2,7 +2,7 @@ title: "Reference: Atom Feed" --- -# Atom Feed +# Atom Feed Reference Atom is a syndication format based on XML that provides a robust framework for web feeds. Feedsmith provides comprehensive parsing and generation capabilities. @@ -39,7 +39,8 @@ Atom is a syndication format based on XML that provides a robust framework for w Trackback, YouTube, W3C Basic Geo, - GeoRSS Simple + GeoRSS Simple, + XML @@ -85,7 +86,6 @@ Generates Atom XML from feed data. import { generateAtomFeed } from 'feedsmith' const xml = generateAtomFeed(feedData, { - lenient: true, stylesheets: [{ type: 'text/xsl', href: '/feed.xsl' }] }) ``` @@ -101,7 +101,7 @@ const xml = generateAtomFeed(feedData, { | Option | Type | Default | Description | |--------|------|---------|-------------| -| `lenient` | `boolean` | `false` | Enable lenient mode for relaxed validation, see [Lenient Mode](/generating/lenient-mode) | +| `strict` | `boolean` | `false` | Enable strict mode for spec-required field validation, see [Strict Mode](/generating/strict-mode) | | `stylesheets` | `Stylesheet[]` | - | Add stylesheets for visual formatting, see [Feed Styling](/generating/styling) | #### Returns @@ -129,16 +129,16 @@ const isAtom = detectAtomFeed(xmlContent) ## Types -All Atom types are available under the `Atom` namespace: +All Atom types are available under the `AtomFeed` namespace: ```typescript -import type { Atom } from 'feedsmith/types' +import type { AtomFeed } from 'feedsmith' // Access any type from the definitions below -type Feed = Atom.Feed -type Entry = Atom.Entry -type Link = Atom.Link -type Person = Atom.Person +type Feed = AtomFeed.Feed +type Entry = AtomFeed.Entry +type Link = AtomFeed.Link +type Person = AtomFeed.Person // … see type definitions below for all available types ``` @@ -147,7 +147,7 @@ See the [TypeScript guide](/reference/typescript) for usage examples. ### Type Definitions > [!INFO] -> `TDate` represents date fields in the type definitions. When **parsing**, dates are returned as strings in their original format (see [Parsing › Handling Dates](/parsing/dates) for more details). When **generating**, dates should be provided as JavaScript `Date` objects. +> For details on type parameters (`TDate`, `TStrict`) and `Requirable` markers, see [TypeScript Reference](/reference/typescript#tdate). <<< @/../src/feeds/atom/common/types.ts#reference diff --git a/docs/reference/feeds/json-feed.md b/docs/reference/feeds/json-feed.md index 641efcfd..3f6def0c 100644 --- a/docs/reference/feeds/json-feed.md +++ b/docs/reference/feeds/json-feed.md @@ -2,7 +2,7 @@ title: "Reference: JSON Feed" --- -# JSON Feed +# JSON Feed Reference JSON Feed is a syndication format based on JSON that provides a simple, straightforward way to publish feeds. Feedsmith provides full parsing and generation capabilities. @@ -62,9 +62,7 @@ Generates JSON Feed from feed data. ```typescript import { generateJsonFeed } from 'feedsmith' -const json = generateJsonFeed(feedData, { - lenient: true -}) +const json = generateJsonFeed(feedData) ``` #### Parameters @@ -78,7 +76,7 @@ const json = generateJsonFeed(feedData, { | Option | Type | Default | Description | |--------|------|---------|-------------| -| `lenient` | `boolean` | `false` | Enable lenient mode for relaxed validation, see [Lenient Mode](/generating/lenient-mode) | +| `strict` | `boolean` | `false` | Enable strict mode for spec-required field validation, see [Strict Mode](/generating/strict-mode) | #### Returns `object` - Generated JSON Feed @@ -105,16 +103,16 @@ const isJsonFeed = detectJsonFeed(jsonContent) ## Types -All JSON Feed types are available under the `Json` namespace: +All JSON Feed types are available under the `JsonFeed` namespace: ```typescript -import type { Json } from 'feedsmith/types' +import type { JsonFeed } from 'feedsmith' // Access any type from the definitions below -type Feed = Json.Feed -type Item = Json.Item -type Author = Json.Author -type Attachment = Json.Attachment +type Feed = JsonFeed.Feed +type Item = JsonFeed.Item +type Author = JsonFeed.Author +type Attachment = JsonFeed.Attachment // … see type definitions below for all available types ``` @@ -123,7 +121,7 @@ See the [TypeScript guide](/reference/typescript) for usage examples. ### Type Definitions > [!INFO] -> `TDate` represents date fields in the type definitions. When **parsing**, dates are returned as strings in their original format (see [Parsing › Handling Dates](/parsing/dates) for more details). When **generating**, dates should be provided as JavaScript `Date` objects. +> For details on type parameters (`TDate`, `TStrict`) and `Requirable` markers, see [TypeScript Reference](/reference/typescript#tdate). <<< @/../src/feeds/json/common/types.ts#reference diff --git a/docs/reference/feeds/rdf.md b/docs/reference/feeds/rdf.md index 9fef3cab..9ad617f7 100644 --- a/docs/reference/feeds/rdf.md +++ b/docs/reference/feeds/rdf.md @@ -2,7 +2,7 @@ title: "Reference: RDF Feed" --- -# RDF Feed +# RDF Feed Reference RDF (Resource Description Framework) Site Summary is an early XML-based syndication format that uses RDF metadata. Feedsmith provides full parsing capabilities. @@ -19,17 +19,18 @@ RDF (Resource Description Framework) Site Summary is an early XML-based syndicat Namespaces - RDF, Atom, Dublin Core, + Dublin Core Terms, Syndication, Content, Slash, Media RSS, - GeoRSS Simple, - Dublin Core Terms, Comment API, - Administrative + Administrative, + GeoRSS Simple, + RDF, + XML @@ -94,16 +95,16 @@ const isRdf = detectRdfFeed(xmlContent) ## Types -All RDF types are available under the `Rdf` namespace: +All RDF types are available under the `RdfFeed` namespace: ```typescript -import type { Rdf } from 'feedsmith/types' +import type { RdfFeed } from 'feedsmith' // Access any type from the definitions below -type Feed = Rdf.Feed -type Item = Rdf.Item -type Image = Rdf.Image -type TextInput = Rdf.TextInput +type Feed = RdfFeed.Feed +type Item = RdfFeed.Item +type Image = RdfFeed.Image +type TextInput = RdfFeed.TextInput // … see type definitions below for all available types ``` diff --git a/docs/reference/feeds/rss.md b/docs/reference/feeds/rss.md index 955d587a..22290ec1 100644 --- a/docs/reference/feeds/rss.md +++ b/docs/reference/feeds/rss.md @@ -2,7 +2,7 @@ title: "Reference: RSS Feed" --- -# RSS Feed +# RSS Feed Reference RSS (Really Simple Syndication) is one of the most widely used web feed formats. Feedsmith automatically normalizes legacy elements to their modern equivalents. @@ -46,7 +46,8 @@ RSS (Really Simple Syndication) is one of the most widely used web feed formats. Source, blogChannel, W3C Basic Geo, - GeoRSS Simple + GeoRSS Simple, + XML @@ -92,7 +93,6 @@ Generates RSS XML from feed data. import { generateRssFeed } from 'feedsmith' const xml = generateRssFeed(feedData, { - lenient: true, stylesheets: [{ type: 'text/xsl', href: '/feed.xsl' }] }) ``` @@ -108,7 +108,7 @@ const xml = generateRssFeed(feedData, { | Option | Type | Default | Description | |--------|------|---------|-------------| -| `lenient` | `boolean` | `false` | Enable lenient mode for relaxed validation, see [Lenient Mode](/generating/lenient-mode) | +| `strict` | `boolean` | `false` | Enable strict mode for spec-required field validation, see [Strict Mode](/generating/strict-mode) | | `stylesheets` | `Stylesheet[]` | - | Add stylesheets for visual formatting, see [Feed Styling](/generating/styling) | #### Returns @@ -136,16 +136,16 @@ const isRss = detectRssFeed(xmlContent) ## Types -All RSS types are available under the `Rss` namespace: +All RSS types are available under the `RssFeed` namespace: ```typescript -import type { Rss } from 'feedsmith/types' +import type { RssFeed } from 'feedsmith' // Access any type from the definitions below -type Feed = Rss.Feed -type Item = Rss.Item -type Category = Rss.Category -type Enclosure = Rss.Enclosure +type Feed = RssFeed.Feed +type Item = RssFeed.Item +type Category = RssFeed.Category +type Enclosure = RssFeed.Enclosure // … see type definitions below for all available types ``` @@ -154,7 +154,7 @@ See the [TypeScript guide](/reference/typescript) for usage examples. ### Type Definitions > [!INFO] -> `TDate` represents date fields in the type definitions. When **parsing**, dates are returned as strings in their original format (see [Parsing › Handling Dates](/parsing/dates) for more details). When **generating**, dates should be provided as JavaScript `Date` objects. +> For details on type parameters (`TDate`, `TStrict`) and `Requirable` markers, see [TypeScript Reference](/reference/typescript#tdate). <<< @/../src/feeds/rss/common/types.ts#reference diff --git a/docs/reference/namespaces/acast.md b/docs/reference/namespaces/acast.md index 020ff48b..99ac0b97 100644 --- a/docs/reference/namespaces/acast.md +++ b/docs/reference/namespaces/acast.md @@ -2,7 +2,7 @@ title: "Reference: Acast Namespace" --- -# Acast Namespace +# Acast Namespace Reference The Acast namespace provides podcast-specific metadata for Acast's podcast hosting platform, including show and episode identifiers, encrypted settings, and network information. diff --git a/docs/reference/namespaces/admin.md b/docs/reference/namespaces/admin.md index fbe80c13..b1943d14 100644 --- a/docs/reference/namespaces/admin.md +++ b/docs/reference/namespaces/admin.md @@ -2,7 +2,7 @@ title: "Reference: Administrative Namespace" --- -# Administrative Namespace +# Administrative Namespace Reference The Administrative namespace (MVCB - Meta Vocabulary for Community Building) provides administrative metadata about RSS/RDF feeds, enabling better identification of feed generators and error reporting contacts. diff --git a/docs/reference/namespaces/app.md b/docs/reference/namespaces/app.md index 07f0c546..32b167aa 100644 --- a/docs/reference/namespaces/app.md +++ b/docs/reference/namespaces/app.md @@ -2,7 +2,7 @@ title: "Reference: Atom Publishing Protocol Namespace" --- -# Atom Publishing Protocol Namespace +# Atom Publishing Protocol Namespace Reference Extends Atom feeds with elements for content management workflows. @@ -33,6 +33,9 @@ Extends Atom feeds with elements for content management workflows. ## Types +> [!INFO] +> For details on type parameters (`TDate`), see [TypeScript Reference](/reference/typescript#tdate). + <<< @/../src/namespaces/app/common/types.ts#reference ## Related diff --git a/docs/reference/namespaces/arxiv.md b/docs/reference/namespaces/arxiv.md index b056e5ed..d053b040 100644 --- a/docs/reference/namespaces/arxiv.md +++ b/docs/reference/namespaces/arxiv.md @@ -2,7 +2,7 @@ title: "Reference: arXiv Namespace" --- -# arXiv Namespace +# arXiv Namespace Reference arXiv is an extension namespace for the arXiv preprint repository API, providing metadata specific to scholarly papers in physics, mathematics, computer science, and related fields. diff --git a/docs/reference/namespaces/atom.md b/docs/reference/namespaces/atom.md index bbca23b2..718c299a 100644 --- a/docs/reference/namespaces/atom.md +++ b/docs/reference/namespaces/atom.md @@ -2,7 +2,7 @@ title: "Reference: Atom Namespace" --- -# Atom Namespace +# Atom Namespace Reference The Atom namespace allows RSS and RDF feeds to include Atom-specific elements, providing richer metadata and linking capabilities. This namespace provides partial Atom elements that can be embedded within other feed formats. @@ -37,7 +37,7 @@ The Atom namespace allows RSS and RDF feeds to include Atom-specific elements, p ## Types > [!INFO] -> `TDate` represents date fields in the type definitions. When **parsing**, dates are returned as strings in their original format (see [Parsing › Handling Dates](/parsing/dates) for more details). When **generating**, dates should be provided as JavaScript `Date` objects. +> For details on type parameters (`TDate`, `TStrict`) and `Requirable` markers, see [TypeScript Reference](/reference/typescript#tdate). <<< @/../src/feeds/atom/common/types.ts#reference diff --git a/docs/reference/namespaces/blogchannel.md b/docs/reference/namespaces/blogchannel.md index 34a9e523..aa3122e9 100644 --- a/docs/reference/namespaces/blogchannel.md +++ b/docs/reference/namespaces/blogchannel.md @@ -2,7 +2,7 @@ title: "Reference: blogChannel Namespace" --- -# blogChannel Namespace +# blogChannel Namespace Reference The blogChannel namespace is an RSS 2.0 module for weblogging applications, providing metadata about blog-related content such as blogrolls, recommended links, and subscription lists. diff --git a/docs/reference/namespaces/cc.md b/docs/reference/namespaces/cc.md index 30616a7e..0e96fa0d 100644 --- a/docs/reference/namespaces/cc.md +++ b/docs/reference/namespaces/cc.md @@ -2,7 +2,7 @@ title: "Reference: ccREL Namespace" --- -# ccREL Namespace +# ccREL Namespace Reference The Creative Commons Rights Expression Language (ccREL) enables RSS and Atom feeds to declare copyright licenses and additional permissions for feed content. diff --git a/docs/reference/namespaces/content.md b/docs/reference/namespaces/content.md index 0ed03e44..453d1c42 100644 --- a/docs/reference/namespaces/content.md +++ b/docs/reference/namespaces/content.md @@ -2,7 +2,7 @@ title: "Reference: Content Namespace" --- -# Content Namespace +# Content Namespace Reference The Content namespace allows RSS and RDF feeds to include full content alongside or instead of summaries. It provides a way to embed complete articles or posts within feed items. diff --git a/docs/reference/namespaces/creativecommons.md b/docs/reference/namespaces/creativecommons.md index 7f3fdc95..d2ccbddb 100644 --- a/docs/reference/namespaces/creativecommons.md +++ b/docs/reference/namespaces/creativecommons.md @@ -2,7 +2,7 @@ title: "Reference: Creative Commons Namespace" --- -# Creative Commons Namespace +# Creative Commons Namespace Reference The Creative Commons namespace provides elements for specifying the license under which the feed content is distributed. This allows content creators to clearly indicate their licensing terms using Creative Commons or other license URLs. diff --git a/docs/reference/namespaces/dc.md b/docs/reference/namespaces/dc.md index db49a3ef..65ea4a06 100644 --- a/docs/reference/namespaces/dc.md +++ b/docs/reference/namespaces/dc.md @@ -2,7 +2,7 @@ title: "Reference: Dublin Core Namespace" --- -# Dublin Core Namespace +# Dublin Core Namespace Reference The Dublin Core namespace provides standardized metadata elements for describing digital resources. It offers a simple and effective way to add bibliographic information to feeds and items. @@ -38,7 +38,7 @@ The Dublin Core namespace provides standardized metadata elements for describing ## Types > [!INFO] -> `TDate` represents date fields in the type definitions. When **parsing**, dates are returned as strings in their original format (see [Parsing › Handling Dates](/parsing/dates) for more details). When **generating**, dates should be provided as JavaScript `Date` objects. +> For details on type parameters (`TDate`), see [TypeScript Reference](/reference/typescript#tdate). <<< @/../src/namespaces/dc/common/types.ts#reference diff --git a/docs/reference/namespaces/dcterms.md b/docs/reference/namespaces/dcterms.md index 58852ed2..dca90665 100644 --- a/docs/reference/namespaces/dcterms.md +++ b/docs/reference/namespaces/dcterms.md @@ -2,7 +2,7 @@ title: "Reference: Dublin Core Terms Namespace" --- -# Dublin Core Terms Namespace +# Dublin Core Terms Namespace Reference The Dublin Core Terms namespace provides extended metadata elements based on the Dublin Core Metadata Initiative, offering comprehensive resource description capabilities. @@ -38,7 +38,7 @@ The Dublin Core Terms namespace provides extended metadata elements based on the ## Types > [!INFO] -> `TDate` represents date fields in the type definitions. When **parsing**, dates are returned as strings in their original format (see [Parsing › Handling Dates](/parsing/dates) for more details). When **generating**, dates should be provided as JavaScript `Date` objects. +> For details on type parameters (`TDate`), see [TypeScript Reference](/reference/typescript#tdate). <<< @/../src/namespaces/dcterms/common/types.ts#reference diff --git a/docs/reference/namespaces/feedpress.md b/docs/reference/namespaces/feedpress.md index b1955b4b..19eace02 100644 --- a/docs/reference/namespaces/feedpress.md +++ b/docs/reference/namespaces/feedpress.md @@ -2,7 +2,7 @@ title: "Reference: FeedPress Namespace" --- -# FeedPress Namespace +# FeedPress Namespace Reference The FeedPress namespace provides elements for FeedPress-specific feed metadata, including podcast identifiers, newsletter identifiers, locale information, and custom CSS file references. diff --git a/docs/reference/namespaces/geo.md b/docs/reference/namespaces/geo.md index 8c33e029..7f6ae060 100644 --- a/docs/reference/namespaces/geo.md +++ b/docs/reference/namespaces/geo.md @@ -2,7 +2,7 @@ title: "Reference: W3C Basic Geo Namespace" --- -# W3C Basic Geo Namespace +# W3C Basic Geo Namespace Reference The W3C Basic Geo (WGS84 lat/long) Vocabulary provides a simple way to represent geographic coordinates in RSS and Atom feeds using the WGS84 geodetic reference datum. diff --git a/docs/reference/namespaces/georss.md b/docs/reference/namespaces/georss.md index a31c8332..ad0b6b35 100644 --- a/docs/reference/namespaces/georss.md +++ b/docs/reference/namespaces/georss.md @@ -2,7 +2,7 @@ title: "Reference: GeoRSS Simple Namespace" --- -# GeoRSS Simple Namespace +# GeoRSS Simple Namespace Reference The GeoRSS Simple namespace enables geographic tagging of RSS feeds and items, allowing publishers to associate location information with their content. @@ -37,6 +37,9 @@ The GeoRSS Simple namespace enables geographic tagging of RSS feeds and items, a ## Types +> [!INFO] +> For details on type parameters (`TStrict`) and `Requirable` markers, see [TypeScript Reference](/reference/typescript#tstrict). + <<< @/../src/namespaces/georss/common/types.ts#reference ## Related diff --git a/docs/reference/namespaces/googleplay.md b/docs/reference/namespaces/googleplay.md index 83401254..023c3f0a 100644 --- a/docs/reference/namespaces/googleplay.md +++ b/docs/reference/namespaces/googleplay.md @@ -2,7 +2,7 @@ title: "Reference: Google Play Podcast Namespace" --- -# Google Play Podcast Namespace +# Google Play Podcast Namespace Reference The Google Play Podcast namespace provides podcast-specific metadata for feed and episode information optimized for Google Play's podcast platform, including author details, content descriptions, and content policies. @@ -36,6 +36,9 @@ The Google Play Podcast namespace provides podcast-specific metadata for feed an ## Structure +> [!INFO] +> For details on type parameters (`TStrict`) and `Requirable` markers, see [TypeScript Reference](/reference/typescript#tstrict). + <<< @/../src/namespaces/googleplay/common/types.ts#reference ## Related diff --git a/docs/reference/namespaces/itunes.md b/docs/reference/namespaces/itunes.md index 4e0cebd2..00d9de40 100644 --- a/docs/reference/namespaces/itunes.md +++ b/docs/reference/namespaces/itunes.md @@ -2,7 +2,7 @@ title: "Reference: iTunes Namespace" --- -# iTunes Namespace +# iTunes Namespace Reference The iTunes namespace provides podcast-specific metadata for RSS and Atom feeds. This namespace is essential for podcast distribution through Apple Podcasts and other podcast platforms. @@ -36,6 +36,9 @@ The iTunes namespace provides podcast-specific metadata for RSS and Atom feeds. ## Types +> [!INFO] +> For details on type parameters (`TStrict`) and `Requirable` markers, see [TypeScript Reference](/reference/typescript#tstrict). + <<< @/../src/namespaces/itunes/common/types.ts#reference ## Related diff --git a/docs/reference/namespaces/media.md b/docs/reference/namespaces/media.md index cfc55f67..2d03c48d 100644 --- a/docs/reference/namespaces/media.md +++ b/docs/reference/namespaces/media.md @@ -2,7 +2,7 @@ title: "Reference: Media RSS Namespace" --- -# Media RSS Namespace +# Media RSS Namespace Reference The Media RSS namespace provides rich media metadata for RSS feeds, enabling comprehensive description of multimedia content including videos, images, and audio files. @@ -37,6 +37,9 @@ The Media RSS namespace provides rich media metadata for RSS feeds, enabling com ## Types +> [!INFO] +> For details on type parameters (`TStrict`) and `Requirable` markers, see [TypeScript Reference](/reference/typescript#tstrict). + <<< @/../src/namespaces/media/common/types.ts#reference ## Related diff --git a/docs/reference/namespaces/opensearch.md b/docs/reference/namespaces/opensearch.md index 8deeeb1b..896aad99 100644 --- a/docs/reference/namespaces/opensearch.md +++ b/docs/reference/namespaces/opensearch.md @@ -2,7 +2,7 @@ title: "Reference: OpenSearch Namespace" --- -# OpenSearch Namespace +# OpenSearch Namespace Reference The OpenSearch namespace provides elements for communicating search metadata and pagination information in RSS and Atom feeds. It enables search engines and APIs to publish search results in standard syndication formats. @@ -33,6 +33,9 @@ The OpenSearch namespace provides elements for communicating search metadata and ## Types +> [!INFO] +> For details on type parameters (`TStrict`) and `Requirable` markers, see [TypeScript Reference](/reference/typescript#tstrict). + <<< @/../src/namespaces/opensearch/common/types.ts#reference ## Related diff --git a/docs/reference/namespaces/pingback.md b/docs/reference/namespaces/pingback.md index d05326f3..b9bc298b 100644 --- a/docs/reference/namespaces/pingback.md +++ b/docs/reference/namespaces/pingback.md @@ -2,7 +2,7 @@ title: "Reference: Pingback Namespace" --- -# Pingback Namespace +# Pingback Namespace Reference The Pingback namespace provides a mechanism for notifying websites when content references or links to them, enabling automatic trackback of linkages between web resources. diff --git a/docs/reference/namespaces/podcast.md b/docs/reference/namespaces/podcast.md index 969fd95e..9997292d 100644 --- a/docs/reference/namespaces/podcast.md +++ b/docs/reference/namespaces/podcast.md @@ -2,7 +2,7 @@ title: "Reference: Podcast Index Namespace" --- -# Podcast Index Namespace +# Podcast Index Namespace Reference The Podcast Index namespace implements the Podcasting 2.0 specification, providing advanced features for modern podcasting including transcripts, chapters, value streaming, and enhanced metadata. @@ -34,7 +34,7 @@ The Podcast Index namespace implements the Podcasting 2.0 specification, providi ## Types > [!INFO] -> `TDate` represents date fields in the type definitions. When **parsing**, dates are returned as strings in their original format (see [Parsing › Handling Dates](/parsing/dates) for more details). When **generating**, dates should be provided as JavaScript `Date` objects. +> For details on type parameters (`TDate`, `TStrict`) and `Requirable` markers, see [TypeScript Reference](/reference/typescript#tdate). <<< @/../src/namespaces/podcast/common/types.ts#reference diff --git a/docs/reference/namespaces/prism.md b/docs/reference/namespaces/prism.md index 0d0387e6..132969d2 100644 --- a/docs/reference/namespaces/prism.md +++ b/docs/reference/namespaces/prism.md @@ -2,7 +2,7 @@ title: "Reference: PRISM Namespace" --- -# PRISM Namespace +# PRISM Namespace Reference The PRISM (Publishing Requirements for Industry Standard Metadata) namespace provides comprehensive metadata elements for scholarly and academic publishing, including bibliographic information, page ranges, DOIs, and publication details. @@ -36,7 +36,7 @@ The PRISM (Publishing Requirements for Industry Standard Metadata) namespace pro ## Types > [!INFO] -> `TDate` represents date fields in the type definitions. When **parsing**, dates are returned as strings in their original format (see [Parsing › Handling Dates](/parsing/dates) for more details). When **generating**, dates should be provided as JavaScript `Date` objects. +> For details on type parameters (`TDate`), see [TypeScript Reference](/reference/typescript#tdate). <<< @/../src/namespaces/prism/common/types.ts#reference diff --git a/docs/reference/namespaces/psc.md b/docs/reference/namespaces/psc.md index a64f250f..86cf0064 100644 --- a/docs/reference/namespaces/psc.md +++ b/docs/reference/namespaces/psc.md @@ -2,7 +2,7 @@ title: "Reference: Podlove Simple Chapters Namespace" --- -# Podlove Simple Chapters Namespace +# Podlove Simple Chapters Namespace Reference The Podlove Simple Chapters (PSC) namespace provides structured chapter information for podcasts and other media, allowing creators to define timed segments with titles, links, and images. @@ -36,6 +36,9 @@ The Podlove Simple Chapters (PSC) namespace provides structured chapter informat ## Structure +> [!INFO] +> For details on type parameters (`TStrict`) and `Requirable` markers, see [TypeScript Reference](/reference/typescript#tstrict). + <<< @/../src/namespaces/psc/common/types.ts#reference ## Related diff --git a/docs/reference/namespaces/rawvoice.md b/docs/reference/namespaces/rawvoice.md index 34e5fc93..7675d356 100644 --- a/docs/reference/namespaces/rawvoice.md +++ b/docs/reference/namespaces/rawvoice.md @@ -2,7 +2,7 @@ title: "Reference: RawVoice Namespace" --- -# RawVoice Namespace +# RawVoice Namespace Reference The RawVoice namespace provides elements for enhanced podcast and video content delivery, including live streaming, video formats, and episode metadata. @@ -35,6 +35,9 @@ The RawVoice namespace provides elements for enhanced podcast and video content ## Types +> [!INFO] +> For details on type parameters (`TDate`, `TStrict`) and `Requirable` markers, see [TypeScript Reference](/reference/typescript#tdate). + <<< @/../src/namespaces/rawvoice/common/types.ts#reference ## Related diff --git a/docs/reference/namespaces/rdf.md b/docs/reference/namespaces/rdf.md index 7450f812..b3eba209 100644 --- a/docs/reference/namespaces/rdf.md +++ b/docs/reference/namespaces/rdf.md @@ -2,7 +2,7 @@ title: "Reference: RDF Namespace" --- -# RDF Namespace +# RDF Namespace Reference Built-in namespace for RDF feeds exposing standard RDF metadata. diff --git a/docs/reference/namespaces/slash.md b/docs/reference/namespaces/slash.md index 5481c63f..49ad8165 100644 --- a/docs/reference/namespaces/slash.md +++ b/docs/reference/namespaces/slash.md @@ -2,7 +2,7 @@ title: "Reference: Slash Namespace" --- -# Slash Namespace +# Slash Namespace Reference The Slash namespace provides metadata about user engagement, particularly comment counts. Originally created by Slashdot, it's now widely used to indicate discussion activity on feed items. diff --git a/docs/reference/namespaces/source.md b/docs/reference/namespaces/source.md index 487512c9..9121c9a3 100644 --- a/docs/reference/namespaces/source.md +++ b/docs/reference/namespaces/source.md @@ -2,7 +2,7 @@ title: "Reference: Source Namespace" --- -# Source Namespace +# Source Namespace Reference The Source namespace provides elements for enhanced feed metadata, including social media accounts, subscription lists, blogrolls, and source content in various formats like Markdown and OPML outlines. @@ -33,6 +33,9 @@ The Source namespace provides elements for enhanced feed metadata, including soc ## Types +> [!INFO] +> For details on type parameters (`TStrict`) and `Requirable` markers, see [TypeScript Reference](/reference/typescript#tstrict). + <<< @/../src/namespaces/source/common/types.ts#reference ## Related diff --git a/docs/reference/namespaces/spotify.md b/docs/reference/namespaces/spotify.md index 5f4d207d..5bb31999 100644 --- a/docs/reference/namespaces/spotify.md +++ b/docs/reference/namespaces/spotify.md @@ -2,7 +2,7 @@ title: "Reference: Spotify Namespace" --- -# Spotify Namespace +# Spotify Namespace Reference The Spotify namespace provides podcast-specific metadata for Spotify's podcast platform, including episode limits and country targeting information. @@ -33,6 +33,9 @@ The Spotify namespace provides podcast-specific metadata for Spotify's podcast p ## Types +> [!INFO] +> For details on type parameters (`TStrict`) and `Requirable` markers, see [TypeScript Reference](/reference/typescript#tstrict). + <<< @/../src/namespaces/spotify/common/types.ts#reference ## Related diff --git a/docs/reference/namespaces/sy.md b/docs/reference/namespaces/sy.md index c6bf0e30..b4e39d7c 100644 --- a/docs/reference/namespaces/sy.md +++ b/docs/reference/namespaces/sy.md @@ -2,7 +2,7 @@ title: "Reference: Syndication Namespace" --- -# Syndication Namespace +# Syndication Namespace Reference The Syndication namespace provides information about the frequency and timing of feed updates. It helps aggregators understand how often to check for new content. @@ -38,7 +38,7 @@ The Syndication namespace provides information about the frequency and timing of ## Types > [!INFO] -> `TDate` represents date fields in the type definitions. When **parsing**, dates are returned as strings in their original format (see [Parsing › Handling Dates](/parsing/dates) for more details). When **generating**, dates should be provided as JavaScript `Date` objects. +> For details on type parameters (`TDate`), see [TypeScript Reference](/reference/typescript#tdate). <<< @/../src/namespaces/sy/common/types.ts#reference diff --git a/docs/reference/namespaces/thr.md b/docs/reference/namespaces/thr.md index 4a60afaa..02db4eaa 100644 --- a/docs/reference/namespaces/thr.md +++ b/docs/reference/namespaces/thr.md @@ -2,7 +2,7 @@ title: "Reference: Atom Threading Namespace" --- -# Atom Threading Namespace +# Atom Threading Namespace Reference The Atom Threading namespace provides elements for representing threaded discussions and comment relationships in Atom feeds, enabling proper conversation threading. @@ -37,7 +37,7 @@ The Atom Threading namespace provides elements for representing threaded discuss ## Types > [!INFO] -> `TDate` represents date fields in the type definitions. When **parsing**, dates are returned as strings in their original format (see [Parsing › Handling Dates](/parsing/dates) for more details). When **generating**, dates should be provided as JavaScript `Date` objects. +> For details on type parameters (`TDate`, `TStrict`) and `Requirable` markers, see [TypeScript Reference](/reference/typescript#tdate). <<< @/../src/namespaces/thr/common/types.ts#reference diff --git a/docs/reference/namespaces/trackback.md b/docs/reference/namespaces/trackback.md index 62328c3b..3b9e3f77 100644 --- a/docs/reference/namespaces/trackback.md +++ b/docs/reference/namespaces/trackback.md @@ -2,7 +2,7 @@ title: "Reference: Trackback Namespace" --- -# Trackback Namespace +# Trackback Namespace Reference The Trackback namespace enables peer-to-peer communication between web sites that publish related content. In its simplest form, trackback is a means of sending a message that lets a site know you've published a link to one of its pages. diff --git a/docs/reference/namespaces/wfw.md b/docs/reference/namespaces/wfw.md index e8cdee06..355bb929 100644 --- a/docs/reference/namespaces/wfw.md +++ b/docs/reference/namespaces/wfw.md @@ -2,7 +2,7 @@ title: "Reference: Comment API Namespace" --- -# Comment API Namespace +# Comment API Namespace Reference The Comment API namespace provides elements for linking to comment feeds and comment posting interfaces, enabling better integration between feeds and commenting systems. diff --git a/docs/reference/namespaces/xml.md b/docs/reference/namespaces/xml.md new file mode 100644 index 00000000..82b98f8b --- /dev/null +++ b/docs/reference/namespaces/xml.md @@ -0,0 +1,40 @@ +# XML Namespace Reference + +Supports `xml:lang`, `xml:base`, `xml:space`, and `xml:id` attributes at both feed and item/entry level in RSS, Atom, and RDF feeds. + + + + + + + + + + + + + + + + + + + + + + + + +
Namespace URIhttp://www.w3.org/XML/1998/namespace
SpecificationNamespaces in XML
Prefix<xml:*>
Available in + RSS, + Atom, + RDF +
Propertyxml
+ +## Types + +<<< @/../src/namespaces/xml/common/types.ts#reference + +## Related + +- **[Parsing Namespaces](/parsing/namespaces)** - How namespace parsing works diff --git a/docs/reference/namespaces/yt.md b/docs/reference/namespaces/yt.md index b5d68d6e..e4a8ddbf 100644 --- a/docs/reference/namespaces/yt.md +++ b/docs/reference/namespaces/yt.md @@ -2,7 +2,7 @@ title: "Reference: YouTube Namespace" --- -# YouTube Namespace +# YouTube Namespace Reference The YouTube namespace provides YouTube-specific metadata for RSS feeds, enabling identification of YouTube videos and channels within RSS feeds. diff --git a/docs/reference/opml.md b/docs/reference/opml.md index a7d063ef..55af1958 100644 --- a/docs/reference/opml.md +++ b/docs/reference/opml.md @@ -2,7 +2,7 @@ title: "Reference: OPML" --- -# OPML +# OPML Reference OPML (Outline Processor Markup Language) is a format for exchanging outline-structured information, commonly used for sharing feed subscription lists. @@ -68,7 +68,6 @@ Generates OPML XML from OPML data. import { generateOpml } from 'feedsmith' const xml = generateOpml(opmlData, { - lenient: true, stylesheets: [{ type: 'text/xsl', href: '/opml.xsl' }], extraOutlineAttributes: ['customIcon', 'updateInterval'] }) @@ -85,7 +84,7 @@ const xml = generateOpml(opmlData, { | Option | Type | Default | Description | |--------|------|---------|-------------| -| `lenient` | `boolean` | `false` | Enable lenient mode for relaxed validation, see [Lenient Mode](/generating/lenient-mode) | +| `strict` | `boolean` | `false` | Enable strict mode for spec-required field validation, see [Strict Mode](/generating/strict-mode) | | `stylesheets` | `Stylesheet[]` | - | Add stylesheets for visual formatting, see [Feed Styling](/generating/styling) | | `extraOutlineAttributes` | `string[]` | - | Custom attributes to include in outline elements. Only specified attributes are included in generated XML, see [examples](/generating/examples#extra-outline-attributes) | @@ -97,7 +96,7 @@ const xml = generateOpml(opmlData, { All OPML types are available under the `Opml` namespace: ```typescript -import type { Opml } from 'feedsmith/types' +import type { Opml } from 'feedsmith' // Access any type from the definitions below type Document = Opml.Document @@ -112,7 +111,7 @@ See the [TypeScript guide](/reference/typescript) for usage examples. ### Type Definitions > [!INFO] -> `TDate` represents date fields in the type definitions. When **parsing**, dates are returned as strings in their original format (see [Parsing › Handling Dates](/parsing/dates) for more details). When **generating**, dates should be provided as JavaScript `Date` objects. +> For details on type parameters (`TDate`, `TStrict`) and `Requirable` markers, see [TypeScript Reference](/reference/typescript#tdate). <<< @/../src/opml/common/types.ts#reference diff --git a/docs/reference/typescript.md b/docs/reference/typescript.md index db8f1f99..5e5d9ed0 100644 --- a/docs/reference/typescript.md +++ b/docs/reference/typescript.md @@ -8,33 +8,31 @@ Feedsmith is built with TypeScript and provides comprehensive type definitions f ## Importing Types -All types are available through the `feedsmith/types` export: +All types are available through the main `feedsmith` export: ```typescript -import type { Rss, Atom, Json, Rdf, Opml } from 'feedsmith/types' +import type { RssFeed, AtomFeed, JsonFeed, RdfFeed, Opml } from 'feedsmith' ``` Each namespace contains the complete type system for that format: ```typescript // RSS types -type Feed = Rss.Feed -type Item = Rss.Item -type Category = Rss.Category -type Enclosure = Rss.Enclosure +type Feed = RssFeed.Feed +type Item = RssFeed.Item +type Category = RssFeed.Category +type Enclosure = RssFeed.Enclosure // Atom types -type AtomFeed = Atom.Feed -type Entry = Atom.Entry -type Link = Atom.Link +type Entry = AtomFeed.Entry +type Link = AtomFeed.Link // JSON Feed types -type JsonFeed = Json.Feed -type JsonItem = Json.Item -type Author = Json.Author +type JsonItem = JsonFeed.Item +type Author = JsonFeed.Author // RDF types -type RdfFeed = Rdf.Feed +type RdfItem = RdfFeed.Item // OPML types type Document = Opml.Document @@ -46,10 +44,9 @@ type Outline = Opml.Outline When parsing, dates are returned as strings. Use `` for the generic parameter: ```typescript -import type { Rss } from 'feedsmith/types' -import { parseRssFeed } from 'feedsmith' +import { type RssFeed, parseRssFeed } from 'feedsmith' -const feed: Rss.Feed = parseRssFeed(xmlContent) +const feed: RssFeed.Feed = parseRssFeed(xmlContent) // Access properties with full type safety feed.title // string | undefined @@ -60,38 +57,14 @@ feed.items?.[0]?.enclosures // Enclosure[] | undefined > [!NOTE] > Explicit typing is usually not required when parsing since the parse functions already return properly typed objects. TypeScript will automatically infer the correct types. -### Working with DeepPartial - -The `DeepPartial` type recursively makes all properties optional, including nested objects and arrays. This accurately represents the reality of parsed feeds where any field might be missing. - -Use it to recreate the same type that parse functions return: - -```typescript -import type { Rss, DeepPartial } from 'feedsmith/types' -import { parseRssFeed } from 'feedsmith' - -const feed = parseRssFeed(xmlContent) - -function processFeed(feed: DeepPartial>) { - console.log(feed.title) // string | undefined -} - -function processItem(item: DeepPartial>) { - console.log(item.title) // string | undefined -} - -processFeed(feed) -``` - ## Using Types with Generating When generating, you can use `Date` objects. Use `` for the generic parameter: ```typescript -import type { Rss } from 'feedsmith/types' -import { generateRssFeed } from 'feedsmith' +import { type RssFeed, generateRssFeed } from 'feedsmith' -const feed: Rss.Feed = { +const feed: RssFeed.Feed = { title: 'My Podcast', link: 'https://example.com', description: 'A great podcast', @@ -110,17 +83,84 @@ const feed: Rss.Feed = { const xml = generateRssFeed(feed) ``` - +## Universal Parser Return Type + +The universal `parseFeed` function returns a discriminated union typed as `AnyFeed`: + +```typescript +import { type AnyFeed, parseFeed } from 'feedsmith' + +const result: AnyFeed = parseFeed(content) + +if (result.format === 'rss') { + result.feed // RssFeed.Feed +} else if (result.format === 'atom') { + result.feed // AtomFeed.Feed +} +``` + +## Importing Namespace Types + +All namespace types are exported from the main package, allowing direct type access: + +```typescript +import type { ItunesNs, DcNs, MediaNs, PodcastNs } from 'feedsmith' +``` + +Each namespace contains its complete type system: + +```typescript +// Dublin Core namespace types +type DcFeed = DcNs.Feed +type DcItem = DcNs.Item + +// Media namespace types +type MediaFeed = MediaNs.Feed +type MediaItem = MediaNs.Item +type MediaContent = MediaNs.Content +type MediaGroup = MediaNs.Group +``` + +This is useful when you need to type variables or function parameters with namespace-specific types: + +```typescript +import type { ItunesNs, PodcastNs } from 'feedsmith' +import { generateRssFeed } from 'feedsmith' + +const itunesCategory: ItunesNs.Category = { + text: 'Technology', + categories: [{ text: 'Software How-To' }] +} + +const transcript: PodcastNs.Transcript = { + url: 'https://example.com/transcript.srt', + type: 'application/srt' +} + +const xml = generateRssFeed({ + title: 'My Podcast', + itunes: { + categories: [itunesCategory] + }, + items: [{ + title: 'Episode 1', + podcast: { + transcripts: [transcript] + } + }] +}) +``` + +Each namespace's type export name can be found in the "Types" section of its reference documentation. ## Complete Example Here's an example on how you can utilize the types for sub-elements of the RSS feed while generating a podcast feed: ```typescript -import type { Rss } from 'feedsmith/types' -import { generateRssFeed } from 'feedsmith' +import { type RssFeed, generateRssFeed } from 'feedsmith' -const items: Array> = [{ +const items: Array> = [{ title: 'Episode 1: Introduction', description: 'Getting started with TypeScript', pubDate: new Date('2024-01-15T10:00:00Z'), @@ -136,7 +176,7 @@ const items: Array> = [{ } }] -const feed: Rss.Feed = { +const feed: RssFeed.Feed = { title: 'My TypeScript Podcast', link: 'https://mypodcast.com', description: 'A podcast about TypeScript', @@ -158,9 +198,9 @@ const feed: Rss.Feed = { const xml = generateRssFeed(feed) ``` -## The TDate Parameter +## The TDate Parameter {#tdate} -The `TDate` generic parameter indicates how dates are represented in feed the typed objects. This differentiation is currently needed as Feedsmith intentionally does not parse dates ([see related page in the docs](/parsing/dates)). +The `TDate` generic parameter indicates how dates are represented in the typed objects. This differentiation is needed as Feedsmith intentionally does not parse dates (see [Handling Dates](/parsing/dates)). In general, use: - `Type` when parsing @@ -168,10 +208,10 @@ In general, use: ```typescript // Parsing - dates are strings -const parsed: Rss.Feed = parseRssFeed(xml) +const parsed: RssFeed.Feed = parseRssFeed(xml) // Generating - dates are Date objects -const generated: Rss.Feed = { +const generated: RssFeed.Feed = { title: 'Feed', link: 'https://example.com', description: 'Description', @@ -179,3 +219,24 @@ const generated: Rss.Feed = { items: [] } ``` + +## The TStrict Parameter {#tstrict} + +The `TStrict` generic parameter controls whether specification-required fields are enforced at compile time. When you pass `{ strict: true }` to generate functions, TypeScript uses the strict variant of the type, making required fields mandatory. + +See [Strict Mode](/generating/strict-mode) for usage details. + +### Requirable Markers {#requirable} + +In type definitions, fields wrapped in `Requirable` become mandatory when strict mode is enabled: + +```typescript +// Type definition (simplified) +type Enclosure = Strict<{ + url: Requirable // Required in strict mode + length: Requirable // Required in strict mode + type: Requirable // Required in strict mode +}, TStrict> +``` + +Look for `Requirable<...>` in reference documentation to identify which fields become required in strict mode. diff --git a/package.json b/package.json index bf7eaac1..3b6b2e57 100644 --- a/package.json +++ b/package.json @@ -31,13 +31,6 @@ "type": "module", "main": "./dist/index.cjs", "types": "./dist/index.d.cts", - "typesVersions": { - "*": { - "types": [ - "./dist/types.d.ts" - ] - } - }, "exports": { ".": { "import": { @@ -49,16 +42,6 @@ "default": "./dist/index.cjs" } }, - "./types": { - "import": { - "types": "./dist/types.d.ts", - "default": "./dist/types.js" - }, - "require": { - "types": "./dist/types.d.cts", - "default": "./dist/types.cjs" - } - }, "./package.json": "./package.json" }, "files": [ @@ -66,7 +49,7 @@ ], "scripts": { "prepare": "lefthook install", - "build": "tsdown src/index.ts src/types.ts --format cjs,esm --dts --clean --unbundle --no-fixed-extension", + "build": "tsdown src/index.ts --format cjs,esm --dts --clean --unbundle --no-fixed-extension", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs" }, diff --git a/src/common/config.ts b/src/common/config.ts index 444f3d44..03d85e80 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -129,6 +129,7 @@ export const locales = { invalidInputOpml: 'Invalid input OPML', invalidInputAtom: 'Invalid input Atom', invalidInputRss: 'Invalid input RSS', + invalidInputJson: 'Invalid input JSON', } export const namespaceUris = { diff --git a/src/common/errors.test.ts b/src/common/errors.test.ts new file mode 100644 index 00000000..c4ecba27 --- /dev/null +++ b/src/common/errors.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'bun:test' +import { DetectError, GenerateError, MalformedError, ParseError } from './errors.js' + +describe('DetectError', () => { + it('should set message', () => { + const error = new DetectError('Test error') + + expect(error.message).toBe('Test error') + }) + + it('should be instance of Error', () => { + const error = new DetectError('Test error') + + expect(error).toBeInstanceOf(Error) + }) + + it('should have name set to DetectError', () => { + const error = new DetectError('Test error') + + expect(error.name).toBe('DetectError') + }) +}) + +describe('GenerateError', () => { + it('should set message', () => { + const error = new GenerateError('Test error') + + expect(error.message).toBe('Test error') + }) + + it('should be instance of Error', () => { + const error = new GenerateError('Test error') + + expect(error).toBeInstanceOf(Error) + }) + + it('should have name set to GenerateError', () => { + const error = new GenerateError('Test error') + + expect(error.name).toBe('GenerateError') + }) +}) + +describe('MalformedError', () => { + it('should set message', () => { + const error = new MalformedError('Test error') + + expect(error.message).toBe('Test error') + }) + + it('should be instance of Error', () => { + const error = new MalformedError('Test error') + + expect(error).toBeInstanceOf(Error) + }) + + it('should have name set to MalformedError', () => { + const error = new MalformedError('Test error') + + expect(error.name).toBe('MalformedError') + }) +}) + +describe('ParseError', () => { + it('should set message', () => { + const error = new ParseError('Test error') + + expect(error.message).toBe('Test error') + }) + + it('should be instance of Error', () => { + const error = new ParseError('Test error') + + expect(error).toBeInstanceOf(Error) + }) + + it('should have name set to ParseError', () => { + const error = new ParseError('Test error') + + expect(error.name).toBe('ParseError') + }) +}) diff --git a/src/common/errors.ts b/src/common/errors.ts new file mode 100644 index 00000000..867b45d9 --- /dev/null +++ b/src/common/errors.ts @@ -0,0 +1,27 @@ +export class DetectError extends Error { + constructor(message: string) { + super(message) + this.name = 'DetectError' + } +} + +export class GenerateError extends Error { + constructor(message: string) { + super(message) + this.name = 'GenerateError' + } +} + +export class MalformedError extends Error { + constructor(message: string) { + super(message) + this.name = 'MalformedError' + } +} + +export class ParseError extends Error { + constructor(message: string) { + super(message) + this.name = 'ParseError' + } +} diff --git a/src/common/parse.test.ts b/src/common/parse.test.ts index 948bde05..c02d60a8 100644 --- a/src/common/parse.test.ts +++ b/src/common/parse.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'bun:test' import { locales } from './config.js' +import { DetectError } from './errors.js' import { parse } from './parse.js' describe('parse', () => { @@ -14,7 +15,7 @@ describe('parse', () => { const expected = { format: 'atom' as const, feed: { - title: 'Feed', + title: { value: 'Feed' }, id: 'example-feed', }, } @@ -249,6 +250,15 @@ describe('parse', () => { expect(() => parse(123)).toThrowError(locales.unrecognizedFeedFormat) }) + describe('error types', () => { + it('should throw DetectError for unrecognized format', () => { + const throwing = () => parse('not a feed') + + expect(throwing).toThrow(DetectError) + expect(throwing).toThrow(locales.unrecognizedFeedFormat) + }) + }) + describe('leading/trailing garbage', () => { it('should parse RSS feed with PHP warning before XML declaration', () => { const value = ` @@ -313,7 +323,7 @@ describe('parse', () => { const expected = { format: 'atom' as const, feed: { - title: 'Feed', + title: { value: 'Feed' }, id: 'example-feed', }, } @@ -421,7 +431,7 @@ describe('parse', () => { const expected = { format: 'atom' as const, feed: { - title: 'Feed', + title: { value: 'Feed' }, id: 'example-feed', }, } @@ -429,4 +439,28 @@ describe('parse', () => { expect(parse(value)).toEqual(expected) }) }) + + describe('parseDateFn', () => { + it('should apply custom parseDateFn and detect format correctly', () => { + const value = ` + + + + Test + Wed, 15 Mar 2023 12:00:00 GMT + + + ` + const result = parse(value, { parseDateFn: (raw) => new Date(raw) }) + const expected = { + format: 'rss' as const, + feed: { + title: 'Test', + pubDate: new Date('Wed, 15 Mar 2023 12:00:00 GMT'), + }, + } + + expect(result).toEqual(expected) + }) + }) }) diff --git a/src/common/parse.ts b/src/common/parse.ts index 62d17b69..f3a590cb 100644 --- a/src/common/parse.ts +++ b/src/common/parse.ts @@ -1,29 +1,30 @@ -import type { Atom } from '../feeds/atom/common/types.js' +import type { AtomFeed } from '../feeds/atom/common/types.js' import { detect as detectAtomFeed } from '../feeds/atom/detect/index.js' import { parse as parseAtomFeed } from '../feeds/atom/parse/index.js' -import type { Json } from '../feeds/json/common/types.js' +import type { JsonFeed } from '../feeds/json/common/types.js' import { detect as detectJsonFeed } from '../feeds/json/detect/index.js' import { parse as parseJsonFeed } from '../feeds/json/parse/index.js' -import type { Rdf } from '../feeds/rdf/common/types.js' +import type { RdfFeed } from '../feeds/rdf/common/types.js' import { detect as detectRdfFeed } from '../feeds/rdf/detect/index.js' import { parse as parseRdfFeed } from '../feeds/rdf/parse/index.js' -import type { Rss } from '../feeds/rss/common/types.js' +import type { RssFeed } from '../feeds/rss/common/types.js' import { detect as detectRssFeed } from '../feeds/rss/detect/index.js' import { parse as parseRssFeed } from '../feeds/rss/parse/index.js' import { locales } from './config.js' -import type { DeepPartial, ParseOptions } from './types.js' +import { DetectError } from './errors.js' +import type { ParseMainOptions } from './types.js' import { parseJsonObject } from './utils.js' -export type Parse = ( - value: unknown, - options?: ParseOptions, -) => - | { format: 'rss'; feed: DeepPartial> } - | { format: 'atom'; feed: DeepPartial> } - | { format: 'rdf'; feed: DeepPartial> } - | { format: 'json'; feed: DeepPartial> } +export type AnyFeed = + | { format: 'rss'; feed: RssFeed.Feed } + | { format: 'atom'; feed: AtomFeed.Feed } + | { format: 'rdf'; feed: RdfFeed.Feed } + | { format: 'json'; feed: JsonFeed.Feed } -export const parse: Parse = (value, options) => { +export const parse = ( + value: unknown, + options?: ParseMainOptions, +): AnyFeed => { if (detectRssFeed(value)) { return { format: 'rss', feed: parseRssFeed(value, options) } } @@ -42,5 +43,5 @@ export const parse: Parse = (value, options) => { return { format: 'json', feed: parseJsonFeed(json, options) } } - throw new Error(locales.unrecognizedFeedFormat) + throw new DetectError(locales.unrecognizedFeedFormat) } diff --git a/src/common/types.ts b/src/common/types.ts index c0161c82..0e588aa0 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -2,11 +2,21 @@ // biome-ignore lint/suspicious/noExplicitAny: Temporary solution until the Unreliable type fixed. export type Unreliable = any -export type DateLike = Date | string - -export type ExtraFields, V = unknown> = { - [K in F[number]]?: V -} +type Simplify = { [K in keyof T]: T[K] } +type Unwrap = T extends { __requirable: infer U } ? U : T +type HasMarker = [Extract] extends [never] ? false : true +type Optional = { [K in keyof T]-?: object extends Pick ? K : never }[keyof T] +type Marked = { [K in keyof T]-?: HasMarker extends true ? K : never }[keyof T] + +export type Strict = Simplify< + S extends true + ? { [K in Exclude>]-?: Unwrap } & { + [K in Optional]?: Unwrap + } + : { [K in Exclude | Marked>]-?: Unwrap } & { + [K in Optional | Marked]?: Unwrap + } +> export type AnyOf = Partial<{ [P in keyof T]-?: NonNullable }> & { [P in keyof T]-?: Pick<{ [Q in keyof T]-?: NonNullable }, P> }[keyof T] @@ -24,15 +34,6 @@ export type IsPlainObject = : true : false -export type RemoveUndefined = T extends undefined ? never : T - -export type DeepPartial = - IsPlainObject extends true - ? { [P in keyof T]?: DeepPartial> } - : T extends Array - ? Array> - : T - export type DeepOmit = T extends Array ? Array> @@ -40,20 +41,34 @@ export type DeepOmit = ? Pick<{ [P in keyof T]: DeepOmit }, Exclude> : T -export type ParseExactUtil = (value: Unreliable) => R | undefined +export type Requirable = T | { __requirable: T } -export type ParsePartialUtil = ( - value: Unreliable, - options?: O, -) => DeepPartial | undefined +export type DateLike = Date | string + +// Date-aware parse utils need to return different date types depending on the +// parseDateFn option, but TypeScript can't express generic functions through +// type aliases like ParseUtilPartial. Using `any` here lets all parse utils +// share the same ParseUtilPartial pattern while the public parse() entry points +// enforce the correct TDate type via generics. +// biome-ignore lint/suspicious/noExplicitAny: See above reasoning. +export type DateAny = any + +export type ExtraFields, V = unknown> = { + [K in F[number]]?: V +} + +export type ParseUtilExact = (value: Unreliable) => R | undefined + +export type ParseUtilPartial = (value: Unreliable, options?: O) => R | undefined export type GenerateUtil = ( value: V | undefined, options?: O, ) => Unreliable | undefined -export type ParseOptions = { +export type ParseMainOptions = { maxItems?: number + parseDateFn?: (raw: string) => TDate } export type XmlStylesheet = { @@ -65,21 +80,21 @@ export type XmlStylesheet = { alternate?: boolean } -export type XmlGenerateOptions = O & { - lenient?: F +export type GenerateMainXmlOptions = O & { + strict?: S stylesheets?: Array } -export type JsonGenerateOptions = O & { - lenient?: F -} - -export type XmlGenerateMain> = ( - value: F extends true ? L : S, - options?: XmlGenerateOptions, +export type GenerateMainXml> = ( + value: S extends true ? SV : LV, + options?: GenerateMainXmlOptions, ) => string -export type JsonGenerateMain> = ( - value: F extends true ? L : S, - options?: JsonGenerateOptions, +export type GenerateMainJsonOptions = O & { + strict?: S +} + +export type GenerateMainJson> = ( + value: S extends true ? SV : LV, + options?: GenerateMainJsonOptions, ) => unknown diff --git a/src/common/utils.test.ts b/src/common/utils.test.ts index d05ec889..16a05f40 100644 --- a/src/common/utils.test.ts +++ b/src/common/utils.test.ts @@ -1,11 +1,10 @@ import { describe, expect, it } from 'bun:test' import { type XMLBuilder, XMLParser } from 'fast-xml-parser' import { namespacePrefixes, namespaceUris } from './config.js' -import type { ParseExactUtil } from './types.js' +import type { ParseUtilExact } from './types.js' import { createNamespaceNormalizator, detectNamespaces, - generateArrayOrSingular, generateBoolean, generateCdataString, generateCsvOf, @@ -15,7 +14,6 @@ import { generateRdfResource, generateRfc822Date, generateRfc3339Date, - generateSingularOrArray, generateXml, generateXmlStylesheet, generateYesNoBoolean, @@ -1624,6 +1622,43 @@ describe('parseDate', () => { expect(parseDate(value)).toBeUndefined() }) + + it('should apply custom parseDateFn', () => { + const value = '2023-03-15T12:00:00Z' + const expected = new Date('2023-03-15T12:00:00Z') + + expect(parseDate(value, (raw) => new Date(raw))).toEqual(expected) + }) + + it('should apply custom parseDateFn returning number', () => { + const value = '2023-03-15T12:00:00Z' + const expected = 1678881600000 + + expect(parseDate(value, (raw) => new Date(raw).getTime())).toBe(expected) + }) + + it('should return undefined when parseDateFn is provided but value is empty', () => { + const value = '' + + expect(parseDate(value, (raw) => new Date(raw))).toBeUndefined() + }) + + it('should normalize value before passing to parseDateFn', () => { + const value = ' 2023-03-15T12:00:00Z ' + const expected = new Date('2023-03-15T12:00:00Z') + + expect(parseDate(value, (raw) => new Date(raw))).toEqual(expected) + }) + + it('should propagate error when parseDateFn throws', () => { + const value = 'invalid-date' + const parseDateFn = () => { + throw new Error('Parse failed') + } + const throwing = () => parseDate(value, parseDateFn) + + expect(throwing).toThrow('Parse failed') + }) }) describe('generateBoolean', () => { @@ -1746,7 +1781,7 @@ describe('parseSingularOf', () => { }) it('should work with custom parse functions', () => { - const parseUpperCase: ParseExactUtil = (value) => { + const parseUpperCase: ParseUtilExact = (value) => { return typeof value === 'string' ? value.toUpperCase() : undefined } @@ -1837,7 +1872,7 @@ describe('parseArray', () => { }) describe('parseArrayOf', () => { - const parser: ParseExactUtil = (value) => { + const parser: ParseUtilExact = (value) => { if (typeof value === 'number') { return value.toString() } @@ -4240,193 +4275,6 @@ describe('createNamespaceNormalizator', () => { }) }) -describe('generateArrayOrSingular', () => { - it('should prioritize plural values over singular values', () => { - const generator = (value: string) => value.toUpperCase() - const result = generateArrayOrSingular(['a', 'b', 'c'], 'x', generator) - - expect(result).toEqual(['A', 'B', 'C']) - }) - - it('should use singular value when plural is undefined', () => { - const generator = (value: string) => value.toUpperCase() - const result = generateArrayOrSingular(undefined, 'x', generator) - - expect(result).toEqual('X') - }) - - it('should return undefined when both values are undefined', () => { - const generator = (value: string) => value.toUpperCase() - const result = generateArrayOrSingular(undefined, undefined, generator) - - expect(result).toBeUndefined() - }) - - it('should filter out undefined results from array', () => { - const generator = (value: string | undefined) => (value === 'skip' ? undefined : value) - const result = generateArrayOrSingular(['a', 'skip', 'b'], 'x', generator) - - expect(result).toEqual(['a', 'b']) - }) - - it('should return undefined when all array items generate undefined', () => { - const generator = () => undefined - const result = generateArrayOrSingular(['a', 'b'], 'x', generator) - - expect(result).toBeUndefined() - }) - - it('should work with generateCdataString', () => { - const result = generateArrayOrSingular( - ['

HTML

', 'Plain text'], - undefined, - generateCdataString, - ) - - expect(result).toEqual([{ '#cdata': '

HTML

' }, 'Plain text']) - }) - - it('should work with generatePlainString', () => { - const result = generateArrayOrSingular([' value1 ', 'value2'], undefined, generatePlainString) - - expect(result).toEqual(['value1', 'value2']) - }) - - it('should work with generateNumber', () => { - const result = generateArrayOrSingular([42, 100], undefined, generateNumber) - - expect(result).toEqual([42, 100]) - }) - - it('should handle empty plural array', () => { - const generator = (value: string) => value.toUpperCase() - const result = generateArrayOrSingular([], 'x', generator) - - expect(result).toBeUndefined() - }) - - it('should handle singular value that generates undefined', () => { - const generator = (value: string) => (value === 'skip' ? undefined : value) - const result = generateArrayOrSingular(undefined, 'skip', generator) - - expect(result).toBeUndefined() - }) - - it('should preserve order of array elements', () => { - const generator = (value: number) => value * 2 - const result = generateArrayOrSingular([1, 2, 3, 4, 5], 99, generator) - - expect(result).toEqual([2, 4, 6, 8, 10]) - }) - - it('should work with complex object transformations', () => { - const generator = (value: string) => ({ name: value, length: value.length }) - const result = generateArrayOrSingular(['foo', 'bar'], 'baz', generator) - - expect(result).toEqual([ - { name: 'foo', length: 3 }, - { name: 'bar', length: 3 }, - ]) - }) - - it('should ignore singular when plural is empty array', () => { - const generator = (value: string) => value.toUpperCase() - const result = generateArrayOrSingular([], 'x', generator) - - expect(result).toBeUndefined() - }) -}) - -describe('generateSingularOrArray', () => { - it('should prioritize singular value over plural values', () => { - const generator = (value: string) => value.toUpperCase() - const result = generateSingularOrArray('x', ['a', 'b', 'c'], generator) - - expect(result).toEqual('X') - }) - - it('should use plural values when singular is undefined', () => { - const generator = (value: string) => value.toUpperCase() - const result = generateSingularOrArray(undefined, ['a', 'b', 'c'], generator) - - expect(result).toEqual(['A', 'B', 'C']) - }) - - it('should return undefined when both values are undefined', () => { - const generator = (value: string) => value.toUpperCase() - const result = generateSingularOrArray(undefined, undefined, generator) - - expect(result).toBeUndefined() - }) - - it('should filter out undefined results from array', () => { - const generator = (value: string | undefined) => (value === 'skip' ? undefined : value) - const result = generateSingularOrArray(undefined, ['a', 'skip', 'b'], generator) - - expect(result).toEqual(['a', 'b']) - }) - - it('should return undefined when all array items generate undefined', () => { - const generator = () => undefined - const result = generateSingularOrArray(undefined, ['a', 'b'], generator) - - expect(result).toBeUndefined() - }) - - it('should work with generateCdataString', () => { - const result = generateSingularOrArray('

HTML

', undefined, generateCdataString) - - expect(result).toEqual({ '#cdata': '

HTML

' }) - }) - - it('should work with generatePlainString', () => { - const result = generateSingularOrArray(' value ', undefined, generatePlainString) - - expect(result).toEqual('value') - }) - - it('should work with generateNumber', () => { - const result = generateSingularOrArray(42, undefined, generateNumber) - - expect(result).toEqual(42) - }) - - it('should handle empty plural array', () => { - const generator = (value: string) => value.toUpperCase() - const result = generateSingularOrArray(undefined, [], generator) - - expect(result).toBeUndefined() - }) - - it('should handle singular value that generates undefined', () => { - const generator = (value: string) => (value === 'skip' ? undefined : value) - const result = generateSingularOrArray('skip', ['a', 'b'], generator) - - expect(result).toBeUndefined() - }) - - it('should preserve order of array elements when using plural', () => { - const generator = (value: number) => value * 2 - const result = generateSingularOrArray(undefined, [1, 2, 3, 4, 5], generator) - - expect(result).toEqual([2, 4, 6, 8, 10]) - }) - - it('should work with complex object transformations', () => { - const generator = (value: string) => ({ name: value, length: value.length }) - const result = generateSingularOrArray('foo', undefined, generator) - - expect(result).toEqual({ name: 'foo', length: 3 }) - }) - - it('should ignore plural when singular is defined', () => { - const generator = (value: string) => value.toUpperCase() - const result = generateSingularOrArray('x', ['a', 'b', 'c'], generator) - - expect(result).toEqual('X') - }) -}) - describe('parseJsonObject', () => { it('should return object unchanged when input is object', () => { const value = { title: 'Test', count: 42 } diff --git a/src/common/utils.ts b/src/common/utils.ts index 630b50cf..91f5b230 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -2,9 +2,10 @@ import { decodeHTML } from 'entities' import type { XMLBuilder } from 'fast-xml-parser' import type { AnyOf, + DateAny, DateLike, GenerateUtil, - ParseExactUtil, + ParseUtilExact, Unreliable, XmlStylesheet, } from './types.js' @@ -96,7 +97,7 @@ export const trimObject = >(object: T): AnyOf< export const trimArray = ( value: Array | undefined, - parse?: ParseExactUtil, + parse?: ParseUtilExact, ): Array | undefined => { if (!Array.isArray(value) || value.length === 0) { return @@ -133,40 +134,10 @@ export const trimArray = ( return result.length > 0 ? result : undefined } -// TODO: Remove this once deprecated fields are removed in next major version. -export const generateArrayOrSingular = ( - pluralValues: Array | undefined, - singularValue: V | undefined, - generator: (value: V) => unknown, -) => { - if (isPresent(pluralValues)) { - return trimArray(pluralValues.map(generator)) - } - - if (isPresent(singularValue)) { - return generator(singularValue) - } -} - -// TODO: Remove this once deprecated fields are removed in next major version. -export const generateSingularOrArray = ( - singularValue: V | undefined, - pluralValues: Array | undefined, - generator: (value: V) => unknown, -) => { - if (isPresent(singularValue)) { - return generator(singularValue) - } - - if (isPresent(pluralValues)) { - return trimArray(pluralValues.map(generator)) - } -} - -const commentStartTag = '' const cdataStartTag = '' +const commentStartTag = '' export const hasEntities = (text: string) => { const ampIndex = text.indexOf('&') @@ -239,7 +210,7 @@ const decodeWithCdata = (text: string): string => { return result } -export const parseString: ParseExactUtil = (value) => { +export const parseString: ParseUtilExact = (value) => { if (typeof value === 'string') { if (value === '') { return @@ -259,7 +230,7 @@ export const parseString: ParseExactUtil = (value) => { // HTML comment stripping. JSON.parse already produces the final string, so any // `<` / ` Feed - urn:uuid:test - 2024-01-01T00:00:00Z - - Post<!-- comment --> Title - urn:uuid:1 - 2024-01-01T00:00:00Z - - - ` - const expected = { - title: 'Test Feed', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - entries: [ - { - title: 'Post Title', - id: 'urn:uuid:1', - updated: '2024-01-01T00:00:00Z', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-X09: should parse entries appearing before feed metadata', () => { - const value = ` - - - - Post - urn:uuid:1 - 2024-01-01T00:00:00Z - - Test - urn:uuid:test - 2024-01-15T00:00:00Z - - ` - const expected = { - title: 'Test', - id: 'urn:uuid:test', - updated: '2024-01-15T00:00:00Z', - entries: [ - { - title: 'Post', - id: 'urn:uuid:1', - updated: '2024-01-01T00:00:00Z', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - }) - - describe('stop node edge cases', () => { - it('RW-D12: should preserve HTML tags in CDATA content', () => { - const value = ` - - - Test - urn:uuid:test - 2024-01-01T00:00:00Z - - Post - urn:uuid:1 - 2024-01-01T00:00:00Z -

Text with link and
break

]]>
-
-
- ` - const expected = { - title: 'Test', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - entries: [ - { - title: 'Post', - id: 'urn:uuid:1', - updated: '2024-01-01T00:00:00Z', - content: - '

Text with link and
break

', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-D04: should preserve escaped HTML in summary', () => { - const value = ` - - - Test - urn:uuid:test - 2024-01-01T00:00:00Z - - Post - urn:uuid:1 - 2024-01-01T00:00:00Z - <p>Escaped paragraph</p> - - - ` - const expected = { - title: 'Test', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - entries: [ - { - title: 'Post', - id: 'urn:uuid:1', - updated: '2024-01-01T00:00:00Z', - summary: '

Escaped paragraph

', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - }) - - describe('partial and unusual structures', () => { - it('RW-N02: should handle entry with only id and updated', () => { - const value = ` - - - Test - urn:uuid:test - 2024-01-01T00:00:00Z - - urn:uuid:1 - 2024-01-01T00:00:00Z - - - ` - const expected = { - title: 'Test', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - entries: [ - { - id: 'urn:uuid:1', - updated: '2024-01-01T00:00:00Z', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-A08: should handle generator with uri and version attributes', () => { - const value = ` - - - Test - urn:uuid:test - 2024-01-01T00:00:00Z - MyGenerator - - ` - const expected = { - title: 'Test', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - generator: { - text: 'MyGenerator', - uri: 'https://example.com/gen', - version: '2.0', - }, - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-A07: should handle category with term, scheme, and label', () => { - const value = ` - - - Test - urn:uuid:test - 2024-01-01T00:00:00Z - - Post - urn:uuid:1 - 2024-01-01T00:00:00Z - - - - - ` - const expected = { - title: 'Test', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - entries: [ - { - title: 'Post', - id: 'urn:uuid:1', - updated: '2024-01-01T00:00:00Z', - categories: [ - { term: 'tech', scheme: 'https://example.com/categories', label: 'Technology' }, - { term: 'programming' }, - ], - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-NS11: should handle entry source element', () => { - const value = ` - - - Aggregator - urn:uuid:aggregator - 2024-01-01T00:00:00Z - - Reposted Article - urn:uuid:1 - 2024-01-01T00:00:00Z - - Original Blog - urn:uuid:original - - - - - ` - const expected = { - title: 'Aggregator', - id: 'urn:uuid:aggregator', - updated: '2024-01-01T00:00:00Z', - entries: [ - { - title: 'Reposted Article', - id: 'urn:uuid:1', - updated: '2024-01-01T00:00:00Z', - source: { - title: 'Original Blog', - id: 'urn:uuid:original', - links: [{ href: 'https://original.example.com', rel: 'alternate' }], - }, - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-Q10: should not confuse entry title with source title', () => { - const value = ` - - - Aggregator - urn:uuid:aggregator - 2024-01-01T00:00:00Z - - Article Title - urn:uuid:1 - 2024-01-01T00:00:00Z - - Blog Name - urn:uuid:blog - - - - ` - const expected = { - title: 'Aggregator', - id: 'urn:uuid:aggregator', - updated: '2024-01-01T00:00:00Z', - entries: [ - { - title: 'Article Title', - id: 'urn:uuid:1', - updated: '2024-01-01T00:00:00Z', - source: { - title: 'Blog Name', - id: 'urn:uuid:blog', - }, - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-A11: should parse feed with xml:lang attribute (attribute not captured)', () => { - const value = ` - - - English Feed - urn:uuid:test - 2024-01-01T00:00:00Z - - Post - urn:uuid:1 - 2024-01-01T00:00:00Z - - - ` - const expected = { - title: 'English Feed', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - entries: [ - { - title: 'Post', - id: 'urn:uuid:1', - updated: '2024-01-01T00:00:00Z', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-L06: should handle link with no rel attribute defaulting to just href', () => { - const value = ` - - - Test - urn:uuid:test - 2024-01-01T00:00:00Z - - - Post - urn:uuid:1 - 2024-01-01T00:00:00Z - - - - ` - const expected = { - title: 'Test', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - links: [{ href: 'https://example.com/' }], - entries: [ - { - title: 'Post', - id: 'urn:uuid:1', - updated: '2024-01-01T00:00:00Z', - links: [{ href: 'https://example.com/post/1' }], - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-Q06: should handle rights element', () => { - const value = ` - - - Test - urn:uuid:test - 2024-01-01T00:00:00Z - © 2024 Example Corp - - ` - const expected = { - title: 'Test', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - rights: '\u00A9 2024 Example Corp', - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-D14: should decode double-escaped entities only once', () => { - const value = ` - - - Test - urn:uuid:test - 2024-01-01T00:00:00Z - - CSS, &lt;pre&gt;, and more - urn:uuid:1 - 2024-01-01T00:00:00Z - - - ` - const expected = { - title: 'Test', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - entries: [ - { - title: 'CSS, <pre>, and more', - id: 'urn:uuid:1', - updated: '2024-01-01T00:00:00Z', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-D15: should decode entity-encoded HTML in content', () => { - const value = ` - - - Test - urn:uuid:test - 2024-01-01T00:00:00Z - - Post - urn:uuid:1 - 2024-01-01T00:00:00Z - <p>Hello <strong>world</strong></p> - - - ` - const expected = { - title: 'Test', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - entries: [ - { - title: 'Post', - id: 'urn:uuid:1', - updated: '2024-01-01T00:00:00Z', - content: '

Hello world

', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-D16: should parse content and summary independently regardless of document order', () => { - const value = ` - - - Test - urn:uuid:test - 2024-01-01T00:00:00Z - - Post - urn:uuid:1 - 2024-01-01T00:00:00Z - Full article content here - Brief summary - - - ` - const expected = { - title: 'Test', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - entries: [ - { - title: 'Post', - id: 'urn:uuid:1', - updated: '2024-01-01T00:00:00Z', - content: 'Full article content here', - summary: 'Brief summary', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-L13: should decode XML entities in link href attribute', () => { - const value = ` - - - Test - urn:uuid:test - 2024-01-01T00:00:00Z - - Post - urn:uuid:1 - 2024-01-01T00:00:00Z - - - - ` - const expected = { - title: 'Test', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - entries: [ - { - title: 'Post', - id: 'urn:uuid:1', - updated: '2024-01-01T00:00:00Z', - links: [{ href: 'https://example.com/search?q=test&sort=new', rel: 'alternate' }], - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-L15: should omit href from link when it is empty', () => { - const value = ` - - - Test - urn:uuid:test - 2024-01-01T00:00:00Z - - Post - urn:uuid:1 - 2024-01-01T00:00:00Z - - - - ` - const expected = { - title: 'Test', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - entries: [ - { - title: 'Post', - id: 'urn:uuid:1', - updated: '2024-01-01T00:00:00Z', - links: [{ rel: 'alternate', type: 'text/html' }], - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-L16: should capture all links without filtering by rel', () => { - const value = ` - - - Test - urn:uuid:test - 2024-01-01T00:00:00Z - - Post - urn:uuid:1 - 2024-01-01T00:00:00Z - - - - - ` - const expected = { - title: 'Test', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - entries: [ - { - title: 'Post', - id: 'urn:uuid:1', - updated: '2024-01-01T00:00:00Z', - links: [ - { href: 'https://example.com/feed/1', rel: 'self' }, - { href: 'https://example.com/post/1', rel: 'alternate', type: 'text/html' }, - ], - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-N20: should omit self-closing subtitle with type attribute only', () => { - const value = ` - - - Test - urn:uuid:test - 2024-01-01T00:00:00Z - - - ` - const expected = { - title: 'Test', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-A12: should omit author when name element is self-closing', () => { - const value = ` - - - Test - urn:uuid:test - 2024-01-01T00:00:00Z - - Post - urn:uuid:1 - 2024-01-01T00:00:00Z - - - - ` - const expected = { - title: 'Test', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - entries: [ - { - title: 'Post', - id: 'urn:uuid:1', - updated: '2024-01-01T00:00:00Z', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-NS20: should handle xmlns with spaces around equals sign', () => { - const value = `Testurn:uuid:test2024-01-01T00:00:00Z` - const expected = { - title: 'Test', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-NS21: should preserve HTML5 summary element inside XHTML content without confusing it with Atom summary', () => { - const value = ` - - - Test - urn:uuid:test - 2024-01-01T00:00:00Z - - Post - urn:uuid:1 - 2024-01-01T00:00:00Z -
Click to expand

Details here

-
-
- ` - const expected = { - title: 'Test', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - entries: [ - { - title: 'Post', - id: 'urn:uuid:1', - updated: '2024-01-01T00:00:00Z', - content: - '
Click to expand

Details here

', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-NS22: should preserve raw XML inside content with XML media type', () => { - const value = ` - - - Test - urn:uuid:test - 2024-01-01T00:00:00Z - - Post - urn:uuid:1 - 2024-01-01T00:00:00Z - x - - - ` - const expected = { - title: 'Test', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - entries: [ - { - title: 'Post', - id: 'urn:uuid:1', - updated: '2024-01-01T00:00:00Z', - content: 'x', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-NS23: should parse feed with non-standard a10: prefix for Atom namespace', () => { - const value = `Testurn:uuid:test2024-01-01T00:00:00Z` - const expected = { - title: 'Test', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-T11: should trim whitespace from date values', () => { - const value = ` - - - Test - urn:uuid:test - 2024-01-01T00:00:00Z - - Post - urn:uuid:1 - 2024-01-15T10:30:00Z - - - ` - const expected = { - title: 'Test', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - entries: [ - { - title: 'Post', - id: 'urn:uuid:1', - updated: '2024-01-15T10:30:00Z', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-X16: should parse Atom 0.3 feed with legacy namespace and element names', () => { - const value = `Atom 0.3 Feedurn:uuid:test2024-01-01T00:00:00ZA legacy feed` - const expected = { - title: 'Atom 0.3 Feed', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - subtitle: 'A legacy feed', - } - - expect(parse(value)).toEqual(expected) - }) - }) - }) - - describe('xml comment stripping', () => { - it('should strip XML comments from element content', () => { - const value = ` - - - Test<!-- hidden --> Feed - urn:uuid:test - 2024-01-01T00:00:00Z - - Post<!-- comment --> Title - urn:uuid:1 - 2024-01-01T00:00:00Z - - - ` - const expected = { - title: 'Test Feed', - id: 'urn:uuid:test', - updated: '2024-01-01T00:00:00Z', - entries: [ - { - title: 'Post Title', - id: 'urn:uuid:1', - updated: '2024-01-01T00:00:00Z', - }, - ], - } - - expect(parse(value)).toEqual(expected) + expect(result).toEqual(expected) }) }) }) diff --git a/src/feeds/atom/parse/index.ts b/src/feeds/atom/parse/index.ts index 52015bba..6f24c91c 100644 --- a/src/feeds/atom/parse/index.ts +++ b/src/feeds/atom/parse/index.ts @@ -1,22 +1,33 @@ import { locales } from '../../../common/config.js' -import type { DeepPartial, ParseOptions } from '../../../common/types.js' +import { DetectError, MalformedError, ParseError } from '../../../common/errors.js' +import type { ParseMainOptions, Unreliable } from '../../../common/types.js' import { detectAtomFeed } from '../../../index.js' -import type { Atom } from '../common/types.js' +import type { AtomFeed } from '../common/types.js' import { normalizeNamespaces, parser } from './config.js' import { retrieveFeed } from './utils.js' -export const parse = (value: unknown, options?: ParseOptions): DeepPartial> => { +export const parse = ( + value: unknown, + options?: ParseMainOptions, +): AtomFeed.Feed => { if (!detectAtomFeed(value)) { - throw new Error(locales.invalidFeedFormat) + throw new DetectError(locales.invalidFeedFormat) + } + + let normalized: Unreliable + + try { + const object = parser.parse(value) + normalized = normalizeNamespaces(object) + } catch { + throw new MalformedError(locales.invalidFeedFormat) } - const object = parser.parse(value) - const normalized = normalizeNamespaces(object) const parsed = retrieveFeed(normalized, options) if (!parsed) { - throw new Error(locales.invalidFeedFormat) + throw new ParseError(locales.invalidFeedFormat) } - return parsed + return parsed as AtomFeed.Feed } diff --git a/src/feeds/atom/parse/utils.test.ts b/src/feeds/atom/parse/utils.test.ts index db6292c6..d41cb29a 100644 --- a/src/feeds/atom/parse/utils.test.ts +++ b/src/feeds/atom/parse/utils.test.ts @@ -2,12 +2,14 @@ import { describe, expect, it } from 'bun:test' import { createNamespaceGetter, parseCategory, + parseContent, parseEntry, parseFeed, parseGenerator, parseLink, parsePerson, parseSource, + parseText, retrieveFeed, retrieveGeneratorUri, retrievePersonUri, @@ -112,6 +114,167 @@ describe('createNamespaceGetter', () => { }) }) +describe('parseText', () => { + it('should parse simple string value', () => { + const value = 'Simple text' + const expected = { value: 'Simple text' } + + expect(parseText(value)).toEqual(expected) + }) + + it('should parse object with text content', () => { + const value = { '#text': 'Text content' } + const expected = { value: 'Text content' } + + expect(parseText(value)).toEqual(expected) + }) + + it('should parse object with text and type', () => { + const value = { '#text': 'HTML content', '@type': 'html' } + const expected = { value: 'HTML content', type: 'html' } + + expect(parseText(value)).toEqual(expected) + }) + + it('should handle xhtml type', () => { + const value = { '#text': '

XHTML content

', '@type': 'xhtml' } + const expected = { value: '

XHTML content

', type: 'xhtml' } + + expect(parseText(value)).toEqual(expected) + }) + + it('should return undefined for empty string', () => { + expect(parseText('')).toBeUndefined() + }) + + it('should return undefined for whitespace-only string', () => { + expect(parseText(' ')).toBeUndefined() + }) + + it('should return undefined for non-object, non-string input', () => { + expect(parseText(null)).toBeUndefined() + expect(parseText(undefined)).toBeUndefined() + expect(parseText(123)).toBeUndefined() + }) + + it('should parse object with xml namespace attributes', () => { + const value = { + '#text': 'Contenu en français', + '@type': 'text', + '@xml:lang': 'fr', + '@xml:base': 'http://example.org/', + } + const expected = { + value: 'Contenu en français', + type: 'text', + xml: { + lang: 'fr', + base: 'http://example.org/', + }, + } + + expect(parseText(value)).toEqual(expected) + }) + + it('should parse object with only xml namespace attributes', () => { + const value = { + '#text': 'English summary', + '@xml:lang': 'en', + } + const expected = { + value: 'English summary', + xml: { + lang: 'en', + }, + } + + expect(parseText(value)).toEqual(expected) + }) + + it('should return undefined for object with empty text', () => { + const value = { '#text': '' } + + expect(parseText(value)).toBeUndefined() + }) +}) + +describe('parseContent', () => { + it('should parse simple string value', () => { + const value = 'Simple content' + const expected = { value: 'Simple content' } + + expect(parseContent(value)).toEqual(expected) + }) + + it('should parse object with text content', () => { + const value = { '#text': 'Content text' } + const expected = { value: 'Content text' } + + expect(parseContent(value)).toEqual(expected) + }) + + it('should parse object with text, type and src', () => { + const value = { + '#text': 'Content text', + '@type': 'html', + '@src': 'https://example.com/content', + } + const expected = { + value: 'Content text', + type: 'html', + src: 'https://example.com/content', + } + + expect(parseContent(value)).toEqual(expected) + }) + + it('should parse content with only src attribute', () => { + const value = { + '@type': 'video/mp4', + '@src': 'https://example.com/video.mp4', + } + const expected = { + type: 'video/mp4', + src: 'https://example.com/video.mp4', + } + + expect(parseContent(value)).toEqual(expected) + }) + + it('should return undefined for empty string', () => { + expect(parseContent('')).toBeUndefined() + }) + + it('should return undefined for whitespace-only string', () => { + expect(parseContent(' ')).toBeUndefined() + }) + + it('should parse object with xml namespace attributes', () => { + const value = { + '#text': '
XHTML content
', + '@type': 'xhtml', + '@xml:base': 'http://example.org/entry/1', + '@xml:lang': 'en-US', + } + const expected = { + value: '
XHTML content
', + type: 'xhtml', + xml: { + base: 'http://example.org/entry/1', + lang: 'en-US', + }, + } + + expect(parseContent(value)).toEqual(expected) + }) + + it('should return undefined for non-object, non-string input', () => { + expect(parseContent(null)).toBeUndefined() + expect(parseContent(undefined)).toBeUndefined() + expect(parseContent(123)).toBeUndefined() + }) +}) + describe('parseLink', () => { it('should parse complete link object', () => { const value = { @@ -571,7 +734,7 @@ describe('parseGenerator', () => { describe('parseSource', () => { const expectedFull = { id: 'urn:uuid:60a76c80-d399-11d9-b91C-0003939e0af6', - title: 'Example Feed', + title: { value: 'Example Feed' }, updated: '2003-12-13T18:30:02Z', authors: [{ name: 'John Doe' }], links: [{ href: 'https://example.com/' }], @@ -580,8 +743,8 @@ describe('parseSource', () => { generator: { text: 'Example Generator' }, icon: 'https://example.com/favicon.ico', logo: 'https://example.com/logo.png', - rights: 'Copyright 2003, Example Corp.', - subtitle: 'A blog about examples', + rights: { value: 'Copyright 2003, Example Corp.' }, + subtitle: { value: 'A blog about examples' }, } it('should parse complete source object (with #text)', () => { @@ -649,7 +812,7 @@ describe('parseSource', () => { title: { '#text': 'Example Feed' }, } const expected = { - title: 'Example Feed', + title: { value: 'Example Feed' }, } expect(parseSource(value)).toEqual(expected) @@ -663,7 +826,7 @@ describe('parseSource', () => { } const expected = { id: '123', - title: '456', + title: { value: '456' }, links: [{ href: 'https://example.com/' }], } @@ -791,18 +954,18 @@ describe('retrieveSubtitle', () => { subtitle: { '#text': 'Feed subtitle' }, tagline: { '#text': 'Feed tagline' }, } - const expected = 'Feed subtitle' + const expected = { value: 'Feed subtitle' } - expect(retrieveSubtitle(value)).toBe(expected) + expect(retrieveSubtitle(value)).toEqual(expected) }) it('should fall back to tagline (Atom 0.3) if subtitle is missing', () => { const value = { tagline: { '#text': 'Feed tagline' }, } - const expected = 'Feed tagline' + const expected = { value: 'Feed tagline' } - expect(retrieveSubtitle(value)).toBe(expected) + expect(retrieveSubtitle(value)).toEqual(expected) }) it('should return undefined if no subtitle fields exist', () => { @@ -817,9 +980,9 @@ describe('retrieveSubtitle', () => { const value = { subtitle: { '#text': 123 }, } - const expected = '123' + const expected = { value: '123' } - expect(retrieveSubtitle(value)).toBe(expected) + expect(retrieveSubtitle(value)).toEqual(expected) }) it('should return undefined for non-object input', () => { @@ -833,11 +996,11 @@ describe('retrieveSubtitle', () => { describe('parseEntry', () => { const expectedFull = { id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', - title: 'Entry Title', + title: { value: 'Entry Title' }, updated: '2023-01-01T12:00:00Z', authors: [{ name: 'John Doe' }], - content: '

Entry content

', - summary: 'Entry summary', + content: { value: '

Entry content

' }, + summary: { value: 'Entry summary' }, published: '2023-01-01T10:00:00Z', links: [ { href: 'https://example.com/entry', rel: 'alternate' }, @@ -845,10 +1008,10 @@ describe('parseEntry', () => { ], categories: [{ term: 'technology' }, { term: 'web' }], contributors: [{ name: 'Jane Smith' }], - rights: 'Copyright 2023', + rights: { value: 'Copyright 2023' }, source: { id: 'urn:uuid:60a76c80-d399-11d9-b91C-0003939e0af6', - title: 'Source Feed', + title: { value: 'Source Feed' }, }, } @@ -943,7 +1106,7 @@ describe('parseEntry', () => { } const expected = { id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', - title: 'Entry Title', + title: { value: 'Entry Title' }, } expect(parseEntry(value)).toEqual(expected) @@ -958,7 +1121,7 @@ describe('parseEntry', () => { } const expected = { id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', - title: 'Entry Title', + title: { value: 'Entry Title' }, published: '2003-12-13T08:29:29-04:00', updated: '2003-12-13T18:30:02Z', } @@ -975,8 +1138,8 @@ describe('parseEntry', () => { } const expected = { id: '123', - title: '456', - content: '789', + title: { value: '456' }, + content: { value: '789' }, links: [{ href: 'https://example.com/' }], } @@ -989,7 +1152,7 @@ describe('parseEntry', () => { updated: { '#text': '2023-01-01T12:00:00Z' }, } const expected = { - title: 'Entry Title', + title: { value: 'Entry Title' }, updated: '2023-01-01T12:00:00Z', } @@ -1024,10 +1187,9 @@ describe('parseEntry', () => { } const expected = { id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', - title: 'Example Entry', + title: { value: 'Example Entry' }, dc: { creators: ['John Doe'], - creator: 'John Doe', }, } @@ -1053,7 +1215,7 @@ describe('parseEntry', () => { } const expected = { id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', - title: 'Podcast Episode Entry', + title: { value: 'Podcast Episode Entry' }, psc: { chapters: [ { @@ -1080,11 +1242,10 @@ describe('parseEntry', () => { } const expected = { id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', - title: 'Example Entry', + title: { value: 'Example Entry' }, dcterms: { licenses: ['MIT License'], - license: 'MIT License', - created: '2023-02-01T00:00:00Z', + created: ['2023-02-01T00:00:00Z'], }, } @@ -1099,7 +1260,7 @@ describe('parseEntry', () => { } const expected = { id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', - title: 'Example Entry', + title: { value: 'Example Entry' }, slash: { comments: 10 }, } @@ -1115,7 +1276,7 @@ describe('parseEntry', () => { } const expected = { id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', - title: 'Example Entry', + title: { value: 'Example Entry' }, itunes: { duration: 3600, explicit: false, @@ -1133,7 +1294,7 @@ describe('parseEntry', () => { } const expected = { id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', - title: 'Example Entry', + title: { value: 'Example Entry' }, media: { contents: [{ url: 'https://example.com/video.mp4', type: 'video/mp4' }], }, @@ -1150,7 +1311,7 @@ describe('parseEntry', () => { } const expected = { id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', - title: 'Example Entry', + title: { value: 'Example Entry' }, georss: { point: { lat: 42.3601, lng: -71.0589 }, }, @@ -1170,7 +1331,7 @@ describe('parseEntry', () => { } const expected = { id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', - title: 'Example Entry', + title: { value: 'Example Entry' }, thr: { inReplyTos: [{ ref: 'http://example.com/posts/1', href: 'http://example.com/posts/1' }], }, @@ -1188,7 +1349,7 @@ describe('parseEntry', () => { } const expected = { id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', - title: 'Example Entry', + title: { value: 'Example Entry' }, wfw: { comment: 'https://example.com/comment', commentRss: 'https://example.com/comments/feed', @@ -1207,7 +1368,7 @@ describe('parseEntry', () => { } const expected = { id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', - title: 'Example Entry', + title: { value: 'Example Entry' }, yt: { videoId: 'abc123', channelId: 'UCexample', @@ -1221,10 +1382,10 @@ describe('parseEntry', () => { describe('parseFeed', () => { const expectedFull = { id: 'urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6', - title: 'Example Feed', + title: { value: 'Example Feed' }, updated: '2023-01-01T12:00:00Z', authors: [{ name: 'John Doe' }], - subtitle: 'A subtitle for my feed', + subtitle: { value: 'A subtitle for my feed' }, links: [ { href: 'https://example.com/', rel: 'alternate' }, { href: 'https://example.com/feed', rel: 'self' }, @@ -1234,19 +1395,19 @@ describe('parseFeed', () => { generator: { text: 'Example Generator', uri: 'https://example.com/gen', version: '1.0' }, icon: 'https://example.com/favicon.ico', logo: 'https://example.com/logo.png', - rights: 'Copyright 2023, Example Corp.', + rights: { value: 'Copyright 2023, Example Corp.' }, entries: [ { id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', - title: 'First Entry', + title: { value: 'First Entry' }, updated: '2023-01-01T10:00:00Z', - content: '

First entry content

', + content: { value: '

First entry content

' }, }, { id: 'urn:uuid:1225c695-cfb8-4ebb-bbbb-80da344efa6a', - title: 'Second Entry', + title: { value: 'Second Entry' }, updated: '2023-01-02T10:00:00Z', - content: '

Second entry content

', + content: { value: '

Second entry content

' }, }, ], } @@ -1397,7 +1558,7 @@ describe('parseFeed', () => { title: { '#text': 'Example Feed' }, } const expected = { - title: 'Example Feed', + title: { value: 'Example Feed' }, } expect(parseFeed(value)).toEqual(expected) @@ -1419,13 +1580,13 @@ describe('parseFeed', () => { } const expected = { id: 'urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6', - title: 'Example Feed', + title: { value: 'Example Feed' }, updated: '2003-12-13T18:30:02Z', - subtitle: 'A tagline for my feed', + subtitle: { value: 'A tagline for my feed' }, entries: [ { id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', - title: 'First Entry', + title: { value: 'First Entry' }, published: '2003-12-13T08:29:29-04:00', }, ], @@ -1443,9 +1604,9 @@ describe('parseFeed', () => { } const expected = { id: '123', - title: '456', + title: { value: '456' }, links: [{ href: 'https://example.com/' }], - entries: [{ id: '789', title: 'First Entry' }], + entries: [{ id: '789', title: { value: 'First Entry' } }], } expect(parseFeed(value)).toEqual(expected) @@ -1457,7 +1618,7 @@ describe('parseFeed', () => { updated: { '#text': '2023-01-01T12:00:00Z' }, } const expected = { - title: 'Example Feed', + title: { value: 'Example Feed' }, updated: '2023-01-01T12:00:00Z', } @@ -1510,10 +1671,10 @@ describe('parseFeed', () => { } const expected = { id: 'urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6', - title: 'Example Feed', + title: { value: 'Example Feed' }, entries: [ - { id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', title: 'Valid Entry' }, - { title: 'Invalid Entry' }, + { id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', title: { value: 'Valid Entry' } }, + { title: { value: 'Invalid Entry' } }, { id: 'urn:uuid:1225c695-cfb8-4ebb-cccc-80da344efa6a' }, ], } @@ -1529,10 +1690,9 @@ describe('parseFeed', () => { } const expected = { id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', - title: 'Example Feed', + title: { value: 'Example Feed' }, dc: { creators: ['John Doe'], - creator: 'John Doe', }, } @@ -1547,7 +1707,7 @@ describe('parseFeed', () => { } const expected = { id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', - title: 'Example Feed', + title: { value: 'Example Feed' }, sy: { updateFrequency: 5 }, } @@ -1563,11 +1723,10 @@ describe('parseFeed', () => { } const expected = { id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', - title: 'Example Feed', + title: { value: 'Example Feed' }, dcterms: { licenses: ['Creative Commons Attribution 4.0'], - license: 'Creative Commons Attribution 4.0', - created: '2023-01-01T00:00:00Z', + created: ['2023-01-01T00:00:00Z'], }, } @@ -1582,7 +1741,7 @@ describe('parseFeed', () => { } const expected = { id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', - title: 'Example Feed', + title: { value: 'Example Feed' }, yt: { channelId: 'UCexample', }, @@ -1604,7 +1763,7 @@ describe('parseFeed', () => { } const expected = { id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', - title: 'Example Feed', + title: { value: 'Example Feed' }, admin: { errorReportsTo: 'mailto:webmaster@example.com', generatorAgent: 'http://www.movabletype.org/?v=3.2', @@ -1635,15 +1794,15 @@ describe('parseFeed', () => { } const expected = { id: 'urn:uuid:feed-id', - title: 'Test Feed', + title: { value: 'Test Feed' }, entries: [ { id: 'urn:uuid:entry-1', - title: 'Entry 1', + title: { value: 'Entry 1' }, }, { id: 'urn:uuid:entry-2', - title: 'Entry 2', + title: { value: 'Entry 2' }, }, ], } @@ -1668,7 +1827,7 @@ describe('parseFeed', () => { } const expected = { id: 'urn:uuid:feed-id', - title: 'Test Feed', + title: { value: 'Test Feed' }, } expect(parseFeed(value, { maxItems: 0 })).toEqual(expected) @@ -1691,15 +1850,15 @@ describe('parseFeed', () => { } const expected = { id: 'urn:uuid:feed-id', - title: 'Test Feed', + title: { value: 'Test Feed' }, entries: [ { id: 'urn:uuid:entry-1', - title: 'Entry 1', + title: { value: 'Entry 1' }, }, { id: 'urn:uuid:entry-2', - title: 'Entry 2', + title: { value: 'Entry 2' }, }, ], } @@ -1718,7 +1877,7 @@ describe('retrieveFeed', () => { } const expected = { id: 'urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6', - title: 'Example Feed', + title: { value: 'Example Feed' }, } expect(retrieveFeed(value)).toEqual(expected) @@ -1733,7 +1892,7 @@ describe('retrieveFeed', () => { } const expected = { id: 'urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6', - title: 'Example Feed', + title: { value: 'Example Feed' }, } expect(retrieveFeed(value)).toEqual(expected) diff --git a/src/feeds/atom/parse/utils.ts b/src/feeds/atom/parse/utils.ts index 2444bcc8..5a9cf899 100644 --- a/src/feeds/atom/parse/utils.ts +++ b/src/feeds/atom/parse/utils.ts @@ -1,6 +1,7 @@ -import type { ParseOptions, Unreliable } from '../../../common/types.js' +import type { DateAny, Unreliable } from '../../../common/types.js' import { detectNamespaces, + isNonEmptyString, isObject, parseArrayOf, parseDate, @@ -19,7 +20,7 @@ import { import { retrieveItemOrFeed as retrieveCc } from '../../../namespaces/cc/parse/utils.js' import { retrieveItemOrFeed as retrieveCreativeCommonsItemOrFeed } from '../../../namespaces/creativecommons/parse/utils.js' import { retrieveItemOrFeed as retrieveDcItemOrFeed } from '../../../namespaces/dc/parse/utils.js' -import { retrieveItemOrFeed as retrieveDctermsItemOrFeed } from '../../../namespaces/dcterms/parse/utils.js' +import { retrieveItemOrFeed as retrieveDcTermsItemOrFeed } from '../../../namespaces/dcterms/parse/utils.js' import { retrieveItemOrFeed as retrieveGeoItemOrFeed } from '../../../namespaces/geo/parse/utils.js' import { retrieveItemOrFeed as retrieveGeoRssItemOrFeed } from '../../../namespaces/georss/parse/utils.js' import { @@ -45,11 +46,12 @@ import { } from '../../../namespaces/thr/parse/utils.js' import { retrieveItem as retrieveTrackbackItem } from '../../../namespaces/trackback/parse/utils.js' import { retrieveItem as retrieveWfwItem } from '../../../namespaces/wfw/parse/utils.js' +import { retrieveItemOrFeed as retrieveXmlItemOrFeed } from '../../../namespaces/xml/parse/utils.js' import { retrieveFeed as retrieveYtFeed, retrieveItem as retrieveYtItem, } from '../../../namespaces/yt/parse/utils.js' -import type { Atom, ParsePartialUtil } from '../common/types.js' +import type { AtomFeed, ParseUtilPartial } from '../common/types.js' export const createNamespaceGetter = ( value: Record, @@ -62,7 +64,54 @@ export const createNamespaceGetter = ( return (key: string) => value[prefix + key] } -export const parseLink: ParsePartialUtil> = (value) => { +export const parseText: ParseUtilPartial = (value) => { + if (isNonEmptyString(value)) { + const parsed = parseString(value) + + return parsed ? { value: parsed } : undefined + } + + if (!isObject(value)) { + return + } + + const parsedValue = parseString(retrieveText(value)) + + if (!parsedValue) { + return + } + + const text = { + value: parsedValue, + type: parseString(value['@type']), + xml: retrieveXmlItemOrFeed(value), + } + + return trimObject(text) as AtomFeed.Text +} + +export const parseContent: ParseUtilPartial = (value) => { + if (isNonEmptyString(value)) { + const parsed = parseString(value) + + return parsed ? { value: parsed } : undefined + } + + if (!isObject(value)) { + return + } + + const content = { + value: parseString(retrieveText(value)), + type: parseString(value['@type']), + src: parseString(value['@src']), + xml: retrieveXmlItemOrFeed(value), + } + + return trimObject(content) +} + +export const parseLink: ParseUtilPartial> = (value, options) => { if (!isObject(value)) { return } @@ -75,13 +124,13 @@ export const parseLink: ParsePartialUtil> = (value) => { hreflang: parseString(value['@hreflang']), title: parseString(value['@title']), length: parseNumber(value['@length']), - thr: namespaces.has('thr') ? retrieveThrLink(value) : undefined, + thr: namespaces.has('thr') ? retrieveThrLink(value, options) : undefined, } return trimObject(link) } -export const retrievePersonUri: ParsePartialUtil = (value, options) => { +export const retrievePersonUri: ParseUtilPartial = (value, options) => { if (!isObject(value)) { return } @@ -93,7 +142,7 @@ export const retrievePersonUri: ParsePartialUtil = (value, options) => { return uri || url } -export const parsePerson: ParsePartialUtil = (value, options) => { +export const parsePerson: ParseUtilPartial = (value, options) => { if (!isObject(value)) { return } @@ -110,7 +159,7 @@ export const parsePerson: ParsePartialUtil = (value, options) => { return trimObject(person) } -export const parseCategory: ParsePartialUtil = (value) => { +export const parseCategory: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -124,7 +173,7 @@ export const parseCategory: ParsePartialUtil = (value) => { return trimObject(category) } -export const retrieveGeneratorUri: ParsePartialUtil = (value) => { +export const retrieveGeneratorUri: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -135,7 +184,7 @@ export const retrieveGeneratorUri: ParsePartialUtil = (value) => { return uri || url } -export const parseGenerator: ParsePartialUtil = (value) => { +export const parseGenerator: ParseUtilPartial = (value) => { const generator = { text: parseString(retrieveText(value)), uri: retrieveGeneratorUri(value), @@ -145,7 +194,7 @@ export const parseGenerator: ParsePartialUtil = (value) => { return trimObject(generator) } -export const parseSource: ParsePartialUtil> = (value, options) => { +export const parseSource: ParseUtilPartial> = (value, options) => { if (!isObject(value)) { return } @@ -160,24 +209,30 @@ export const parseSource: ParsePartialUtil> = (value, option id: parseSingularOf(get('id'), (value) => parseString(retrieveText(value))), links: parseArrayOf(get('link'), (value) => parseLink(value, options)), logo: parseSingularOf(get('logo'), (value) => parseString(retrieveText(value))), - rights: parseSingularOf(get('rights'), (value) => parseString(retrieveText(value))), - subtitle: parseSingularOf(get('subtitle'), (value) => parseString(retrieveText(value))), - title: parseSingularOf(get('title'), (value) => parseString(retrieveText(value))), - updated: retrieveUpdated(value), + rights: parseSingularOf(get('rights'), parseText), + subtitle: parseSingularOf(get('subtitle'), parseText), + title: parseSingularOf(get('title'), parseText), + updated: retrieveUpdated(value, options), } return trimObject(source) } -export const retrievePublished: ParsePartialUtil = (value, options) => { +export const retrievePublished: ParseUtilPartial = (value, options) => { if (!isObject(value)) { return } const get = createNamespaceGetter(value, options?.prefix) - const published = parseSingularOf(get('published'), (value) => parseDate(retrieveText(value))) // Atom 1.0. - const issued = parseSingularOf(get('issued'), (value) => parseDate(retrieveText(value))) // Atom 0.3. - const created = parseSingularOf(get('created'), (value) => parseDate(retrieveText(value))) // Atom 0.3. + const published = parseSingularOf(get('published'), (value) => + parseDate(retrieveText(value), options?.parseDateFn), + ) // Atom 1.0. + const issued = parseSingularOf(get('issued'), (value) => + parseDate(retrieveText(value), options?.parseDateFn), + ) // Atom 0.3. + const created = parseSingularOf(get('created'), (value) => + parseDate(retrieveText(value), options?.parseDateFn), + ) // Atom 0.3. // The "created" date is not entirely valid as "published date", but if it's there when // no other date is present, it's a good-enough fallback especially that it's not present @@ -185,31 +240,35 @@ export const retrievePublished: ParsePartialUtil = (value, options) => { return published || issued || created } -export const retrieveUpdated: ParsePartialUtil = (value, options) => { +export const retrieveUpdated: ParseUtilPartial = (value, options) => { if (!isObject(value)) { return } const get = createNamespaceGetter(value, options?.prefix) - const updated = parseSingularOf(get('updated'), (value) => parseDate(retrieveText(value))) // Atom 1.0. - const modified = parseSingularOf(get('modified'), (value) => parseDate(retrieveText(value))) // Atom 0.3. + const updated = parseSingularOf(get('updated'), (value) => + parseDate(retrieveText(value), options?.parseDateFn), + ) // Atom 1.0. + const modified = parseSingularOf(get('modified'), (value) => + parseDate(retrieveText(value), options?.parseDateFn), + ) // Atom 0.3. return updated || modified } -export const retrieveSubtitle: ParsePartialUtil = (value, options) => { +export const retrieveSubtitle: ParseUtilPartial = (value, options) => { if (!isObject(value)) { return } const get = createNamespaceGetter(value, options?.prefix) - const subtitle = parseSingularOf(get('subtitle'), (value) => parseString(retrieveText(value))) // Atom 1.0. - const tagline = parseSingularOf(get('tagline'), (value) => parseString(retrieveText(value))) // Atom 0.3. + const subtitle = parseSingularOf(get('subtitle'), parseText) // Atom 1.0. + const tagline = parseSingularOf(get('tagline'), parseText) // Atom 0.3. return subtitle || tagline } -export const parseEntry: ParsePartialUtil> = (value, options) => { +export const parseEntry: ParseUtilPartial> = (value, options) => { if (!isObject(value)) { return } @@ -219,20 +278,20 @@ export const parseEntry: ParsePartialUtil> = (value, options) const entry = { authors: parseArrayOf(get('author'), (value) => parsePerson(value, options)), categories: parseArrayOf(get('category'), (value) => parseCategory(value, options)), - content: parseSingularOf(get('content'), (value) => parseString(retrieveText(value))), + content: parseSingularOf(get('content'), (value) => parseContent(value, options)), contributors: parseArrayOf(get('contributor'), (value) => parsePerson(value, options)), id: parseSingularOf(get('id'), (value) => parseString(retrieveText(value))), links: parseArrayOf(get('link'), (value) => parseLink(value, options)), published: retrievePublished(value, options), - rights: parseSingularOf(get('rights'), (value) => parseString(retrieveText(value))), - source: parseSingularOf(get('source'), parseSource), - summary: parseSingularOf(get('summary'), (value) => parseString(retrieveText(value))), - title: parseSingularOf(get('title'), (value) => parseString(retrieveText(value))), + rights: parseSingularOf(get('rights'), parseText), + source: parseSingularOf(get('source'), (value) => parseSource(value, options)), + summary: parseSingularOf(get('summary'), parseText), + title: parseSingularOf(get('title'), parseText), updated: retrieveUpdated(value, options), - app: namespaces?.has('app') ? retrieveAppEntry(value) : undefined, + app: namespaces?.has('app') ? retrieveAppEntry(value, options) : undefined, arxiv: namespaces?.has('arxiv') ? retrieveArxivEntry(value) : undefined, cc: namespaces?.has('cc') ? retrieveCc(value) : undefined, - dc: namespaces?.has('dc') ? retrieveDcItemOrFeed(value) : undefined, + dc: namespaces?.has('dc') ? retrieveDcItemOrFeed(value, options) : undefined, slash: namespaces?.has('slash') ? retrieveSlashItem(value) : undefined, itunes: namespaces?.has('itunes') ? retrieveItunesItem(value) : undefined, googleplay: namespaces?.has('googleplay') ? retrieveGooglePlayItem(value) : undefined, @@ -241,7 +300,7 @@ export const parseEntry: ParsePartialUtil> = (value, options) georss: namespaces?.has('georss') ? retrieveGeoRssItemOrFeed(value) : undefined, geo: namespaces?.has('geo') ? retrieveGeoItemOrFeed(value) : undefined, thr: namespaces?.has('thr') ? retrieveThrItem(value) : undefined, - dcterms: namespaces?.has('dcterms') ? retrieveDctermsItemOrFeed(value) : undefined, + dcterms: namespaces?.has('dcterms') ? retrieveDcTermsItemOrFeed(value, options) : undefined, creativeCommons: namespaces?.has('creativecommons') ? retrieveCreativeCommonsItemOrFeed(value) : undefined, @@ -249,12 +308,13 @@ export const parseEntry: ParsePartialUtil> = (value, options) yt: namespaces?.has('yt') ? retrieveYtItem(value) : undefined, pingback: namespaces?.has('pingback') ? retrievePingbackItem(value) : undefined, trackback: namespaces?.has('trackback') ? retrieveTrackbackItem(value) : undefined, + xml: options?.asNamespace ? undefined : retrieveXmlItemOrFeed(value), } return trimObject(entry) } -export const parseFeed: ParsePartialUtil> = (value, options) => { +export const parseFeed: ParseUtilPartial> = (value, options) => { if (!isObject(value)) { return } @@ -270,20 +330,20 @@ export const parseFeed: ParsePartialUtil> = (value, options) = id: parseSingularOf(get('id'), (value) => parseString(retrieveText(value))), links: parseArrayOf(get('link'), (value) => parseLink(value, options)), logo: parseSingularOf(get('logo'), (value) => parseString(retrieveText(value))), - rights: parseSingularOf(get('rights'), (value) => parseString(retrieveText(value))), + rights: parseSingularOf(get('rights'), parseText), subtitle: retrieveSubtitle(value, options), - title: parseSingularOf(get('title'), (value) => parseString(retrieveText(value))), + title: parseSingularOf(get('title'), parseText), updated: retrieveUpdated(value, options), entries: parseArrayOf(get('entry'), (value) => parseEntry(value, options), options?.maxItems), cc: namespaces?.has('cc') ? retrieveCc(value) : undefined, - dc: namespaces?.has('dc') ? retrieveDcItemOrFeed(value) : undefined, - sy: namespaces?.has('sy') ? retrieveSyFeed(value) : undefined, + dc: namespaces?.has('dc') ? retrieveDcItemOrFeed(value, options) : undefined, + sy: namespaces?.has('sy') ? retrieveSyFeed(value, options) : undefined, itunes: namespaces?.has('itunes') ? retrieveItunesFeed(value) : undefined, googleplay: namespaces?.has('googleplay') ? retrieveGooglePlayFeed(value) : undefined, media: namespaces?.has('media') ? retrieveMediaItemOrFeed(value) : undefined, georss: namespaces?.has('georss') ? retrieveGeoRssItemOrFeed(value) : undefined, geo: namespaces?.has('geo') ? retrieveGeoItemOrFeed(value) : undefined, - dcterms: namespaces?.has('dcterms') ? retrieveDctermsItemOrFeed(value) : undefined, + dcterms: namespaces?.has('dcterms') ? retrieveDcTermsItemOrFeed(value, options) : undefined, creativeCommons: namespaces?.has('creativecommons') ? retrieveCreativeCommonsItemOrFeed(value) : undefined, @@ -291,12 +351,13 @@ export const parseFeed: ParsePartialUtil> = (value, options) = yt: namespaces?.has('yt') ? retrieveYtFeed(value) : undefined, admin: namespaces?.has('admin') ? retrieveAdminFeed(value) : undefined, pingback: namespaces?.has('pingback') ? retrievePingbackFeed(value) : undefined, + xml: options?.asNamespace ? undefined : retrieveXmlItemOrFeed(value), } return trimObject(feed) } -export const retrieveFeed = (value: Unreliable, options?: ParseOptions) => { +export const retrieveFeed: ParseUtilPartial> = (value, options) => { const notNamespaced = parseSingularOf(value?.feed, (value) => parseFeed(value, options)) const namespaced = parseSingularOf(value?.['atom:feed'], (value) => parseFeed(value, { ...options, prefix: 'atom:' }), diff --git a/src/feeds/atom/references/atom-03.json b/src/feeds/atom/references/atom-03.json index be9f63cf..4519a1d8 100644 --- a/src/feeds/atom/references/atom-03.json +++ b/src/feeds/atom/references/atom-03.json @@ -25,9 +25,19 @@ { "href": "/feed.atom?page=1", "rel": "prev", "type": "application/atom+xml" }, { "href": "/feed.atom?page=3", "rel": "next", "type": "application/atom+xml" } ], - "subtitle": "For documentation only", - "title": "Sample Feed", + "subtitle": { + "value": "For documentation only", + "type": "text/html" + }, + "title": { + "value": "Sample Feed", + "type": "text/plain" + }, "updated": "2004-04-20T11:56:34Z", + "xml": { + "base": "http://example.org/", + "lang": "en" + }, "entries": [ { "authors": [ @@ -41,7 +51,14 @@ "label": "Documentation" } ], - "content": "
Watch out for nasty tricks
", + "content": { + "value": "
Watch out for nasty tricks
", + "type": "application/xhtml+xml", + "xml": { + "base": "http://example.org/entry/3", + "lang": "en-US" + } + }, "contributors": [ { "name": "Joe", "uri": "http://example.org/joe/", "email": "joe@example.org" }, { "name": "Sam", "uri": "http://example.org/sam/", "email": "sam@example.org" } @@ -71,11 +88,19 @@ "links": [ { "href": "http://original-source.example.org/", "rel": "alternate", "type": "text/html" } ], - "title": "Original Feed Title", + "title": { + "value": "Original Feed Title" + }, "updated": "2004-04-18T12:00:00Z" }, - "summary": "Watch out for nasty tricks", - "title": "First entry title", + "summary": { + "value": "Watch out for nasty tricks", + "type": "text/plain" + }, + "title": { + "value": "First entry title", + "type": "text/plain" + }, "updated": "2004-04-20T11:56:34Z" }, { @@ -85,20 +110,35 @@ "id": "tag:feedparser.org,2004-04-20:/docs/examples/atom03.xml:4", "links": [{ "href": "/entry/4", "rel": "alternate", "type": "text/html" }], "published": "2004-04-20T08:45:00Z", - "summary": "This is a shorter entry with minimal elements to show contrast", - "title": "Second entry title", + "summary": { + "value": "This is a shorter entry with minimal elements to show contrast" + }, + "title": { + "value": "Second entry title" + }, "updated": "2004-04-20T09:10:00Z" }, { "authors": [ { "name": "Jane Smith", "uri": "http://example.org/jane/", "email": "jane@example.org" } ], - "content": "
Watch out for nasty tricks
", + "content": { + "value": "
Watch out for nasty tricks
", + "type": "application/xhtml+xml", + "xml": { + "base": "http://example.org/entry/3", + "lang": "en-US" + } + }, "id": "tag:feedparser.org,2004-04-20:/docs/examples/atom03.xml:4", "links": [{ "href": "/entry/4", "rel": "alternate", "type": "text/html" }], "published": "2004-04-20T08:45:00Z", - "summary": "This is an entry showing a text version of content", - "title": "Third entry title", + "summary": { + "value": "This is an entry showing a text version of content" + }, + "title": { + "value": "Third entry title" + }, "updated": "2004-04-20T09:10:00Z" } ] diff --git a/src/feeds/atom/references/atom-10.json b/src/feeds/atom/references/atom-10.json index b50eb215..23acc9ca 100644 --- a/src/feeds/atom/references/atom-10.json +++ b/src/feeds/atom/references/atom-10.json @@ -64,10 +64,23 @@ } ], "logo": "http://example.org/logo.png", - "rights": "

Copyright © 2023, Example Organization. All rights reserved.

", - "subtitle": "A comprehensive example showing all Atom 1.0 elements", - "title": "Comprehensive Atom 1.0 Example Feed", + "rights": { + "value": "

Copyright © 2023, Example Organization. All rights reserved.

", + "type": "html" + }, + "subtitle": { + "value": "A comprehensive example showing all Atom 1.0 elements", + "type": "html" + }, + "title": { + "value": "Comprehensive Atom 1.0 Example Feed", + "type": "text" + }, "updated": "2023-01-15T12:00:00Z", + "xml": { + "base": "http://example.org/", + "lang": "en" + }, "entries": [ { "authors": [ @@ -83,7 +96,14 @@ }, { "term": "complete", "label": "Complete Example" } ], - "content": "
\n

Comprehensive Atom 1.0 Entry

\n

This entry demonstrates all possible elements in an Atom 1.0 entry.

\n
    \n
  • Required elements: id, title, updated
  • \n
  • Optional elements: author, category, content, contributor, link, published, rights, source, summary
  • \n
\n

The content can contain rich XHTML markup.

\n
", + "content": { + "value": "
\n

Comprehensive Atom 1.0 Entry

\n

This entry demonstrates all possible elements in an Atom 1.0 entry.

\n
    \n
  • Required elements: id, title, updated
  • \n
  • Optional elements: author, category, content, contributor, link, published, rights, source, summary
  • \n
\n

The content can contain rich XHTML markup.

\n
", + "type": "xhtml", + "xml": { + "base": "http://example.org/entry/1", + "lang": "en-US" + } + }, "contributors": [ { "name": "Technical Editor", @@ -125,7 +145,10 @@ } ], "published": "2023-01-10T08:30:00Z", - "rights": "

Copyright © 2023, Entry Copyright Holder. All rights reserved.

", + "rights": { + "value": "

Copyright © 2023, Entry Copyright Holder. All rights reserved.

", + "type": "html" + }, "source": { "authors": [ { @@ -160,34 +183,64 @@ { "href": "http://original.example.org/", "rel": "alternate" } ], "logo": "http://original.example.org/logo.png", - "rights": "Copyright © 2023, Original Organization", - "subtitle": "Original source subtitle", - "title": "Original Source Feed", + "rights": { + "value": "Copyright © 2023, Original Organization" + }, + "subtitle": { + "value": "Original source subtitle" + }, + "title": { + "value": "Original Source Feed" + }, "updated": "2023-01-10T08:00:00Z" }, - "summary": "This is a comprehensive example entry showing all possible Atom 1.0 elements in an entry.", - "title": "Comprehensive Entry Example", + "summary": { + "value": "This is a comprehensive example entry showing all possible Atom 1.0 elements in an entry.", + "type": "text" + }, + "title": { + "value": "Comprehensive Entry Example", + "type": "text" + }, "updated": "2023-01-15T11:45:00Z" }, { - "content": "This entry contains only the required elements: id, title, and updated.", + "content": { + "value": "This entry contains only the required elements: id, title, and updated.", + "type": "text" + }, "id": "tag:example.org,2023:entry-2", - "title": "Minimal Required Entry", + "title": { + "value": "Minimal Required Entry" + }, "updated": "2023-01-14T15:30:00Z" }, { - "content": "This is plain text content without any markup.", + "content": { + "value": "This is plain text content without any markup.", + "type": "text" + }, "id": "tag:example.org,2023:entry-3", "links": [{ "href": "/entry/3", "rel": "alternate" }], - "summary": "

This is a summary with HTML markup.

", - "title": "Content Types Example", + "summary": { + "value": "

This is a summary with HTML markup.

", + "type": "html" + }, + "title": { + "value": "Content Types Example" + }, "updated": "2023-01-13T18:45:00Z" }, { "id": "tag:example.org,2023:entry-4", - "summary": "This entry references its content rather than including it inline.", - "title": "Content by Reference", - "updated": "2023-01-12T09:15:00Z" + "summary": { + "value": "This entry references its content rather than including it inline." + }, + "title": { + "value": "Content by Reference" + }, + "updated": "2023-01-12T09:15:00Z", + "content": { "src": "http://example.org/full-content.html", "type": "text/html" } }, { "authors": [ @@ -202,17 +255,35 @@ "email": "co-author@example.org" } ], - "content": "This entry has multiple authors to demonstrate how multiple author elements can be used.", + "content": { + "value": "This entry has multiple authors to demonstrate how multiple author elements can be used.", + "type": "text" + }, "id": "tag:example.org,2023:entry-5", - "title": "Multiple Authors Example", + "title": { + "value": "Multiple Authors Example" + }, "updated": "2023-01-11T14:20:00Z" }, { - "content": "Cet article est écrit en français pour démontrer l'utilisation de l'attribut xml:lang.", + "content": { + "value": "Cet article est écrit en français pour démontrer l'utilisation de l'attribut xml:lang.", + "type": "text" + }, "id": "tag:example.org,2023:entry-6", - "summary": "This is an English summary of a French entry to demonstrate language-specific content.", - "title": "Exemple de Contenu en Français", - "updated": "2023-01-10T11:05:00Z" + "summary": { + "value": "This is an English summary of a French entry to demonstrate language-specific content.", + "xml": { + "lang": "en" + } + }, + "title": { + "value": "Exemple de Contenu en Français" + }, + "updated": "2023-01-10T11:05:00Z", + "xml": { + "lang": "fr" + } } ] } diff --git a/src/feeds/atom/references/atom-ns.json b/src/feeds/atom/references/atom-ns.json index b5093cbb..457ec0c3 100644 --- a/src/feeds/atom/references/atom-ns.json +++ b/src/feeds/atom/references/atom-ns.json @@ -1,10 +1,14 @@ { "id": "example-feed", - "title": "Example Feed", + "title": { + "value": "Example Feed" + }, "entries": [ { "id": "example-entry", - "title": "Example Entry", + "title": { + "value": "Example Entry" + }, "authors": [ { "name": "H1 Collaboration" @@ -46,36 +50,30 @@ "sources": ["https://example.org/entry-source"], "languages": ["en-US"], "relations": ["https://example.org/related-entry"], - "creator": "Jack Jackson", - "contributor": "Assistant Editor Mike Thompson", - "date": "2022-01-01T12:00:00.000Z", - "description": "Detailed description of the example entry content", - "title": "Dublin Core Enhanced Entry Title", - "subject": "Article, Tutorial, Example", - "publisher": "Example News Organization", - "type": "Article", - "format": "application/xhtml+xml", - "identifier": "urn:uuid:98765432-9876-9876-9876-987654321def", - "source": "https://example.org/entry-source", - "language": "en-US", - "relation": "https://example.org/related-entry", - "coverage": "United States", - "rights": "Copyright 2025 Example News Organization" + "coverage": ["United States"], + "rights": ["Copyright 2025 Example News Organization"] }, "dcterms": { "abstracts": ["This article explores advanced concepts in technology"], + "accessRights": ["Public access with attribution required"], "accrualMethods": ["Single publication"], "accrualPeriodicities": ["One-time"], "accrualPolicies": ["Editorial review required"], "alternatives": ["Alternative Item Title"], "audiences": ["Technical professionals"], + "available": ["2022-01-01T12:00:00.000Z"], "bibliographicCitations": [ "Smith, J. (2022). Example Item. Retrieved from https://example.org/item/1" ], + "conformsTo": ["HTML5 standard"], "contributors": ["Assistant Editor Mike Thompson"], "coverages": ["United States technology sector"], + "created": ["2022-01-01T12:00:00.000Z"], "creators": ["Jack Jackson"], + "dateAccepted": ["2022-01-01T12:00:00.000Z"], + "dateCopyrighted": ["2022-01-01T12:00:00.000Z"], "dates": ["2022-01-01T12:00:00.000Z"], + "dateSubmitted": ["2022-01-01T12:00:00.000Z"], "descriptions": ["Detailed description of the example item content and its significance"], "educationLevels": ["Advanced"], "extents": ["Approximately 2500 words"], @@ -85,75 +83,34 @@ "hasVersions": ["1.1"], "identifiers": ["urn:uuid:item-98765432-9876-9876-9876-987654321def"], "instructionalMethods": ["Step-by-step tutorial"], + "isFormatOf": ["https://example.org/item-source"], + "isPartOf": ["Example Article Series"], + "isReferencedBy": ["https://example.org/item/1/citations"], + "isReplacedBy": ["https://example.org/item/1/updated"], + "isRequiredBy": ["https://example.org/item/1/prerequisites"], + "issued": ["2022-01-01T12:00:00.000Z"], + "isVersionOf": ["https://example.org/item/1/original"], "languages": ["en-US"], "licenses": ["Creative Commons Attribution 4.0"], "mediators": ["Editorial Team"], "mediums": ["Digital"], + "modified": ["2022-06-01T12:00:00.000Z"], "provenances": ["Originally published by Example News Organization"], "publishers": ["Example News Organization"], + "references": ["https://example.org/item/1/references"], "relations": ["https://example.org/related-article"], + "replaces": ["https://example.org/item/1/previous"], + "requires": ["Basic understanding of web technologies"], + "rights": ["Copyright 2025 Example News Organization"], "rightsHolders": ["Example News Organization"], "sources": ["https://example.org/item-source"], "spatials": ["United States"], "subjects": ["Article, Tutorial, Example"], + "tableOfContents": ["Introduction, Main Content, Conclusion"], "temporals": ["2022"], "titles": ["Dublin Core Terms Enhanced Item Title"], "types": ["Article"], - "created": "2022-01-01T12:00:00.000Z", - "license": "Creative Commons Attribution 4.0", - "abstract": "This article explores advanced concepts in technology", - "accessRights": "Public access with attribution required", - "accrualMethod": "Single publication", - "accrualPeriodicity": "One-time", - "accrualPolicy": "Editorial review required", - "alternative": "Alternative Item Title", - "audience": "Technical professionals", - "available": "2022-01-01T12:00:00.000Z", - "bibliographicCitation": "Smith, J. (2022). Example Item. Retrieved from https://example.org/item/1", - "conformsTo": "HTML5 standard", - "contributor": "Assistant Editor Mike Thompson", - "coverage": "United States technology sector", - "creator": "Jack Jackson", - "date": "2022-01-01T12:00:00.000Z", - "dateAccepted": "2022-01-01T12:00:00.000Z", - "dateCopyrighted": "2022-01-01T12:00:00.000Z", - "dateSubmitted": "2022-01-01T12:00:00.000Z", - "description": "Detailed description of the example item content and its significance", - "educationLevel": "Advanced", - "extent": "Approximately 2500 words", - "format": "text/html", - "hasFormat": "https://example.org/item/1.pdf", - "hasPart": "https://example.org/item/1/section1", - "hasVersion": "1.1", - "identifier": "urn:uuid:item-98765432-9876-9876-9876-987654321def", - "instructionalMethod": "Step-by-step tutorial", - "isFormatOf": "https://example.org/item-source", - "isPartOf": "Example Article Series", - "isReferencedBy": "https://example.org/item/1/citations", - "isReplacedBy": "https://example.org/item/1/updated", - "isRequiredBy": "https://example.org/item/1/prerequisites", - "issued": "2022-01-01T12:00:00.000Z", - "isVersionOf": "https://example.org/item/1/original", - "language": "en-US", - "mediator": "Editorial Team", - "medium": "Digital", - "modified": "2022-06-01T12:00:00.000Z", - "provenance": "Originally published by Example News Organization", - "publisher": "Example News Organization", - "references": "https://example.org/item/1/references", - "relation": "https://example.org/related-article", - "replaces": "https://example.org/item/1/previous", - "requires": "Basic understanding of web technologies", - "rights": "Copyright 2025 Example News Organization", - "rightsHolder": "Example News Organization", - "source": "https://example.org/item-source", - "spatial": "United States", - "subject": "Article, Tutorial, Example", - "tableOfContents": "Introduction, Main Content, Conclusion", - "temporal": "2022", - "title": "Dublin Core Terms Enhanced Item Title", - "type": "Article", - "valid": "2025-12-31T23:59:59.000Z" + "valid": ["2025-12-31T23:59:59.000Z"] }, "cc": { "license": "https://creativecommons.org/licenses/by/4.0/", @@ -389,126 +346,6 @@ ] } ], - "group": { - "contents": [ - { - "url": "https://example.com/videos/sample-hd.mp4", - "fileSize": 45678912, - "type": "video/mp4", - "medium": "video", - "expression": "full", - "bitrate": 5000, - "framerate": 60, - "duration": 180, - "height": 1080, - "width": 1920, - "lang": "en", - "title": { - "value": "HD Version (1080p)" - } - }, - { - "url": "https://example.com/videos/sample-sd.mp4", - "fileSize": 23456789, - "type": "video/mp4", - "medium": "video", - "expression": "full", - "bitrate": 2500, - "framerate": 30, - "duration": 180, - "height": 720, - "width": 1280, - "lang": "en", - "title": { - "value": "SD Version (720p)" - } - }, - { - "url": "https://example.com/videos/sample.webm", - "fileSize": 19876543, - "type": "video/webm", - "medium": "video", - "expression": "full", - "bitrate": 2000, - "framerate": 30, - "duration": 180, - "height": 720, - "width": 1280, - "lang": "en", - "title": { - "value": "WebM Version" - } - }, - { - "url": "https://example.com/audio/sample.mp3", - "fileSize": 3456789, - "type": "audio/mpeg", - "medium": "audio", - "expression": "full", - "bitrate": 320, - "samplingrate": 44.1, - "channels": 2, - "duration": 180, - "lang": "en", - "title": { - "value": "Audio Only Version" - } - }, - { - "url": "https://example.com/captions/sample-en.srt", - "type": "text/srt", - "medium": "document", - "expression": "sample", - "lang": "en", - "title": { - "value": "English Subtitles" - } - }, - { - "url": "https://example.com/captions/sample-es.srt", - "type": "text/srt", - "medium": "document", - "expression": "sample", - "lang": "es", - "title": { - "value": "Spanish Subtitles" - } - } - ], - "title": { - "value": "Multi-Format Content Example" - }, - "description": { - "value": "This video is available in multiple formats and resolutions" - }, - "thumbnails": [ - { - "url": "https://example.com/thumbnails/group-main.jpg", - "width": 640, - "height": 360 - }, - { - "url": "https://example.com/thumbnails/group-alt.jpg", - "width": 1280, - "height": 720 - } - ], - "keywords": ["group", "multiple", "formats", "resolutions"], - "categories": [ - { - "name": "Technology" - } - ], - "ratings": [ - { - "value": "PG", - "scheme": "urn:mpaa" - } - ], - "copyright": { - "value": "© 2025 Example Media Inc." - } - }, "groups": [ { "contents": [ @@ -819,34 +656,28 @@ "sources": ["https://example.org/original-source"], "languages": ["en-US"], "relations": ["https://example.org/related-content"], - "creator": "John Doe", - "contributor": "Jane Smith", - "date": "2022-01-01T12:00:00.000Z", - "description": "This is an example of description.", - "title": "Dublin Core Enhanced Feed Title", - "subject": "Technology, Programming, Web Development", - "publisher": "Example Publishing Company", - "type": "Text", - "format": "application/atom+xml", - "identifier": "urn:uuid:12345678-1234-1234-1234-123456789abc", - "source": "https://example.org/original-source", - "language": "en-US", - "relation": "https://example.org/related-content", - "coverage": "Global", - "rights": "Copyright 2025 Example Publishing Company" + "coverage": ["Global"], + "rights": ["Copyright 2025 Example Publishing Company"] }, "dcterms": { "abstracts": ["This is an abstract of the feed content"], + "accessRights": ["Public access with attribution required"], "accrualMethods": ["Regular updates"], "accrualPeriodicities": ["Daily"], "accrualPolicies": ["Content added based on editorial calendar"], "alternatives": ["Alternative Feed Title"], "audiences": ["General technology audience"], + "available": ["2022-01-01T12:00:00.000Z"], "bibliographicCitations": ["Example Feed. (2022). Retrieved from https://example.org"], + "conformsTo": ["RSS 2.0 Specification"], "contributors": ["Jane Smith"], "coverages": ["Global technology topics"], + "created": ["2022-01-01T12:00:00.000Z"], "creators": ["John Doe"], + "dateAccepted": ["2022-01-01T12:00:00.000Z"], + "dateCopyrighted": ["2022-01-01T12:00:00.000Z"], "dates": ["2022-01-01T12:00:00.000Z"], + "dateSubmitted": ["2022-01-01T12:00:00.000Z"], "descriptions": ["Detailed description of the feed content and purpose"], "educationLevels": ["Intermediate to advanced"], "extents": ["Approximately 1000 words per article"], @@ -856,75 +687,34 @@ "hasVersions": ["2.0"], "identifiers": ["urn:uuid:feed-12345678-1234-1234-1234-123456789abc"], "instructionalMethods": ["Practical examples and tutorials"], + "isFormatOf": ["https://example.org/original-content"], + "isPartOf": ["Example Media Network"], + "isReferencedBy": ["https://example.org/references"], + "isReplacedBy": ["https://example.org/new-feed"], + "isRequiredBy": ["https://example.org/dependent-feeds"], + "issued": ["2022-01-01T12:00:00.000Z"], + "isVersionOf": ["https://example.org/original-feed"], "languages": ["en-US"], "licenses": ["Creative Commons Attribution 4.0"], "mediators": ["Content Management System"], "mediums": ["Digital"], + "modified": ["2023-01-01T12:00:00.000Z"], "provenances": ["Originally created by Example Publishing Company"], "publishers": ["Example Publishing Company"], + "references": ["https://example.org/referenced-sources"], "relations": ["https://example.org/related-feeds"], + "replaces": ["https://example.org/old-feed"], + "requires": ["RSS 2.0 compatible reader"], + "rights": ["Copyright 2025 Example Publishing Company"], "rightsHolders": ["Example Publishing Company"], "sources": ["https://example.org/original-source"], "spatials": ["Global"], "subjects": ["Technology, Programming, Web Development"], + "tableOfContents": ["Latest articles, tutorials, and news"], "temporals": ["2022-present"], "titles": ["Dublin Core Terms Enhanced Feed Title"], "types": ["Text"], - "created": "2022-01-01T12:00:00.000Z", - "license": "Creative Commons Attribution 4.0", - "abstract": "This is an abstract of the feed content", - "accessRights": "Public access with attribution required", - "accrualMethod": "Regular updates", - "accrualPeriodicity": "Daily", - "accrualPolicy": "Content added based on editorial calendar", - "alternative": "Alternative Feed Title", - "audience": "General technology audience", - "available": "2022-01-01T12:00:00.000Z", - "bibliographicCitation": "Example Feed. (2022). Retrieved from https://example.org", - "conformsTo": "RSS 2.0 Specification", - "contributor": "Jane Smith", - "coverage": "Global technology topics", - "creator": "John Doe", - "date": "2022-01-01T12:00:00.000Z", - "dateAccepted": "2022-01-01T12:00:00.000Z", - "dateCopyrighted": "2022-01-01T12:00:00.000Z", - "dateSubmitted": "2022-01-01T12:00:00.000Z", - "description": "Detailed description of the feed content and purpose", - "educationLevel": "Intermediate to advanced", - "extent": "Approximately 1000 words per article", - "format": "application/atom+xml", - "hasFormat": "https://example.org/feed.atom", - "hasPart": "https://example.org/category/tutorials", - "hasVersion": "2.0", - "identifier": "urn:uuid:feed-12345678-1234-1234-1234-123456789abc", - "instructionalMethod": "Practical examples and tutorials", - "isFormatOf": "https://example.org/original-content", - "isPartOf": "Example Media Network", - "isReferencedBy": "https://example.org/references", - "isReplacedBy": "https://example.org/new-feed", - "isRequiredBy": "https://example.org/dependent-feeds", - "issued": "2022-01-01T12:00:00.000Z", - "isVersionOf": "https://example.org/original-feed", - "language": "en-US", - "mediator": "Content Management System", - "medium": "Digital", - "modified": "2023-01-01T12:00:00.000Z", - "provenance": "Originally created by Example Publishing Company", - "publisher": "Example Publishing Company", - "references": "https://example.org/referenced-sources", - "relation": "https://example.org/related-feeds", - "replaces": "https://example.org/old-feed", - "requires": "RSS 2.0 compatible reader", - "rights": "Copyright 2025 Example Publishing Company", - "rightsHolder": "Example Publishing Company", - "source": "https://example.org/original-source", - "spatial": "Global", - "subject": "Technology, Programming, Web Development", - "tableOfContents": "Latest articles, tutorials, and news", - "temporal": "2022-present", - "title": "Dublin Core Terms Enhanced Feed Title", - "type": "Text", - "valid": "2025-12-31T23:59:59.000Z" + "valid": ["2025-12-31T23:59:59.000Z"] }, "cc": { "license": "https://creativecommons.org/licenses/by-nc-sa/4.0/", @@ -1170,126 +960,6 @@ ] } ], - "group": { - "contents": [ - { - "url": "https://example.com/videos/sample-hd.mp4", - "fileSize": 45678912, - "type": "video/mp4", - "medium": "video", - "expression": "full", - "bitrate": 5000, - "framerate": 60, - "duration": 180, - "height": 1080, - "width": 1920, - "lang": "en", - "title": { - "value": "HD Version (1080p)" - } - }, - { - "url": "https://example.com/videos/sample-sd.mp4", - "fileSize": 23456789, - "type": "video/mp4", - "medium": "video", - "expression": "full", - "bitrate": 2500, - "framerate": 30, - "duration": 180, - "height": 720, - "width": 1280, - "lang": "en", - "title": { - "value": "SD Version (720p)" - } - }, - { - "url": "https://example.com/videos/sample.webm", - "fileSize": 19876543, - "type": "video/webm", - "medium": "video", - "expression": "full", - "bitrate": 2000, - "framerate": 30, - "duration": 180, - "height": 720, - "width": 1280, - "lang": "en", - "title": { - "value": "WebM Version" - } - }, - { - "url": "https://example.com/audio/sample.mp3", - "fileSize": 3456789, - "type": "audio/mpeg", - "medium": "audio", - "expression": "full", - "bitrate": 320, - "samplingrate": 44.1, - "channels": 2, - "duration": 180, - "lang": "en", - "title": { - "value": "Audio Only Version" - } - }, - { - "url": "https://example.com/captions/sample-en.srt", - "type": "text/srt", - "medium": "document", - "expression": "sample", - "lang": "en", - "title": { - "value": "English Subtitles" - } - }, - { - "url": "https://example.com/captions/sample-es.srt", - "type": "text/srt", - "medium": "document", - "expression": "sample", - "lang": "es", - "title": { - "value": "Spanish Subtitles" - } - } - ], - "title": { - "value": "Multi-Format Content Example" - }, - "description": { - "value": "This video is available in multiple formats and resolutions" - }, - "thumbnails": [ - { - "url": "https://example.com/thumbnails/group-main.jpg", - "width": 640, - "height": 360 - }, - { - "url": "https://example.com/thumbnails/group-alt.jpg", - "width": 1280, - "height": 720 - } - ], - "keywords": ["group", "multiple", "formats", "resolutions"], - "categories": [ - { - "name": "Technology" - } - ], - "ratings": [ - { - "value": "PG", - "scheme": "urn:mpaa" - } - ], - "copyright": { - "value": "© 2025 Example Media Inc." - } - }, "groups": [ { "contents": [ diff --git a/src/feeds/json/common/types.ts b/src/feeds/json/common/types.ts index 700ccea6..affabbb3 100644 --- a/src/feeds/json/common/types.ts +++ b/src/feeds/json/common/types.ts @@ -1,58 +1,82 @@ -import type { DateLike } from '../../../common/types.js' +import type { + GenerateUtil as BaseGenerateUtil, + ParseUtilPartial as BaseParseUtilPartial, + DateAny, + ParseMainOptions, + Requirable, + Strict, +} from '../../../common/types.js' + +export type ParseUtilPartial = BaseParseUtilPartial> + +export type GenerateUtil = BaseGenerateUtil // #region reference -export namespace Json { +export namespace JsonFeed { export type Author = { name?: string url?: string avatar?: string } - export type Attachment = { - url: string - mime_type: string - title?: string - size_in_bytes?: number - duration_in_seconds?: number - } + export type Attachment = Strict< + { + url: Requirable // Required in spec. + mime_type: Requirable // Required in spec. + title?: string + size_in_bytes?: number + duration_in_seconds?: number + }, + TStrict + > - export type Item = { - id: string - url?: string - external_url?: string - title?: string - content_html?: string - content_text?: string - summary?: string - image?: string - banner_image?: string - date_published?: TDate - date_modified?: TDate - tags?: Array - authors?: Array - language?: string - attachments?: Array - } & ({ content_html: string } | { content_text: string }) + export type Item = Strict< + { + id: Requirable // Required in spec. + url?: string + external_url?: string + title?: string + content_html?: string // At least one of content_html or content_text is required in spec. + content_text?: string // At least one of content_html or content_text is required in spec. + summary?: string + image?: string + banner_image?: string + date_published?: TDate + date_modified?: TDate + tags?: Array + authors?: Array + language?: string + attachments?: Array> + }, + TStrict + > & + (TStrict extends true ? { content_html: string } | { content_text: string } : unknown) - export type Hub = { - type: string - url: string - } + export type Hub = Strict< + { + type: Requirable // Required in spec. + url: Requirable // Required in spec. + }, + TStrict + > - export type Feed = { - title: string - home_page_url?: string - feed_url?: string - description?: string - user_comment?: string - next_url?: string - icon?: string - favicon?: string - language?: string - expired?: boolean - hubs?: Array - authors?: Array - items: Array> - } + export type Feed = Strict< + { + title: Requirable // Required in spec. + home_page_url?: string + feed_url?: string + description?: string + user_comment?: string + next_url?: string + icon?: string + favicon?: string + language?: string + expired?: boolean + hubs?: Array> + authors?: Array + items: Requirable>> // Required in spec. + }, + TStrict + > } // #endregion reference diff --git a/src/feeds/json/generate/index.test.ts b/src/feeds/json/generate/index.test.ts index 2f3ca150..897ce67e 100644 --- a/src/feeds/json/generate/index.test.ts +++ b/src/feeds/json/generate/index.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'bun:test' +import { locales } from '../../../common/config.js' +import { GenerateError } from '../../../common/errors.js' import { generate } from './index.js' describe('generate', () => { @@ -53,8 +55,95 @@ describe('generate', () => { }) }) -describe('generate with lenient mode', () => { - it('should accept partial feeds with lenient: true', () => { +describe('strict mode', () => { + it('should require title and items in strict mode', () => { + // @ts-expect-error: This is for testing purposes. + generate({ title: 'Test' }, { strict: true }) + }) + + it('should accept feed with all required fields in strict mode', () => { + generate({ title: 'Test', items: [] }, { strict: true }) + }) + + it('should require item id in strict mode', () => { + generate( + { + title: 'Test', + // @ts-expect-error: This is for testing purposes. + items: [{ content_text: 'Hello' }], + }, + { strict: true }, + ) + }) + + it('should accept items with content_html in strict mode', () => { + generate( + { title: 'Test', items: [{ id: '1', content_html: '

Hello

' }] }, + { strict: true }, + ) + }) + + it('should accept items with content_text in strict mode', () => { + generate({ title: 'Test', items: [{ id: '1', content_text: 'Hello' }] }, { strict: true }) + }) + + it('should accept items with both content fields in strict mode', () => { + generate( + { title: 'Test', items: [{ id: '1', content_html: '

Hello

', content_text: 'Hello' }] }, + { strict: true }, + ) + }) + + it('should require nested type fields in strict mode', () => { + generate( + { + title: 'Test', + items: [ + { + id: '1', + content_html: '

Hello

', + // @ts-expect-error: This is for testing purposes. + attachments: [{ url: 'https://example.com/file.mp3' }], + }, + ], + }, + { strict: true }, + ) + }) + + it('should accept nested types with all required fields in strict mode', () => { + generate( + { + title: 'Test', + items: [ + { + id: '1', + content_html: '

Hello

', + attachments: [{ url: 'https://example.com/file.mp3', mime_type: 'audio/mpeg' }], + }, + ], + hubs: [{ type: 'websub', url: 'https://example.com/hub' }], + }, + { strict: true }, + ) + }) + + it('should accept partial feed in lenient mode', () => { + generate({ title: 'Test' }) + }) +}) + +describe('error types', () => { + it('should throw GenerateError for invalid input', () => { + const throwing = () => generate({}) + + expect(throwing).toThrow(GenerateError) + expect(throwing).toThrow(locales.invalidInputJson) + }) +}) + +describe('generate edge cases', () => { + it('should accept partial feeds', () => { const value = { title: 'Test Feed', } @@ -63,10 +152,10 @@ describe('generate with lenient mode', () => { title: 'Test Feed', } - expect(generate(value, { lenient: true })).toEqual(expected) + expect(generate(value)).toEqual(expected) }) - it('should accept feeds with string dates in lenient mode', () => { + it('should accept feeds with string dates', () => { const value = { title: 'Test Feed', date_published: '2023-01-01T00:00:00.000Z', @@ -91,10 +180,10 @@ describe('generate with lenient mode', () => { ], } - expect(generate(value, { lenient: true })).toEqual(expected) + expect(generate(value)).toEqual(expected) }) - it('should preserve invalid date strings in lenient mode', () => { + it('should preserve invalid date strings', () => { const value = { title: 'Test Feed', date_published: 'not-a-valid-date', @@ -106,6 +195,6 @@ describe('generate with lenient mode', () => { date_published: 'not-a-valid-date', } - expect(generate(value, { lenient: true })).toEqual(expected) + expect(generate(value)).toEqual(expected) }) }) diff --git a/src/feeds/json/generate/index.ts b/src/feeds/json/generate/index.ts index d614cfef..7adf118c 100644 --- a/src/feeds/json/generate/index.ts +++ b/src/feeds/json/generate/index.ts @@ -1,9 +1,17 @@ -import type { DateLike, DeepPartial, JsonGenerateMain } from '../../../common/types.js' -import type { Json } from '../common/types.js' +import { locales } from '../../../common/config.js' +import { GenerateError } from '../../../common/errors.js' +import type { DateLike, GenerateMainJson } from '../../../common/types.js' +import type { JsonFeed } from '../common/types.js' import { generateFeed } from './utils.js' -export const generate: JsonGenerateMain, DeepPartial>> = ( +export const generate: GenerateMainJson, JsonFeed.Feed> = ( value, ) => { - return generateFeed(value as Json.Feed) + const generated = generateFeed(value) + + if (!generated) { + throw new GenerateError(locales.invalidInputJson) + } + + return generated } diff --git a/src/feeds/json/generate/utils.test.ts b/src/feeds/json/generate/utils.test.ts index 149d8b52..17532c0a 100644 --- a/src/feeds/json/generate/utils.test.ts +++ b/src/feeds/json/generate/utils.test.ts @@ -216,4 +216,14 @@ describe('generateFeed', () => { expect(generateFeed(value)).toEqual(expected) }) + + it('should handle non-object inputs', () => { + // @ts-expect-error: This is for testing purposes. + expect(generateFeed('string')).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generateFeed(123)).toBeUndefined() + expect(generateFeed(undefined)).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generateFeed(null)).toBeUndefined() + }) }) diff --git a/src/feeds/json/generate/utils.ts b/src/feeds/json/generate/utils.ts index 5f04e27a..c968710a 100644 --- a/src/feeds/json/generate/utils.ts +++ b/src/feeds/json/generate/utils.ts @@ -1,8 +1,8 @@ -import type { DateLike, GenerateUtil } from '../../../common/types.js' -import { generateRfc3339Date, trimArray, trimObject } from '../../../common/utils.js' -import type { Json } from '../common/types.js' +import type { DateLike } from '../../../common/types.js' +import { generateRfc3339Date, isObject, trimArray, trimObject } from '../../../common/utils.js' +import type { GenerateUtil, JsonFeed } from '../common/types.js' -export const generateItem: GenerateUtil> = (item) => { +export const generateItem: GenerateUtil> = (item) => { const value = { ...item, date_published: generateRfc3339Date(item?.date_published), @@ -12,12 +12,24 @@ export const generateItem: GenerateUtil> = (item) => { return trimObject(value) } -export const generateFeed: GenerateUtil> = (feed) => { +export const generateFeed: GenerateUtil> = (feed) => { + if (!isObject(feed)) { + return + } + const value = { - version: 'https://jsonfeed.org/version/1.1', ...feed, items: trimArray(feed?.items, generateItem), } - return trimObject(value) + const trimmed = trimObject(value) + + if (!trimmed) { + return + } + + return { + version: 'https://jsonfeed.org/version/1.1', + ...trimmed, + } } diff --git a/src/feeds/json/parse/index.test.ts b/src/feeds/json/parse/index.test.ts index 045d76ec..52ac6303 100644 --- a/src/feeds/json/parse/index.test.ts +++ b/src/feeds/json/parse/index.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'bun:test' import { locales } from '../../../common/config.js' +import { DetectError, ParseError } from '../../../common/errors.js' import { parse } from './index.js' describe('parse', () => { @@ -261,6 +262,25 @@ describe('parse', () => { expect(() => parse(123)).toThrowError(locales.invalidFeedFormat) }) + describe('error types', () => { + it('should throw DetectError for non-feed input', () => { + const throwing = () => parse({}) + + expect(throwing).toThrow(DetectError) + expect(throwing).toThrow(locales.invalidFeedFormat) + }) + + it('should throw ParseError for detected but invalid feed', () => { + const value = { + version: 'https://jsonfeed.org/version/1', + } + const throwing = () => parse(value) + + expect(throwing).toThrow(ParseError) + expect(throwing).toThrow(locales.invalidFeedFormat) + }) + }) + describe('with maxItems option', () => { const commonValue = { version: 'https://jsonfeed.org/version/1.1', @@ -330,795 +350,32 @@ describe('parse', () => { }) }) - // Edge cases and quirks observed in feeds found in the wild. - describe('real world feeds', () => { - describe('author handling', () => { - it('RW-J01: should handle v1 singular author object', () => { - const value = { - version: 'https://jsonfeed.org/version/1', - title: 'Blog', - author: { name: 'John Doe', url: 'https://example.com' }, - items: [{ id: '1', content_text: 'Hello' }], - } - const expected = { - title: 'Blog', - authors: [{ name: 'John Doe', url: 'https://example.com' }], - items: [{ id: '1', content_text: 'Hello' }], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-J01: should prefer v1.1 authors array over v1 author object', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Blog', - authors: [{ name: 'Alice' }, { name: 'Bob' }], - author: { name: 'Ignored' }, - items: [{ id: '1', content_text: 'Hello' }], - } - const expected = { - title: 'Blog', - authors: [{ name: 'Alice' }, { name: 'Bob' }], - items: [{ id: '1', content_text: 'Hello' }], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-J01: should handle author on item level', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Blog', - items: [ - { - id: '1', - content_text: 'Hello', - authors: [ - { - name: 'Alice', - url: 'https://alice.example.com', - avatar: 'https://alice.example.com/avatar.jpg', - }, - ], - }, - ], - } - const expected = { - title: 'Blog', - items: [ - { - id: '1', - content_text: 'Hello', - authors: [ - { - name: 'Alice', - url: 'https://alice.example.com', - avatar: 'https://alice.example.com/avatar.jpg', - }, - ], - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - }) - - describe('content handling', () => { - it('RW-J06: should parse content_html with raw HTML', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Blog', - items: [ - { - id: '1', - content_html: '

Hello world

', - }, - ], - } - const expected = { - title: 'Blog', - items: [ - { - id: '1', - content_html: '

Hello world

', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-J06: should parse both content_text and content_html', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Blog', - items: [ - { - id: '1', - content_text: 'Hello world', - content_html: '

Hello world

', - }, - ], - } - const expected = { - title: 'Blog', - items: [ - { - id: '1', - content_text: 'Hello world', - content_html: '

Hello world

', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-J06: should handle item with only content_text', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Blog', - items: [ - { - id: '1', - content_text: 'Plain text content', - }, - ], - } - const expected = { - title: 'Blog', - items: [ - { - id: '1', - content_text: 'Plain text content', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - }) - - describe('attachments', () => { - it('RW-M08: should parse attachments with all fields', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Podcast', - items: [ - { - id: '1', - content_text: 'Episode notes', - attachments: [ - { - url: 'https://example.com/episode.mp3', - mime_type: 'audio/mpeg', - title: 'Episode 1', - size_in_bytes: 12345678, - duration_in_seconds: 3600, - }, - ], - }, - ], - } - const expected = { - title: 'Podcast', - items: [ - { - id: '1', - content_text: 'Episode notes', - attachments: [ - { - url: 'https://example.com/episode.mp3', - mime_type: 'audio/mpeg', - title: 'Episode 1', - size_in_bytes: 12345678, - duration_in_seconds: 3600, - }, - ], - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-M08: should parse multiple attachments', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Podcast', - items: [ - { - id: '1', - content_text: 'Episode', - attachments: [ - { url: 'https://example.com/ep.mp3', mime_type: 'audio/mpeg' }, - { url: 'https://example.com/ep.ogg', mime_type: 'audio/ogg' }, - ], - }, - ], - } - const expected = { - title: 'Podcast', - items: [ - { - id: '1', - content_text: 'Episode', - attachments: [ - { url: 'https://example.com/ep.mp3', mime_type: 'audio/mpeg' }, - { url: 'https://example.com/ep.ogg', mime_type: 'audio/ogg' }, - ], - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - }) - - describe('tags', () => { - it('RW-J07: should parse tags as array of strings', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Blog', - items: [ - { - id: '1', - content_text: 'Hello', - tags: ['javascript', 'typescript', 'nodejs'], - }, - ], - } - const expected = { - title: 'Blog', - items: [ - { - id: '1', - content_text: 'Hello', - tags: ['javascript', 'typescript', 'nodejs'], - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - }) - - describe('missing and empty elements', () => { - it('RW-N13: should ignore unknown custom fields', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Blog', - custom_extension: { foo: 'bar' }, - items: [ - { - id: '1', - content_text: 'Hello', - _custom: 'value', - }, - ], - } - const expected = { - title: 'Blog', - items: [ - { - id: '1', - content_text: 'Hello', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-N04: should handle null values in fields', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Blog', - description: null, - items: [ - { - id: '1', - content_text: 'Hello', - title: null, - summary: null, - }, - ], - } - const expected = { - title: 'Blog', - items: [ - { - id: '1', - content_text: 'Hello', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-N05: should handle empty string values', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Blog', - description: '', - items: [ - { - id: '1', - content_text: 'Hello', - title: '', - }, - ], - } - const expected = { - title: 'Blog', - items: [ - { - id: '1', - content_text: 'Hello', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-J03: should handle item with numeric id', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Blog', - items: [ - { - id: 42, - content_text: 'Hello', - }, - ], - } - const expected = { - title: 'Blog', - items: [ - { - id: '42', - content_text: 'Hello', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - }) - - describe('feed metadata', () => { - it('RW-Q08: should parse feed with all metadata fields', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'My Blog', - home_page_url: 'https://example.com', - feed_url: 'https://example.com/feed.json', - description: 'A blog about stuff', - icon: 'https://example.com/icon.png', - favicon: 'https://example.com/favicon.ico', - language: 'en-US', - expired: false, - items: [{ id: '1', content_text: 'Hello' }], - } - const expected = { - title: 'My Blog', - home_page_url: 'https://example.com', - feed_url: 'https://example.com/feed.json', - description: 'A blog about stuff', - icon: 'https://example.com/icon.png', - favicon: 'https://example.com/favicon.ico', - language: 'en-US', - expired: false, - items: [{ id: '1', content_text: 'Hello' }], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-Q08: should parse hubs for WebSub support', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Blog', - hubs: [{ type: 'WebSub', url: 'https://hub.example.com' }], - items: [{ id: '1', content_text: 'Hello' }], - } - const expected = { - title: 'Blog', - hubs: [{ type: 'WebSub', url: 'https://hub.example.com' }], - items: [{ id: '1', content_text: 'Hello' }], - } - - expect(parse(value)).toEqual(expected) - }) - }) - - describe('type coercion edge cases', () => { - it('RW-J05: should drop boolean id (not coerced to string)', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Blog', - items: [ - { - id: true, - content_text: 'Hello', - }, - ], - } - const expected = { - title: 'Blog', - items: [ - { - content_text: 'Hello', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-J04: should handle numeric zero as id', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Blog', - items: [ - { - id: 0, - content_text: 'Hello', - }, - ], - } - const expected = { - title: 'Blog', - items: [ - { - id: '0', - content_text: 'Hello', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-J09: should handle expired as true', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Archived Blog', - expired: true, - items: [{ id: '1', content_text: 'Old post' }], - } - const expected = { - title: 'Archived Blog', - expired: true, - items: [{ id: '1', content_text: 'Old post' }], - } - - expect(parse(value)).toEqual(expected) - }) - }) - - describe('case insensitivity', () => { - it('RW-J08: should handle uppercase property names', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - Title: 'Blog', - DESCRIPTION: 'A test blog', - items: [ - { - ID: '1', - Content_Text: 'Hello', - }, - ], - } - const expected = { - title: 'Blog', - description: 'A test blog', - items: [ - { - id: '1', - content_text: 'Hello', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-J08: should handle mixed case in nested objects', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Blog', - Authors: [{ Name: 'Alice', URL: 'https://alice.com' }], - items: [{ id: '1', content_text: 'Hello' }], - } - const expected = { - title: 'Blog', - authors: [{ name: 'Alice', url: 'https://alice.com' }], - items: [{ id: '1', content_text: 'Hello' }], - } - - expect(parse(value)).toEqual(expected) - }) - }) - - describe('unusual but valid structures', () => { - it('RW-J02: should handle author as plain string (non-spec but common)', () => { - const value = { - version: 'https://jsonfeed.org/version/1', - title: 'Blog', - author: 'John Doe', - items: [{ id: '1', content_text: 'Hello' }], - } - const expected = { - title: 'Blog', - authors: [{ name: 'John Doe' }], - items: [{ id: '1', content_text: 'Hello' }], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-N09: should handle empty items array', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Empty Blog', - items: [], - } - const expected = { - title: 'Empty Blog', - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-N10: should handle items with only empty objects', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Blog', - items: [{}], - } - const expected = { - title: 'Blog', - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-A10: should handle attachment with no mime_type', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Podcast', - items: [ - { - id: '1', - content_text: 'Episode', - attachments: [{ url: 'https://example.com/file.mp3' }], - }, - ], - } - const expected = { - title: 'Podcast', - items: [ - { - id: '1', - content_text: 'Episode', - attachments: [{ url: 'https://example.com/file.mp3' }], - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-T04: should handle item with date_published and date_modified', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Blog', - items: [ - { - id: '1', - content_text: 'Hello', - date_published: '2024-01-15T12:00:00Z', - date_modified: '2024-01-16T14:30:00+02:00', - }, - ], - } - const expected = { - title: 'Blog', - items: [ - { - id: '1', - content_text: 'Hello', - date_published: '2024-01-15T12:00:00Z', - date_modified: '2024-01-16T14:30:00+02:00', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-N11: should handle whitespace-only title', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: ' ', - items: [{ id: '1', content_text: 'Hello' }], - } - const expected = { - items: [{ id: '1', content_text: 'Hello' }], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-J07: should handle tags with empty strings filtered out', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Blog', - items: [ - { - id: '1', - content_text: 'Hello', - tags: ['javascript', '', 'typescript', ' '], - }, - ], - } - const expected = { - title: 'Blog', - items: [ - { - id: '1', - content_text: 'Hello', - tags: ['javascript', 'typescript'], - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-J10: should handle authors as plain object instead of array', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Test Blog', - items: [ - { - id: '1', - content_text: 'Hello', - authors: { name: 'John Doe' }, - }, - ], - } - const expected = { - title: 'Test Blog', - items: [ - { - id: '1', - content_text: 'Hello', - authors: [{ name: 'John Doe' }], - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-J11: should handle items as single object instead of array', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Blog', - items: { + describe('parseDateFn', () => { + it('should apply custom parseDateFn to item dates', () => { + const value = { + version: 'https://jsonfeed.org/version/1.1', + title: 'Test', + items: [ + { id: '1', - content_text: 'Solo post', - url: 'https://example.com/post/1', + date_published: '2023-01-01T00:00:00Z', + date_modified: '2023-01-02T00:00:00Z', }, - } - const expected = { - title: 'Blog', - items: [ - { - id: '1', - content_text: 'Solo post', - url: 'https://example.com/post/1', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-N25: should drop author with all empty string fields', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Test', - items: [ - { - id: '1', - content_text: 'Post', - authors: [{ name: '', url: '', avatar: '' }], - }, - ], - } - const expected = { - title: 'Test', - items: [ - { - id: '1', - content_text: 'Post', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-J12: should parse summary as separate field without content', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Test', - items: [ - { - id: '1', - summary: 'This is a summary', - }, - ], - } - const expected = { - title: 'Test', - items: [ - { - id: '1', - summary: 'This is a summary', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-J13: should coerce string size_in_bytes and duration_in_seconds to numbers', () => { - const value = { - version: 'https://jsonfeed.org/version/1.1', - title: 'Test', - items: [ - { - id: '1', - content_text: 'Episode', - attachments: [ - { - url: 'https://example.com/episode.mp3', - mime_type: 'audio/mpeg', - size_in_bytes: '12345678', - duration_in_seconds: '3661', - }, - ], - }, - ], - } - const expected = { - title: 'Test', - items: [ - { - id: '1', - content_text: 'Episode', - attachments: [ - { - url: 'https://example.com/episode.mp3', - mime_type: 'audio/mpeg', - size_in_bytes: 12345678, - duration_in_seconds: 3661, - }, - ], - }, - ], - } + ], + } + const expected = { + title: 'Test', + items: [ + { + id: '1', + date_published: new Date('2023-01-01T00:00:00Z'), + date_modified: new Date('2023-01-02T00:00:00Z'), + }, + ], + } + const result = parse(value, { parseDateFn: (raw) => new Date(raw) }) - expect(parse(value)).toEqual(expected) - }) + expect(result).toEqual(expected) }) }) }) diff --git a/src/feeds/json/parse/index.ts b/src/feeds/json/parse/index.ts index 8e437b9a..2a89157f 100644 --- a/src/feeds/json/parse/index.ts +++ b/src/feeds/json/parse/index.ts @@ -1,22 +1,27 @@ import { locales } from '../../../common/config.js' -import type { DeepPartial, ParseOptions } from '../../../common/types.js' +import { DetectError, ParseError } from '../../../common/errors.js' +import type { ParseMainOptions } from '../../../common/types.js' import { parseJsonObject } from '../../../common/utils.js' import { detectJsonFeed } from '../../../index.js' -import type { Json } from '../common/types.js' +import type { JsonFeed } from '../common/types.js' import { parseFeed } from './utils.js' -export const parse = (value: unknown, options?: ParseOptions): DeepPartial> => { +export const parse = ( + value: unknown, + options?: ParseMainOptions, +): JsonFeed.Feed => { const json = parseJsonObject(value) + // TODO: Detect malformed JSON input and throw MalformedError. if (!detectJsonFeed(json)) { - throw new Error(locales.invalidFeedFormat) + throw new DetectError(locales.invalidFeedFormat) } const parsed = parseFeed(json, options) if (!parsed) { - throw new Error(locales.invalidFeedFormat) + throw new ParseError(locales.invalidFeedFormat) } - return parsed + return parsed as JsonFeed.Feed } diff --git a/src/feeds/json/parse/utils.ts b/src/feeds/json/parse/utils.ts index 1d64eedf..871a907f 100644 --- a/src/feeds/json/parse/utils.ts +++ b/src/feeds/json/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParseOptions, ParsePartialUtil } from '../../../common/types.js' +import type { DateAny } from '../../../common/types.js' import { isNonEmptyStringOrNumber, isObject, @@ -10,7 +10,7 @@ import { parseSingularOf, trimObject, } from '../../../common/utils.js' -import type { Json } from '../common/types.js' +import type { JsonFeed, ParseUtilPartial } from '../common/types.js' export const createCaseInsensitiveGetter = (value: Record) => { return (requestedKey: string) => { @@ -28,7 +28,7 @@ export const createCaseInsensitiveGetter = (value: Record) => { } } -export const parseAuthor: ParsePartialUtil = (value) => { +export const parseAuthor: ParseUtilPartial = (value) => { if (isObject(value)) { const get = createCaseInsensitiveGetter(value) const author = { @@ -49,7 +49,7 @@ export const parseAuthor: ParsePartialUtil = (value) => { } } -export const retrieveAuthors: ParsePartialUtil> = (value) => { +export const retrieveAuthors: ParseUtilPartial> = (value) => { if (!isObject(value)) { return } @@ -64,7 +64,7 @@ export const retrieveAuthors: ParsePartialUtil> = (value) => return parsedAuthors?.length ? parsedAuthors : parsedAuthor } -export const parseAttachment: ParsePartialUtil = (value) => { +export const parseAttachment: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -81,7 +81,7 @@ export const parseAttachment: ParsePartialUtil = (value) => { return trimObject(attachment) } -export const parseItem: ParsePartialUtil> = (value) => { +export const parseItem: ParseUtilPartial> = (value, options) => { if (!isObject(value)) { return } @@ -97,8 +97,12 @@ export const parseItem: ParsePartialUtil> = (value) => { summary: parseSingularOf(get('summary'), parseJsonString), image: parseSingularOf(get('image'), parseJsonString), banner_image: parseSingularOf(get('banner_image'), parseJsonString), - date_published: parseSingularOf(get('date_published'), parseDate), - date_modified: parseSingularOf(get('date_modified'), parseDate), + date_published: parseSingularOf(get('date_published'), (value) => + parseDate(value, options?.parseDateFn), + ), + date_modified: parseSingularOf(get('date_modified'), (value) => + parseDate(value, options?.parseDateFn), + ), tags: parseArrayOf(get('tags'), parseJsonString), authors: retrieveAuthors(value), language: parseSingularOf(get('language'), parseJsonString), @@ -108,7 +112,7 @@ export const parseItem: ParsePartialUtil> = (value) => { return trimObject(item) } -export const parseHub: ParsePartialUtil = (value) => { +export const parseHub: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -122,7 +126,7 @@ export const parseHub: ParsePartialUtil = (value) => { return trimObject(hub) } -export const parseFeed: ParsePartialUtil, ParseOptions> = (value, options) => { +export const parseFeed: ParseUtilPartial> = (value, options) => { if (!isObject(value)) { return } @@ -141,7 +145,7 @@ export const parseFeed: ParsePartialUtil, ParseOptions> = (val expired: parseSingularOf(get('expired'), parseBoolean), hubs: parseArrayOf(get('hubs'), parseHub), authors: retrieveAuthors(value), - items: parseArrayOf(get('items'), parseItem, options?.maxItems), + items: parseArrayOf(get('items'), (value) => parseItem(value, options), options?.maxItems), } return trimObject(feed) diff --git a/src/feeds/rdf/common/types.ts b/src/feeds/rdf/common/types.ts index f75a8240..fa93eaea 100644 --- a/src/feeds/rdf/common/types.ts +++ b/src/feeds/rdf/common/types.ts @@ -1,4 +1,10 @@ -import type { DateLike } from '../../../common/types.js' +import type { + ParseUtilPartial as BaseParseUtilPartial, + DateAny, + ParseMainOptions, + Requirable, + Strict, +} from '../../../common/types.js' import type { AdminNs } from '../../../namespaces/admin/common/types.js' import type { AtomNs } from '../../../namespaces/atom/common/types.js' import type { ContentNs } from '../../../namespaces/content/common/types.js' @@ -10,54 +16,71 @@ import type { RdfNs } from '../../../namespaces/rdf/common/types.js' import type { SlashNs } from '../../../namespaces/slash/common/types.js' import type { SyNs } from '../../../namespaces/sy/common/types.js' import type { WfwNs } from '../../../namespaces/wfw/common/types.js' +import type { XmlNs } from '../../../namespaces/xml/common/types.js' + +export type ParseUtilPartial = BaseParseUtilPartial> // #region reference -export namespace Rdf { - export type Image = { - title: string - link: string - url?: string - rdf?: RdfNs.About - } +export namespace RdfFeed { + export type Image = Strict< + { + title: Requirable // Required in spec. + link: Requirable // Required in spec. + url: Requirable // Required in spec. + rdf?: RdfNs.About + }, + TStrict + > - export type TextInput = { - title: string - description: string - name: string - link: string - rdf?: RdfNs.About - } + export type TextInput = Strict< + { + title: Requirable // Required in spec. + description: Requirable // Required in spec. + name: Requirable // Required in spec. + link: Requirable // Required in spec. + rdf?: RdfNs.About + }, + TStrict + > - export type Item = { - title: string - link: string - description?: string - rdf?: RdfNs.About - atom?: AtomNs.Entry - dc?: DcNs.ItemOrFeed - content?: ContentNs.Item - slash?: SlashNs.Item - media?: MediaNs.ItemOrFeed - georss?: GeoRssNs.ItemOrFeed - dcterms?: DcTermsNs.ItemOrFeed - wfw?: WfwNs.Item - } + export type Item = Strict< + { + title: Requirable // Required in spec. + link: Requirable // Required in spec. + description?: string + rdf?: RdfNs.About + atom?: AtomNs.Entry + dc?: DcNs.ItemOrFeed + content?: ContentNs.Item + slash?: SlashNs.Item + media?: MediaNs.ItemOrFeed + georss?: GeoRssNs.ItemOrFeed + dcterms?: DcTermsNs.ItemOrFeed + wfw?: WfwNs.Item + xml?: XmlNs.ItemOrFeed + }, + TStrict + > - export type Feed = { - title: string - link: string - description: string - image?: Image - items?: Array> - textInput?: TextInput - rdf?: RdfNs.About - atom?: AtomNs.Feed - dc?: DcNs.ItemOrFeed - sy?: SyNs.Feed - media?: MediaNs.ItemOrFeed - georss?: GeoRssNs.ItemOrFeed - dcterms?: DcTermsNs.ItemOrFeed - admin?: AdminNs.Feed - } + export type Feed = Strict< + { + title: Requirable // Required in spec. + link: Requirable // Required in spec. + description: Requirable // Required in spec. + image?: Image + items?: Array> + textInput?: TextInput + rdf?: RdfNs.About + atom?: AtomNs.Feed + dc?: DcNs.ItemOrFeed + sy?: SyNs.Feed + media?: MediaNs.ItemOrFeed + georss?: GeoRssNs.ItemOrFeed + dcterms?: DcTermsNs.ItemOrFeed + admin?: AdminNs.Feed + xml?: XmlNs.ItemOrFeed + }, + TStrict + > } // #endregion reference diff --git a/src/feeds/rdf/parse/index.test.ts b/src/feeds/rdf/parse/index.test.ts index 67f86dcb..e97accec 100644 --- a/src/feeds/rdf/parse/index.test.ts +++ b/src/feeds/rdf/parse/index.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'bun:test' import { locales, namespaceUris } from '../../../common/config.js' +import { DetectError, MalformedError, ParseError } from '../../../common/errors.js' import { parse } from './index.js' describe('parse', () => { @@ -83,6 +84,46 @@ describe('parse', () => { expect(() => parse(123)).toThrowError(locales.invalidFeedFormat) }) + describe('error types', () => { + it('should throw DetectError for non-feed input', () => { + const throwing = () => parse('not a feed') + + expect(throwing).toThrow(DetectError) + expect(throwing).toThrow(locales.invalidFeedFormat) + }) + + it('should throw MalformedError for malformed XML', () => { + const value = ` + + + + Test + + ` + const throwing = () => parse(value) + + expect(throwing).toThrow(MalformedError) + expect(throwing).toThrow(locales.invalidFeedFormat) + }) + + it('should throw ParseError for valid XML with invalid structure', () => { + const value = ` + + + + ` + const throwing = () => parse(value) + + expect(throwing).toThrow(ParseError) + expect(throwing).toThrow(locales.invalidFeedFormat) + }) + }) + it('should handle non-standard atom namespace prefix', () => { const value = ` @@ -292,7 +333,6 @@ describe('parse', () => { description: 'Item Description', dc: { creators: ['John Doe'], - creator: 'John Doe', }, rdf: { about: 'http://example.com/item1' }, }, @@ -337,8 +377,6 @@ describe('parse', () => { dc: { creators: ['John Doe'], dates: ['2023-01-01'], - creator: 'John Doe', - date: '2023-01-01', }, rdf: { about: 'http://example.com/item1' }, }, @@ -382,7 +420,6 @@ describe('parse', () => { description: 'Item Description', dc: { creators: ['John Doe'], - creator: 'John Doe', }, rdf: { about: 'http://example.com/item1' }, }, @@ -477,7 +514,6 @@ describe('parse', () => { description: 'RDF Feed Description', dc: { creators: ['Feed Author'], - creator: 'Feed Author', }, sy: { updatePeriod: 'hourly', @@ -491,8 +527,6 @@ describe('parse', () => { dc: { creators: ['John Doe'], dates: ['2023-01-01'], - creator: 'John Doe', - date: '2023-01-01', }, slash: { comments: 42, @@ -536,7 +570,6 @@ describe('parse', () => { title: 'Item Title', dc: { creators: ['Should not normalize (empty URI)'], - creator: 'Should not normalize (empty URI)', }, rdf: { about: 'http://example.com/item1' }, }, @@ -599,14 +632,12 @@ describe('parse', () => { const expected = { dc: { creators: ['Channel Author'], - creator: 'Channel Author', }, items: [ { title: 'Item without about', dc: { creators: ['Item Author'], - creator: 'Item Author', }, }, ], @@ -681,7 +712,6 @@ describe('parse', () => { title: 'Item', dc: { creators: ['John'], - creator: 'John', }, rdf: { about: 'http://example.com/item1' }, }, @@ -720,7 +750,6 @@ describe('parse', () => { title: 'Item', dc: { creators: ['John'], - creator: 'John', }, rdf: { about: 'http://example.com/item1' }, }, @@ -759,7 +788,6 @@ describe('parse', () => { title: 'Item', dc: { creators: ['John'], - creator: 'John', }, rdf: { about: 'http://example.com/item1' }, }, @@ -798,7 +826,6 @@ describe('parse', () => { title: 'Item', dc: { creators: ['John'], - creator: 'John', }, rdf: { about: 'http://example.com/item1' }, }, @@ -837,7 +864,6 @@ describe('parse', () => { title: 'Item', dc: { creators: ['John'], - creator: 'John', }, rdf: { about: 'http://example.com/item1' }, }, @@ -876,7 +902,6 @@ describe('parse', () => { title: 'Item', dc: { creators: ['John'], - creator: 'John', }, rdf: { about: 'http://example.com/item1' }, }, @@ -917,8 +942,6 @@ describe('parse', () => { dcterms: { creators: ['Jane Doe'], titles: ['DC Terms Title'], - creator: 'Jane Doe', - title: 'DC Terms Title', }, rdf: { about: 'http://example.com/item1' }, }, @@ -998,9 +1021,8 @@ describe('parse', () => { link: 'http://example.com', description: 'Test feed with Dublin Core Terms namespace', dcterms: { - created: '2023-01-01T00:00:00.000Z', + created: ['2023-01-01T00:00:00.000Z'], licenses: ['Creative Commons Attribution 4.0'], - license: 'Creative Commons Attribution 4.0', }, rdf: { about: 'http://example.com' }, items: [ @@ -1008,9 +1030,8 @@ describe('parse', () => { title: 'First item', link: 'http://example.com/item1', dcterms: { - created: '2023-02-01T00:00:00.000Z', + created: ['2023-02-01T00:00:00.000Z'], licenses: ['MIT License'], - license: 'MIT License', }, rdf: { about: 'http://example.com/item1' }, }, @@ -1221,11 +1242,8 @@ describe('parse', () => { description: 'Test feed with Dublin Core namespace', dc: { creators: ['John Doe'], - creator: 'John Doe', publishers: ['Example Publishing'], - publisher: 'Example Publishing', languages: ['en-US'], - language: 'en-US', }, rdf: { about: 'http://example.com' }, items: [ @@ -1234,11 +1252,8 @@ describe('parse', () => { link: 'http://example.com/item1', dc: { creators: ['Jane Smith'], - creator: 'Jane Smith', dates: ['2023-01-15T10:00:00Z'], - date: '2023-01-15T10:00:00Z', subjects: ['Technology'], - subject: 'Technology', }, rdf: { about: 'http://example.com/item1' }, }, @@ -1541,603 +1556,72 @@ describe('parse', () => { }) }) - // Edge cases and quirks observed in feeds found in the wild. - describe('real world feeds', () => { - describe('character encoding', () => { - it('RW-E01: should decode HTML numeric character references', () => { - const value = ` - - - - Café Blog - http://example.com - Test - - - ` - const expected = { - title: 'Caf\u00e9 Blog', - link: 'http://example.com', - description: 'Test', - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-E03: should decode named HTML entities in item title', () => { - const value = ` - - - - Test - http://example.com - Test - - - News – Update - - - ` - const expected = { - title: 'Test', - link: 'http://example.com', - description: 'Test', - items: [ - { - title: 'News \u2013 Update', - rdf: { about: 'http://example.com/1' }, - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - }) - - describe('cdata handling', () => { - it('RW-C09: should handle CDATA in item description', () => { - const value = ` - - - - Test - http://example.com - Test - - - Item - HTML with bold

]]>
-
-
- ` - const expected = { - title: 'Test', - link: 'http://example.com', - description: 'Test', - items: [ - { - title: 'Item', - description: '

HTML with bold

', - rdf: { about: 'http://example.com/1' }, - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-C02: should handle CDATA in channel title', () => { - const value = ` - - - - <![CDATA[Test & Blog]]> - http://example.com - Test - - - ` - const expected = { - title: 'Test & Blog', - link: 'http://example.com', - description: 'Test', - } - - expect(parse(value)).toEqual(expected) - }) - }) - - describe('namespace edge cases', () => { - it('RW-NS01: should handle non-standard prefix for dc namespace', () => { - const value = ` - - - - Test - http://example.com - Test - - - Post - Author - 2024-01-15 - - - ` - const expected = { - title: 'Test', - link: 'http://example.com', - description: 'Test', - items: [ - { - title: 'Post', - dc: { - creators: ['Author'], - creator: 'Author', - dates: ['2024-01-15'], - date: '2024-01-15', - }, - rdf: { about: 'http://example.com/1' }, - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - }) - - describe('missing and empty elements', () => { - it('RW-N08: should parse item with no description', () => { - const value = ` - - - - Test - http://example.com - Test - - - Title Only - http://example.com/1 - - - ` - const expected = { - title: 'Test', - link: 'http://example.com', - description: 'Test', - items: [ - { - title: 'Title Only', - link: 'http://example.com/1', - rdf: { about: 'http://example.com/1' }, - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-N01: should parse feed with no items', () => { - const value = ` - - - - Empty - http://example.com - No items - - - ` - const expected = { - title: 'Empty', - link: 'http://example.com', - description: 'No items', - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-N03: should handle empty self-closing description', () => { - const value = ` - - - - Test - http://example.com - - - - ` - const expected = { - title: 'Test', - link: 'http://example.com', - } - - expect(parse(value)).toEqual(expected) - }) - }) - - describe('multiple elements', () => { - it('RW-M04: should parse multiple dc:subject as categories', () => { - const value = ` - - - - Test - http://example.com - Test - - - Post - Technology - Science - Open Source - - - ` - const expected = { - title: 'Test', - link: 'http://example.com', - description: 'Test', - items: [ - { - title: 'Post', - dc: { - subjects: ['Technology', 'Science', 'Open Source'], - subject: 'Technology', - }, - rdf: { about: 'http://example.com/1' }, - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - }) - - describe('content:encoded', () => { - it('RW-NS09: should parse content:encoded with complex HTML in CDATA', () => { - const value = ` - - - - Test - http://example.com - Test - - - Post -

Title

Text with link

]]>
-
-
- ` - const expected = { - title: 'Test', - link: 'http://example.com', - description: 'Test', - items: [ - { - title: 'Post', - content: { - encoded: - '

Title

Text with link

', - }, - rdf: { about: 'http://example.com/1' }, - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - }) - - describe('malformed XML resilience', () => { - it('RW-E10: should handle BOM at start of feed', () => { - const value = `\uFEFF - - - BOM Feed - http://example.com - Test - - - ` - const expected = { - title: 'BOM Feed', - link: 'http://example.com', - description: 'Test', - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-E06: should decode   entity in item title', () => { - const value = ` - - - - Test - http://example.com - Test - - - Hello World - - - ` - const expected = { - title: 'Test', - link: 'http://example.com', - description: 'Test', - items: [ - { - title: 'Hello\u00A0World', - rdf: { about: 'http://example.com/1' }, - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-X01: should partially parse truncated XML without throwing', () => { - const value = ` - - - - Test - ` - const expected = { - title: 'Test', - } - - expect(parse(value)).toEqual(expected) - }) - }) - - describe('double-encoded and special entities', () => { - it('RW-E04: should single-decode double-encoded entities', () => { - const value = ` - - - - Test - http://example.com - Test - - - Tom &amp; Jerry - - - ` - const expected = { - title: 'Test', - link: 'http://example.com', - description: 'Test', - items: [ - { - title: 'Tom & Jerry', - rdf: { about: 'http://example.com/1' }, - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-E07: should decode © entity', () => { - const value = ` - - - - © 2024 Example - http://example.com - Test - - - ` - const expected = { - title: '\u00A9 2024 Example', - link: 'http://example.com', - description: 'Test', - } - - expect(parse(value)).toEqual(expected) - }) - }) - - describe('multiple items and rdf:about', () => { - it('RW-M07: should parse multiple items preserving rdf:about on each', () => { - const value = ` - - - - Test - http://example.com - Test - - - First - http://example.com/1 - - - Second - http://example.com/2 - - - Third - http://example.com/3 - - - ` - const expected = { - title: 'Test', - link: 'http://example.com', - description: 'Test', - items: [ - { - title: 'First', - link: 'http://example.com/1', - rdf: { about: 'http://example.com/1' }, - }, - { - title: 'Second', - link: 'http://example.com/2', - rdf: { about: 'http://example.com/2' }, - }, - { - title: 'Third', - link: 'http://example.com/3', - rdf: { about: 'http://example.com/3' }, - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-NS13: should handle prefixed core elements in RDF feed', () => { - const value = ` - - - - Prefixed Feed - http://example.com - A feed using prefixed RSS elements - - - Prefixed Item - http://example.com/1 - - - ` - const expected = { - title: 'Prefixed Feed', - link: 'http://example.com', - description: 'A feed using prefixed RSS elements', - items: [ - { - title: 'Prefixed Item', - link: 'http://example.com/1', - rdf: { about: 'http://example.com/1' }, - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-N12: should handle item with no rdf:about', () => { - const value = ` - - - - Test - http://example.com - Test - - - No About - http://example.com/1 - - - ` - const expected = { - title: 'Test', - link: 'http://example.com', - description: 'Test', - items: [ - { - title: 'No About', - link: 'http://example.com/1', - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - - it('RW-X15: should parse items as top-level siblings outside channel', () => { - const value = ` - - - - Test Channel - http://example.com - A test channel - - - First Item - http://example.com/1 - - - Second Item - http://example.com/2 - - - ` - const expected = { - title: 'Test Channel', - link: 'http://example.com', - description: 'A test channel', - items: [ - { - title: 'First Item', - link: 'http://example.com/1', - rdf: { about: 'http://example.com/1' }, - }, - { - title: 'Second Item', - link: 'http://example.com/2', - rdf: { about: 'http://example.com/2' }, - }, - ], - } - - expect(parse(value)).toEqual(expected) - }) - }) - }) - - describe('xml comment stripping', () => { - it('should strip XML comments from element content', () => { + describe('parseDateFn', () => { + it('should apply custom parseDateFn to namespace dates', () => { const value = ` - - Test<!-- hidden --> Feed - http://example.com - Test + xmlns="http://purl.org/rss/1.0/" + xmlns:dc="http://purl.org/dc/elements/1.1/" + > + + Test - - Post<!-- comment --> Title - http://example.com/post + + Item + 2023-03-15T12:00:00Z ` const expected = { - title: 'Test Feed', - link: 'http://example.com', - description: 'Test', + title: 'Test', + rdf: { + about: 'http://example.com', + }, items: [ { - title: 'Post Title', - link: 'http://example.com/post', + title: 'Item', + rdf: { + about: 'http://example.com/item1', + }, + dc: { + dates: [new Date('2023-03-15T12:00:00Z')], + }, }, ], } + const result = parse(value, { parseDateFn: (raw) => new Date(raw) }) - expect(parse(value)).toEqual(expected) + expect(result).toEqual(expected) + }) + + it('should apply custom parseDateFn to sy namespace dates', () => { + const value = ` + + + + Test + 2023-03-15T12:00:00Z + + + ` + const expected = { + title: 'Test', + rdf: { + about: 'http://example.com', + }, + sy: { + updateBase: new Date('2023-03-15T12:00:00Z'), + }, + } + const result = parse(value, { parseDateFn: (raw) => new Date(raw) }) + + expect(result).toEqual(expected) }) }) }) diff --git a/src/feeds/rdf/parse/index.ts b/src/feeds/rdf/parse/index.ts index db2338bf..e823bf6e 100644 --- a/src/feeds/rdf/parse/index.ts +++ b/src/feeds/rdf/parse/index.ts @@ -1,22 +1,33 @@ import { locales } from '../../../common/config.js' -import type { DeepPartial, ParseOptions } from '../../../common/types.js' +import { DetectError, MalformedError, ParseError } from '../../../common/errors.js' +import type { ParseMainOptions, Unreliable } from '../../../common/types.js' import { detectRdfFeed } from '../../../index.js' -import type { Rdf } from '../common/types.js' +import type { RdfFeed } from '../common/types.js' import { normalizeNamespaces, parser } from './config.js' import { retrieveFeed } from './utils.js' -export const parse = (value: unknown, options?: ParseOptions): DeepPartial> => { +export const parse = ( + value: unknown, + options?: ParseMainOptions, +): RdfFeed.Feed => { if (!detectRdfFeed(value)) { - throw new Error(locales.invalidFeedFormat) + throw new DetectError(locales.invalidFeedFormat) + } + + let normalized: Unreliable + + try { + const object = parser.parse(value) + normalized = normalizeNamespaces(object) + } catch { + throw new MalformedError(locales.invalidFeedFormat) } - const object = parser.parse(value) - const normalized = normalizeNamespaces(object) const parsed = retrieveFeed(normalized, options) if (!parsed) { - throw new Error(locales.invalidFeedFormat) + throw new ParseError(locales.invalidFeedFormat) } - return parsed + return parsed as RdfFeed.Feed } diff --git a/src/feeds/rdf/parse/utils.test.ts b/src/feeds/rdf/parse/utils.test.ts index 0558b4cc..c0831f01 100644 --- a/src/feeds/rdf/parse/utils.test.ts +++ b/src/feeds/rdf/parse/utils.test.ts @@ -630,7 +630,6 @@ describe('parseItem', () => { link: 'http://example.com', dc: { creators: ['John Doe'], - creator: 'John Doe', }, } @@ -664,8 +663,7 @@ describe('parseItem', () => { link: 'http://example.com', dcterms: { licenses: ['MIT License'], - license: 'MIT License', - created: '2023-02-01T00:00:00Z', + created: ['2023-02-01T00:00:00Z'], }, } @@ -1603,7 +1601,6 @@ describe('parseFeed', () => { ], dc: { creators: ['John Doe'], - creator: 'John Doe', }, } @@ -1661,8 +1658,7 @@ describe('parseFeed', () => { ], dcterms: { licenses: ['Creative Commons Attribution 4.0'], - license: 'Creative Commons Attribution 4.0', - created: '2023-01-01T00:00:00Z', + created: ['2023-01-01T00:00:00Z'], }, } diff --git a/src/feeds/rdf/parse/utils.ts b/src/feeds/rdf/parse/utils.ts index 265be9fc..508de191 100644 --- a/src/feeds/rdf/parse/utils.ts +++ b/src/feeds/rdf/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParseOptions, ParsePartialUtil } from '../../../common/types.js' +import type { DateAny } from '../../../common/types.js' import { detectNamespaces, isObject, @@ -17,14 +17,15 @@ import { } from '../../../namespaces/atom/parse/utils.js' import { retrieveItem as retrieveContentItem } from '../../../namespaces/content/parse/utils.js' import { retrieveItemOrFeed as retrieveDcItemOrFeed } from '../../../namespaces/dc/parse/utils.js' -import { retrieveItemOrFeed as retrieveDctermsItemOrFeed } from '../../../namespaces/dcterms/parse/utils.js' +import { retrieveItemOrFeed as retrieveDcTermsItemOrFeed } from '../../../namespaces/dcterms/parse/utils.js' import { retrieveItemOrFeed as retrieveGeoRssItemOrFeed } from '../../../namespaces/georss/parse/utils.js' import { retrieveItemOrFeed as retrieveMediaItemOrFeed } from '../../../namespaces/media/parse/utils.js' import { retrieveAbout as retrieveRdfAbout } from '../../../namespaces/rdf/parse/utils.js' import { retrieveItem as retrieveSlashItem } from '../../../namespaces/slash/parse/utils.js' import { retrieveFeed as retrieveSyFeed } from '../../../namespaces/sy/parse/utils.js' import { retrieveItem as retrieveWfwItem } from '../../../namespaces/wfw/parse/utils.js' -import type { Rdf } from '../common/types.js' +import { retrieveItemOrFeed as retrieveXmlItemOrFeed } from '../../../namespaces/xml/parse/utils.js' +import type { ParseUtilPartial, RdfFeed } from '../common/types.js' const retrieveByAbout = (elements: unknown, resourceUri: string | undefined): unknown => { if (!resourceUri) { @@ -48,7 +49,7 @@ const findByTocReference = (value: unknown, property: string): unknown => { return retrieveByAbout(value[property], resourceUri) } -export const parseImage: ParsePartialUtil = (value) => { +export const parseImage: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -63,11 +64,11 @@ export const parseImage: ParsePartialUtil = (value) => { return trimObject(image) } -export const retrieveImage: ParsePartialUtil = (value) => { +export const retrieveImage: ParseUtilPartial = (value) => { return parseImage(findByTocReference(value, 'image')) ?? parseSingularOf(value?.image, parseImage) } -export const parseTextInput: ParsePartialUtil = (value) => { +export const parseTextInput: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -83,14 +84,14 @@ export const parseTextInput: ParsePartialUtil = (value) => { return trimObject(textInput) } -export const retrieveTextInput: ParsePartialUtil = (value) => { +export const retrieveTextInput: ParseUtilPartial = (value) => { return ( parseTextInput(findByTocReference(value, 'textinput')) ?? parseSingularOf(value?.textinput, parseTextInput) ) } -export const parseItem: ParsePartialUtil> = (value) => { +export const parseItem: ParseUtilPartial> = (value, options) => { if (!isObject(value)) { return } @@ -101,23 +102,21 @@ export const parseItem: ParsePartialUtil> = (value) => { link: parseSingularOf(value.link, (value) => parseString(retrieveText(value))), description: parseSingularOf(value.description, (value) => parseString(retrieveText(value))), rdf: retrieveRdfAbout(value), - atom: namespaces.has('atom') ? retrieveAtomEntry(value) : undefined, - dc: namespaces.has('dc') ? retrieveDcItemOrFeed(value) : undefined, + atom: namespaces.has('atom') ? retrieveAtomEntry(value, options) : undefined, + dc: namespaces.has('dc') ? retrieveDcItemOrFeed(value, options) : undefined, content: namespaces.has('content') ? retrieveContentItem(value) : undefined, slash: namespaces.has('slash') ? retrieveSlashItem(value) : undefined, media: namespaces.has('media') ? retrieveMediaItemOrFeed(value) : undefined, georss: namespaces.has('georss') ? retrieveGeoRssItemOrFeed(value) : undefined, - dcterms: namespaces.has('dcterms') ? retrieveDctermsItemOrFeed(value) : undefined, + dcterms: namespaces.has('dcterms') ? retrieveDcTermsItemOrFeed(value, options) : undefined, wfw: namespaces.has('wfw') ? retrieveWfwItem(value) : undefined, + xml: retrieveXmlItemOrFeed(value), } return trimObject(item) } -export const retrieveItems: ParsePartialUtil>, ParseOptions> = ( - value, - options, -) => { +export const retrieveItems: ParseUtilPartial>> = (value, options) => { if (!isObject(value)) { return } @@ -132,17 +131,17 @@ export const retrieveItems: ParsePartialUtil>, ParseOptio ) const items = trimArray( itemUris?.map((uri) => retrieveByAbout(value.item, uri)), - parseItem, + (value) => parseItem(value, options), ) if (items?.length) { return items } - return parseArrayOf(value?.item, parseItem, options?.maxItems) + return parseArrayOf(value?.item, (value) => parseItem(value, options), options?.maxItems) } -export const parseFeed: ParsePartialUtil, ParseOptions> = (value, options) => { +export const parseFeed: ParseUtilPartial> = (value, options) => { if (!isObject(value)) { return } @@ -157,18 +156,19 @@ export const parseFeed: ParsePartialUtil, ParseOptions> = (valu items: retrieveItems(value, options), textInput: retrieveTextInput(value), rdf: retrieveRdfAbout(channel), - atom: namespaces.has('atom') ? retrieveAtomFeed(channel) : undefined, - dc: namespaces.has('dc') ? retrieveDcItemOrFeed(channel) : undefined, - sy: namespaces.has('sy') ? retrieveSyFeed(channel) : undefined, + atom: namespaces.has('atom') ? retrieveAtomFeed(channel, options) : undefined, + dc: namespaces.has('dc') ? retrieveDcItemOrFeed(channel, options) : undefined, + sy: namespaces.has('sy') ? retrieveSyFeed(channel, options) : undefined, media: namespaces.has('media') ? retrieveMediaItemOrFeed(channel) : undefined, georss: namespaces.has('georss') ? retrieveGeoRssItemOrFeed(channel) : undefined, - dcterms: namespaces.has('dcterms') ? retrieveDctermsItemOrFeed(channel) : undefined, + dcterms: namespaces.has('dcterms') ? retrieveDcTermsItemOrFeed(channel, options) : undefined, admin: namespaces.has('admin') ? retrieveAdminFeed(channel) : undefined, + xml: retrieveXmlItemOrFeed(value), } return trimObject(feed) } -export const retrieveFeed: ParsePartialUtil, ParseOptions> = (value, options) => { +export const retrieveFeed: ParseUtilPartial> = (value, options) => { return parseSingularOf(value?.rdf, (value) => parseFeed(value, options)) } diff --git a/src/feeds/rdf/references/rdf-ns.json b/src/feeds/rdf/references/rdf-ns.json index 3ad96b53..8c7d4964 100644 --- a/src/feeds/rdf/references/rdf-ns.json +++ b/src/feeds/rdf/references/rdf-ns.json @@ -22,36 +22,30 @@ "sources": ["https://example.org/item-source"], "languages": ["en-US"], "relations": ["https://example.org/related-article"], - "creator": "Jack Jackson", - "contributor": "Assistant Editor Mike Thompson", - "date": "2022-01-01T12:00+00:00", - "description": "This article explores advanced concepts in technology", - "title": "Dublin Core Enhanced Item Title", - "subject": "Article, Tutorial, Example", - "publisher": "Example News Organization", - "type": "Article", - "format": "text/html", - "identifier": "urn:uuid:item-98765432-9876-9876-9876-987654321def", - "source": "https://example.org/item-source", - "language": "en-US", - "relation": "https://example.org/related-article", - "coverage": "United States", - "rights": "Copyright 2025 Example News Organization" + "coverage": ["United States"], + "rights": ["Copyright 2025 Example News Organization"] }, "dcterms": { "abstracts": ["This article explores advanced concepts in technology"], + "accessRights": ["Public access with attribution required"], "accrualMethods": ["Single publication"], "accrualPeriodicities": ["One-time"], "accrualPolicies": ["Editorial review required"], "alternatives": ["Alternative Item Title"], "audiences": ["Technical professionals"], + "available": ["2022-01-01T12:00+00:00"], "bibliographicCitations": [ "Smith, J. (2022). Example Item. Retrieved from https://example.org/item/1" ], + "conformsTo": ["HTML5 standard"], "contributors": ["Assistant Editor Mike Thompson"], "coverages": ["United States technology sector"], + "created": ["2022-01-01T12:00+00:00"], "creators": ["Jack Jackson"], + "dateAccepted": ["2022-01-01T12:00+00:00"], + "dateCopyrighted": ["2022-01-01T12:00+00:00"], "dates": ["2022-01-01T12:00+00:00"], + "dateSubmitted": ["2022-01-01T12:00+00:00"], "descriptions": ["Detailed description of the example item content and its significance"], "educationLevels": ["Advanced"], "extents": ["Approximately 2500 words"], @@ -61,75 +55,34 @@ "hasVersions": ["1.1"], "identifiers": ["urn:uuid:item-98765432-9876-9876-9876-987654321def"], "instructionalMethods": ["Step-by-step tutorial"], + "isFormatOf": ["https://example.org/item-source"], + "isPartOf": ["Example Article Series"], + "isReferencedBy": ["https://example.org/item/1/citations"], + "isReplacedBy": ["https://example.org/item/1/updated"], + "isRequiredBy": ["https://example.org/item/1/prerequisites"], + "issued": ["2022-01-01T12:00+00:00"], + "isVersionOf": ["https://example.org/item/1/original"], "languages": ["en-US"], "licenses": ["Creative Commons Attribution 4.0"], "mediators": ["Editorial Team"], "mediums": ["Digital"], + "modified": ["2022-06-01T12:00+00:00"], "provenances": ["Originally published by Example News Organization"], "publishers": ["Example News Organization"], + "references": ["https://example.org/item/1/references"], "relations": ["https://example.org/related-article"], + "replaces": ["https://example.org/item/1/previous"], + "requires": ["Basic understanding of web technologies"], + "rights": ["Copyright 2025 Example News Organization"], "rightsHolders": ["Example News Organization"], "sources": ["https://example.org/item-source"], "spatials": ["United States"], "subjects": ["Article, Tutorial, Example"], + "tableOfContents": ["Introduction, Main Content, Conclusion"], "temporals": ["2022"], "titles": ["Dublin Core Terms Enhanced Item Title"], "types": ["Article"], - "created": "2022-01-01T12:00+00:00", - "license": "Creative Commons Attribution 4.0", - "abstract": "This article explores advanced concepts in technology", - "accessRights": "Public access with attribution required", - "accrualMethod": "Single publication", - "accrualPeriodicity": "One-time", - "accrualPolicy": "Editorial review required", - "alternative": "Alternative Item Title", - "audience": "Technical professionals", - "available": "2022-01-01T12:00+00:00", - "bibliographicCitation": "Smith, J. (2022). Example Item. Retrieved from https://example.org/item/1", - "conformsTo": "HTML5 standard", - "contributor": "Assistant Editor Mike Thompson", - "coverage": "United States technology sector", - "creator": "Jack Jackson", - "date": "2022-01-01T12:00+00:00", - "dateAccepted": "2022-01-01T12:00+00:00", - "dateCopyrighted": "2022-01-01T12:00+00:00", - "dateSubmitted": "2022-01-01T12:00+00:00", - "description": "Detailed description of the example item content and its significance", - "educationLevel": "Advanced", - "extent": "Approximately 2500 words", - "format": "text/html", - "hasFormat": "https://example.org/item/1.pdf", - "hasPart": "https://example.org/item/1/section1", - "hasVersion": "1.1", - "identifier": "urn:uuid:item-98765432-9876-9876-9876-987654321def", - "instructionalMethod": "Step-by-step tutorial", - "isFormatOf": "https://example.org/item-source", - "isPartOf": "Example Article Series", - "isReferencedBy": "https://example.org/item/1/citations", - "isReplacedBy": "https://example.org/item/1/updated", - "isRequiredBy": "https://example.org/item/1/prerequisites", - "issued": "2022-01-01T12:00+00:00", - "isVersionOf": "https://example.org/item/1/original", - "language": "en-US", - "mediator": "Editorial Team", - "medium": "Digital", - "modified": "2022-06-01T12:00+00:00", - "provenance": "Originally published by Example News Organization", - "publisher": "Example News Organization", - "references": "https://example.org/item/1/references", - "relation": "https://example.org/related-article", - "replaces": "https://example.org/item/1/previous", - "requires": "Basic understanding of web technologies", - "rights": "Copyright 2025 Example News Organization", - "rightsHolder": "Example News Organization", - "source": "https://example.org/item-source", - "spatial": "United States", - "subject": "Article, Tutorial, Example", - "tableOfContents": "Introduction, Main Content, Conclusion", - "temporal": "2022", - "title": "Dublin Core Terms Enhanced Item Title", - "type": "Article", - "valid": "2025-12-31T23:59:59+00:00" + "valid": ["2025-12-31T23:59:59+00:00"] }, "slash": { "section": "articles", @@ -296,126 +249,6 @@ ] } ], - "group": { - "contents": [ - { - "url": "https://example.com/videos/sample-hd.mp4", - "fileSize": 45678912, - "type": "video/mp4", - "medium": "video", - "expression": "full", - "bitrate": 5000, - "framerate": 60, - "duration": 180, - "height": 1080, - "width": 1920, - "lang": "en", - "title": { - "value": "HD Version (1080p)" - } - }, - { - "url": "https://example.com/videos/sample-sd.mp4", - "fileSize": 23456789, - "type": "video/mp4", - "medium": "video", - "expression": "full", - "bitrate": 2500, - "framerate": 30, - "duration": 180, - "height": 720, - "width": 1280, - "lang": "en", - "title": { - "value": "SD Version (720p)" - } - }, - { - "url": "https://example.com/videos/sample.webm", - "fileSize": 19876543, - "type": "video/webm", - "medium": "video", - "expression": "full", - "bitrate": 2000, - "framerate": 30, - "duration": 180, - "height": 720, - "width": 1280, - "lang": "en", - "title": { - "value": "WebM Version" - } - }, - { - "url": "https://example.com/audio/sample.mp3", - "fileSize": 3456789, - "type": "audio/mpeg", - "medium": "audio", - "expression": "full", - "bitrate": 320, - "samplingrate": 44.1, - "channels": 2, - "duration": 180, - "lang": "en", - "title": { - "value": "Audio Only Version" - } - }, - { - "url": "https://example.com/captions/sample-en.srt", - "type": "text/srt", - "medium": "document", - "expression": "sample", - "lang": "en", - "title": { - "value": "English Subtitles" - } - }, - { - "url": "https://example.com/captions/sample-es.srt", - "type": "text/srt", - "medium": "document", - "expression": "sample", - "lang": "es", - "title": { - "value": "Spanish Subtitles" - } - } - ], - "title": { - "value": "Multi-Format Content Example" - }, - "description": { - "value": "This video is available in multiple formats and resolutions" - }, - "thumbnails": [ - { - "url": "https://example.com/thumbnails/group-main.jpg", - "width": 640, - "height": 360 - }, - { - "url": "https://example.com/thumbnails/group-alt.jpg", - "width": 1280, - "height": 720 - } - ], - "keywords": ["group", "multiple", "formats", "resolutions"], - "categories": [ - { - "name": "Technology" - } - ], - "ratings": [ - { - "value": "PG", - "scheme": "urn:mpaa" - } - ], - "copyright": { - "value": "© 2025 Example Media Inc." - } - }, "groups": [ { "contents": [ @@ -671,6 +504,10 @@ }, "rdf": { "about": "http://example.org/item/1" + }, + "xml": { + "lang": "en-US", + "base": "http://example.org/item/1/" } } ], @@ -688,34 +525,28 @@ "sources": ["https://example.org/original-source"], "languages": ["en-US"], "relations": ["https://example.org/related-content"], - "creator": "John Doe", - "contributor": "Jane Smith", - "date": "2022-01-01T12:00+00:00", - "description": "This is an example of description.", - "title": "Dublin Core Enhanced Feed Title", - "subject": "Technology, Programming, Web Development", - "publisher": "Example Publishing Company", - "type": "Text", - "format": "application/rdf+xml", - "identifier": "urn:uuid:12345678-1234-1234-1234-123456789abc", - "source": "https://example.org/original-source", - "language": "en-US", - "relation": "https://example.org/related-content", - "coverage": "Global", - "rights": "Copyright 2025 Example Publishing Company" + "coverage": ["Global"], + "rights": ["Copyright 2025 Example Publishing Company"] }, "dcterms": { "abstracts": ["This is an abstract of the feed content"], + "accessRights": ["Public access with attribution required"], "accrualMethods": ["Regular updates"], "accrualPeriodicities": ["Daily"], "accrualPolicies": ["Content added based on editorial calendar"], "alternatives": ["Alternative Feed Title"], "audiences": ["General technology audience"], + "available": ["2022-01-01T12:00+00:00"], "bibliographicCitations": ["Example Feed. (2022). Retrieved from https://example.org"], + "conformsTo": ["RSS 2.0 Specification"], "contributors": ["Jane Smith"], "coverages": ["Global technology topics"], + "created": ["2022-01-01T12:00+00:00"], "creators": ["John Doe"], + "dateAccepted": ["2022-01-01T12:00+00:00"], + "dateCopyrighted": ["2022-01-01T12:00+00:00"], "dates": ["2022-01-01T12:00+00:00"], + "dateSubmitted": ["2022-01-01T12:00+00:00"], "descriptions": ["Detailed description of the feed content and purpose"], "educationLevels": ["Intermediate to advanced"], "extents": ["Approximately 1000 words per article"], @@ -725,75 +556,34 @@ "hasVersions": ["2.0"], "identifiers": ["urn:uuid:feed-12345678-1234-1234-1234-123456789abc"], "instructionalMethods": ["Practical examples and tutorials"], + "isFormatOf": ["https://example.org/original-content"], + "isPartOf": ["Example Media Network"], + "isReferencedBy": ["https://example.org/references"], + "isReplacedBy": ["https://example.org/new-feed"], + "isRequiredBy": ["https://example.org/dependent-feeds"], + "issued": ["2022-01-01T12:00+00:00"], + "isVersionOf": ["https://example.org/original-feed"], "languages": ["en-US"], "licenses": ["Creative Commons Attribution 4.0"], "mediators": ["Content Management System"], "mediums": ["Digital"], + "modified": ["2023-01-01T12:00+00:00"], "provenances": ["Originally created by Example Publishing Company"], "publishers": ["Example Publishing Company"], + "references": ["https://example.org/referenced-sources"], "relations": ["https://example.org/related-feeds"], + "replaces": ["https://example.org/old-feed"], + "requires": ["RSS 2.0 compatible reader"], + "rights": ["Copyright 2025 Example Publishing Company"], "rightsHolders": ["Example Publishing Company"], "sources": ["https://example.org/original-source"], "spatials": ["Global"], "subjects": ["Technology, Programming, Web Development"], + "tableOfContents": ["Latest articles, tutorials, and news"], "temporals": ["2022-present"], "titles": ["Dublin Core Terms Enhanced Feed Title"], "types": ["Text"], - "created": "2022-01-01T12:00+00:00", - "license": "Creative Commons Attribution 4.0", - "abstract": "This is an abstract of the feed content", - "accessRights": "Public access with attribution required", - "accrualMethod": "Regular updates", - "accrualPeriodicity": "Daily", - "accrualPolicy": "Content added based on editorial calendar", - "alternative": "Alternative Feed Title", - "audience": "General technology audience", - "available": "2022-01-01T12:00+00:00", - "bibliographicCitation": "Example Feed. (2022). Retrieved from https://example.org", - "conformsTo": "RSS 2.0 Specification", - "contributor": "Jane Smith", - "coverage": "Global technology topics", - "creator": "John Doe", - "date": "2022-01-01T12:00+00:00", - "dateAccepted": "2022-01-01T12:00+00:00", - "dateCopyrighted": "2022-01-01T12:00+00:00", - "dateSubmitted": "2022-01-01T12:00+00:00", - "description": "Detailed description of the feed content and purpose", - "educationLevel": "Intermediate to advanced", - "extent": "Approximately 1000 words per article", - "format": "application/rdf+xml", - "hasFormat": "https://example.org/feed.atom", - "hasPart": "https://example.org/category/tutorials", - "hasVersion": "2.0", - "identifier": "urn:uuid:feed-12345678-1234-1234-1234-123456789abc", - "instructionalMethod": "Practical examples and tutorials", - "isFormatOf": "https://example.org/original-content", - "isPartOf": "Example Media Network", - "isReferencedBy": "https://example.org/references", - "isReplacedBy": "https://example.org/new-feed", - "isRequiredBy": "https://example.org/dependent-feeds", - "issued": "2022-01-01T12:00+00:00", - "isVersionOf": "https://example.org/original-feed", - "language": "en-US", - "mediator": "Content Management System", - "medium": "Digital", - "modified": "2023-01-01T12:00+00:00", - "provenance": "Originally created by Example Publishing Company", - "publisher": "Example Publishing Company", - "references": "https://example.org/referenced-sources", - "relation": "https://example.org/related-feeds", - "replaces": "https://example.org/old-feed", - "requires": "RSS 2.0 compatible reader", - "rights": "Copyright 2025 Example Publishing Company", - "rightsHolder": "Example Publishing Company", - "source": "https://example.org/original-source", - "spatial": "Global", - "subject": "Technology, Programming, Web Development", - "tableOfContents": "Latest articles, tutorials, and news", - "temporal": "2022-present", - "title": "Dublin Core Terms Enhanced Feed Title", - "type": "Text", - "valid": "2025-12-31T23:59:59+00:00" + "valid": ["2025-12-31T23:59:59+00:00"] }, "sy": { "updatePeriod": "hourly", @@ -1015,126 +805,6 @@ ] } ], - "group": { - "contents": [ - { - "url": "https://example.com/videos/sample-hd.mp4", - "fileSize": 45678912, - "type": "video/mp4", - "medium": "video", - "expression": "full", - "bitrate": 5000, - "framerate": 60, - "duration": 180, - "height": 1080, - "width": 1920, - "lang": "en", - "title": { - "value": "HD Version (1080p)" - } - }, - { - "url": "https://example.com/videos/sample-sd.mp4", - "fileSize": 23456789, - "type": "video/mp4", - "medium": "video", - "expression": "full", - "bitrate": 2500, - "framerate": 30, - "duration": 180, - "height": 720, - "width": 1280, - "lang": "en", - "title": { - "value": "SD Version (720p)" - } - }, - { - "url": "https://example.com/videos/sample.webm", - "fileSize": 19876543, - "type": "video/webm", - "medium": "video", - "expression": "full", - "bitrate": 2000, - "framerate": 30, - "duration": 180, - "height": 720, - "width": 1280, - "lang": "en", - "title": { - "value": "WebM Version" - } - }, - { - "url": "https://example.com/audio/sample.mp3", - "fileSize": 3456789, - "type": "audio/mpeg", - "medium": "audio", - "expression": "full", - "bitrate": 320, - "samplingrate": 44.1, - "channels": 2, - "duration": 180, - "lang": "en", - "title": { - "value": "Audio Only Version" - } - }, - { - "url": "https://example.com/captions/sample-en.srt", - "type": "text/srt", - "medium": "document", - "expression": "sample", - "lang": "en", - "title": { - "value": "English Subtitles" - } - }, - { - "url": "https://example.com/captions/sample-es.srt", - "type": "text/srt", - "medium": "document", - "expression": "sample", - "lang": "es", - "title": { - "value": "Spanish Subtitles" - } - } - ], - "title": { - "value": "Multi-Format Content Example" - }, - "description": { - "value": "This video is available in multiple formats and resolutions" - }, - "thumbnails": [ - { - "url": "https://example.com/thumbnails/group-main.jpg", - "width": 640, - "height": 360 - }, - { - "url": "https://example.com/thumbnails/group-alt.jpg", - "width": 1280, - "height": 720 - } - ], - "keywords": ["group", "multiple", "formats", "resolutions"], - "categories": [ - { - "name": "Technology" - } - ], - "ratings": [ - { - "value": "PG", - "scheme": "urn:mpaa" - } - ], - "copyright": { - "value": "© 2025 Example Media Inc." - } - }, "groups": [ { "contents": [ @@ -1390,5 +1060,9 @@ }, "rdf": { "about": "http://example.org/rss" + }, + "xml": { + "lang": "en", + "base": "http://example.org/" } } diff --git a/src/feeds/rdf/references/rdf-ns.xml b/src/feeds/rdf/references/rdf-ns.xml index 4e2107ba..44f561b9 100644 --- a/src/feeds/rdf/references/rdf-ns.xml +++ b/src/feeds/rdf/references/rdf-ns.xml @@ -1,5 +1,7 @@ - + Example Item http://example.org/item/1 diff --git a/src/feeds/rss/common/types.ts b/src/feeds/rss/common/types.ts index 5567974c..26162b2f 100644 --- a/src/feeds/rss/common/types.ts +++ b/src/feeds/rss/common/types.ts @@ -1,4 +1,11 @@ -import type { DateLike } from '../../../common/types.js' +import type { + GenerateUtil as BaseGenerateUtil, + ParseUtilPartial as BaseParseUtilPartial, + DateAny, + ParseMainOptions, + Requirable, + Strict, +} from '../../../common/types.js' import type { AcastNs } from '../../../namespaces/acast/common/types.js' import type { AdminNs } from '../../../namespaces/admin/common/types.js' import type { AtomNs } from '../../../namespaces/atom/common/types.js' @@ -27,144 +34,180 @@ import type { SyNs } from '../../../namespaces/sy/common/types.js' import type { ThrNs } from '../../../namespaces/thr/common/types.js' import type { TrackbackNs } from '../../../namespaces/trackback/common/types.js' import type { WfwNs } from '../../../namespaces/wfw/common/types.js' +import type { XmlNs } from '../../../namespaces/xml/common/types.js' -// #region reference -export namespace Rss { - /** @internal Intermediary type before Person refactoring. Do not use downstream. */ - export type PersonLike = string | { name?: string; email?: string } +export type ParseUtilPartial = BaseParseUtilPartial> - export type Person = string +export type GenerateUtil = BaseGenerateUtil - export type Category = { - name: string - domain?: string +// #region reference +export namespace RssFeed { + export type Person = { + name?: string + email?: string + // Parse-only. Extracted from URLs found in person strings. Not included in generated output, + // as the RSS spec has no standard way to encode links in person fields. + link?: string } - export type Cloud = { - domain: string - port: number - path: string - registerProcedure: string - protocol: string - } + export type Category = Strict< + { + name: Requirable // Required in spec. + domain?: string + }, + TStrict + > - export type Image = { - url: string - title: string - link: string - description?: string - height?: number - width?: number - } + export type Cloud = Strict< + { + domain: Requirable // Required in spec. + port: Requirable // Required in spec. + path: Requirable // Required in spec. + registerProcedure: Requirable // Required in spec. + protocol: Requirable // Required in spec. + }, + TStrict + > - export type TextInput = { - title: string - description: string - name: string - link: string - } + export type Image = Strict< + { + url: Requirable // Required in spec. + title: Requirable // Required in spec. + link: Requirable // Required in spec. + description?: string + height?: number + width?: number + }, + TStrict + > - export type Enclosure = { - url: string - length: number - type: string - } + export type TextInput = Strict< + { + title: Requirable // Required in spec. + description: Requirable // Required in spec. + name: Requirable // Required in spec. + link: Requirable // Required in spec. + }, + TStrict + > + + export type Enclosure = Strict< + { + url: Requirable // Required in spec. + length: Requirable // Required in spec. + type: Requirable // Required in spec. + }, + TStrict + > export type SkipHours = Array export type SkipDays = Array - export type Guid = { - value: string - isPermaLink?: boolean - } + export type Guid = Strict< + { + value: Requirable // Required in spec. + isPermaLink?: boolean + }, + TStrict + > - export type Source = { - title: string - url?: string - } + export type Source = Strict< + { + title: Requirable // Required in spec. + url: Requirable // Required in spec. + }, + TStrict + > - export type Item = { - title?: string - link?: string - description?: string - authors?: Array - categories?: Array - comments?: string - enclosures?: Array - guid?: Guid - pubDate?: TDate - source?: Source - atom?: AtomNs.Entry - cc?: CcNs.ItemOrFeed - dc?: DcNs.ItemOrFeed - content?: ContentNs.Item - creativeCommons?: CreativeCommonsNs.ItemOrFeed - slash?: SlashNs.Item - itunes?: ItunesNs.Item - podcast?: PodcastNs.Item - psc?: PscNs.Item - googleplay?: GooglePlayNs.Item - media?: MediaNs.ItemOrFeed - georss?: GeoRssNs.ItemOrFeed - geo?: GeoNs.ItemOrFeed - thr?: ThrNs.Item - dcterms?: DcTermsNs.ItemOrFeed - prism?: PrismNs.Item - wfw?: WfwNs.Item - sourceNs?: SourceNs.Item - rawvoice?: RawVoiceNs.Item - spotify?: SpotifyNs.Item - pingback?: PingbackNs.Item - trackback?: TrackbackNs.Item - acast?: AcastNs.Item - } & ({ title: string } | { description: string }) + export type Item = Strict< + { + title?: string // At least one of title or description is required in spec. + link?: string + description?: string // At least one of title or description is required in spec. + authors?: Array + categories?: Array> + comments?: string + enclosures?: Array> + guid?: Guid + pubDate?: TDate + source?: Source + atom?: AtomNs.Entry + cc?: CcNs.ItemOrFeed + dc?: DcNs.ItemOrFeed + content?: ContentNs.Item + creativeCommons?: CreativeCommonsNs.ItemOrFeed + slash?: SlashNs.Item + itunes?: ItunesNs.Item + podcast?: PodcastNs.Item + psc?: PscNs.Item + googleplay?: GooglePlayNs.Item + media?: MediaNs.ItemOrFeed + georss?: GeoRssNs.ItemOrFeed + geo?: GeoNs.ItemOrFeed + thr?: ThrNs.Item + dcterms?: DcTermsNs.ItemOrFeed + prism?: PrismNs.Item + wfw?: WfwNs.Item + sourceNs?: SourceNs.Item + rawvoice?: RawVoiceNs.Item + spotify?: SpotifyNs.Item + pingback?: PingbackNs.Item + trackback?: TrackbackNs.Item + acast?: AcastNs.Item + xml?: XmlNs.ItemOrFeed + }, + TStrict + > & + (TStrict extends true ? { title: string } | { description: string } : unknown) - export type Feed = { - title: string - // INFO: Spec mentions required "link", but the "link" might be missing as well when the - // atom:link rel="self" is present so that's why the "link" is not required in this type. - link?: string - description: string - language?: string - copyright?: string - managingEditor?: TPerson - webMaster?: TPerson - pubDate?: TDate - lastBuildDate?: TDate - categories?: Array - generator?: string - docs?: string - cloud?: Cloud - ttl?: number - image?: Image - rating?: string - textInput?: TextInput - skipHours?: Array - skipDays?: Array - items?: Array> - atom?: AtomNs.Feed - cc?: CcNs.ItemOrFeed - dc?: DcNs.ItemOrFeed - sy?: SyNs.Feed - itunes?: ItunesNs.Feed - podcast?: PodcastNs.Feed - googleplay?: GooglePlayNs.Feed - media?: MediaNs.ItemOrFeed - georss?: GeoRssNs.ItemOrFeed - geo?: GeoNs.ItemOrFeed - dcterms?: DcTermsNs.ItemOrFeed - prism?: PrismNs.Feed - creativeCommons?: CreativeCommonsNs.ItemOrFeed - feedpress?: FeedPressNs.Feed - opensearch?: OpenSearchNs.Feed - admin?: AdminNs.Feed - sourceNs?: SourceNs.Feed - blogChannel?: BlogChannelNs.Feed - rawvoice?: RawVoiceNs.Feed - spotify?: SpotifyNs.Feed - pingback?: PingbackNs.Feed - acast?: AcastNs.Feed - } + export type Feed = Strict< + { + title: Requirable // Required in spec. + link: Requirable // Required in spec (but may be missing when atom:link rel="self" is present). + description: Requirable // Required in spec. + language?: string + copyright?: string + managingEditor?: Person + webMaster?: Person + pubDate?: TDate + lastBuildDate?: TDate + categories?: Array> + generator?: string + docs?: string + cloud?: Cloud + ttl?: number + image?: Image + rating?: string + textInput?: TextInput + skipHours?: Array + skipDays?: Array + items?: Array> + atom?: AtomNs.Feed + cc?: CcNs.ItemOrFeed + dc?: DcNs.ItemOrFeed + sy?: SyNs.Feed + itunes?: ItunesNs.Feed + podcast?: PodcastNs.Feed + googleplay?: GooglePlayNs.Feed + media?: MediaNs.ItemOrFeed + georss?: GeoRssNs.ItemOrFeed + geo?: GeoNs.ItemOrFeed + dcterms?: DcTermsNs.ItemOrFeed + prism?: PrismNs.Feed + creativeCommons?: CreativeCommonsNs.ItemOrFeed + feedpress?: FeedPressNs.Feed + opensearch?: OpenSearchNs.Feed + admin?: AdminNs.Feed + sourceNs?: SourceNs.Feed + blogChannel?: BlogChannelNs.Feed + rawvoice?: RawVoiceNs.Feed + spotify?: SpotifyNs.Feed + pingback?: PingbackNs.Feed + acast?: AcastNs.Feed + xml?: XmlNs.ItemOrFeed + }, + TStrict + > } // #endregion reference diff --git a/src/feeds/rss/generate/index.test.ts b/src/feeds/rss/generate/index.test.ts index f94c7592..82985a92 100644 --- a/src/feeds/rss/generate/index.test.ts +++ b/src/feeds/rss/generate/index.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'bun:test' -import type { DateLike, DeepPartial } from '../../../common/types.js' -import type { Rss } from '../common/types.js' +import { locales } from '../../../common/config.js' +import { GenerateError } from '../../../common/errors.js' +import type { DateLike } from '../../../common/types.js' +import type { RssFeed } from '../common/types.js' import { generate } from './index.js' describe('generate', () => { @@ -64,13 +66,13 @@ describe('generate', () => { title: 'Feed with dc namespace', description: 'Test feed with Dublin Core namespace', dc: { - creator: 'John Doe', + creators: ['John Doe'], }, items: [ { title: 'First item', dc: { - creator: 'Jane Smith', + creators: ['Jane Smith'], }, }, ], @@ -92,10 +94,10 @@ describe('generate', () => { expect(generate(value)).toEqual(expected) }) - it('should generate RSS with object author format', () => { + it('should generate RSS with structured person fields', () => { const value = { - title: 'Feed with object author', - description: 'Test feed with object author format', + title: 'Feed with structured persons', + description: 'Test feed with structured person fields', managingEditor: { name: 'Editor Name', email: 'editor@example.com', @@ -125,8 +127,8 @@ describe('generate', () => { const expected = ` - Feed with object author - Test feed with object author format + Feed with structured persons + Test feed with structured person fields editor@example.com (Editor Name) webmaster@example.com (Webmaster) @@ -142,20 +144,56 @@ describe('generate', () => { expect(generate(value)).toEqual(expected) }) + it('should generate RSS with CDATA-wrapped person name containing special characters', () => { + const value = { + title: 'Test', + description: 'Test', + managingEditor: { + name: 'Tom & Jerry', + email: 'tom@example.com', + }, + items: [ + { + title: 'Post', + authors: [{ name: "O'Brien & Associates", email: 'info@example.com' }], + }, + ], + } + const expected = ` + + + Test + Test + + + + + Post + + + + + + +` + + expect(generate(value)).toEqual(expected) + }) + it('should generate RSS with dcterms namespace', () => { const value = { title: 'Feed with dcterms namespace', description: 'Test feed with Dublin Core Terms namespace', dcterms: { - created: new Date('2023-01-01T00:00:00Z'), - license: 'Creative Commons Attribution 4.0', + created: [new Date('2023-01-01T00:00:00Z')], + licenses: ['Creative Commons Attribution 4.0'], }, items: [ { title: 'First item', dcterms: { - created: new Date('2023-02-01T00:00:00Z'), - license: 'MIT License', + created: [new Date('2023-02-01T00:00:00Z')], + licenses: ['MIT License'], }, }, ], @@ -1105,11 +1143,97 @@ describe('generate', () => { }) }) -describe('generate with lenient mode', () => { - it('should accept partial feeds with lenient: true', () => { - const value: DeepPartial> = { +describe('strict mode', () => { + it('should require title, link, description in strict mode', () => { + // @ts-expect-error: This is for testing purposes. + generate({ title: 'Test' }, { strict: true }) + }) + + it('should accept feed with all required fields in strict mode', () => { + generate({ title: 'Test', link: 'https://example.com', description: 'Desc' }, { strict: true }) + }) + + it('should require nested type fields in strict mode', () => { + generate( + { + title: 'Test', + link: 'https://example.com', + description: 'Desc', + items: [ + { + title: 'Item', + description: 'Desc', + // @ts-expect-error: This is for testing purposes. + enclosures: [{ url: 'https://example.com/file.mp3' }], + }, + ], + }, + { strict: true }, + ) + }) + + it('should accept nested types with all required fields in strict mode', () => { + generate( + { + title: 'Test', + link: 'https://example.com', + description: 'Desc', + items: [ + { + title: 'Item', + enclosures: [{ url: 'https://example.com/file.mp3', length: 1000, type: 'audio/mpeg' }], + }, + ], + }, + { strict: true }, + ) + }) + + it('should accept item with only title in strict mode', () => { + generate( + { + title: 'Test', + link: 'https://example.com', + description: 'Desc', + items: [{ title: 'Item Title' }], + }, + { strict: true }, + ) + }) + + it('should accept item with only description in strict mode', () => { + generate( + { + title: 'Test', + link: 'https://example.com', + description: 'Desc', + items: [{ description: 'Item Description' }], + }, + { strict: true }, + ) + }) + + it('should accept item with both title and description in strict mode', () => { + generate( + { + title: 'Test', + link: 'https://example.com', + description: 'Desc', + items: [{ title: 'Item Title', description: 'Item Description' }], + }, + { strict: true }, + ) + }) + + it('should accept partial feed in lenient mode', () => { + generate({ title: 'Test' }) + }) +}) + +describe('generate edge cases', () => { + it('should accept partial feeds', () => { + const value: RssFeed.Feed = { title: 'Test Feed', - // Missing required 'description' field. } const expected = ` @@ -1119,11 +1243,11 @@ describe('generate with lenient mode', () => { ` - expect(generate(value, { lenient: true })).toEqual(expected) + expect(generate(value)).toEqual(expected) }) - it('should accept feeds with string dates in lenient mode', () => { - const value: DeepPartial> = { + it('should accept feeds with string dates', () => { + const value: RssFeed.Feed = { title: 'Test Feed', description: 'Test Description', pubDate: '2023-01-01T00:00:00.000Z', @@ -1148,11 +1272,11 @@ describe('generate with lenient mode', () => { ` - expect(generate(value, { lenient: true })).toEqual(expected) + expect(generate(value)).toEqual(expected) }) - it('should preserve invalid date strings in lenient mode', () => { - const value: DeepPartial> = { + it('should preserve invalid date strings', () => { + const value: RssFeed.Feed = { title: 'Test Feed', description: 'Test Description', pubDate: 'not-a-valid-date', @@ -1177,11 +1301,11 @@ describe('generate with lenient mode', () => {
` - expect(generate(value, { lenient: true })).toEqual(expected) + expect(generate(value)).toEqual(expected) }) it('should handle deeply nested partial objects', () => { - const value: DeepPartial> = { + const value: RssFeed.Feed = { title: 'Test Feed', items: [ { @@ -1214,11 +1338,11 @@ describe('generate with lenient mode', () => {
` - expect(generate(value, { lenient: true })).toEqual(expected) + expect(generate(value)).toEqual(expected) }) it('should handle mixed Date objects and string dates', () => { - const value: DeepPartial> = { + const value: RssFeed.Feed = { title: 'Mixed Dates Feed', description: 'Feed with both Date objects and strings', pubDate: new Date('2023-01-01T00:00:00.000Z'), @@ -1253,7 +1377,7 @@ describe('generate with lenient mode', () => {
` - expect(generate(value, { lenient: true })).toEqual(expected) + expect(generate(value)).toEqual(expected) }) it('should generate RSS with acast namespace', () => { @@ -1312,4 +1436,47 @@ describe('generate with lenient mode', () => { expect(generate(value)).toEqual(expected) }) + + it('should generate RSS with xml namespace', () => { + const value = { + title: 'Feed with xml namespace', + description: 'Test feed with XML namespace attributes', + xml: { + lang: 'en', + base: 'http://example.org/', + }, + items: [ + { + title: 'Item with XML namespace', + xml: { + lang: 'en-US', + base: 'http://example.org/item/1/', + }, + }, + ], + } + const expected = ` + + + Feed with xml namespace + Test feed with XML namespace attributes + + Item with XML namespace + + + +` + + expect(generate(value)).toEqual(expected) + }) +}) + +describe('error types', () => { + it('should throw GenerateError for empty input', () => { + const value = {} + const throwing = () => generate(value) + + expect(throwing).toThrow(GenerateError) + expect(throwing).toThrow(locales.invalidInputRss) + }) }) diff --git a/src/feeds/rss/generate/index.ts b/src/feeds/rss/generate/index.ts index c1c1b4ee..bc70010f 100644 --- a/src/feeds/rss/generate/index.ts +++ b/src/feeds/rss/generate/index.ts @@ -1,18 +1,19 @@ import { locales } from '../../../common/config.js' -import type { DateLike, DeepPartial, XmlGenerateMain } from '../../../common/types.js' +import { GenerateError } from '../../../common/errors.js' +import type { DateLike, GenerateMainXml } from '../../../common/types.js' import { generateXml } from '../../../common/utils.js' -import type { Rss } from '../common/types.js' +import type { RssFeed } from '../common/types.js' import { builder } from './config.js' import { generateFeed } from './utils.js' -export const generate: XmlGenerateMain< - Rss.Feed, - DeepPartial> -> = (value, options) => { - const generated = generateFeed(value as Rss.Feed) +export const generate: GenerateMainXml, RssFeed.Feed> = ( + value, + options, +) => { + const generated = generateFeed(value) if (!generated) { - throw new Error(locales.invalidInputRss) + throw new GenerateError(locales.invalidInputRss) } return generateXml(builder, generated, options) diff --git a/src/feeds/rss/generate/utils.test.ts b/src/feeds/rss/generate/utils.test.ts index a70e856d..65e54a9c 100644 --- a/src/feeds/rss/generate/utils.test.ts +++ b/src/feeds/rss/generate/utils.test.ts @@ -15,13 +15,6 @@ import { } from './utils.js' describe('generatePerson', () => { - it('should pass through string person', () => { - const value = 'john.doe@example.com (John Doe)' - const expected = 'john.doe@example.com (John Doe)' - - expect(generatePerson(value)).toEqual(expected) - }) - it('should generate person with both name and email', () => { const value = { name: 'John Doe', @@ -50,22 +43,58 @@ describe('generatePerson', () => { expect(generatePerson(value)).toEqual(expected) }) - it('should handle empty object', () => { - const value = {} + it('should silently drop link (no RSS spec support)', () => { + const value = { + name: 'John Doe', + email: 'john@example.com', + link: 'https://example.com', + } + const expected = 'john@example.com (John Doe)' + + expect(generatePerson(value)).toEqual(expected) + }) + + it('should return undefined for link-only person', () => { + const value = { link: 'https://example.com' } expect(generatePerson(value)).toBeUndefined() }) - it('should handle object with empty strings', () => { + it('should wrap in CDATA when name contains special characters', () => { + const value = { + name: 'Tom & Jerry', + email: 'tom@example.com', + } + const expected = { '#cdata': 'tom@example.com (Tom & Jerry)' } + + expect(generatePerson(value)).toEqual(expected) + }) + + it('should generate person with parentheses in name', () => { const value = { - name: '', - email: '', + name: 'John (Editor)', + email: 'john@example.com', } - const expected = undefined + const expected = 'john@example.com (John (Editor))' expect(generatePerson(value)).toEqual(expected) }) + it('should wrap in CDATA when name contains angle brackets', () => { + const value = { name: 'John ' } + const expected = { '#cdata': 'John ' } + + expect(generatePerson(value)).toEqual(expected) + }) + + it('should handle empty object', () => { + expect(generatePerson({})).toBeUndefined() + }) + + it('should handle object with empty strings', () => { + expect(generatePerson({ name: '', email: '' })).toBeUndefined() + }) + it('should trim whitespace around name and email', () => { const value = { name: ' John Doe ', @@ -97,16 +126,17 @@ describe('generatePerson', () => { }) it('should handle whitespace-only for both fields', () => { - const value = { - name: ' ', - email: ' ', - } - - expect(generatePerson(value)).toBeUndefined() + expect(generatePerson({ name: ' ', email: ' ' })).toBeUndefined() }) it('should handle non-object inputs gracefully', () => { expect(generatePerson(undefined)).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generatePerson('string')).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generatePerson(null)).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generatePerson(123)).toBeUndefined() }) }) @@ -141,14 +171,12 @@ describe('generateCategory', () => { domain: undefined, } - // @ts-expect-error: This is for testing purposes. expect(generateCategory(value)).toBeUndefined() }) it('should handle empty object', () => { const value = {} - // @ts-expect-error: This is for testing purposes. expect(generateCategory(value)).toBeUndefined() }) @@ -186,14 +214,12 @@ describe('generateCloud', () => { protocol: undefined, } - // @ts-expect-error: This is for testing purposes. expect(generateCloud(value)).toBeUndefined() }) it('should handle empty object', () => { const value = {} - // @ts-expect-error: This is for testing purposes. expect(generateCloud(value)).toBeUndefined() }) @@ -231,7 +257,6 @@ describe('generateImage', () => { link: undefined, } - // @ts-expect-error: This is for testing purposes. expect(generateImage(value)).toBeUndefined() }) @@ -266,7 +291,6 @@ describe('generateTextInput', () => { link: undefined, } - // @ts-expect-error: This is for testing purposes. expect(generateTextInput(value)).toBeUndefined() }) @@ -298,14 +322,12 @@ describe('generateEnclosure', () => { type: undefined, } - // @ts-expect-error: This is for testing purposes. expect(generateEnclosure(value)).toBeUndefined() }) it('should handle empty object', () => { const value = {} - // @ts-expect-error: This is for testing purposes. expect(generateEnclosure(value)).toBeUndefined() }) @@ -423,14 +445,12 @@ describe('generateGuid', () => { isPermaLink: undefined, } - // @ts-expect-error: This is for testing purposes. expect(generateGuid(value)).toBeUndefined() }) it('should handle empty object', () => { const value = {} - // @ts-expect-error: This is for testing purposes. expect(generateGuid(value)).toBeUndefined() }) @@ -453,12 +473,14 @@ describe('generateSource', () => { expect(generateSource(value)).toEqual(expected) }) - it('should generate source with minimal properties', () => { + it('should generate source with all required properties', () => { const value = { title: 'Example Source', + url: 'https://example.com/feed.xml', } const expected = { '#text': 'Example Source', + '@url': 'https://example.com/feed.xml', } expect(generateSource(value)).toEqual(expected) @@ -470,14 +492,12 @@ describe('generateSource', () => { url: undefined, } - // @ts-expect-error: This is for testing purposes. expect(generateSource(value)).toBeUndefined() }) it('should handle empty object', () => { const value = {} - // @ts-expect-error: This is for testing purposes. expect(generateSource(value)).toBeUndefined() }) @@ -492,7 +512,7 @@ describe('generateItem', () => { title: 'Example Item', link: 'https://example.com/item/123', description: 'Item description', - authors: ['john.doe@example.com (John Doe)'], + authors: [{ name: 'John Doe', email: 'john.doe@example.com' }], categories: [{ name: 'Technology', domain: 'https://example.com/categories' }], comments: 'https://example.com/item/123/comments', enclosures: [ @@ -571,12 +591,12 @@ describe('generateItem', () => { const value = { title: 'Item with dc namespace', dc: { - creator: 'Jane Smith', + creators: ['Jane Smith'], }, } const expected = { title: 'Item with dc namespace', - 'dc:creator': 'Jane Smith', + 'dc:creator': ['Jane Smith'], } expect(generateItem(value)).toEqual(expected) @@ -631,14 +651,14 @@ describe('generateItem', () => { const value = { title: 'Item with DCTerms namespace', dcterms: { - created: new Date('2023-02-01T00:00:00Z'), - license: 'MIT License', + created: [new Date('2023-02-01T00:00:00Z')], + licenses: ['MIT License'], }, } const expected = { title: 'Item with DCTerms namespace', - 'dcterms:created': '2023-02-01T00:00:00.000Z', - 'dcterms:license': 'MIT License', + 'dcterms:created': ['2023-02-01T00:00:00.000Z'], + 'dcterms:license': ['MIT License'], } expect(generateItem(value)).toEqual(expected) @@ -685,14 +705,12 @@ describe('generateItem', () => { link: undefined, } - // @ts-expect-error: This is for testing purposes. expect(generateItem(value)).toBeUndefined() }) it('should handle empty object', () => { const value = {} - // @ts-expect-error: This is for testing purposes. expect(generateItem(value)).toBeUndefined() }) @@ -1047,8 +1065,8 @@ describe('generateFeed', () => { description: 'Example feed description', language: 'en-US', copyright: '© 2023 Example Corp', - managingEditor: 'editor@example.com (Editor Name)', - webMaster: 'webmaster@example.com (Webmaster Name)', + managingEditor: { name: 'Editor Name', email: 'editor@example.com' }, + webMaster: { name: 'Webmaster Name', email: 'webmaster@example.com' }, pubDate: new Date('2023-03-15T12:00:00Z'), lastBuildDate: new Date('2023-03-15T12:00:00Z'), categories: [{ name: 'Technology' }], @@ -1181,14 +1199,12 @@ describe('generateFeed', () => { link: undefined, } - // @ts-expect-error: This is for testing purposes. expect(generateFeed(value)).toBeUndefined() }) it('should handle empty object', () => { const value = {} - // @ts-expect-error: This is for testing purposes. expect(generateFeed(value)).toBeUndefined() }) @@ -1274,7 +1290,7 @@ describe('generateFeed', () => { title: 'Feed with dc namespace', description: 'Description', dc: { - creator: 'John Doe', + creators: ['John Doe'], }, } const expected = { @@ -1284,7 +1300,7 @@ describe('generateFeed', () => { channel: { title: 'Feed with dc namespace', description: 'Description', - 'dc:creator': 'John Doe', + 'dc:creator': ['John Doe'], }, }, } @@ -1297,8 +1313,8 @@ describe('generateFeed', () => { title: 'Feed with DCTerms namespace', description: 'Description', dcterms: { - created: new Date('2023-01-01T00:00:00Z'), - license: 'Creative Commons Attribution 4.0', + created: [new Date('2023-01-01T00:00:00Z')], + licenses: ['Creative Commons Attribution 4.0'], }, } const expected = { @@ -1308,8 +1324,8 @@ describe('generateFeed', () => { channel: { title: 'Feed with DCTerms namespace', description: 'Description', - 'dcterms:created': '2023-01-01T00:00:00.000Z', - 'dcterms:license': 'Creative Commons Attribution 4.0', + 'dcterms:created': ['2023-01-01T00:00:00.000Z'], + 'dcterms:license': ['Creative Commons Attribution 4.0'], }, }, } @@ -2102,4 +2118,44 @@ describe('generateFeed', () => { expect(generateFeed(value)).toEqual(expected) }) + + it('should generate RSS feed with xml namespace properties', () => { + const value = { + title: 'Feed with XML namespace', + description: 'A feed with XML namespace attributes', + xml: { + lang: 'en', + base: 'http://example.org/', + }, + items: [ + { + title: 'Item with XML namespace', + xml: { + lang: 'en-US', + base: 'http://example.org/item/1/', + }, + }, + ], + } + const expected = { + rss: { + '@version': '2.0', + '@xml:lang': 'en', + '@xml:base': 'http://example.org/', + channel: { + title: 'Feed with XML namespace', + description: 'A feed with XML namespace attributes', + item: [ + { + title: 'Item with XML namespace', + '@xml:lang': 'en-US', + '@xml:base': 'http://example.org/item/1/', + }, + ], + }, + }, + } + + expect(generateFeed(value)).toEqual(expected) + }) }) diff --git a/src/feeds/rss/generate/utils.ts b/src/feeds/rss/generate/utils.ts index 77332189..0c5b3ace 100644 --- a/src/feeds/rss/generate/utils.ts +++ b/src/feeds/rss/generate/utils.ts @@ -1,5 +1,5 @@ import { namespaceUris } from '../../../common/config.js' -import type { DateLike, GenerateUtil } from '../../../common/types.js' +import type { DateLike } from '../../../common/types.js' import { generateBoolean, generateCdataString, @@ -26,7 +26,7 @@ import { generateItemOrFeed as generateCc } from '../../../namespaces/cc/generat import { generateItem as generateContentItem } from '../../../namespaces/content/generate/utils.js' import { generateItemOrFeed as generateCreativeCommonsItemOrFeed } from '../../../namespaces/creativecommons/generate/utils.js' import { generateItemOrFeed as generateDcItemOrFeed } from '../../../namespaces/dc/generate/utils.js' -import { generateItemOrFeed as generateDctermsItemOrFeed } from '../../../namespaces/dcterms/generate/utils.js' +import { generateItemOrFeed as generateDcTermsItemOrFeed } from '../../../namespaces/dcterms/generate/utils.js' import { generateFeed as generateFeedPressFeed } from '../../../namespaces/feedpress/generate/utils.js' import { generateItemOrFeed as generateGeoItemOrFeed } from '../../../namespaces/geo/generate/utils.js' import { generateItemOrFeed as generateGeoRssItemOrFeed } from '../../../namespaces/georss/generate/utils.js' @@ -70,32 +70,32 @@ import { generateFeed as generateSyFeed } from '../../../namespaces/sy/generate/ import { generateItem as generateThrItem } from '../../../namespaces/thr/generate/utils.js' import { generateItem as generateTrackbackItem } from '../../../namespaces/trackback/generate/utils.js' import { generateItem as generateWfwItem } from '../../../namespaces/wfw/generate/utils.js' -import type { Rss } from '../common/types.js' +import { generateItemOrFeed as generateXmlItemOrFeed } from '../../../namespaces/xml/generate/utils.js' +import type { GenerateUtil, RssFeed } from '../common/types.js' -export const generatePerson: GenerateUtil = (person) => { - if (isObject(person)) { - const name = generatePlainString(person.name) - const email = generatePlainString(person.email) - - if (email && name) { - return generateCdataString(`${email} (${name})`) - } +export const generatePerson: GenerateUtil = (person) => { + if (!isObject(person)) { + return + } - if (name) { - return generateCdataString(name) - } + const name = generatePlainString(person.name) + const email = generatePlainString(person.email) + // person.link is intentionally not generated (no RSS spec support). - if (email) { - return generateCdataString(email) - } + if (email && name) { + return generateCdataString(`${email} (${name})`) + } - return + if (name) { + return generateCdataString(name) } - return generateCdataString(person) + if (email) { + return generateCdataString(email) + } } -export const generateCategory: GenerateUtil = (category) => { +export const generateCategory: GenerateUtil = (category) => { if (!isObject(category)) { return } @@ -108,7 +108,7 @@ export const generateCategory: GenerateUtil = (category) => { return trimObject(value) } -export const generateCloud: GenerateUtil = (cloud) => { +export const generateCloud: GenerateUtil = (cloud) => { if (!isObject(cloud)) { return } @@ -124,7 +124,7 @@ export const generateCloud: GenerateUtil = (cloud) => { return trimObject(value) } -export const generateImage: GenerateUtil = (image) => { +export const generateImage: GenerateUtil = (image) => { if (!isObject(image)) { return } @@ -141,7 +141,7 @@ export const generateImage: GenerateUtil = (image) => { return trimObject(value) } -export const generateTextInput: GenerateUtil = (textInput) => { +export const generateTextInput: GenerateUtil = (textInput) => { if (!isObject(textInput)) { return } @@ -156,7 +156,7 @@ export const generateTextInput: GenerateUtil = (textInput) => { return trimObject(value) } -export const generateEnclosure: GenerateUtil = (enclosure) => { +export const generateEnclosure: GenerateUtil = (enclosure) => { if (!isObject(enclosure)) { return } @@ -170,7 +170,7 @@ export const generateEnclosure: GenerateUtil = (enclosure) => { return trimObject(value) } -export const generateSkipHours: GenerateUtil = (skipHours) => { +export const generateSkipHours: GenerateUtil = (skipHours) => { const value = { hour: trimArray(skipHours, generateNumber), } @@ -178,7 +178,7 @@ export const generateSkipHours: GenerateUtil = (skipHours) => { return trimObject(value) } -export const generateSkipDays: GenerateUtil = (skipDays) => { +export const generateSkipDays: GenerateUtil = (skipDays) => { const value = { day: trimArray(skipDays, generateCdataString), } @@ -186,7 +186,7 @@ export const generateSkipDays: GenerateUtil = (skipDays) => { return trimObject(value) } -export const generateGuid: GenerateUtil = (guid) => { +export const generateGuid: GenerateUtil = (guid) => { const value = { ...generateTextOrCdataString(guid?.value), '@isPermaLink': generateBoolean(guid?.isPermaLink), @@ -195,7 +195,7 @@ export const generateGuid: GenerateUtil = (guid) => { return trimObject(value) } -export const generateSource: GenerateUtil = (source) => { +export const generateSource: GenerateUtil = (source) => { if (!isObject(source)) { return } @@ -208,7 +208,7 @@ export const generateSource: GenerateUtil = (source) => { return trimObject(value) } -export const generateItem: GenerateUtil> = (item) => { +export const generateItem: GenerateUtil> = (item) => { if (!isObject(item)) { return } @@ -238,7 +238,7 @@ export const generateItem: GenerateUtil> = (i ...generateGeoRssItemOrFeed(item.georss), ...generateGeoItemOrFeed(item.geo), ...generateThrItem(item.thr), - ...generateDctermsItemOrFeed(item.dcterms), + ...generateDcTermsItemOrFeed(item.dcterms), ...generatePrismItem(item.prism), ...generateWfwItem(item.wfw), ...generateSourceItem(item.sourceNs), @@ -247,12 +247,13 @@ export const generateItem: GenerateUtil> = (i ...generatePingbackItem(item.pingback), ...generateTrackbackItem(item.trackback), ...generateAcastItem(item.acast), + ...generateXmlItemOrFeed(item.xml), } return trimObject(value) } -export const generateFeed: GenerateUtil> = (feed) => { +export const generateFeed: GenerateUtil> = (feed) => { if (!isObject(feed)) { return } @@ -287,7 +288,7 @@ export const generateFeed: GenerateUtil> = (f ...generateMediaItemOrFeed(feed.media), ...generateGeoRssItemOrFeed(feed.georss), ...generateGeoItemOrFeed(feed.geo), - ...generateDctermsItemOrFeed(feed.dcterms), + ...generateDcTermsItemOrFeed(feed.dcterms), ...generatePrismFeed(feed.prism), ...generateCreativeCommonsItemOrFeed(feed.creativeCommons), ...generateFeedPressFeed(feed.feedpress), @@ -312,6 +313,7 @@ export const generateFeed: GenerateUtil> = (f rss: { '@version': '2.0', ...generateNamespaceAttrs(trimmedValue, namespaceUris), + ...generateXmlItemOrFeed(feed.xml), channel: trimmedValue, }, } diff --git a/src/feeds/rss/parse/index.test.ts b/src/feeds/rss/parse/index.test.ts index 55a8eb6c..92ba06fa 100644 --- a/src/feeds/rss/parse/index.test.ts +++ b/src/feeds/rss/parse/index.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'bun:test' import { locales } from '../../../common/config.js' +import { DetectError, MalformedError, ParseError } from '../../../common/errors.js' import { parse } from './index.js' describe('parse', () => { @@ -214,6 +215,38 @@ describe('parse', () => { expect(() => parse(123)).toThrowError(locales.invalidFeedFormat) }) + describe('error types', () => { + it('should throw DetectError for non-feed input', () => { + const throwing = () => parse('not a feed') + + expect(throwing).toThrow(DetectError) + expect(throwing).toThrow(locales.invalidFeedFormat) + }) + + it('should throw MalformedError for malformed XML', () => { + const value = ` + + + + Test + + ` + const throwing = () => parse(value) + + expect(throwing).toThrow(MalformedError) + expect(throwing).toThrow(locales.invalidFeedFormat) + }) + + it('should throw ParseError for valid XML with invalid structure', () => { + const value = '' + const throwing = () => parse(value) + + expect(throwing).toThrow(ParseError) + expect(throwing).toThrow(locales.invalidFeedFormat) + }) + }) + it('should handle non-standard atom namespace prefix', () => { const value = ` @@ -318,7 +351,6 @@ describe('parse', () => { description: 'Item Description', dc: { creators: ['John Doe'], - creator: 'John Doe', }, }, ], @@ -362,8 +394,6 @@ describe('parse', () => { dc: { creators: ['John Doe'], dates: ['2023-01-01'], - creator: 'John Doe', - date: '2023-01-01', }, }, { @@ -405,7 +435,6 @@ describe('parse', () => { description: 'Item Description', dc: { creators: ['John Doe'], - creator: 'John Doe', }, }, ], @@ -501,8 +530,6 @@ describe('parse', () => { dc: { creators: ['John Doe'], dates: ['2023-01-01'], - creator: 'John Doe', - date: '2023-01-01', }, media: { title: { @@ -549,7 +576,6 @@ describe('parse', () => { title: 'Item Title', dc: { creators: ['Should not be normalized (empty URI)'], - creator: 'Should not be normalized (empty URI)', }, }, ], @@ -623,8 +649,6 @@ describe('parse', () => { dc: { creators: ['John Doe'], dates: ['2023-01-01'], - creator: 'John Doe', - date: '2023-01-01', }, }, ], @@ -658,7 +682,6 @@ describe('parse', () => { title: 'Item', dc: { creators: ['John'], - creator: 'John', }, }, ], @@ -691,7 +714,6 @@ describe('parse', () => { title: 'Item', dc: { creators: ['John'], - creator: 'John', }, }, ], @@ -724,7 +746,6 @@ describe('parse', () => { title: 'Item', dc: { creators: ['John'], - creator: 'John', }, }, ], @@ -757,7 +778,6 @@ describe('parse', () => { title: 'Item', dc: { creators: ['John'], - creator: 'John', }, }, ], @@ -790,7 +810,6 @@ describe('parse', () => { title: 'Item', dc: { creators: ['John'], - creator: 'John', }, }, ], @@ -823,7 +842,6 @@ describe('parse', () => { title: 'Item', dc: { creators: ['John'], - creator: 'John', }, }, ], @@ -858,8 +876,6 @@ describe('parse', () => { dcterms: { creators: ['Jane Doe'], titles: ['DC Terms Title'], - creator: 'Jane Doe', - title: 'DC Terms Title', }, }, ], @@ -907,7 +923,7 @@ describe('parse', () => { }) describe('author', () => { - it('RW-M03: should parse item author as plain text', () => { + it('RW-M03: should parse item author in RFC 2822 format', () => { const value = ` @@ -929,7 +945,7 @@ describe('parse', () => { items: [ { title: 'Test Post', - authors: ['john@example.com (John Doe)'], + authors: [{ email: 'john@example.com', name: 'John Doe' }], }, ], } @@ -959,7 +975,7 @@ describe('parse', () => { items: [ { title: 'Test Post', - authors: ['John Doe'], + authors: [{ name: 'John Doe' }], }, ], } @@ -1596,7 +1612,10 @@ describe('parse', () => { items: [ { title: 'Post', - authors: ['alice@example.com (Alice)', 'bob@example.com (Bob)'], + authors: [ + { email: 'alice@example.com', name: 'Alice' }, + { email: 'bob@example.com', name: 'Bob' }, + ], }, ], } @@ -1870,7 +1889,6 @@ describe('parse', () => { title: 'Post', dc: { creators: ['John Doe'], - creator: 'John Doe', }, }, ], @@ -1910,7 +1928,6 @@ describe('parse', () => { title: 'Post', dc: { creators: ['Jane'], - creator: 'Jane', }, }, ], @@ -1943,7 +1960,6 @@ describe('parse', () => { title: 'Post', dc: { creators: ['Author'], - creator: 'Author', }, }, ], @@ -1976,7 +1992,6 @@ describe('parse', () => { title: 'Post', dc: { creators: ['Inline Author'], - creator: 'Inline Author', }, }, ], @@ -2085,7 +2100,7 @@ describe('parse', () => { expect(parse(value)).toEqual(expected) }) - it('RW-Q09: should preserve RFC 2822 author email format', () => { + it('RW-Q09: should parse RFC 2822 author email format into structured person', () => { const value = ` @@ -2107,7 +2122,7 @@ describe('parse', () => { items: [ { title: 'Post', - authors: ['john@example.com (John Doe)'], + authors: [{ email: 'john@example.com', name: 'John Doe' }], }, ], } @@ -2850,7 +2865,6 @@ describe('parse', () => { title: 'Post', dc: { dates: ['2025-02-21T16:00:00Z'], - date: '2025-02-21T16:00:00Z', }, }, ], @@ -2989,7 +3003,6 @@ describe('parse', () => { title: 'Post', dc: { creators: ['John Doe'], - creator: 'John Doe', }, }, ], @@ -3253,10 +3266,9 @@ describe('parse', () => { items: [ { title: 'Post', - authors: ['Regular Author'], + authors: [{ name: 'Regular Author' }], dc: { creators: ['DC Author'], - creator: 'DC Author', }, }, ], @@ -3290,7 +3302,6 @@ describe('parse', () => { title: 'Post', dc: { creators: ['Alice', 'Bob'], - creator: 'Alice', }, }, ], @@ -3522,7 +3533,7 @@ describe('parse', () => { items: [ { title: 'Post', - dc: { creators: ['Alice', 'Bob', 'Charlie'], creator: 'Alice' }, + dc: { creators: ['Alice', 'Bob', 'Charlie'] }, }, ], } @@ -3842,7 +3853,60 @@ describe('parse', () => { ` const result = parse(value) - expect(result.items?.[0]?.authors).toEqual(['John Doe']) + expect(result.items?.[0]?.authors).toEqual([{ name: 'John Doe' }]) + }) + }) + + describe('person fields', () => { + it('should parse managingEditor in RFC 2822 format', () => { + const value = ` + + + + Test + https://example.com + Test + editor@example.com (Editor Name) + + + ` + const result = parse(value) + + expect(result.managingEditor).toEqual({ email: 'editor@example.com', name: 'Editor Name' }) + }) + + it('should parse webMaster in RFC 2822 format', () => { + const value = ` + + + + Test + https://example.com + Test + webmaster@example.com (Webmaster) + + + ` + const result = parse(value) + + expect(result.webMaster).toEqual({ email: 'webmaster@example.com', name: 'Webmaster' }) + }) + + it('should parse managingEditor with email only', () => { + const value = ` + + + + Test + https://example.com + Test + editor@example.com + + + ` + const result = parse(value) + + expect(result.managingEditor).toEqual({ email: 'editor@example.com' }) }) }) @@ -4167,29 +4231,199 @@ describe('parse', () => { }) }) - describe('xml comment stripping', () => { - it('should strip XML comments from element content', () => { + describe('parseDateFn', () => { + it('should apply custom parseDateFn to feed and item dates', () => { const value = ` - + - Test<!-- hidden --> Feed - https://example.com - Test + Test + Wed, 15 Mar 2023 12:00:00 GMT - Post<!-- comment --> Title + Item + Thu, 16 Mar 2023 12:00:00 GMT ` const expected = { - title: 'Test Feed', - link: 'https://example.com', - description: 'Test', - items: [{ title: 'Post Title' }], + title: 'Test', + pubDate: new Date('Wed, 15 Mar 2023 12:00:00 GMT'), + items: [ + { + title: 'Item', + pubDate: new Date('Thu, 16 Mar 2023 12:00:00 GMT'), + }, + ], } + const result = parse(value, { parseDateFn: (raw) => new Date(raw) }) - expect(parse(value)).toEqual(expected) + expect(result).toEqual(expected) + }) + + it('should apply custom parseDateFn to namespace dates', () => { + const value = ` + + + + Test + + Item + 2023-03-15T12:00:00Z + + + + ` + const expected = { + title: 'Test', + items: [ + { + title: 'Item', + dc: { + dates: [new Date('2023-03-15T12:00:00Z')], + }, + }, + ], + } + const result = parse(value, { parseDateFn: (raw) => new Date(raw) }) + + expect(result).toEqual(expected) + }) + + it('should propagate error when parseDateFn throws', () => { + const value = ` + + + + Test + invalid + + + ` + const parseDateFn = () => { + throw new Error('Parse failed') + } + const throwing = () => parse(value, { parseDateFn }) + + expect(throwing).toThrow('Parse failed') + }) + + it('should apply custom parseDateFn to podcast namespace dates', () => { + const value = ` + + + + Test + Trailer + + Weekly + + + ` + const expected = { + title: 'Test', + podcast: { + trailers: [ + { + display: 'Trailer', + url: 'https://example.com/trailer.mp3', + pubDate: new Date('Thu, 16 Mar 2023 12:00:00 GMT'), + }, + ], + liveItems: [ + { + status: 'live', + start: new Date('2023-03-15T12:00:00Z'), + end: new Date('2023-03-15T13:00:00Z'), + }, + ], + updateFrequency: { + display: 'Weekly', + dtstart: new Date('2023-03-20T00:00:00Z'), + rrule: 'FREQ=WEEKLY', + }, + }, + } + const result = parse(value, { parseDateFn: (raw) => new Date(raw) }) + + expect(result).toEqual(expected) + }) + + it('should apply custom parseDateFn to sy namespace dates', () => { + const value = ` + + + + Test + 2023-03-15T12:00:00Z + + + ` + const expected = { + title: 'Test', + sy: { + updateBase: new Date('2023-03-15T12:00:00Z'), + }, + } + const result = parse(value, { parseDateFn: (raw) => new Date(raw) }) + + expect(result).toEqual(expected) + }) + + it('should apply custom parseDateFn to prism namespace dates', () => { + const value = ` + + + + Test + + Item + 2023-03-15T12:00:00Z + 2023-03-16T12:00:00Z + + + + ` + const expected = { + title: 'Test', + items: [ + { + title: 'Item', + prism: { + publicationDates: [new Date('2023-03-15T12:00:00Z')], + modificationDate: new Date('2023-03-16T12:00:00Z'), + }, + }, + ], + } + const result = parse(value, { parseDateFn: (raw) => new Date(raw) }) + + expect(result).toEqual(expected) + }) + + it('should apply custom parseDateFn to rawvoice namespace dates', () => { + const value = ` + + + + Test + https://example.com/stream + + + ` + const expected = { + title: 'Test', + rawvoice: { + liveStream: { + url: 'https://example.com/stream', + schedule: new Date('2023-03-15T12:00:00Z'), + duration: '01:00:00', + }, + }, + } + const result = parse(value, { parseDateFn: (raw) => new Date(raw) }) + + expect(result).toEqual(expected) }) }) }) diff --git a/src/feeds/rss/parse/index.ts b/src/feeds/rss/parse/index.ts index 283e5bee..29cae4ea 100644 --- a/src/feeds/rss/parse/index.ts +++ b/src/feeds/rss/parse/index.ts @@ -1,22 +1,33 @@ import { locales } from '../../../common/config.js' -import type { DeepPartial, ParseOptions } from '../../../common/types.js' +import { DetectError, MalformedError, ParseError } from '../../../common/errors.js' +import type { ParseMainOptions, Unreliable } from '../../../common/types.js' import { detectRssFeed } from '../../../index.js' -import type { Rss } from '../common/types.js' +import type { RssFeed } from '../common/types.js' import { normalizeNamespaces, parser } from './config.js' import { retrieveFeed } from './utils.js' -export const parse = (value: unknown, options?: ParseOptions): DeepPartial> => { +export const parse = ( + value: unknown, + options?: ParseMainOptions, +): RssFeed.Feed => { if (!detectRssFeed(value)) { - throw new Error(locales.invalidFeedFormat) + throw new DetectError(locales.invalidFeedFormat) + } + + let normalized: Unreliable + + try { + const object = parser.parse(value) + normalized = normalizeNamespaces(object) + } catch { + throw new MalformedError(locales.invalidFeedFormat) } - const object = parser.parse(value) - const normalized = normalizeNamespaces(object) const parsed = retrieveFeed(normalized, options) if (!parsed) { - throw new Error(locales.invalidFeedFormat) + throw new ParseError(locales.invalidFeedFormat) } - return parsed + return parsed as RssFeed.Feed } diff --git a/src/feeds/rss/parse/utils.test.ts b/src/feeds/rss/parse/utils.test.ts index 7a9dbb4a..857e7692 100644 --- a/src/feeds/rss/parse/utils.test.ts +++ b/src/feeds/rss/parse/utils.test.ts @@ -16,51 +16,765 @@ import { retrieveImage, retrieveItems, retrieveTextInput, + stripMailto, } from './utils.js' +describe('stripMailto', () => { + it('should strip mailto: prefix', () => { + const value = 'mailto:john@example.com' + const expected = 'john@example.com' + + expect(stripMailto(value)).toBe(expected) + }) + + it('should strip mailto: prefix case-insensitively', () => { + const value = 'MAILTO:john@example.com' + const expected = 'john@example.com' + + expect(stripMailto(value)).toBe(expected) + }) + + it('should strip query string when mailto: prefix is present', () => { + const value = 'mailto:john@example.com?subject=feedback' + const expected = 'john@example.com' + + expect(stripMailto(value)).toBe(expected) + }) + + it('should not strip query string when mailto: prefix is absent', () => { + const value = 'john@example.com?subject=feedback' + const expected = 'john@example.com?subject=feedback' + + expect(stripMailto(value)).toBe(expected) + }) + + it('should return value unchanged when no mailto: prefix', () => { + const value = 'john@example.com' + const expected = 'john@example.com' + + expect(stripMailto(value)).toBe(expected) + }) + + it('should return non-email string unchanged', () => { + const value = 'John Doe' + const expected = 'John Doe' + + expect(stripMailto(value)).toBe(expected) + }) +}) + describe('parsePerson', () => { - it('should parse author string (with #text)', () => { - const value = { - '#text': 'John Doe (john@example.com)', - } - const expected = 'John Doe (john@example.com)' + describe('name only', () => { + it('should parse plain name string', () => { + const value = 'John Doe' + const expected = { name: 'John Doe' } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse name via #text object', () => { + const value = { '#text': 'John Doe' } + const expected = { name: 'John Doe' } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse name via nested name object', () => { + const value = { name: { '#text': 'John Doe' } } + const expected = { name: 'John Doe' } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should coerce number value to name', () => { + const value = { '#text': 123 } + const expected = { name: '123' } - expect(parsePerson(value)).toBe(expected) + expect(parsePerson(value)).toEqual(expected) + }) + + it('should decode HTML entities in name', () => { + const value = { '#text': 'John & Jane' } + const expected = { name: 'John & Jane' } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should handle CDATA in name', () => { + const value = { '#text': '' } + const expected = { name: 'John Doe' } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse accented characters in name', () => { + const value = 'José García' + const expected = { name: 'José García' } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse CJK characters in name', () => { + const value = '田中太郎' + const expected = { name: '田中太郎' } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should use first element when value is an array', () => { + const value = ['John Doe', 'Jane Smith'] + const expected = { name: 'John Doe' } + + expect(parsePerson(value)).toEqual(expected) + }) }) - it('should parse author string (without #text)', () => { - const value = 'John Doe (john@example.com)' - const expected = 'John Doe (john@example.com)' + describe('email only', () => { + it('should parse bare email address', () => { + const value = 'john@example.com' + const expected = { email: 'john@example.com' } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should strip mailto: prefix', () => { + const value = 'mailto:john@example.com' + const expected = { email: 'john@example.com' } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should strip MAILTO: prefix (case-insensitive)', () => { + const value = 'MAILTO:john@example.com' + const expected = { email: 'john@example.com' } - expect(parsePerson(value)).toBe(expected) + expect(parsePerson(value)).toEqual(expected) + }) + + it('should strip mailto: query string parameters', () => { + const value = 'mailto:john@example.com?subject=feedback' + const expected = { email: 'john@example.com' } + + expect(parsePerson(value)).toEqual(expected) + }) }) - it('should parse author nested under author.name', () => { - const value = { - name: { - '#text': 'John Doe', - }, - } + describe('url only', () => { + it('should parse http URL', () => { + const value = 'http://example.com' + const expected = { link: 'http://example.com' } - expect(parsePerson(value)).toBe('John Doe') + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse https URL', () => { + const value = 'https://example.com/~john' + const expected = { link: 'https://example.com/~john' } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse www URL', () => { + const value = 'www.example.com' + const expected = { link: 'www.example.com' } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse URL containing @ (Mastodon)', () => { + const value = 'https://mastodon.social/@user' + const expected = { link: 'https://mastodon.social/@user' } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse URL containing @ (Medium)', () => { + const value = 'https://medium.com/@author' + const expected = { link: 'https://medium.com/@author' } + + expect(parsePerson(value)).toEqual(expected) + }) }) - it('should handle coercible values', () => { - const value = { - '#text': 123, - } + describe('invalid inputs', () => { + it('should return undefined for empty string', () => { + expect(parsePerson('')).toBeUndefined() + }) + + it('should return undefined for whitespace only', () => { + expect(parsePerson(' ')).toBeUndefined() + }) + + it('should return undefined for undefined', () => { + expect(parsePerson(undefined)).toBeUndefined() + }) - expect(parsePerson(value)).toBe('123') + it('should return undefined for null', () => { + expect(parsePerson(null)).toBeUndefined() + }) + + it('should return undefined for empty object', () => { + expect(parsePerson({})).toBeUndefined() + }) }) - it('should return undefined for empty object', () => { - const value = {} + describe('bracket decomposition', () => { + describe('email (name) — RSS spec format', () => { + it('should parse standard format', () => { + const value = 'john@example.com (John Doe)' + const expected = { + email: 'john@example.com', + name: 'John Doe', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse international characters in bracketed name', () => { + const value = 'taro@example.com (田中太郎)' + const expected = { + email: 'taro@example.com', + name: '田中太郎', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse accented characters in bracketed name', () => { + const value = 'jdasilva@example.br (João da Silva)' + const expected = { + email: 'jdasilva@example.br', + name: 'João da Silva', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should handle email with plus addressing', () => { + const value = 'me+spam@example.com (Editor)' + const expected = { + email: 'me+spam@example.com', + name: 'Editor', + } + + expect(parsePerson(value)).toEqual(expected) + }) + }) + + describe('name (email) — common format', () => { + it('should parse parentheses', () => { + const value = 'John Doe (john@example.com)' + const expected = { + name: 'John Doe', + email: 'john@example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse square brackets', () => { + const value = 'John Doe [john@example.com]' + const expected = { + name: 'John Doe', + email: 'john@example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse angle brackets', () => { + const value = 'John Doe ' + const expected = { + name: 'John Doe', + email: 'john@example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should strip mailto: prefix inside brackets', () => { + const value = 'John Doe (mailto:john@example.com)' + const expected = { + name: 'John Doe', + email: 'john@example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should strip mailto: query string inside brackets', () => { + const value = 'John Doe (mailto:john@example.com?subject=feedback)' + const expected = { + name: 'John Doe', + email: 'john@example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + }) + + describe('name + URL', () => { + it('should parse parentheses', () => { + const value = 'John Doe (https://example.com)' + const expected = { + name: 'John Doe', + link: 'https://example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse square brackets', () => { + const value = 'John Doe [https://example.com]' + const expected = { + name: 'John Doe', + link: 'https://example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse angle brackets', () => { + const value = 'John Doe ' + const expected = { + name: 'John Doe', + link: 'https://example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse URL containing @ in brackets', () => { + const value = 'John Doe (https://mastodon.social/@johndoe)' + const expected = { + name: 'John Doe', + link: 'https://mastodon.social/@johndoe', + } + + expect(parsePerson(value)).toEqual(expected) + }) + }) + + describe('email + URL', () => { + it('should parse parentheses', () => { + const value = 'john@example.com (https://example.com)' + const expected = { + email: 'john@example.com', + link: 'https://example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse square brackets', () => { + const value = 'john@example.com [https://example.com]' + const expected = { + email: 'john@example.com', + link: 'https://example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse angle brackets', () => { + const value = 'john@example.com ' + const expected = { + email: 'john@example.com', + link: 'https://example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + }) + + describe('email + name in alternative brackets', () => { + it('should parse square brackets', () => { + const value = 'john@example.com [John Doe]' + const expected = { + email: 'john@example.com', + name: 'John Doe', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse angle brackets', () => { + const value = 'john@example.com ' + const expected = { + email: 'john@example.com', + name: 'John Doe', + } + + expect(parsePerson(value)).toEqual(expected) + }) + }) + + describe('all three components', () => { + it('should parse name (url)', () => { + const value = 'John Doe (https://example.com)' + const expected = { + name: 'John Doe', + email: 'john@example.com', + link: 'https://example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse name (email) [url]', () => { + const value = 'John Doe (john@example.com) [https://example.com]' + const expected = { + name: 'John Doe', + email: 'john@example.com', + link: 'https://example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse email (name) [url]', () => { + const value = 'john@example.com (John Doe) [https://example.com]' + const expected = { + email: 'john@example.com', + name: 'John Doe', + link: 'https://example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse email [name] (url)', () => { + const value = 'john@example.com [John Doe] (https://example.com)' + const expected = { + email: 'john@example.com', + name: 'John Doe', + link: 'https://example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse (name) [url]', () => { + const value = ' (John Doe) [https://example.com]' + const expected = { + email: 'john@example.com', + name: 'John Doe', + link: 'https://example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse [url] (name)', () => { + const value = ' [https://example.com] (John Doe)' + const expected = { + email: 'john@example.com', + link: 'https://example.com', + name: 'John Doe', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse name [url]', () => { + const value = 'John Doe [https://example.com]' + const expected = { + name: 'John Doe', + email: 'john@example.com', + link: 'https://example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse name [url] ', () => { + const value = 'John Doe [https://example.com] ' + const expected = { + name: 'John Doe', + link: 'https://example.com', + email: 'john@example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + }) + + describe('multiple values (first wins)', () => { + it('should use first email when multiple emails present', () => { + const value = 'John (john1@x.com) (john2@x.com)' + const expected = { + name: 'John (john2@x.com)', + email: 'john1@x.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should use first URL when multiple URLs present', () => { + const value = 'John (https://x.com/1) (https://x.com/2)' + const expected = { + name: 'John (https://x.com/2)', + link: 'https://x.com/1', + } + + expect(parsePerson(value)).toEqual(expected) + }) + }) - expect(parsePerson(value)).toBeUndefined() + describe('name split by bracketed content', () => { + it('should reassemble name around bracketed email', () => { + const value = 'Mock (john@example.com) Name' + const expected = { + name: 'Mock Name', + email: 'john@example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should reassemble name around bracketed URL', () => { + const value = 'Mock (https://example.com) Name' + const expected = { + name: 'Mock Name', + link: 'https://example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + }) + + describe('non-email/URL bracket content preserved in name', () => { + it('should preserve role in parentheses', () => { + const value = 'John Doe (Editor)' + const expected = { name: 'John Doe (Editor)' } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should preserve role and extract email', () => { + const value = 'John Doe (Editor) ' + const expected = { + name: 'John Doe (Editor)', + email: 'john@example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should preserve title in square brackets and extract email and url', () => { + const value = 'Dr. John [CEO] (https://example.com)' + const expected = { + name: 'Dr. John [CEO]', + email: 'john@example.com', + link: 'https://example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + }) + + describe('nested brackets', () => { + it('should handle nested parentheses', () => { + const value = 'John ((nick)) ' + const expected = { + name: 'John ((nick))', + email: 'john@example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should handle nested parentheses with name inside', () => { + const value = 'John (hello (world)) ' + const expected = { + name: 'John (hello (world))', + email: 'john@example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + }) + + describe('unmatched brackets', () => { + it('should preserve unmatched parenthesis as literal text', () => { + const value = 'John (test' + const expected = { name: 'John (test' } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should preserve unmatched angle bracket as literal text', () => { + const value = 'John { + const value = 'John [incomplete' + const expected = { name: 'John [incomplete' } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should skip empty brackets and extract email', () => { + const value = 'John () ' + const expected = { + name: 'John', + email: 'john@example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should parse bracketed-only email input', () => { + const value = '(john@example.com)' + const expected = { email: 'john@example.com' } + + expect(parsePerson(value)).toEqual(expected) + }) + }) }) - it('should return undefined for undefined value', () => { - expect(parsePerson(undefined)).toBeUndefined() + describe('unbracketed mixed content', () => { + it('should extract email before name', () => { + const value = 'jsmith@example.org John Smith' + const expected = { + name: 'John Smith', + email: 'jsmith@example.org', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should extract email after name', () => { + const value = 'John Doe john@example.com' + const expected = { + name: 'John Doe', + email: 'john@example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should extract URL after name', () => { + const value = 'John Doe https://example.com' + const expected = { + name: 'John Doe', + link: 'https://example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should extract email from middle of name', () => { + const value = 'John john@example.com Doe' + const expected = { + name: 'John Doe', + email: 'john@example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should extract URL from middle of name', () => { + const value = 'John https://example.com Doe' + const expected = { + name: 'John Doe', + link: 'https://example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should strip mailto: prefix from unbracketed email', () => { + const value = 'John Doe mailto:john@example.com' + const expected = { + name: 'John Doe', + email: 'john@example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should extract URL containing @ after name', () => { + const value = 'John Doe https://mastodon.social/@johndoe' + const expected = { + name: 'John Doe', + link: 'https://mastodon.social/@johndoe', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should treat plain string without email or URL as name', () => { + const value = 'John Doe, Editor-in-Chief' + const expected = { name: 'John Doe, Editor-in-Chief' } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should preserve hyphenated name', () => { + const value = 'Jean-Pierre de la Croix' + const expected = { name: 'Jean-Pierre de la Croix' } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should preserve name with slash separator', () => { + const value = 'Smith / Jones' + const expected = { name: 'Smith / Jones' } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should treat string with @ but no valid email or URL as name', () => { + const value = 'John @ Company' + const expected = { name: 'John @ Company' } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should strip comma separator between name and email', () => { + const value = 'John Smith, john@example.com' + const expected = { + name: 'John Smith', + email: 'john@example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should strip hyphen separator between name and email', () => { + const value = 'John Smith - john@example.com' + const expected = { + name: 'John Smith', + email: 'john@example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should strip separators between name, email and URL', () => { + const value = 'John Smith - john@example.com - http://example.com/' + const expected = { + name: 'John Smith', + email: 'john@example.com', + link: 'http://example.com/', + } + + expect(parsePerson(value)).toEqual(expected) + }) + + it('should strip mailto: query string from unbracketed email', () => { + const value = 'John Doe mailto:john@example.com?subject=feedback' + const expected = { + name: 'John Doe', + email: 'john@example.com', + } + + expect(parsePerson(value)).toEqual(expected) + }) }) }) @@ -640,7 +1354,7 @@ describe('parseItem', () => { title: 'Item Title', link: 'https://example.com/item', description: 'Item Description', - authors: ['John Doe (john@example.com)'], + authors: [{ name: 'John Doe', email: 'john@example.com' }], categories: [ { name: 'Technology', domain: 'http://example.com/categories' }, { name: 'Web Development' }, @@ -766,7 +1480,7 @@ describe('parseItem', () => { expect(parseItem(value)).toEqual({ title: '123', - authors: ['456'], + authors: [{ name: '456' }], }) }) @@ -816,7 +1530,6 @@ describe('parseItem', () => { title: 'Example Entry', dc: { creators: ['John Doe'], - creator: 'John Doe', }, } @@ -868,8 +1581,7 @@ describe('parseItem', () => { title: 'Example Entry', dcterms: { licenses: ['MIT License'], - license: 'MIT License', - created: '2023-02-01T00:00:00Z', + created: ['2023-02-01T00:00:00Z'], }, } @@ -1011,8 +1723,8 @@ describe('parseFeed', () => { description: 'Feed Description', language: 'en-us', copyright: '© 2023 Example', - managingEditor: 'editor@example.com', - webMaster: 'webmaster@example.com', + managingEditor: { email: 'editor@example.com' }, + webMaster: { email: 'webmaster@example.com' }, pubDate: 'Mon, 15 Mar 2023 12:00:00 GMT', lastBuildDate: 'Mon, 15 Mar 2023 13:00:00 GMT', categories: [{ name: 'Technology', domain: 'http://example.com/categories' }], @@ -1366,7 +2078,7 @@ describe('parseFeed', () => { { title: 'Item 2', description: 'Item 2 Description', - authors: ['Author 1', 'Author 2'], + authors: [{ name: 'Author 1' }, { name: 'Author 2' }], }, ], image: { @@ -1409,7 +2121,6 @@ describe('parseFeed', () => { link: 'https://example.com', dc: { creators: ['John Doe'], - creator: 'John Doe', }, } @@ -1445,8 +2156,7 @@ describe('parseFeed', () => { link: 'https://example.com', dcterms: { licenses: ['Creative Commons Attribution 4.0'], - license: 'Creative Commons Attribution 4.0', - created: '2023-01-01T00:00:00Z', + created: ['2023-01-01T00:00:00Z'], }, } @@ -1570,6 +2280,20 @@ describe('parseFeed', () => { expect(parseFeed(value)).toEqual(expected) }) + + it('should parse managingEditor with name and email (RFC 2822 format)', () => { + const value = { channel: { managingeditor: { '#text': 'editor@example.com (Editor Name)' } } } + const expected = { email: 'editor@example.com', name: 'Editor Name' } + + expect(parseFeed(value)?.managingEditor).toEqual(expected) + }) + + it('should parse webMaster with name and email (RFC 2822 format)', () => { + const value = { channel: { webmaster: { '#text': 'webmaster@example.com (Webmaster Name)' } } } + const expected = { email: 'webmaster@example.com', name: 'Webmaster Name' } + + expect(parseFeed(value)?.webMaster).toEqual(expected) + }) }) describe('retrieveImage', () => { diff --git a/src/feeds/rss/parse/utils.ts b/src/feeds/rss/parse/utils.ts index cf00d769..2f97b60d 100644 --- a/src/feeds/rss/parse/utils.ts +++ b/src/feeds/rss/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParseOptions, ParsePartialUtil } from '../../../common/types.js' +import type { DateAny } from '../../../common/types.js' import { detectNamespaces, isObject, @@ -27,7 +27,7 @@ import { retrieveItemOrFeed as retrieveCc } from '../../../namespaces/cc/parse/u import { retrieveItem as retrieveContentItem } from '../../../namespaces/content/parse/utils.js' import { retrieveItemOrFeed as retrieveCreativeCommonsItemOrFeed } from '../../../namespaces/creativecommons/parse/utils.js' import { retrieveItemOrFeed as retrieveDcItemOrFeed } from '../../../namespaces/dc/parse/utils.js' -import { retrieveItemOrFeed as retrieveDctermsItemOrFeed } from '../../../namespaces/dcterms/parse/utils.js' +import { retrieveItemOrFeed as retrieveDcTermsItemOrFeed } from '../../../namespaces/dcterms/parse/utils.js' import { retrieveFeed as retrieveFeedPressFeed } from '../../../namespaces/feedpress/parse/utils.js' import { retrieveItemOrFeed as retrieveGeoItemOrFeed } from '../../../namespaces/geo/parse/utils.js' import { retrieveItemOrFeed as retrieveGeoRssItemOrFeed } from '../../../namespaces/georss/parse/utils.js' @@ -71,13 +71,188 @@ import { retrieveFeed as retrieveSyFeed } from '../../../namespaces/sy/parse/uti import { retrieveItem as retrieveThrItem } from '../../../namespaces/thr/parse/utils.js' import { retrieveItem as retrieveTrackbackItem } from '../../../namespaces/trackback/parse/utils.js' import { retrieveItem as retrieveWfwItem } from '../../../namespaces/wfw/parse/utils.js' -import type { Rss } from '../common/types.js' +import { retrieveItemOrFeed as retrieveXmlItemOrFeed } from '../../../namespaces/xml/parse/utils.js' +import type { ParseUtilPartial, RssFeed } from '../common/types.js' + +const emailRegex = /^[^\s@()<>[\]]+@[^\s@()<>[\]]+\.[^\s@()<>[\]]+$/ +const urlRegex = /^(?:https?:\/\/|www\.)[^\s]+\.[^\s]+$/ +const mailtoRegex = /^mailto:/i +const hasBracketsRegex = /[<[(]/ +const whitespaceRegex = /\s+/ +const commonSeparatorsRegex = /^[\s,\-|/:;]+|[\s,\-|/:;]+$/g +const mailtoQueryRegex = /\?.*$/ + +export const stripMailto = (value: string) => { + const stripped = value.replace(mailtoRegex, '') + + if (stripped !== value) { + return stripped.replace(mailtoQueryRegex, '') + } + + return stripped +} + +const closeBracketFor = (char: string) => { + if (char === '<') { + return '>' + } + + if (char === '(') { + return ')' + } + + return ']' +} + +const parseSimplePerson = (raw: string): RssFeed.Person | undefined => { + const stripped = stripMailto(raw) + + if (urlRegex.test(stripped)) { + return { link: stripped } + } + + if (emailRegex.test(stripped)) { + return { email: stripped } + } +} + +const parseUnbracketedPerson = (raw: string): RssFeed.Person | undefined => { + // If no email/URL markers anywhere, the entire string is a name. + if (raw.indexOf('@') === -1 && raw.indexOf('://') === -1 && raw.indexOf('www.') === -1) { + return { name: raw } + } + + const words = raw.split(whitespaceRegex) + const nameParts: Array = [] + let email: string | undefined + let link: string | undefined + + for (const word of words) { + const stripped = stripMailto(word) + + if (urlRegex.test(word) && !link) { + link = word + } else if (emailRegex.test(stripped) && !email) { + email = stripped + } else { + nameParts.push(word) + } + } + + if (!email && !link) { + return { name: raw } + } -export const parsePerson: ParsePartialUtil = (value) => { - return parseSingularOf(value?.name ?? value, (value) => parseString(retrieveText(value))) + const person = { + name: parseString(nameParts.join(' ').replace(commonSeparatorsRegex, '')), + email, + link, + } + + return trimObject(person) } -export const parseCategory: ParsePartialUtil = (value) => { +const parseBracketedPerson = (raw: string): RssFeed.Person | undefined => { + const person: RssFeed.Person = {} + const nameParts: Array = [] + const length = raw.length + + let hasUnbracketedName = false + let i = 0 + + while (i < length) { + let chunk: string | undefined + let isBracketed = false + let openBracket = '' + let closeBracket = '' + + const char = raw[i] + + if (char === '<' || char === '(' || char === '[') { + openBracket = char + closeBracket = closeBracketFor(char) + + const start = i + 1 + let depth = 1 + let end = start + + while (end < length && depth > 0) { + if (raw[end] === openBracket) { + depth++ + } else if (raw[end] === closeBracket) { + depth-- + } + end++ + } + + if (depth === 0) { + isBracketed = true + chunk = parseString(raw.slice(start, end - 1)) + i = end + } else { + // Unmatched bracket — treat as literal text. + const literalStart = i + i = start + while (i < length && raw[i] !== '<' && raw[i] !== '(' && raw[i] !== '[') { + i++ + } + chunk = parseString(raw.slice(literalStart, i)) + } + } else { + const start = i + while (i < length && raw[i] !== '<' && raw[i] !== '(' && raw[i] !== '[') { + i++ + } + chunk = parseString(raw.slice(start, i)) + } + + if (!chunk) { + continue + } + + const strippedChunk = stripMailto(chunk) + + if (urlRegex.test(chunk) && !person.link) { + person.link = chunk + } else if (emailRegex.test(strippedChunk) && !person.email) { + person.email = strippedChunk + } else if (isBracketed) { + nameParts.push(hasUnbracketedName ? `${openBracket}${chunk}${closeBracket}` : chunk) + } else { + hasUnbracketedName = true + nameParts.push(chunk) + } + } + + person.name = parseString(nameParts.join(' ')) + + return trimObject(person) +} + +export const parsePerson: ParseUtilPartial = (value) => { + const raw = parseSingularOf(value?.name ?? value, (v) => parseString(retrieveText(v))) + + if (!raw) { + return + } + + // Step 1. Handles bare email, mailto:email, or URL-only strings. + const simple = parseSimplePerson(raw) + + if (simple) { + return simple + } + + // Step 2. Handles unbracketed mixed content like "John Doe john@example.com". + if (!hasBracketsRegex.test(raw)) { + return parseUnbracketedPerson(raw) + } + + // Step 3. Handles bracketed formats like "email (Name)" or "Name (url)". + return parseBracketedPerson(raw) +} + +export const parseCategory: ParseUtilPartial = (value) => { const category = { name: parseString(retrieveText(value)), domain: parseString(value?.['@domain']), @@ -86,7 +261,7 @@ export const parseCategory: ParsePartialUtil = (value) => { return trimObject(category) } -export const parseCloud: ParsePartialUtil = (value) => { +export const parseCloud: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -102,7 +277,7 @@ export const parseCloud: ParsePartialUtil = (value) => { return trimObject(cloud) } -export const parseImage: ParsePartialUtil = (value) => { +export const parseImage: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -119,7 +294,7 @@ export const parseImage: ParsePartialUtil = (value) => { return trimObject(image) } -export const parseTextInput: ParsePartialUtil = (value) => { +export const parseTextInput: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -134,13 +309,13 @@ export const parseTextInput: ParsePartialUtil = (value) => { return trimObject(textInput) } -export const retrieveImage: ParsePartialUtil = (value) => { +export const retrieveImage: ParseUtilPartial = (value) => { const channel = parseSingular(value?.channel) return parseSingularOf(channel?.image, parseImage) ?? parseSingularOf(value?.image, parseImage) } -export const retrieveTextInput: ParsePartialUtil = (value) => { +export const retrieveTextInput: ParseUtilPartial = (value) => { const channel = parseSingular(value?.channel) return ( @@ -149,27 +324,24 @@ export const retrieveTextInput: ParsePartialUtil = (value) => { ) } -export const retrieveItems: ParsePartialUtil>, ParseOptions> = ( - value, - options, -) => { +export const retrieveItems: ParseUtilPartial>> = (value, options) => { const channel = parseSingular(value?.channel) return ( - parseArrayOf(channel?.item, parseItem, options?.maxItems) ?? - parseArrayOf(value?.item, parseItem, options?.maxItems) + parseArrayOf(channel?.item, (value) => parseItem(value, options), options?.maxItems) ?? + parseArrayOf(value?.item, (value) => parseItem(value, options), options?.maxItems) ) } -export const parseSkipHours: ParsePartialUtil> = (value) => { +export const parseSkipHours: ParseUtilPartial> = (value) => { return trimArray(value?.hour, (value) => parseNumber(retrieveText(value))) } -export const parseSkipDays: ParsePartialUtil> = (value) => { +export const parseSkipDays: ParseUtilPartial> = (value) => { return trimArray(value?.day, (value) => parseString(retrieveText(value))) } -export const parseEnclosure: ParsePartialUtil = (value) => { +export const parseEnclosure: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -183,7 +355,7 @@ export const parseEnclosure: ParsePartialUtil = (value) => { return trimObject(enclosure) } -export const parseGuid: ParsePartialUtil = (value) => { +export const parseGuid: ParseUtilPartial = (value) => { const source = { value: parseString(retrieveText(value)), isPermaLink: parseBoolean(value?.['@ispermalink']), @@ -192,7 +364,7 @@ export const parseGuid: ParsePartialUtil = (value) => { return trimObject(source) } -export const parseSource: ParsePartialUtil = (value) => { +export const parseSource: ParseUtilPartial = (value) => { const source = { title: parseString(retrieveText(value)), url: parseString(value?.['@url']), @@ -201,7 +373,7 @@ export const parseSource: ParsePartialUtil = (value) => { return trimObject(source) } -export const parseItem: ParsePartialUtil> = (value) => { +export const parseItem: ParseUtilPartial> = (value, options) => { if (!isObject(value)) { return } @@ -216,11 +388,13 @@ export const parseItem: ParsePartialUtil> = (value) => { comments: parseSingularOf(value.comments, (value) => parseString(retrieveText(value))), enclosures: parseArrayOf(value.enclosure, parseEnclosure), guid: parseSingularOf(value.guid, parseGuid), - pubDate: parseSingularOf(value.pubdate, (value) => parseDate(retrieveText(value))), + pubDate: parseSingularOf(value.pubdate, (value) => + parseDate(retrieveText(value), options?.parseDateFn), + ), source: parseSingularOf(value.source, parseSource), - atom: namespaces.has('atom') ? retrieveAtomEntry(value) : undefined, + atom: namespaces.has('atom') ? retrieveAtomEntry(value, options) : undefined, cc: namespaces.has('cc') ? retrieveCc(value) : undefined, - dc: namespaces.has('dc') ? retrieveDcItemOrFeed(value) : undefined, + dc: namespaces.has('dc') ? retrieveDcItemOrFeed(value, options) : undefined, content: namespaces.has('content') ? retrieveContentItem(value) : undefined, creativeCommons: namespaces.has('creativecommons') ? retrieveCreativeCommonsItemOrFeed(value) @@ -234,8 +408,8 @@ export const parseItem: ParsePartialUtil> = (value) => { georss: namespaces.has('georss') ? retrieveGeoRssItemOrFeed(value) : undefined, geo: namespaces.has('geo') ? retrieveGeoItemOrFeed(value) : undefined, thr: namespaces.has('thr') ? retrieveThrItem(value) : undefined, - dcterms: namespaces.has('dcterms') ? retrieveDctermsItemOrFeed(value) : undefined, - prism: namespaces.has('prism') ? retrievePrismItem(value) : undefined, + dcterms: namespaces.has('dcterms') ? retrieveDcTermsItemOrFeed(value, options) : undefined, + prism: namespaces.has('prism') ? retrievePrismItem(value, options) : undefined, wfw: namespaces.has('wfw') ? retrieveWfwItem(value) : undefined, sourceNs: namespaces.has('source') ? retrieveSourceItem(value) : undefined, rawvoice: namespaces.has('rawvoice') ? retrieveRawVoiceItem(value) : undefined, @@ -243,18 +417,18 @@ export const parseItem: ParsePartialUtil> = (value) => { pingback: namespaces.has('pingback') ? retrievePingbackItem(value) : undefined, trackback: namespaces.has('trackback') ? retrieveTrackbackItem(value) : undefined, acast: namespaces.has('acast') ? retrieveAcastItem(value) : undefined, + xml: retrieveXmlItemOrFeed(value), } return trimObject(item) } -export const parseFeed: ParsePartialUtil, ParseOptions> = (value, options) => { +export const parseFeed: ParseUtilPartial> = (value, options) => { const channel = parseSingular(value?.channel) if (!isObject(channel)) { return } - const namespaces = detectNamespaces(channel) const feed = { title: parseSingularOf(channel.title, (value) => parseString(retrieveText(value))), @@ -264,9 +438,11 @@ export const parseFeed: ParsePartialUtil, ParseOptions> = (valu copyright: parseSingularOf(channel.copyright, (value) => parseString(retrieveText(value))), managingEditor: parseSingularOf(channel.managingeditor, parsePerson), webMaster: parseSingularOf(channel.webmaster, parsePerson), - pubDate: parseSingularOf(channel.pubdate, (value) => parseDate(retrieveText(value))), + pubDate: parseSingularOf(channel.pubdate, (value) => + parseDate(retrieveText(value), options?.parseDateFn), + ), lastBuildDate: parseSingularOf(channel.lastbuilddate, (value) => - parseDate(retrieveText(value)), + parseDate(retrieveText(value), options?.parseDateFn), ), categories: parseArrayOf(channel.category, parseCategory), generator: parseSingularOf(channel.generator, (value) => parseString(retrieveText(value))), @@ -279,18 +455,18 @@ export const parseFeed: ParsePartialUtil, ParseOptions> = (valu skipHours: parseSingularOf(channel.skiphours, parseSkipHours), skipDays: parseSingularOf(channel.skipdays, parseSkipDays), items: retrieveItems(value, options), - atom: namespaces.has('atom') ? retrieveAtomFeed(channel) : undefined, + atom: namespaces.has('atom') ? retrieveAtomFeed(channel, options) : undefined, cc: namespaces.has('cc') ? retrieveCc(channel) : undefined, - dc: namespaces.has('dc') ? retrieveDcItemOrFeed(channel) : undefined, - sy: namespaces.has('sy') ? retrieveSyFeed(channel) : undefined, + dc: namespaces.has('dc') ? retrieveDcItemOrFeed(channel, options) : undefined, + sy: namespaces.has('sy') ? retrieveSyFeed(channel, options) : undefined, itunes: namespaces.has('itunes') ? retrieveItunesFeed(channel) : undefined, - podcast: namespaces.has('podcast') ? retrievePodcastFeed(channel) : undefined, + podcast: namespaces.has('podcast') ? retrievePodcastFeed(channel, options) : undefined, googleplay: namespaces.has('googleplay') ? retrieveGooglePlayFeed(channel) : undefined, media: namespaces.has('media') ? retrieveMediaItemOrFeed(channel) : undefined, georss: namespaces.has('georss') ? retrieveGeoRssItemOrFeed(channel) : undefined, geo: namespaces.has('geo') ? retrieveGeoItemOrFeed(channel) : undefined, - dcterms: namespaces.has('dcterms') ? retrieveDctermsItemOrFeed(channel) : undefined, - prism: namespaces.has('prism') ? retrievePrismFeed(channel) : undefined, + dcterms: namespaces.has('dcterms') ? retrieveDcTermsItemOrFeed(channel, options) : undefined, + prism: namespaces.has('prism') ? retrievePrismFeed(channel, options) : undefined, creativeCommons: namespaces.has('creativecommons') ? retrieveCreativeCommonsItemOrFeed(channel) : undefined, @@ -299,15 +475,16 @@ export const parseFeed: ParsePartialUtil, ParseOptions> = (valu admin: namespaces.has('admin') ? retrieveAdminFeed(channel) : undefined, sourceNs: namespaces.has('source') ? retrieveSourceFeed(channel) : undefined, blogChannel: namespaces.has('blogchannel') ? retrieveBlogChannelFeed(channel) : undefined, - rawvoice: namespaces.has('rawvoice') ? retrieveRawVoiceFeed(channel) : undefined, + rawvoice: namespaces.has('rawvoice') ? retrieveRawVoiceFeed(channel, options) : undefined, spotify: namespaces.has('spotify') ? retrieveSpotifyFeed(channel) : undefined, pingback: namespaces.has('pingback') ? retrievePingbackFeed(channel) : undefined, acast: namespaces.has('acast') ? retrieveAcastFeed(channel) : undefined, + xml: retrieveXmlItemOrFeed(value), } return trimObject(feed) } -export const retrieveFeed: ParsePartialUtil, ParseOptions> = (value, options) => { +export const retrieveFeed: ParseUtilPartial> = (value, options) => { return parseSingularOf(value?.rss, (value) => parseFeed(value, options)) } diff --git a/src/feeds/rss/references/rss-091.json b/src/feeds/rss/references/rss-091.json index ca79a4ce..944dc42c 100644 --- a/src/feeds/rss/references/rss-091.json +++ b/src/feeds/rss/references/rss-091.json @@ -4,8 +4,8 @@ "description": "For documentation only", "language": "en", "copyright": "Copyright 2004, Mark Pilgrim", - "managingEditor": "editor@example.org", - "webMaster": "webmaster@example.org", + "managingEditor": { "email": "editor@example.org" }, + "webMaster": { "email": "webmaster@example.org" }, "pubDate": "Sat, 19 Mar 1988 07:15:00 GMT", "lastBuildDate": "Sat, 19 Mar 1988 07:15:00 GMT", "image": { diff --git a/src/feeds/rss/references/rss-092.json b/src/feeds/rss/references/rss-092.json index 04b33aa7..2c8ed17d 100644 --- a/src/feeds/rss/references/rss-092.json +++ b/src/feeds/rss/references/rss-092.json @@ -4,8 +4,8 @@ "description": "For documentation only", "language": "en", "copyright": "Copyright 2004, Mark Pilgrim", - "managingEditor": "editor@example.org", - "webMaster": "webmaster@example.org", + "managingEditor": { "email": "editor@example.org" }, + "webMaster": { "email": "webmaster@example.org" }, "pubDate": "Sat, 19 Mar 1988 07:15:00 GMT", "lastBuildDate": "Sat, 19 Mar 1988 07:15:00 GMT", "docs": "http://backend.userland.com/rss092", diff --git a/src/feeds/rss/references/rss-093.json b/src/feeds/rss/references/rss-093.json index f806accf..77bb9058 100644 --- a/src/feeds/rss/references/rss-093.json +++ b/src/feeds/rss/references/rss-093.json @@ -4,8 +4,8 @@ "description": "For documentation only", "language": "en", "copyright": "Copyright 2004, Mark Pilgrim", - "managingEditor": "editor@example.org", - "webMaster": "webmaster@example.org", + "managingEditor": { "email": "editor@example.org" }, + "webMaster": { "email": "webmaster@example.org" }, "pubDate": "Sat, 19 Mar 1988 07:15:00 GMT", "lastBuildDate": "Sat, 19 Mar 1988 07:15:00 GMT", "categories": [{ "name": "Examples1" }, { "name": "Examples2" }], diff --git a/src/feeds/rss/references/rss-094.json b/src/feeds/rss/references/rss-094.json index 54c56ff2..32bd2459 100644 --- a/src/feeds/rss/references/rss-094.json +++ b/src/feeds/rss/references/rss-094.json @@ -4,8 +4,8 @@ "description": "For documentation only", "language": "en", "copyright": "Copyright 2004, Mark Pilgrim", - "managingEditor": "editor@example.org", - "webMaster": "webmaster@example.org", + "managingEditor": { "email": "editor@example.org" }, + "webMaster": { "email": "webmaster@example.org" }, "pubDate": "Sat, 19 Mar 1988 07:15:00 GMT", "lastBuildDate": "Sat, 19 Mar 1988 07:15:00 GMT", "categories": [ @@ -44,7 +44,7 @@ "title": "First item title", "link": "http://example.org/item/1", "description": "Watch out for nasty tricks", - "authors": ["mark@example.org"], + "authors": [{ "email": "mark@example.org" }], "categories": [ { "name": "Miscellaneous" }, { "name": "Technology", "domain": "http://www.example.com/categories" } @@ -68,7 +68,7 @@ "title": "Second item title", "link": "http://example.org/item/2", "description": "Watch out for nasty tricks", - "authors": ["mark@example.org"], + "authors": [{ "email": "mark@example.org" }], "categories": [{ "name": "Miscellaneous" }], "comments": "http://example.org/comments/2", "enclosures": [ diff --git a/src/feeds/rss/references/rss-20.json b/src/feeds/rss/references/rss-20.json index c0e21a14..b8e5f8eb 100644 --- a/src/feeds/rss/references/rss-20.json +++ b/src/feeds/rss/references/rss-20.json @@ -4,8 +4,8 @@ "description": "For documentation only", "language": "en", "copyright": "Copyright 2004, Mark Pilgrim", - "managingEditor": "editor@example.org", - "webMaster": "webmaster@example.org", + "managingEditor": { "email": "editor@example.org" }, + "webMaster": { "email": "webmaster@example.org" }, "pubDate": "Sat, 19 Mar 1988 07:15:00 GMT", "lastBuildDate": "Sat, 19 Mar 1988 07:15:00 GMT", "categories": [ @@ -44,7 +44,7 @@ "title": "First item title", "link": "http://example.org/item/1", "description": "Watch out for nasty tricks", - "authors": ["mark@example.org (Mark Pilgrim)"], + "authors": [{ "email": "mark@example.org", "name": "Mark Pilgrim" }], "categories": [ { "name": "Miscellaneous" }, { "name": "Technology", "domain": "http://www.example.com/categories" } @@ -71,7 +71,7 @@ "title": "Second item title", "link": "http://example.org/item/2", "description": "Watch out for nasty tricks", - "authors": ["mark@example.org (Mark Pilgrim)"], + "authors": [{ "email": "mark@example.org", "name": "Mark Pilgrim" }], "categories": [{ "name": "Miscellaneous" }], "comments": "http://example.org/comments/2", "enclosures": [ diff --git a/src/feeds/rss/references/rss-ns.json b/src/feeds/rss/references/rss-ns.json index 62ce46dd..48168784 100644 --- a/src/feeds/rss/references/rss-ns.json +++ b/src/feeds/rss/references/rss-ns.json @@ -5,7 +5,7 @@ { "title": "Example Item", "link": "http://example.org/item/1", - "authors": ["John Smith"], + "authors": [{ "name": "John Smith" }], "content": { "encoded": "This is an example of content." }, @@ -36,36 +36,30 @@ "sources": ["https://example.org/item-source"], "languages": ["en-US"], "relations": ["https://example.org/related-article"], - "title": "Dublin Core Enhanced Item Title", - "creator": "Jack Jackson", - "subject": "Article, Tutorial, Example", - "description": "Detailed description of the example item content", - "publisher": "Example News Organization", - "contributor": "Assistant Editor Mike Thompson", - "date": "2022-01-01T12:00:00.000Z", - "type": "Article", - "format": "text/html", - "identifier": "urn:uuid:98765432-9876-9876-9876-987654321def", - "source": "https://example.org/item-source", - "language": "en-US", - "relation": "https://example.org/related-article", - "coverage": "United States", - "rights": "Copyright 2025 Example News Organization" + "coverage": ["United States"], + "rights": ["Copyright 2025 Example News Organization"] }, "dcterms": { "abstracts": ["This article explores advanced concepts in technology"], + "accessRights": ["Public access with attribution required"], "accrualMethods": ["Single publication"], "accrualPeriodicities": ["One-time"], "accrualPolicies": ["Editorial review required"], "alternatives": ["Alternative Item Title"], "audiences": ["Technical professionals"], + "available": ["2022-01-01T12:00:00.000Z"], "bibliographicCitations": [ "Smith, J. (2022). Example Item. Retrieved from https://example.org/item/1" ], + "conformsTo": ["HTML5 standard"], "contributors": ["Assistant Editor Mike Thompson"], "coverages": ["United States technology sector"], + "created": ["2022-01-01T12:00:00.000Z"], "creators": ["Jack Jackson"], + "dateAccepted": ["2022-01-01T12:00:00.000Z"], + "dateCopyrighted": ["2022-01-01T12:00:00.000Z"], "dates": ["2022-01-01T12:00:00.000Z"], + "dateSubmitted": ["2022-01-01T12:00:00.000Z"], "descriptions": ["Detailed description of the example item content and its significance"], "educationLevels": ["Advanced"], "extents": ["Approximately 2500 words"], @@ -75,75 +69,34 @@ "hasVersions": ["1.1"], "identifiers": ["urn:uuid:item-98765432-9876-9876-9876-987654321def"], "instructionalMethods": ["Step-by-step tutorial"], + "isFormatOf": ["https://example.org/item-source"], + "isPartOf": ["Example Article Series"], + "isReferencedBy": ["https://example.org/item/1/citations"], + "isReplacedBy": ["https://example.org/item/1/updated"], + "isRequiredBy": ["https://example.org/item/1/prerequisites"], + "issued": ["2022-01-01T12:00:00.000Z"], + "isVersionOf": ["https://example.org/item/1/original"], "languages": ["en-US"], "licenses": ["Creative Commons Attribution 4.0"], "mediators": ["Editorial Team"], "mediums": ["Digital"], + "modified": ["2022-06-01T12:00:00.000Z"], "provenances": ["Originally published by Example News Organization"], "publishers": ["Example News Organization"], + "references": ["https://example.org/item/1/references"], "relations": ["https://example.org/related-article"], + "replaces": ["https://example.org/item/1/previous"], + "requires": ["Basic understanding of web technologies"], + "rights": ["Copyright 2025 Example News Organization"], "rightsHolders": ["Example News Organization"], "sources": ["https://example.org/item-source"], "spatials": ["United States"], "subjects": ["Article, Tutorial, Example"], + "tableOfContents": ["Introduction, Main Content, Conclusion"], "temporals": ["2022"], "titles": ["Dublin Core Terms Enhanced Item Title"], "types": ["Article"], - "created": "2022-01-01T12:00:00.000Z", - "license": "Creative Commons Attribution 4.0", - "abstract": "This article explores advanced concepts in technology", - "accessRights": "Public access with attribution required", - "accrualMethod": "Single publication", - "accrualPeriodicity": "One-time", - "accrualPolicy": "Editorial review required", - "alternative": "Alternative Item Title", - "audience": "Technical professionals", - "available": "2022-01-01T12:00:00.000Z", - "bibliographicCitation": "Smith, J. (2022). Example Item. Retrieved from https://example.org/item/1", - "conformsTo": "HTML5 standard", - "contributor": "Assistant Editor Mike Thompson", - "coverage": "United States technology sector", - "creator": "Jack Jackson", - "date": "2022-01-01T12:00:00.000Z", - "dateAccepted": "2022-01-01T12:00:00.000Z", - "dateCopyrighted": "2022-01-01T12:00:00.000Z", - "dateSubmitted": "2022-01-01T12:00:00.000Z", - "description": "Detailed description of the example item content and its significance", - "educationLevel": "Advanced", - "extent": "Approximately 2500 words", - "format": "text/html", - "hasFormat": "https://example.org/item/1.pdf", - "hasPart": "https://example.org/item/1/section1", - "hasVersion": "1.1", - "identifier": "urn:uuid:item-98765432-9876-9876-9876-987654321def", - "instructionalMethod": "Step-by-step tutorial", - "isFormatOf": "https://example.org/item-source", - "isPartOf": "Example Article Series", - "isReferencedBy": "https://example.org/item/1/citations", - "isReplacedBy": "https://example.org/item/1/updated", - "isRequiredBy": "https://example.org/item/1/prerequisites", - "issued": "2022-01-01T12:00:00.000Z", - "isVersionOf": "https://example.org/item/1/original", - "language": "en-US", - "mediator": "Editorial Team", - "medium": "Digital", - "modified": "2022-06-01T12:00:00.000Z", - "provenance": "Originally published by Example News Organization", - "publisher": "Example News Organization", - "references": "https://example.org/item/1/references", - "relation": "https://example.org/related-article", - "replaces": "https://example.org/item/1/previous", - "requires": "Basic understanding of web technologies", - "rights": "Copyright 2025 Example News Organization", - "rightsHolder": "Example News Organization", - "source": "https://example.org/item-source", - "spatial": "United States", - "subject": "Article, Tutorial, Example", - "tableOfContents": "Introduction, Main Content, Conclusion", - "temporal": "2022", - "title": "Dublin Core Terms Enhanced Item Title", - "type": "Article", - "valid": "2025-12-31T23:59:59.000Z" + "valid": ["2025-12-31T23:59:59.000Z"] }, "prism": { "publicationName": "Nature", @@ -295,13 +248,6 @@ "country": "US" } ], - "location": { - "display": "New York City, NY", - "rel": "subject", - "geo": "geo:40.7128,-74.0060", - "osm": "R8780673", - "country": "US" - }, "season": { "number": 1, "name": "First Season" @@ -368,25 +314,6 @@ ] } ], - "value": { - "type": "lightning", - "method": "keysend", - "suggested": 0.00000002, - "valueRecipients": [ - { - "name": "Host", - "type": "node", - "address": "02d5c1bf8b940dc9cadca86d1b0a3c37fbe39cee83420ef254acd7d8e5edf5f16e", - "split": 90 - }, - { - "name": "Guest", - "type": "node", - "address": "03ae9f91a0cb8ff43840e3c322c4c61f019d8c1c3cea15a25cfc425ac605e61a4a", - "split": 10 - } - ] - }, "images": [ { "href": "https://example.com/images/episode-artwork.jpg", @@ -433,14 +360,7 @@ "server": "chat.example.com", "protocol": "xmpp", "accountId": "episode@conference.example.com" - }, - "chats": [ - { - "server": "chat.example.com", - "protocol": "xmpp", - "accountId": "episode@conference.example.com" - } - ] + } }, "psc": { "chapters": [ @@ -631,126 +551,6 @@ ] } ], - "group": { - "contents": [ - { - "url": "https://example.com/videos/sample-hd.mp4", - "fileSize": 45678912, - "type": "video/mp4", - "medium": "video", - "expression": "full", - "bitrate": 5000, - "framerate": 60, - "duration": 180, - "height": 1080, - "width": 1920, - "lang": "en", - "title": { - "value": "HD Version (1080p)" - } - }, - { - "url": "https://example.com/videos/sample-sd.mp4", - "fileSize": 23456789, - "type": "video/mp4", - "medium": "video", - "expression": "full", - "bitrate": 2500, - "framerate": 30, - "duration": 180, - "height": 720, - "width": 1280, - "lang": "en", - "title": { - "value": "SD Version (720p)" - } - }, - { - "url": "https://example.com/videos/sample.webm", - "fileSize": 19876543, - "type": "video/webm", - "medium": "video", - "expression": "full", - "bitrate": 2000, - "framerate": 30, - "duration": 180, - "height": 720, - "width": 1280, - "lang": "en", - "title": { - "value": "WebM Version" - } - }, - { - "url": "https://example.com/audio/sample.mp3", - "fileSize": 3456789, - "type": "audio/mpeg", - "medium": "audio", - "expression": "full", - "bitrate": 320, - "samplingrate": 44.1, - "channels": 2, - "duration": 180, - "lang": "en", - "title": { - "value": "Audio Only Version" - } - }, - { - "url": "https://example.com/captions/sample-en.srt", - "type": "text/srt", - "medium": "document", - "expression": "sample", - "lang": "en", - "title": { - "value": "English Subtitles" - } - }, - { - "url": "https://example.com/captions/sample-es.srt", - "type": "text/srt", - "medium": "document", - "expression": "sample", - "lang": "es", - "title": { - "value": "Spanish Subtitles" - } - } - ], - "title": { - "value": "Multi-Format Content Example" - }, - "description": { - "value": "This video is available in multiple formats and resolutions" - }, - "thumbnails": [ - { - "url": "https://example.com/thumbnails/group-main.jpg", - "width": 640, - "height": 360 - }, - { - "url": "https://example.com/thumbnails/group-alt.jpg", - "width": 1280, - "height": 720 - } - ], - "keywords": ["group", "multiple", "formats", "resolutions"], - "categories": [ - { - "name": "Technology" - } - ], - "ratings": [ - { - "value": "PG", - "scheme": "urn:mpaa" - } - ], - "copyright": { - "value": "© 2025 Example Media Inc." - } - }, "groups": [ { "contents": [ @@ -1094,6 +894,10 @@ "showId": "abc123def456ghi789jkl012", "episodeUrl": "example-episode-title", "settings": "RXBpc29kZVNldHRpbmdzQmFzZTY0RW5jb2RlZA==" + }, + "xml": { + "lang": "en-US", + "base": "http://example.org/item/1/" } } ], @@ -1124,34 +928,28 @@ "sources": ["https://example.org/original-source"], "languages": ["en-US"], "relations": ["https://example.org/related-content"], - "title": "Dublin Core Enhanced Feed Title", - "creator": "John Doe", - "subject": "Technology, Programming, Web Development", - "description": "This is an example of description.", - "publisher": "Example Publishing Company", - "contributor": "Jane Smith", - "date": "2022-01-01T12:00:00.000Z", - "type": "Text", - "format": "application/rss+xml", - "identifier": "urn:uuid:12345678-1234-1234-1234-123456789abc", - "source": "https://example.org/original-source", - "language": "en-US", - "relation": "https://example.org/related-content", - "coverage": "Global", - "rights": "Copyright 2025 Example Publishing Company" + "coverage": ["Global"], + "rights": ["Copyright 2025 Example Publishing Company"] }, "dcterms": { "abstracts": ["This is an abstract of the feed content"], + "accessRights": ["Public access with attribution required"], "accrualMethods": ["Regular updates"], "accrualPeriodicities": ["Daily"], "accrualPolicies": ["Content added based on editorial calendar"], "alternatives": ["Alternative Feed Title"], "audiences": ["General technology audience"], + "available": ["2022-01-01T12:00:00.000Z"], "bibliographicCitations": ["Example Feed. (2022). Retrieved from https://example.org"], + "conformsTo": ["RSS 2.0 Specification"], "contributors": ["Jane Smith"], "coverages": ["Global technology topics"], + "created": ["2022-01-01T12:00:00.000Z"], "creators": ["John Doe"], + "dateAccepted": ["2022-01-01T12:00:00.000Z"], + "dateCopyrighted": ["2022-01-01T12:00:00.000Z"], "dates": ["2022-01-01T12:00:00.000Z"], + "dateSubmitted": ["2022-01-01T12:00:00.000Z"], "descriptions": ["Detailed description of the feed content and purpose"], "educationLevels": ["Intermediate to advanced"], "extents": ["Approximately 1000 words per article"], @@ -1161,75 +959,34 @@ "hasVersions": ["2.0"], "identifiers": ["urn:uuid:feed-12345678-1234-1234-1234-123456789abc"], "instructionalMethods": ["Practical examples and tutorials"], + "isFormatOf": ["https://example.org/original-content"], + "isPartOf": ["Example Media Network"], + "isReferencedBy": ["https://example.org/references"], + "isReplacedBy": ["https://example.org/new-feed"], + "isRequiredBy": ["https://example.org/dependent-feeds"], + "issued": ["2022-01-01T12:00:00.000Z"], + "isVersionOf": ["https://example.org/original-feed"], "languages": ["en-US"], "licenses": ["Creative Commons Attribution 4.0"], "mediators": ["Content Management System"], "mediums": ["Digital"], + "modified": ["2023-01-01T12:00:00.000Z"], "provenances": ["Originally created by Example Publishing Company"], "publishers": ["Example Publishing Company"], + "references": ["https://example.org/referenced-sources"], "relations": ["https://example.org/related-feeds"], + "replaces": ["https://example.org/old-feed"], + "requires": ["RSS 2.0 compatible reader"], + "rights": ["Copyright 2025 Example Publishing Company"], "rightsHolders": ["Example Publishing Company"], "sources": ["https://example.org/original-source"], "spatials": ["Global"], "subjects": ["Technology, Programming, Web Development"], + "tableOfContents": ["Latest articles, tutorials, and news"], "temporals": ["2022-present"], "titles": ["Dublin Core Terms Enhanced Feed Title"], "types": ["Text"], - "created": "2022-01-01T12:00:00.000Z", - "license": "Creative Commons Attribution 4.0", - "abstract": "This is an abstract of the feed content", - "accessRights": "Public access with attribution required", - "accrualMethod": "Regular updates", - "accrualPeriodicity": "Daily", - "accrualPolicy": "Content added based on editorial calendar", - "alternative": "Alternative Feed Title", - "audience": "General technology audience", - "available": "2022-01-01T12:00:00.000Z", - "bibliographicCitation": "Example Feed. (2022). Retrieved from https://example.org", - "conformsTo": "RSS 2.0 Specification", - "contributor": "Jane Smith", - "coverage": "Global technology topics", - "creator": "John Doe", - "date": "2022-01-01T12:00:00.000Z", - "dateAccepted": "2022-01-01T12:00:00.000Z", - "dateCopyrighted": "2022-01-01T12:00:00.000Z", - "dateSubmitted": "2022-01-01T12:00:00.000Z", - "description": "Detailed description of the feed content and purpose", - "educationLevel": "Intermediate to advanced", - "extent": "Approximately 1000 words per article", - "format": "application/rss+xml", - "hasFormat": "https://example.org/feed.atom", - "hasPart": "https://example.org/category/tutorials", - "hasVersion": "2.0", - "identifier": "urn:uuid:feed-12345678-1234-1234-1234-123456789abc", - "instructionalMethod": "Practical examples and tutorials", - "isFormatOf": "https://example.org/original-content", - "isPartOf": "Example Media Network", - "isReferencedBy": "https://example.org/references", - "isReplacedBy": "https://example.org/new-feed", - "isRequiredBy": "https://example.org/dependent-feeds", - "issued": "2022-01-01T12:00:00.000Z", - "isVersionOf": "https://example.org/original-feed", - "language": "en-US", - "mediator": "Content Management System", - "medium": "Digital", - "modified": "2023-01-01T12:00:00.000Z", - "provenance": "Originally created by Example Publishing Company", - "publisher": "Example Publishing Company", - "references": "https://example.org/referenced-sources", - "relation": "https://example.org/related-feeds", - "replaces": "https://example.org/old-feed", - "requires": "RSS 2.0 compatible reader", - "rights": "Copyright 2025 Example Publishing Company", - "rightsHolder": "Example Publishing Company", - "source": "https://example.org/original-source", - "spatial": "Global", - "subject": "Technology, Programming, Web Development", - "tableOfContents": "Latest articles, tutorials, and news", - "temporal": "2022-present", - "title": "Dublin Core Terms Enhanced Feed Title", - "type": "Text", - "valid": "2025-12-31T23:59:59.000Z" + "valid": ["2025-12-31T23:59:59.000Z"] }, "prism": { "publicationName": "Nature", @@ -1389,13 +1146,6 @@ "country": "US" } ], - "location": { - "display": "San Francisco, CA", - "rel": "creator", - "geo": "geo:37.7749,-122.4194", - "osm": "R4163767", - "country": "US" - }, "trailers": [ { "display": "Season 1 Trailer", @@ -1432,25 +1182,6 @@ ] } ], - "value": { - "type": "lightning", - "method": "keysend", - "suggested": 0.00000005, - "valueRecipients": [ - { - "name": "Host", - "type": "node", - "address": "02d5c1bf8b940dc9cadca86d1b0a3c37fbe39cee83420ef254acd7d8e5edf5f16e", - "split": 90 - }, - { - "name": "Producer", - "type": "node", - "address": "03ae9f91a0cb8ff43840e3c322c4c61f019d8c1c3cea15a25cfc425ac605e61a4a", - "split": 10 - } - ] - }, "medium": "podcast", "images": [ { @@ -1492,15 +1223,7 @@ "protocol": "matrix", "accountId": "@livepodcast:live.example.com", "space": "#live-episode" - }, - "chats": [ - { - "server": "live.example.com", - "protocol": "matrix", - "accountId": "@livepodcast:live.example.com", - "space": "#live-episode" - } - ] + } } ], "blocks": [{ "value": true }, { "value": false, "id": "test" }], @@ -1545,19 +1268,6 @@ "accountId": "examplepodcast", "space": "#general" }, - "chats": [ - { - "server": "irc.example.com", - "protocol": "irc", - "accountId": "examplepodcast", - "space": "#general" - }, - { - "server": "matrix.example.org", - "protocol": "matrix", - "accountId": "@examplepodcast:matrix.org" - } - ], "publisher": { "remoteItem": { "feedGuid": "ead4c236-bf58-58c6-a2c6-publisher-feed-guid", @@ -1725,126 +1435,6 @@ ] } ], - "group": { - "contents": [ - { - "url": "https://example.com/videos/sample-hd.mp4", - "fileSize": 45678912, - "type": "video/mp4", - "medium": "video", - "expression": "full", - "bitrate": 5000, - "framerate": 60, - "duration": 180, - "height": 1080, - "width": 1920, - "lang": "en", - "title": { - "value": "HD Version (1080p)" - } - }, - { - "url": "https://example.com/videos/sample-sd.mp4", - "fileSize": 23456789, - "type": "video/mp4", - "medium": "video", - "expression": "full", - "bitrate": 2500, - "framerate": 30, - "duration": 180, - "height": 720, - "width": 1280, - "lang": "en", - "title": { - "value": "SD Version (720p)" - } - }, - { - "url": "https://example.com/videos/sample.webm", - "fileSize": 19876543, - "type": "video/webm", - "medium": "video", - "expression": "full", - "bitrate": 2000, - "framerate": 30, - "duration": 180, - "height": 720, - "width": 1280, - "lang": "en", - "title": { - "value": "WebM Version" - } - }, - { - "url": "https://example.com/audio/sample.mp3", - "fileSize": 3456789, - "type": "audio/mpeg", - "medium": "audio", - "expression": "full", - "bitrate": 320, - "samplingrate": 44.1, - "channels": 2, - "duration": 180, - "lang": "en", - "title": { - "value": "Audio Only Version" - } - }, - { - "url": "https://example.com/captions/sample-en.srt", - "type": "text/srt", - "medium": "document", - "expression": "sample", - "lang": "en", - "title": { - "value": "English Subtitles" - } - }, - { - "url": "https://example.com/captions/sample-es.srt", - "type": "text/srt", - "medium": "document", - "expression": "sample", - "lang": "es", - "title": { - "value": "Spanish Subtitles" - } - } - ], - "title": { - "value": "Multi-Format Content Example" - }, - "description": { - "value": "This video is available in multiple formats and resolutions" - }, - "thumbnails": [ - { - "url": "https://example.com/thumbnails/group-main.jpg", - "width": 640, - "height": 360 - }, - { - "url": "https://example.com/thumbnails/group-alt.jpg", - "width": 1280, - "height": 720 - } - ], - "keywords": ["group", "multiple", "formats", "resolutions"], - "categories": [ - { - "name": "Technology" - } - ], - "ratings": [ - { - "value": "PG", - "scheme": "urn:mpaa" - } - ], - "copyright": { - "value": "© 2025 Example Media Inc." - } - }, "groups": [ { "contents": [ @@ -2213,5 +1803,9 @@ "value": "Example Network" }, "importedFeed": "https://example.com/original-feed" + }, + "xml": { + "lang": "en", + "base": "http://example.org/" } } diff --git a/src/feeds/rss/references/rss-ns.xml b/src/feeds/rss/references/rss-ns.xml index a5176659..9b9940f6 100644 --- a/src/feeds/rss/references/rss-ns.xml +++ b/src/feeds/rss/references/rss-ns.xml @@ -1,5 +1,7 @@ Example Network https://example.com/original-feed - + Example Item http://example.org/item/1 John Smith diff --git a/src/index.ts b/src/index.ts index 56ece158..3f42fbc5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,39 +1,109 @@ -import type { DateLike } from './common/types.js' -import type { Atom as AtomTypes } from './feeds/atom/common/types.js' -import type { Json as JsonTypes } from './feeds/json/common/types.js' -import type { Rdf as RdfTypes } from './feeds/rdf/common/types.js' -import type { Rss as RssTypes } from './feeds/rss/common/types.js' -import type { Opml as OpmlTypes } from './opml/common/types.js' - +export { DetectError, GenerateError, MalformedError, ParseError } from './common/errors.js' +export type { AnyFeed } from './common/parse.js' export { parse as parseFeed } from './common/parse.js' - -/** @deprecated Use `import type { Atom } from 'feedsmith/types'` and access as `Atom.Feed` instead. */ -export type AtomFeed = AtomTypes.Feed +export type { DateLike, XmlStylesheet } from './common/types.js' +export type { AtomFeed } from './feeds/atom/common/types.js' export { detect as detectAtomFeed } from './feeds/atom/detect/index.js' export { generate as generateAtomFeed } from './feeds/atom/generate/index.js' export { parse as parseAtomFeed } from './feeds/atom/parse/index.js' - -/** @deprecated Use `import type { Json } from 'feedsmith/types'` and access as `Json.Feed` instead. */ -export type JsonFeed = JsonTypes.Feed +export type { JsonFeed } from './feeds/json/common/types.js' export { detect as detectJsonFeed } from './feeds/json/detect/index.js' export { generate as generateJsonFeed } from './feeds/json/generate/index.js' export { parse as parseJsonFeed } from './feeds/json/parse/index.js' - -/** @deprecated Use `import type { Rdf } from 'feedsmith/types'` and access as `Rdf.Feed` instead. */ -export type RdfFeed = RdfTypes.Feed +export type { RdfFeed } from './feeds/rdf/common/types.js' export { detect as detectRdfFeed } from './feeds/rdf/detect/index.js' export { parse as parseRdfFeed } from './feeds/rdf/parse/index.js' - -/** @deprecated Use `import type { Rss } from 'feedsmith/types'` and access as `Rss.Feed` instead. */ -export type RssFeed = RssTypes.Feed +export type { RssFeed } from './feeds/rss/common/types.js' export { detect as detectRssFeed } from './feeds/rss/detect/index.js' export { generate as generateRssFeed } from './feeds/rss/generate/index.js' export { parse as parseRssFeed } from './feeds/rss/parse/index.js' - -/** @deprecated Use `import type { Opml } from 'feedsmith/types'` and access as `Opml.Document` instead. */ -export type Opml< - TDate extends DateLike, - A extends ReadonlyArray = ReadonlyArray, -> = OpmlTypes.Document +export type { AcastNs } from './namespaces/acast/common/types.js' +export type { AdminNs } from './namespaces/admin/common/types.js' +export type { AppNs } from './namespaces/app/common/types.js' +export type { ArxivNs } from './namespaces/arxiv/common/types.js' +export type { AtomNs } from './namespaces/atom/common/types.js' +export type { BlogChannelNs } from './namespaces/blogchannel/common/types.js' +export type { CcNs } from './namespaces/cc/common/types.js' +export type { ContentNs } from './namespaces/content/common/types.js' +export type { CreativeCommonsNs } from './namespaces/creativecommons/common/types.js' +export type { DcNs } from './namespaces/dc/common/types.js' +export type { DcTermsNs } from './namespaces/dcterms/common/types.js' +export type { FeedPressNs } from './namespaces/feedpress/common/types.js' +export type { GeoNs } from './namespaces/geo/common/types.js' +export type { GeoRssNs } from './namespaces/georss/common/types.js' +export type { GooglePlayNs } from './namespaces/googleplay/common/types.js' +export type { ItunesNs } from './namespaces/itunes/common/types.js' +export type { MediaNs } from './namespaces/media/common/types.js' +export type { OpenSearchNs } from './namespaces/opensearch/common/types.js' +export type { PingbackNs } from './namespaces/pingback/common/types.js' +export type { PodcastNs } from './namespaces/podcast/common/types.js' +export type { PrismNs } from './namespaces/prism/common/types.js' +export type { PscNs } from './namespaces/psc/common/types.js' +export type { RawVoiceNs } from './namespaces/rawvoice/common/types.js' +export type { RdfNs } from './namespaces/rdf/common/types.js' +export type { SlashNs } from './namespaces/slash/common/types.js' +export type { SourceNs } from './namespaces/source/common/types.js' +export type { SpotifyNs } from './namespaces/spotify/common/types.js' +export type { SyNs } from './namespaces/sy/common/types.js' +export type { ThrNs } from './namespaces/thr/common/types.js' +export type { TrackbackNs } from './namespaces/trackback/common/types.js' +export type { WfwNs } from './namespaces/wfw/common/types.js' +export type { XmlNs } from './namespaces/xml/common/types.js' +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' + +// Deprecated aliases — declared here so JSDoc survives tsdown's .d.ts bundling. +// Remove this block in 4.x. + +import type { AtomFeed } from './feeds/atom/common/types.js' +import type { JsonFeed } from './feeds/json/common/types.js' +import type { RdfFeed } from './feeds/rdf/common/types.js' +import type { RssFeed } from './feeds/rss/common/types.js' + +/** @deprecated Use `RssFeed` instead. Will be removed in the next major version. */ +export namespace Rss { + export type Person = RssFeed.Person + export type Category = RssFeed.Category + export type Cloud = RssFeed.Cloud + export type Image = RssFeed.Image + export type TextInput = RssFeed.TextInput + export type Enclosure = RssFeed.Enclosure + export type SkipHours = RssFeed.SkipHours + export type SkipDays = RssFeed.SkipDays + export type Guid = RssFeed.Guid + export type Source = RssFeed.Source + export type Item = RssFeed.Item + export type Feed = RssFeed.Feed +} + +/** @deprecated Use `AtomFeed` instead. Will be removed in the next major version. */ +export namespace Atom { + export type Text = AtomFeed.Text + export type Content = AtomFeed.Content + export type Link = AtomFeed.Link + export type Person = AtomFeed.Person + export type Category = AtomFeed.Category + export type Generator = AtomFeed.Generator + export type Source = AtomFeed.Source + export type Entry = AtomFeed.Entry + export type Feed = AtomFeed.Feed +} + +/** @deprecated Use `JsonFeed` instead. Will be removed in the next major version. */ +export namespace Json { + export type Author = JsonFeed.Author + export type Attachment = JsonFeed.Attachment + export type Item = JsonFeed.Item + export type Hub = JsonFeed.Hub + export type Feed = JsonFeed.Feed +} + +/** @deprecated Use `RdfFeed` instead. Will be removed in the next major version. */ +export namespace Rdf { + export type Image = RdfFeed.Image + export type TextInput = RdfFeed.TextInput + export type Item = RdfFeed.Item + export type Feed = RdfFeed.Feed +} diff --git a/src/namespaces/acast/parse/utils.ts b/src/namespaces/acast/parse/utils.ts index 6d11062d..d7a5ba8a 100644 --- a/src/namespaces/acast/parse/utils.ts +++ b/src/namespaces/acast/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { ParseUtilPartial } from '../../../common/types.js' import { isObject, parseSingularOf, @@ -8,7 +8,7 @@ import { } from '../../../common/utils.js' import type { AcastNs } from '../common/types.js' -export const parseSignature: ParsePartialUtil = (value) => { +export const parseSignature: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -22,7 +22,7 @@ export const parseSignature: ParsePartialUtil = (value) => { return trimObject(signature) } -export const parseNetwork: ParsePartialUtil = (value) => { +export const parseNetwork: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -36,7 +36,7 @@ export const parseNetwork: ParsePartialUtil = (value) => { return trimObject(network) } -export const retrieveFeed: ParsePartialUtil = (value) => { +export const retrieveFeed: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -55,7 +55,7 @@ export const retrieveFeed: ParsePartialUtil = (value) => { return trimObject(feed) } -export const retrieveItem: ParsePartialUtil = (value) => { +export const retrieveItem: ParseUtilPartial = (value) => { if (!isObject(value)) { return } diff --git a/src/namespaces/admin/parse/utils.ts b/src/namespaces/admin/parse/utils.ts index 740c48fa..becda162 100644 --- a/src/namespaces/admin/parse/utils.ts +++ b/src/namespaces/admin/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { ParseUtilPartial } from '../../../common/types.js' import { isObject, parseSingularOf, @@ -8,7 +8,7 @@ import { } from '../../../common/utils.js' import type { AdminNs } from '../common/types.js' -export const retrieveFeed: ParsePartialUtil = (value) => { +export const retrieveFeed: ParseUtilPartial = (value) => { if (!isObject(value)) { return } diff --git a/src/namespaces/app/common/types.ts b/src/namespaces/app/common/types.ts index 6163540b..866fd6b7 100644 --- a/src/namespaces/app/common/types.ts +++ b/src/namespaces/app/common/types.ts @@ -1,12 +1,10 @@ -import type { DateLike } from '../../../common/types.js' - // #region reference export namespace AppNs { export type Control = { draft?: boolean } - export type Entry = { + export type Entry = { edited?: TDate control?: Control } diff --git a/src/namespaces/app/parse/utils.ts b/src/namespaces/app/parse/utils.ts index 482c2e88..d38d3e3c 100644 --- a/src/namespaces/app/parse/utils.ts +++ b/src/namespaces/app/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { DateAny, ParseMainOptions, ParseUtilPartial } from '../../../common/types.js' import { isObject, parseDate, @@ -9,7 +9,7 @@ import { } from '../../../common/utils.js' import type { AppNs } from '../common/types.js' -export const parseControl: ParsePartialUtil = (value) => { +export const parseControl: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -21,13 +21,18 @@ export const parseControl: ParsePartialUtil = (value) => { return trimObject(control) } -export const retrieveEntry: ParsePartialUtil> = (value) => { +export const retrieveEntry: ParseUtilPartial, ParseMainOptions> = ( + value, + options, +) => { if (!isObject(value)) { return } const entry = { - edited: parseSingularOf(value['app:edited'], (value) => parseDate(retrieveText(value))), + edited: parseSingularOf(value['app:edited'], (value) => + parseDate(retrieveText(value), options?.parseDateFn), + ), control: parseSingularOf(value['app:control'], parseControl), } diff --git a/src/namespaces/arxiv/parse/utils.ts b/src/namespaces/arxiv/parse/utils.ts index 967affa2..d2d5a066 100644 --- a/src/namespaces/arxiv/parse/utils.ts +++ b/src/namespaces/arxiv/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { ParseUtilPartial } from '../../../common/types.js' import { isObject, parseSingularOf, @@ -8,7 +8,7 @@ import { } from '../../../common/utils.js' import type { ArxivNs } from '../common/types.js' -export const parsePrimaryCategory: ParsePartialUtil = (value) => { +export const parsePrimaryCategory: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -22,7 +22,7 @@ export const parsePrimaryCategory: ParsePartialUtil = ( return trimObject(primaryCategory) } -export const retrieveAuthor: ParsePartialUtil = (value) => { +export const retrieveAuthor: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -36,7 +36,7 @@ export const retrieveAuthor: ParsePartialUtil = (value) => { return trimObject(author) } -export const retrieveEntry: ParsePartialUtil = (value) => { +export const retrieveEntry: ParseUtilPartial = (value) => { if (!isObject(value)) { return } diff --git a/src/namespaces/atom/common/types.ts b/src/namespaces/atom/common/types.ts index efc67a4f..24a38bbc 100644 --- a/src/namespaces/atom/common/types.ts +++ b/src/namespaces/atom/common/types.ts @@ -1,5 +1,5 @@ -import type { DateLike, DeepOmit } from '../../../common/types.js' -import type { Atom } from '../../../feeds/atom/common/types.js' +import type { DeepOmit } from '../../../common/types.js' +import type { AtomFeed } from '../../../feeds/atom/common/types.js' // Namespace properties to exclude when Atom is used as a namespace (not as a feed format). // This includes keys from all levels: Entry/Feed, Person (arxiv), Link (thr), etc. @@ -28,8 +28,8 @@ type NsKeys = // #region reference export namespace AtomNs { - export type Entry = Partial, NsKeys>> + export type Entry = DeepOmit, NsKeys> - export type Feed = Partial, NsKeys>> + export type Feed = DeepOmit, NsKeys> } // #endregion reference diff --git a/src/namespaces/atom/generate/utils.ts b/src/namespaces/atom/generate/utils.ts index 65b8fdf2..32664f87 100644 --- a/src/namespaces/atom/generate/utils.ts +++ b/src/namespaces/atom/generate/utils.ts @@ -1,5 +1,4 @@ import type { DateLike, GenerateUtil } from '../../../common/types.js' -import type { Atom } from '../../../feeds/atom/common/types.js' import { generateEntry as generateAtomEntry, generateFeed as generateAtomFeed, @@ -7,9 +6,9 @@ import { import type { AtomNs } from '../common/types.js' export const generateEntry: GenerateUtil> = (entry) => { - return generateAtomEntry(entry as Atom.Entry, { prefix: 'atom:', asNamespace: true }) + return generateAtomEntry(entry, { prefix: 'atom:', asNamespace: true }) } export const generateFeed: GenerateUtil> = (feed) => { - return generateAtomFeed(feed as Atom.Feed, { prefix: 'atom:', asNamespace: true })?.feed + return generateAtomFeed(feed, { prefix: 'atom:', asNamespace: true })?.feed } diff --git a/src/namespaces/atom/parse/utils.test.ts b/src/namespaces/atom/parse/utils.test.ts index 9b2bdb1a..e79761c7 100644 --- a/src/namespaces/atom/parse/utils.test.ts +++ b/src/namespaces/atom/parse/utils.test.ts @@ -3,7 +3,7 @@ import { retrieveEntry, retrieveFeed } from './utils.js' describe('retrieveEntry', () => { const expectedFull = { - title: 'Entry Title', + title: { value: 'Entry Title' }, id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', updated: '2023-01-01T12:00:00Z', authors: [{ name: 'John Doe' }], @@ -57,13 +57,13 @@ describe('retrieveEntry', () => { describe('retrieveFeed', () => { const expectedFull = { - title: 'Feed Title', + title: { value: 'Feed Title' }, id: 'urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6', updated: '2023-01-01T12:00:00Z', links: [{ href: 'https://example.com/', rel: 'alternate' }], entries: [ { - title: 'Entry Title', + title: { value: 'Entry Title' }, id: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', }, ], diff --git a/src/namespaces/atom/parse/utils.ts b/src/namespaces/atom/parse/utils.ts index ea2549f7..9c763573 100644 --- a/src/namespaces/atom/parse/utils.ts +++ b/src/namespaces/atom/parse/utils.ts @@ -1,14 +1,20 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { DateAny, ParseMainOptions, ParseUtilPartial } from '../../../common/types.js' import { parseEntry as parseAtomEntry, parseFeed as parseAtomFeed, } from '../../../feeds/atom/parse/utils.js' import type { AtomNs } from '../common/types.js' -export const retrieveEntry: ParsePartialUtil> = (value) => { - return parseAtomEntry(value, { prefix: 'atom:', asNamespace: true }) +export const retrieveEntry: ParseUtilPartial, ParseMainOptions> = ( + value, + options, +) => { + return parseAtomEntry(value, { ...options, prefix: 'atom:', asNamespace: true }) } -export const retrieveFeed: ParsePartialUtil> = (value) => { - return parseAtomFeed(value, { prefix: 'atom:', asNamespace: true }) +export const retrieveFeed: ParseUtilPartial, ParseMainOptions> = ( + value, + options, +) => { + return parseAtomFeed(value, { ...options, prefix: 'atom:', asNamespace: true }) } diff --git a/src/namespaces/blogchannel/parse/utils.ts b/src/namespaces/blogchannel/parse/utils.ts index dfbd3de8..5c0b9ffb 100644 --- a/src/namespaces/blogchannel/parse/utils.ts +++ b/src/namespaces/blogchannel/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { ParseUtilPartial } from '../../../common/types.js' import { isObject, parseSingularOf, @@ -8,7 +8,7 @@ import { } from '../../../common/utils.js' import type { BlogChannelNs } from '../common/types.js' -export const retrieveFeed: ParsePartialUtil = (value) => { +export const retrieveFeed: ParseUtilPartial = (value) => { if (!isObject(value)) { return } diff --git a/src/namespaces/cc/parse/utils.ts b/src/namespaces/cc/parse/utils.ts index 1aa51f87..1f839dab 100644 --- a/src/namespaces/cc/parse/utils.ts +++ b/src/namespaces/cc/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { ParseUtilPartial } from '../../../common/types.js' import { isObject, parseSingularOf, @@ -8,7 +8,7 @@ import { } from '../../../common/utils.js' import type { CcNs } from '../common/types.js' -export const retrieveItemOrFeed: ParsePartialUtil = (value) => { +export const retrieveItemOrFeed: ParseUtilPartial = (value) => { if (!isObject(value)) { return } diff --git a/src/namespaces/content/parse/utils.ts b/src/namespaces/content/parse/utils.ts index 5e4a4ab9..840af0d6 100644 --- a/src/namespaces/content/parse/utils.ts +++ b/src/namespaces/content/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { ParseUtilPartial } from '../../../common/types.js' import { isObject, parseSingularOf, @@ -8,7 +8,7 @@ import { } from '../../../common/utils.js' import type { ContentNs } from '../common/types.js' -export const retrieveItem: ParsePartialUtil = (value) => { +export const retrieveItem: ParseUtilPartial = (value) => { if (!isObject(value)) { return } diff --git a/src/namespaces/creativecommons/parse/utils.ts b/src/namespaces/creativecommons/parse/utils.ts index 56ccfbe6..db50fe48 100644 --- a/src/namespaces/creativecommons/parse/utils.ts +++ b/src/namespaces/creativecommons/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { ParseUtilPartial } from '../../../common/types.js' import { isObject, parseArrayOf, @@ -8,7 +8,7 @@ import { } from '../../../common/utils.js' import type { CreativeCommonsNs } from '../common/types.js' -export const retrieveItemOrFeed: ParsePartialUtil = (value) => { +export const retrieveItemOrFeed: ParseUtilPartial = (value) => { if (!isObject(value)) { return } diff --git a/src/namespaces/dc/common/types.ts b/src/namespaces/dc/common/types.ts index 371d138f..b645b64e 100644 --- a/src/namespaces/dc/common/types.ts +++ b/src/namespaces/dc/common/types.ts @@ -1,8 +1,6 @@ -import type { DateLike } from '../../../common/types.js' - // #region reference export namespace DcNs { - export type ItemOrFeed = { + export type ItemOrFeed = { titles?: Array creators?: Array subjects?: Array @@ -16,36 +14,8 @@ export namespace DcNs { sources?: Array languages?: Array relations?: Array - /** @deprecated Use `titles` (array) instead. Dublin Core fields are repeatable. */ - title?: string - /** @deprecated Use `creators` (array) instead. Dublin Core fields are repeatable. */ - creator?: string - /** @deprecated Use `subjects` (array) instead. Dublin Core fields are repeatable. */ - subject?: string - /** @deprecated Use `descriptions` (array) instead. Dublin Core fields are repeatable. */ - description?: string - /** @deprecated Use `publishers` (array) instead. Dublin Core fields are repeatable. */ - publisher?: string - /** @deprecated Use `contributors` (array) instead. Dublin Core fields are repeatable. */ - contributor?: string - /** @deprecated Use `dates` (array) instead. Dublin Core fields are repeatable. */ - date?: TDate - /** @deprecated Use `types` (array) instead. Dublin Core fields are repeatable. */ - type?: string - /** @deprecated Use `formats` (array) instead. Dublin Core fields are repeatable. */ - format?: string - /** @deprecated Use `identifiers` (array) instead. Dublin Core fields are repeatable. */ - identifier?: string - /** @deprecated Use `sources` (array) instead. Dublin Core fields are repeatable. */ - source?: string - /** @deprecated Use `languages` (array) instead. Dublin Core fields are repeatable. */ - language?: string - /** @deprecated Use `relations` (array) instead. Dublin Core fields are repeatable. */ - relation?: string - /** @deprecated This field type will be changed to array in the next major version of the package. Dublin Core fields are repeatable. */ - coverage?: string - /** @deprecated This field type will be changed to array in the next major version of the package. Dublin Core fields are repeatable. */ - rights?: string + coverage?: Array + rights?: Array } } // #endregion reference diff --git a/src/namespaces/dc/generate/utils.test.ts b/src/namespaces/dc/generate/utils.test.ts index abd7cd85..e16b6757 100644 --- a/src/namespaces/dc/generate/utils.test.ts +++ b/src/namespaces/dc/generate/utils.test.ts @@ -3,45 +3,6 @@ import { generateItemOrFeed } from './utils.js' describe('generateItemOrFeed', () => { it('should generate valid itemOrFeed object with all properties', () => { - const value = { - title: 'Test Title', - creator: 'John Doe', - subject: 'Technology', - description: 'A test description', - publisher: 'Test Publisher', - contributor: 'Jane Smith', - date: new Date('2023-01-01T00:00:00Z'), - type: 'Text', - format: 'text/html', - identifier: 'test-id-123', - source: 'Test Source', - language: 'en-US', - relation: 'https://example.com/related', - coverage: 'Global', - rights: 'Copyright 2023', - } - const expected = { - 'dc:title': 'Test Title', - 'dc:creator': 'John Doe', - 'dc:subject': 'Technology', - 'dc:description': 'A test description', - 'dc:publisher': 'Test Publisher', - 'dc:contributor': 'Jane Smith', - 'dc:date': '2023-01-01T00:00:00.000Z', - 'dc:type': 'Text', - 'dc:format': 'text/html', - 'dc:identifier': 'test-id-123', - 'dc:source': 'Test Source', - 'dc:language': 'en-US', - 'dc:relation': 'https://example.com/related', - 'dc:coverage': 'Global', - 'dc:rights': 'Copyright 2023', - } - - expect(generateItemOrFeed(value)).toEqual(expected) - }) - - it('should generate valid itemOrFeed object with all plural properties (single values)', () => { const value = { titles: ['Test Title'], creators: ['John Doe'], @@ -56,6 +17,8 @@ describe('generateItemOrFeed', () => { sources: ['Test Source'], languages: ['en-US'], relations: ['https://example.com/related'], + coverage: ['Global'], + rights: ['Copyright 2023'], } const expected = { 'dc:title': ['Test Title'], @@ -71,12 +34,14 @@ describe('generateItemOrFeed', () => { 'dc:source': ['Test Source'], 'dc:language': ['en-US'], 'dc:relation': ['https://example.com/related'], + 'dc:coverage': ['Global'], + 'dc:rights': ['Copyright 2023'], } expect(generateItemOrFeed(value)).toEqual(expected) }) - it('should generate valid itemOrFeed object with plural properties (multiple values)', () => { + it('should generate valid itemOrFeed object with multiple values', () => { const value = { titles: ['Test Title', 'Alternative Title'], creators: ['John Doe', 'Jane Smith'], @@ -91,6 +56,8 @@ describe('generateItemOrFeed', () => { sources: ['Test Source', 'Another Source'], languages: ['en-US', 'fr-FR'], relations: ['https://example.com/related', 'https://example.com/also-related'], + coverage: ['Global', 'Regional'], + rights: ['Copyright 2023', 'All rights reserved'], } const expected = { 'dc:title': ['Test Title', 'Alternative Title'], @@ -106,21 +73,8 @@ describe('generateItemOrFeed', () => { 'dc:source': ['Test Source', 'Another Source'], 'dc:language': ['en-US', 'fr-FR'], 'dc:relation': ['https://example.com/related', 'https://example.com/also-related'], - } - - expect(generateItemOrFeed(value)).toEqual(expected) - }) - - it('should prefer plural fields over singular when both are provided', () => { - const value = { - titles: ['Plural Title 1', 'Plural Title 2'], - creators: ['Plural Creator'], - title: 'Singular Title', - creator: 'Singular Creator', - } - const expected = { - 'dc:title': ['Plural Title 1', 'Plural Title 2'], - 'dc:creator': ['Plural Creator'], + 'dc:coverage': ['Global', 'Regional'], + 'dc:rights': ['Copyright 2023', 'All rights reserved'], } expect(generateItemOrFeed(value)).toEqual(expected) @@ -128,10 +82,10 @@ describe('generateItemOrFeed', () => { it('should generate itemOrFeed with minimal properties', () => { const value = { - title: 'Minimal Title', + titles: ['Minimal Title'], } const expected = { - 'dc:title': 'Minimal Title', + 'dc:title': ['Minimal Title'], } expect(generateItemOrFeed(value)).toEqual(expected) @@ -152,19 +106,6 @@ describe('generateItemOrFeed', () => { sources: undefined, languages: undefined, relations: undefined, - title: undefined, - creator: undefined, - subject: undefined, - description: undefined, - publisher: undefined, - contributor: undefined, - date: undefined, - type: undefined, - format: undefined, - identifier: undefined, - source: undefined, - language: undefined, - relation: undefined, coverage: undefined, rights: undefined, } @@ -172,7 +113,7 @@ describe('generateItemOrFeed', () => { expect(generateItemOrFeed(value)).toBeUndefined() }) - it('should handle empty arrays in plural fields', () => { + it('should handle empty arrays in fields', () => { const value = { titles: [], creators: ['John Doe'], @@ -194,4 +135,28 @@ describe('generateItemOrFeed', () => { it('should handle non-object inputs gracefully', () => { expect(generateItemOrFeed(undefined)).toBeUndefined() }) + + it('should handle coverage and rights arrays', () => { + const value = { + coverage: ['Worldwide', 'Europe', 'North America'], + rights: ['CC BY 4.0', 'Public Domain'], + } + const expected = { + 'dc:coverage': ['Worldwide', 'Europe', 'North America'], + 'dc:rights': ['CC BY 4.0', 'Public Domain'], + } + + expect(generateItemOrFeed(value)).toEqual(expected) + }) + + it('should handle date strings', () => { + const value = { + dates: ['2023-01-01T00:00:00Z', '2023-06-15T12:00:00Z'], + } + const expected = { + 'dc:date': ['2023-01-01T00:00:00.000Z', '2023-06-15T12:00:00.000Z'], + } + + expect(generateItemOrFeed(value)).toEqual(expected) + }) }) diff --git a/src/namespaces/dc/generate/utils.ts b/src/namespaces/dc/generate/utils.ts index ce51f439..2a575f3c 100644 --- a/src/namespaces/dc/generate/utils.ts +++ b/src/namespaces/dc/generate/utils.ts @@ -1,9 +1,9 @@ import type { DateLike, GenerateUtil } from '../../../common/types.js' import { - generateArrayOrSingular, generateCdataString, generateRfc3339Date, isObject, + trimArray, trimObject, } from '../../../common/utils.js' import type { DcNs } from '../common/types.js' @@ -14,61 +14,21 @@ export const generateItemOrFeed: GenerateUtil> = (item } const value = { - 'dc:title': generateArrayOrSingular(itemOrFeed.titles, itemOrFeed.title, generateCdataString), - 'dc:creator': generateArrayOrSingular( - itemOrFeed.creators, - itemOrFeed.creator, - generateCdataString, - ), - 'dc:subject': generateArrayOrSingular( - itemOrFeed.subjects, - itemOrFeed.subject, - generateCdataString, - ), - 'dc:description': generateArrayOrSingular( - itemOrFeed.descriptions, - itemOrFeed.description, - generateCdataString, - ), - 'dc:publisher': generateArrayOrSingular( - itemOrFeed.publishers, - itemOrFeed.publisher, - generateCdataString, - ), - 'dc:contributor': generateArrayOrSingular( - itemOrFeed.contributors, - itemOrFeed.contributor, - generateCdataString, - ), - 'dc:date': generateArrayOrSingular(itemOrFeed.dates, itemOrFeed.date, generateRfc3339Date), - 'dc:type': generateArrayOrSingular(itemOrFeed.types, itemOrFeed.type, generateCdataString), - 'dc:format': generateArrayOrSingular( - itemOrFeed.formats, - itemOrFeed.format, - generateCdataString, - ), - 'dc:identifier': generateArrayOrSingular( - itemOrFeed.identifiers, - itemOrFeed.identifier, - generateCdataString, - ), - 'dc:source': generateArrayOrSingular( - itemOrFeed.sources, - itemOrFeed.source, - generateCdataString, - ), - 'dc:language': generateArrayOrSingular( - itemOrFeed.languages, - itemOrFeed.language, - generateCdataString, - ), - 'dc:relation': generateArrayOrSingular( - itemOrFeed.relations, - itemOrFeed.relation, - generateCdataString, - ), - 'dc:coverage': generateCdataString(itemOrFeed.coverage), - 'dc:rights': generateCdataString(itemOrFeed.rights), + 'dc:title': trimArray(itemOrFeed.titles, generateCdataString), + 'dc:creator': trimArray(itemOrFeed.creators, generateCdataString), + 'dc:subject': trimArray(itemOrFeed.subjects, generateCdataString), + 'dc:description': trimArray(itemOrFeed.descriptions, generateCdataString), + 'dc:publisher': trimArray(itemOrFeed.publishers, generateCdataString), + 'dc:contributor': trimArray(itemOrFeed.contributors, generateCdataString), + 'dc:date': trimArray(itemOrFeed.dates, generateRfc3339Date), + 'dc:type': trimArray(itemOrFeed.types, generateCdataString), + 'dc:format': trimArray(itemOrFeed.formats, generateCdataString), + 'dc:identifier': trimArray(itemOrFeed.identifiers, generateCdataString), + 'dc:source': trimArray(itemOrFeed.sources, generateCdataString), + 'dc:language': trimArray(itemOrFeed.languages, generateCdataString), + 'dc:relation': trimArray(itemOrFeed.relations, generateCdataString), + 'dc:coverage': trimArray(itemOrFeed.coverage, generateCdataString), + 'dc:rights': trimArray(itemOrFeed.rights, generateCdataString), } return trimObject(value) diff --git a/src/namespaces/dc/parse/utils.test.ts b/src/namespaces/dc/parse/utils.test.ts index 8c7c7ac7..8e91ea4e 100644 --- a/src/namespaces/dc/parse/utils.test.ts +++ b/src/namespaces/dc/parse/utils.test.ts @@ -16,21 +16,8 @@ describe('retrieveItemOrFeed', () => { sources: ['https://example.org/source'], languages: ['en-US'], relations: ['https://example.org/related'], - title: 'Sample Title', - creator: 'John Doe', - subject: 'Test Subject', - description: 'This is a description', - publisher: 'Test Publisher', - contributor: 'Jane Smith', - date: '2023-05-15T09:30:00Z', - type: 'Article', - format: 'text/html', - identifier: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', - source: 'https://example.org/source', - language: 'en-US', - relation: 'https://example.org/related', - coverage: 'Worldwide', - rights: 'Copyright 2023, All rights reserved', + coverage: ['Worldwide'], + rights: ['Copyright 2023, All rights reserved'], } it('should parse complete item or feed object with all properties (with #text)', () => { @@ -115,21 +102,8 @@ describe('retrieveItemOrFeed', () => { sources: ['https://example.org/source', 'https://example.org/alternate-source'], languages: ['en-US', 'fr-FR'], relations: ['https://example.org/related', 'https://example.org/also-related'], - title: 'Sample Title', - creator: 'John Doe', - subject: 'Test Subject', - description: 'This is a description', - publisher: 'Test Publisher', - contributor: 'Jane Smith', - date: '2023-05-15T09:30:00Z', - type: 'Article', - format: 'text/html', - identifier: 'urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', - source: 'https://example.org/source', - language: 'en-US', - relation: 'https://example.org/related', - coverage: 'Worldwide', - rights: 'Copyright 2023, All rights reserved', + coverage: ['Worldwide', 'North America'], + rights: ['Copyright 2023, All rights reserved', 'Creative Commons BY-NC-SA 4.0'], } expect(retrieveItemOrFeed(value)).toEqual(expected) @@ -145,9 +119,6 @@ describe('retrieveItemOrFeed', () => { titles: ['Sample Title'], creators: ['John Doe'], dates: ['2023-05-15T09:30:00Z'], - title: 'Sample Title', - creator: 'John Doe', - date: '2023-05-15T09:30:00Z', } expect(retrieveItemOrFeed(value)).toEqual(expected) @@ -159,7 +130,6 @@ describe('retrieveItemOrFeed', () => { } const expected = { titles: ['Only Title'], - title: 'Only Title', } expect(retrieveItemOrFeed(value)).toEqual(expected) @@ -174,8 +144,6 @@ describe('retrieveItemOrFeed', () => { const expected = { titles: ['123'], identifiers: ['456'], - title: '123', - identifier: '456', } expect(retrieveItemOrFeed(value)).toEqual(expected) @@ -212,8 +180,6 @@ describe('retrieveItemOrFeed', () => { const expected = { creators: ['John Doe'], dates: ['2023-01-01T12:00:00Z'], - creator: 'John Doe', - date: '2023-01-01T12:00:00Z', } expect(retrieveItemOrFeed(value)).toEqual(expected) @@ -227,7 +193,6 @@ describe('retrieveItemOrFeed', () => { } const expected = { creators: ['John Doe'], - creator: 'John Doe', } expect(retrieveItemOrFeed(value)).toEqual(expected) @@ -241,7 +206,6 @@ describe('retrieveItemOrFeed', () => { } const expected = { creators: ['John Doe'], - creator: 'John Doe', } expect(retrieveItemOrFeed(value)).toEqual(expected) @@ -255,8 +219,6 @@ describe('retrieveItemOrFeed', () => { const expected = { identifiers: ['12345'], languages: ['en'], - identifier: '12345', - language: 'en', } expect(retrieveItemOrFeed(value)).toEqual(expected) diff --git a/src/namespaces/dc/parse/utils.ts b/src/namespaces/dc/parse/utils.ts index f2e5108b..f142446e 100644 --- a/src/namespaces/dc/parse/utils.ts +++ b/src/namespaces/dc/parse/utils.ts @@ -1,16 +1,18 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { DateAny, ParseMainOptions, ParseUtilPartial } from '../../../common/types.js' import { isObject, parseArrayOf, parseDate, - parseSingularOf, parseString, retrieveText, trimObject, } from '../../../common/utils.js' import type { DcNs } from '../common/types.js' -export const retrieveItemOrFeed: ParsePartialUtil> = (value) => { +export const retrieveItemOrFeed: ParseUtilPartial< + DcNs.ItemOrFeed, + ParseMainOptions +> = (value, options) => { if (!isObject(value)) { return } @@ -26,38 +28,17 @@ export const retrieveItemOrFeed: ParsePartialUtil> = (va contributors: parseArrayOf(value['dc:contributor'], (value) => parseString(retrieveText(value)), ), - dates: parseArrayOf(value['dc:date'], (value) => parseDate(retrieveText(value))), + dates: parseArrayOf(value['dc:date'], (value) => + parseDate(retrieveText(value), options?.parseDateFn), + ), types: parseArrayOf(value['dc:type'], (value) => parseString(retrieveText(value))), formats: parseArrayOf(value['dc:format'], (value) => parseString(retrieveText(value))), identifiers: parseArrayOf(value['dc:identifier'], (value) => parseString(retrieveText(value))), sources: parseArrayOf(value['dc:source'], (value) => parseString(retrieveText(value))), languages: parseArrayOf(value['dc:language'], (value) => parseString(retrieveText(value))), relations: parseArrayOf(value['dc:relation'], (value) => parseString(retrieveText(value))), - - // Deprecated fields for backward compatibility. - title: parseSingularOf(value['dc:title'], (value) => parseString(retrieveText(value))), - creator: parseSingularOf(value['dc:creator'], (value) => parseString(retrieveText(value))), - subject: parseSingularOf(value['dc:subject'], (value) => parseString(retrieveText(value))), - description: parseSingularOf(value['dc:description'], (value) => - parseString(retrieveText(value)), - ), - publisher: parseSingularOf(value['dc:publisher'], (value) => parseString(retrieveText(value))), - contributor: parseSingularOf(value['dc:contributor'], (value) => - parseString(retrieveText(value)), - ), - date: parseSingularOf(value['dc:date'], (value) => parseDate(retrieveText(value))), - type: parseSingularOf(value['dc:type'], (value) => parseString(retrieveText(value))), - format: parseSingularOf(value['dc:format'], (value) => parseString(retrieveText(value))), - identifier: parseSingularOf(value['dc:identifier'], (value) => - parseString(retrieveText(value)), - ), - source: parseSingularOf(value['dc:source'], (value) => parseString(retrieveText(value))), - language: parseSingularOf(value['dc:language'], (value) => parseString(retrieveText(value))), - relation: parseSingularOf(value['dc:relation'], (value) => parseString(retrieveText(value))), - - // Deprecated fields later on replaced with array type. - coverage: parseSingularOf(value['dc:coverage'], (value) => parseString(retrieveText(value))), - rights: parseSingularOf(value['dc:rights'], (value) => parseString(retrieveText(value))), + coverage: parseArrayOf(value['dc:coverage'], (value) => parseString(retrieveText(value))), + rights: parseArrayOf(value['dc:rights'], (value) => parseString(retrieveText(value))), } return trimObject(itemOrFeed) diff --git a/src/namespaces/dcterms/common/types.ts b/src/namespaces/dcterms/common/types.ts index 6d503399..0107782c 100644 --- a/src/namespaces/dcterms/common/types.ts +++ b/src/namespaces/dcterms/common/types.ts @@ -1,19 +1,24 @@ -import type { DateLike } from '../../../common/types.js' - // #region reference export namespace DcTermsNs { - export type ItemOrFeed = { + export type ItemOrFeed = { abstracts?: Array + accessRights?: Array accrualMethods?: Array accrualPeriodicities?: Array accrualPolicies?: Array alternatives?: Array audiences?: Array + available?: Array bibliographicCitations?: Array + conformsTo?: Array contributors?: Array coverages?: Array + created?: Array creators?: Array + dateAccepted?: Array + dateCopyrighted?: Array dates?: Array + dateSubmitted?: Array descriptions?: Array educationLevels?: Array extents?: Array @@ -23,131 +28,34 @@ export namespace DcTermsNs { hasVersions?: Array identifiers?: Array instructionalMethods?: Array + isFormatOf?: Array + isPartOf?: Array + isReferencedBy?: Array + isReplacedBy?: Array + isRequiredBy?: Array + issued?: Array + isVersionOf?: Array languages?: Array licenses?: Array mediators?: Array mediums?: Array + modified?: Array provenances?: Array publishers?: Array + references?: Array relations?: Array + replaces?: Array + requires?: Array + rights?: Array rightsHolders?: Array sources?: Array spatials?: Array subjects?: Array + tableOfContents?: Array temporals?: Array titles?: Array types?: Array - - /** @deprecated Use `abstracts` (array) instead. Dublin Core Terms fields are repeatable. */ - abstract?: string - /** @deprecated This field type will be changed to array in the next major version of the package. Dublin Core Terms fields are repeatable. */ - accessRights?: string - /** @deprecated Use `accrualMethods` (array) instead. Dublin Core Terms fields are repeatable. */ - accrualMethod?: string - /** @deprecated Use `accrualPeriodicities` (array) instead. Dublin Core Terms fields are repeatable. */ - accrualPeriodicity?: string - /** @deprecated Use `accrualPolicies` (array) instead. Dublin Core Terms fields are repeatable. */ - accrualPolicy?: string - /** @deprecated Use `alternatives` (array) instead. Dublin Core Terms fields are repeatable. */ - alternative?: string - /** @deprecated Use `audiences` (array) instead. Dublin Core Terms fields are repeatable. */ - audience?: string - /** @deprecated This field type will be changed to array in the next major version of the package. Dublin Core Terms fields are repeatable. */ - available?: TDate - /** @deprecated Use `bibliographicCitations` (array) instead. Dublin Core Terms fields are repeatable. */ - bibliographicCitation?: string - /** @deprecated This field type will be changed to array in the next major version of the package. Dublin Core Terms fields are repeatable. */ - conformsTo?: string - /** @deprecated Use `contributors` (array) instead. Dublin Core Terms fields are repeatable. */ - contributor?: string - /** @deprecated Use `coverages` (array) instead. Dublin Core Terms fields are repeatable. */ - coverage?: string - /** @deprecated This field type will be changed to array in the next major version of the package. Dublin Core Terms fields are repeatable. */ - created?: TDate - /** @deprecated Use `creators` (array) instead. Dublin Core Terms fields are repeatable. */ - creator?: string - /** @deprecated Use `dates` (array) instead. Dublin Core Terms fields are repeatable. */ - date?: TDate - /** @deprecated This field type will be changed to array in the next major version of the package. Dublin Core Terms fields are repeatable. */ - dateAccepted?: TDate - /** @deprecated This field type will be changed to array in the next major version of the package. Dublin Core Terms fields are repeatable. */ - dateCopyrighted?: TDate - /** @deprecated This field type will be changed to array in the next major version of the package. Dublin Core Terms fields are repeatable. */ - dateSubmitted?: TDate - /** @deprecated Use `descriptions` (array) instead. Dublin Core Terms fields are repeatable. */ - description?: string - /** @deprecated Use `educationLevels` (array) instead. Dublin Core Terms fields are repeatable. */ - educationLevel?: string - /** @deprecated Use `extents` (array) instead. Dublin Core Terms fields are repeatable. */ - extent?: string - /** @deprecated Use `formats` (array) instead. Dublin Core Terms fields are repeatable. */ - format?: string - /** @deprecated Use `hasFormats` (array) instead. Dublin Core Terms fields are repeatable. */ - hasFormat?: string - /** @deprecated Use `hasParts` (array) instead. Dublin Core Terms fields are repeatable. */ - hasPart?: string - /** @deprecated Use `hasVersions` (array) instead. Dublin Core Terms fields are repeatable. */ - hasVersion?: string - /** @deprecated Use `identifiers` (array) instead. Dublin Core Terms fields are repeatable. */ - identifier?: string - /** @deprecated Use `instructionalMethods` (array) instead. Dublin Core Terms fields are repeatable. */ - instructionalMethod?: string - /** @deprecated This field type will be changed to array in the next major version of the package. Dublin Core Terms fields are repeatable. */ - isFormatOf?: string - /** @deprecated This field type will be changed to array in the next major version of the package. Dublin Core Terms fields are repeatable. */ - isPartOf?: string - /** @deprecated This field type will be changed to array in the next major version of the package. Dublin Core Terms fields are repeatable. */ - isReferencedBy?: string - /** @deprecated This field type will be changed to array in the next major version of the package. Dublin Core Terms fields are repeatable. */ - isReplacedBy?: string - /** @deprecated This field type will be changed to array in the next major version of the package. Dublin Core Terms fields are repeatable. */ - isRequiredBy?: string - /** @deprecated This field type will be changed to array in the next major version of the package. Dublin Core Terms fields are repeatable. */ - issued?: TDate - /** @deprecated This field type will be changed to array in the next major version of the package. Dublin Core Terms fields are repeatable. */ - isVersionOf?: string - /** @deprecated Use `languages` (array) instead. Dublin Core Terms fields are repeatable. */ - language?: string - /** @deprecated Use `licenses` (array) instead. Dublin Core Terms fields are repeatable. */ - license?: string - /** @deprecated Use `mediators` (array) instead. Dublin Core Terms fields are repeatable. */ - mediator?: string - /** @deprecated Use `mediums` (array) instead. Dublin Core Terms fields are repeatable. */ - medium?: string - /** @deprecated This field type will be changed to array in the next major version of the package. Dublin Core Terms fields are repeatable. */ - modified?: TDate - /** @deprecated Use `provenances` (array) instead. Dublin Core Terms fields are repeatable. */ - provenance?: string - /** @deprecated Use `publishers` (array) instead. Dublin Core Terms fields are repeatable. */ - publisher?: string - /** @deprecated This field type will be changed to array in the next major version of the package. Dublin Core Terms fields are repeatable. */ - references?: string - /** @deprecated Use `relations` (array) instead. Dublin Core Terms fields are repeatable. */ - relation?: string - /** @deprecated This field type will be changed to array in the next major version of the package. Dublin Core Terms fields are repeatable. */ - replaces?: string - /** @deprecated This field type will be changed to array in the next major version of the package. Dublin Core Terms fields are repeatable. */ - requires?: string - /** @deprecated This field type will be changed to array in the next major version of the package. Dublin Core Terms fields are repeatable. */ - rights?: string - /** @deprecated Use `rightsHolders` (array) instead. Dublin Core Terms fields are repeatable. */ - rightsHolder?: string - /** @deprecated Use `sources` (array) instead. Dublin Core Terms fields are repeatable. */ - source?: string - /** @deprecated Use `spatials` (array) instead. Dublin Core Terms fields are repeatable. */ - spatial?: string - /** @deprecated Use `subjects` (array) instead. Dublin Core Terms fields are repeatable. */ - subject?: string - /** @deprecated This field type will be changed to array in the next major version of the package. Dublin Core Terms fields are repeatable. */ - tableOfContents?: string - /** @deprecated Use `temporals` (array) instead. Dublin Core Terms fields are repeatable. */ - temporal?: string - /** @deprecated Use `titles` (array) instead. Dublin Core Terms fields are repeatable. */ - title?: string - /** @deprecated Use `types` (array) instead. Dublin Core Terms fields are repeatable. */ - type?: string - /** @deprecated This field type will be changed to array in the next major version of the package. Dublin Core Terms fields are repeatable. */ - valid?: TDate + valid?: Array } } // #endregion reference diff --git a/src/namespaces/dcterms/generate/utils.test.ts b/src/namespaces/dcterms/generate/utils.test.ts index 12cc0008..2975a833 100644 --- a/src/namespaces/dcterms/generate/utils.test.ts +++ b/src/namespaces/dcterms/generate/utils.test.ts @@ -4,147 +4,24 @@ import { generateItemOrFeed } from './utils.js' describe('generateItemOrFeed', () => { it('should generate valid itemOrFeed object with all properties', () => { const value = { - abstract: 'Sample abstract content', - accessRights: 'Open Access', - accrualMethod: 'Manual upload', - accrualPeriodicity: 'Annual', - accrualPolicy: 'Open submission', - alternative: 'Alternative Title', - audience: 'General Public', - available: new Date('2023-06-01T00:00:00Z'), - bibliographicCitation: 'Doe, J. (2023). Sample Work. Publisher.', - conformsTo: 'Dublin Core Metadata Terms', - contributor: 'Jane Smith', - coverage: 'Global', - created: new Date('2023-05-01T12:00:00Z'), - creator: 'John Doe', - date: new Date('2023-05-02T08:30:00Z'), - dateAccepted: new Date('2023-05-10T09:00:00Z'), - dateCopyrighted: new Date('2023-05-15T00:00:00Z'), - dateSubmitted: new Date('2023-05-05T14:30:00Z'), - description: 'A comprehensive description', - educationLevel: 'Graduate level', - extent: '250 pages', - format: 'application/pdf', - hasFormat: 'https://example.org/formats/pdf', - hasPart: 'https://example.org/parts/chapter1', - hasVersion: 'https://example.org/versions/v2', - identifier: 'ISBN:978-0123456789', - instructionalMethod: 'Online learning', - isFormatOf: 'https://example.org/original', - isPartOf: 'https://example.org/collection', - isReferencedBy: 'https://example.org/references/citation1', - isReplacedBy: 'https://example.org/replacement', - isRequiredBy: 'https://example.org/dependent', - issued: new Date('2023-05-20T10:00:00Z'), - isVersionOf: 'https://example.org/original-work', - language: 'en-US', - license: 'Creative Commons Attribution 4.0', - mediator: 'Library System', - medium: 'Digital', - modified: new Date('2023-05-25T16:45:00Z'), - provenance: 'Digitized from original manuscript', - publisher: 'Academic Press', - references: 'https://example.org/ref/source1', - relation: 'https://example.org/related', - replaces: 'https://example.org/old-version', - requires: 'https://example.org/prerequisite', - rights: 'Copyright 2023', - rightsHolder: 'Example University', - source: 'https://example.org/source', - spatial: 'Boston, MA, USA', - subject: 'Academic Research', - tableOfContents: 'Chapter 1, Chapter 2, Chapter 3', - temporal: '2023', - title: 'Sample Title', - type: 'Text', - valid: new Date('2024-05-25T23:59:59Z'), - } - const expected = { - 'dcterms:abstract': 'Sample abstract content', - 'dcterms:accessRights': 'Open Access', - 'dcterms:accrualMethod': 'Manual upload', - 'dcterms:accrualPeriodicity': 'Annual', - 'dcterms:accrualPolicy': 'Open submission', - 'dcterms:alternative': 'Alternative Title', - 'dcterms:audience': 'General Public', - 'dcterms:available': '2023-06-01T00:00:00.000Z', - 'dcterms:bibliographicCitation': 'Doe, J. (2023). Sample Work. Publisher.', - 'dcterms:conformsTo': 'Dublin Core Metadata Terms', - 'dcterms:contributor': 'Jane Smith', - 'dcterms:coverage': 'Global', - 'dcterms:created': '2023-05-01T12:00:00.000Z', - 'dcterms:creator': 'John Doe', - 'dcterms:date': '2023-05-02T08:30:00.000Z', - 'dcterms:dateAccepted': '2023-05-10T09:00:00.000Z', - 'dcterms:dateCopyrighted': '2023-05-15T00:00:00.000Z', - 'dcterms:dateSubmitted': '2023-05-05T14:30:00.000Z', - 'dcterms:description': 'A comprehensive description', - 'dcterms:educationLevel': 'Graduate level', - 'dcterms:extent': '250 pages', - 'dcterms:format': 'application/pdf', - 'dcterms:hasFormat': 'https://example.org/formats/pdf', - 'dcterms:hasPart': 'https://example.org/parts/chapter1', - 'dcterms:hasVersion': 'https://example.org/versions/v2', - 'dcterms:identifier': 'ISBN:978-0123456789', - 'dcterms:instructionalMethod': 'Online learning', - 'dcterms:isFormatOf': 'https://example.org/original', - 'dcterms:isPartOf': 'https://example.org/collection', - 'dcterms:isReferencedBy': 'https://example.org/references/citation1', - 'dcterms:isReplacedBy': 'https://example.org/replacement', - 'dcterms:isRequiredBy': 'https://example.org/dependent', - 'dcterms:issued': '2023-05-20T10:00:00.000Z', - 'dcterms:isVersionOf': 'https://example.org/original-work', - 'dcterms:language': 'en-US', - 'dcterms:license': 'Creative Commons Attribution 4.0', - 'dcterms:mediator': 'Library System', - 'dcterms:medium': 'Digital', - 'dcterms:modified': '2023-05-25T16:45:00.000Z', - 'dcterms:provenance': 'Digitized from original manuscript', - 'dcterms:publisher': 'Academic Press', - 'dcterms:references': 'https://example.org/ref/source1', - 'dcterms:relation': 'https://example.org/related', - 'dcterms:replaces': 'https://example.org/old-version', - 'dcterms:requires': 'https://example.org/prerequisite', - 'dcterms:rights': 'Copyright 2023', - 'dcterms:rightsHolder': 'Example University', - 'dcterms:source': 'https://example.org/source', - 'dcterms:spatial': 'Boston, MA, USA', - 'dcterms:subject': 'Academic Research', - 'dcterms:tableOfContents': 'Chapter 1, Chapter 2, Chapter 3', - 'dcterms:temporal': '2023', - 'dcterms:title': 'Sample Title', - 'dcterms:type': 'Text', - 'dcterms:valid': '2024-05-25T23:59:59.000Z', - } - - expect(generateItemOrFeed(value)).toEqual(expected) - }) - - it('should generate itemOrFeed with minimal properties', () => { - const value = { - title: 'Minimal Title', - } - const expected = { - 'dcterms:title': 'Minimal Title', - } - - expect(generateItemOrFeed(value)).toEqual(expected) - }) - - it('should generate valid itemOrFeed object with all plural properties (single values)', () => { - const value = { - abstracts: ['Sample abstract'], + abstracts: ['Sample abstract content'], + accessRights: ['Open Access'], accrualMethods: ['Manual upload'], accrualPeriodicities: ['Annual'], accrualPolicies: ['Open submission'], alternatives: ['Alternative Title'], audiences: ['General Public'], - bibliographicCitations: ['Doe, J. (2023). Sample Work.'], + available: [new Date('2023-06-01T00:00:00Z')], + bibliographicCitations: ['Doe, J. (2023). Sample Work. Publisher.'], + conformsTo: ['Dublin Core Metadata Terms'], contributors: ['Jane Smith'], coverages: ['Global'], + created: [new Date('2023-05-01T12:00:00Z')], creators: ['John Doe'], + dateAccepted: [new Date('2023-05-10T09:00:00Z')], + dateCopyrighted: [new Date('2023-05-15T00:00:00Z')], dates: [new Date('2023-05-02T08:30:00Z')], + dateSubmitted: [new Date('2023-05-05T14:30:00Z')], descriptions: ['A comprehensive description'], educationLevels: ['Graduate level'], extents: ['250 pages'], @@ -154,33 +31,54 @@ describe('generateItemOrFeed', () => { hasVersions: ['https://example.org/versions/v2'], identifiers: ['ISBN:978-0123456789'], instructionalMethods: ['Online learning'], + isFormatOf: ['https://example.org/original'], + isPartOf: ['https://example.org/collection'], + isReferencedBy: ['https://example.org/references/citation1'], + isReplacedBy: ['https://example.org/replacement'], + isRequiredBy: ['https://example.org/dependent'], + issued: [new Date('2023-05-20T10:00:00Z')], + isVersionOf: ['https://example.org/original-work'], languages: ['en-US'], licenses: ['Creative Commons Attribution 4.0'], mediators: ['Library System'], mediums: ['Digital'], + modified: [new Date('2023-05-25T16:45:00Z')], provenances: ['Digitized from original manuscript'], publishers: ['Academic Press'], + references: ['https://example.org/ref/source1'], relations: ['https://example.org/related'], + replaces: ['https://example.org/old-version'], + requires: ['https://example.org/prerequisite'], + rights: ['Copyright 2023'], rightsHolders: ['Example University'], sources: ['https://example.org/source'], spatials: ['Boston, MA, USA'], subjects: ['Academic Research'], + tableOfContents: ['Chapter 1, Chapter 2, Chapter 3'], temporals: ['2023'], titles: ['Sample Title'], types: ['Text'], + valid: [new Date('2024-05-25T23:59:59Z')], } const expected = { - 'dcterms:abstract': ['Sample abstract'], + 'dcterms:abstract': ['Sample abstract content'], + 'dcterms:accessRights': ['Open Access'], 'dcterms:accrualMethod': ['Manual upload'], 'dcterms:accrualPeriodicity': ['Annual'], 'dcterms:accrualPolicy': ['Open submission'], 'dcterms:alternative': ['Alternative Title'], 'dcterms:audience': ['General Public'], - 'dcterms:bibliographicCitation': ['Doe, J. (2023). Sample Work.'], + 'dcterms:available': ['2023-06-01T00:00:00.000Z'], + 'dcterms:bibliographicCitation': ['Doe, J. (2023). Sample Work. Publisher.'], + 'dcterms:conformsTo': ['Dublin Core Metadata Terms'], 'dcterms:contributor': ['Jane Smith'], 'dcterms:coverage': ['Global'], + 'dcterms:created': ['2023-05-01T12:00:00.000Z'], 'dcterms:creator': ['John Doe'], 'dcterms:date': ['2023-05-02T08:30:00.000Z'], + 'dcterms:dateAccepted': ['2023-05-10T09:00:00.000Z'], + 'dcterms:dateCopyrighted': ['2023-05-15T00:00:00.000Z'], + 'dcterms:dateSubmitted': ['2023-05-05T14:30:00.000Z'], 'dcterms:description': ['A comprehensive description'], 'dcterms:educationLevel': ['Graduate level'], 'dcterms:extent': ['250 pages'], @@ -190,58 +88,68 @@ describe('generateItemOrFeed', () => { 'dcterms:hasVersion': ['https://example.org/versions/v2'], 'dcterms:identifier': ['ISBN:978-0123456789'], 'dcterms:instructionalMethod': ['Online learning'], + 'dcterms:isFormatOf': ['https://example.org/original'], + 'dcterms:isPartOf': ['https://example.org/collection'], + 'dcterms:isReferencedBy': ['https://example.org/references/citation1'], + 'dcterms:isReplacedBy': ['https://example.org/replacement'], + 'dcterms:isRequiredBy': ['https://example.org/dependent'], + 'dcterms:issued': ['2023-05-20T10:00:00.000Z'], + 'dcterms:isVersionOf': ['https://example.org/original-work'], 'dcterms:language': ['en-US'], 'dcterms:license': ['Creative Commons Attribution 4.0'], 'dcterms:mediator': ['Library System'], 'dcterms:medium': ['Digital'], + 'dcterms:modified': ['2023-05-25T16:45:00.000Z'], 'dcterms:provenance': ['Digitized from original manuscript'], 'dcterms:publisher': ['Academic Press'], + 'dcterms:references': ['https://example.org/ref/source1'], 'dcterms:relation': ['https://example.org/related'], + 'dcterms:replaces': ['https://example.org/old-version'], + 'dcterms:requires': ['https://example.org/prerequisite'], + 'dcterms:rights': ['Copyright 2023'], 'dcterms:rightsHolder': ['Example University'], 'dcterms:source': ['https://example.org/source'], 'dcterms:spatial': ['Boston, MA, USA'], 'dcterms:subject': ['Academic Research'], + 'dcterms:tableOfContents': ['Chapter 1, Chapter 2, Chapter 3'], 'dcterms:temporal': ['2023'], 'dcterms:title': ['Sample Title'], 'dcterms:type': ['Text'], + 'dcterms:valid': ['2024-05-25T23:59:59.000Z'], } expect(generateItemOrFeed(value)).toEqual(expected) }) - it('should generate valid itemOrFeed object with plural properties (multiple values)', () => { + it('should generate itemOrFeed with minimal properties', () => { const value = { - abstracts: ['First abstract', 'Second abstract'], - creators: ['John Doe', 'Jane Smith'], - dates: [new Date('2023-01-01T00:00:00Z'), new Date('2023-02-01T00:00:00Z')], - titles: ['Main Title', 'Alternative Title'], + titles: ['Minimal Title'], } const expected = { - 'dcterms:abstract': ['First abstract', 'Second abstract'], - 'dcterms:creator': ['John Doe', 'Jane Smith'], - 'dcterms:date': ['2023-01-01T00:00:00.000Z', '2023-02-01T00:00:00.000Z'], - 'dcterms:title': ['Main Title', 'Alternative Title'], + 'dcterms:title': ['Minimal Title'], } expect(generateItemOrFeed(value)).toEqual(expected) }) - it('should prefer plural fields over singular when both are provided', () => { + it('should generate valid itemOrFeed object with multiple values', () => { const value = { - abstracts: ['Plural Abstract 1', 'Plural Abstract 2'], - creators: ['Plural Creator'], - abstract: 'Singular Abstract', - creator: 'Singular Creator', + abstracts: ['First abstract', 'Second abstract'], + creators: ['John Doe', 'Jane Smith'], + dates: [new Date('2023-01-01T00:00:00Z'), new Date('2023-02-01T00:00:00Z')], + titles: ['Main Title', 'Alternative Title'], } const expected = { - 'dcterms:abstract': ['Plural Abstract 1', 'Plural Abstract 2'], - 'dcterms:creator': ['Plural Creator'], + 'dcterms:abstract': ['First abstract', 'Second abstract'], + 'dcterms:creator': ['John Doe', 'Jane Smith'], + 'dcterms:date': ['2023-01-01T00:00:00.000Z', '2023-02-01T00:00:00.000Z'], + 'dcterms:title': ['Main Title', 'Alternative Title'], } expect(generateItemOrFeed(value)).toEqual(expected) }) - it('should handle empty arrays in plural fields', () => { + it('should handle empty arrays', () => { const value = { abstracts: [], creators: ['John Doe'], @@ -257,16 +165,23 @@ describe('generateItemOrFeed', () => { it('should handle object with only undefined/empty properties', () => { const value = { abstracts: undefined, + accessRights: undefined, accrualMethods: undefined, accrualPeriodicities: undefined, accrualPolicies: undefined, alternatives: undefined, audiences: undefined, + available: undefined, bibliographicCitations: undefined, + conformsTo: undefined, contributors: undefined, coverages: undefined, + created: undefined, creators: undefined, + dateAccepted: undefined, + dateCopyrighted: undefined, dates: undefined, + dateSubmitted: undefined, descriptions: undefined, educationLevels: undefined, extents: undefined, @@ -276,74 +191,33 @@ describe('generateItemOrFeed', () => { hasVersions: undefined, identifiers: undefined, instructionalMethods: undefined, + isFormatOf: undefined, + isPartOf: undefined, + isReferencedBy: undefined, + isReplacedBy: undefined, + isRequiredBy: undefined, + issued: undefined, + isVersionOf: undefined, languages: undefined, licenses: undefined, mediators: undefined, mediums: undefined, + modified: undefined, provenances: undefined, publishers: undefined, + references: undefined, relations: undefined, + replaces: undefined, + requires: undefined, + rights: undefined, rightsHolders: undefined, sources: undefined, spatials: undefined, subjects: undefined, + tableOfContents: undefined, temporals: undefined, titles: undefined, types: undefined, - abstract: undefined, - accessRights: undefined, - accrualMethod: undefined, - accrualPeriodicity: undefined, - accrualPolicy: undefined, - alternative: undefined, - audience: undefined, - available: undefined, - bibliographicCitation: undefined, - conformsTo: undefined, - contributor: undefined, - coverage: undefined, - created: undefined, - creator: undefined, - date: undefined, - dateAccepted: undefined, - dateCopyrighted: undefined, - dateSubmitted: undefined, - description: undefined, - educationLevel: undefined, - extent: undefined, - format: undefined, - hasFormat: undefined, - hasPart: undefined, - hasVersion: undefined, - identifier: undefined, - instructionalMethod: undefined, - isFormatOf: undefined, - isPartOf: undefined, - isReferencedBy: undefined, - isReplacedBy: undefined, - isRequiredBy: undefined, - issued: undefined, - isVersionOf: undefined, - language: undefined, - license: undefined, - mediator: undefined, - medium: undefined, - modified: undefined, - provenance: undefined, - publisher: undefined, - references: undefined, - relation: undefined, - replaces: undefined, - requires: undefined, - rights: undefined, - rightsHolder: undefined, - source: undefined, - spatial: undefined, - subject: undefined, - tableOfContents: undefined, - temporal: undefined, - title: undefined, - type: undefined, valid: undefined, } diff --git a/src/namespaces/dcterms/generate/utils.ts b/src/namespaces/dcterms/generate/utils.ts index 680117c9..0f81c572 100644 --- a/src/namespaces/dcterms/generate/utils.ts +++ b/src/namespaces/dcterms/generate/utils.ts @@ -1,9 +1,9 @@ import type { DateLike, GenerateUtil } from '../../../common/types.js' import { - generateArrayOrSingular, generateCdataString, generateRfc3339Date, isObject, + trimArray, trimObject, } from '../../../common/utils.js' import type { DcTermsNs } from '../common/types.js' @@ -14,189 +14,64 @@ export const generateItemOrFeed: GenerateUtil> = } const value = { - 'dcterms:abstract': generateArrayOrSingular( - itemOrFeed.abstracts, - itemOrFeed.abstract, - generateCdataString, - ), - 'dcterms:accessRights': generateCdataString(itemOrFeed.accessRights), - 'dcterms:accrualMethod': generateArrayOrSingular( - itemOrFeed.accrualMethods, - itemOrFeed.accrualMethod, - generateCdataString, - ), - 'dcterms:accrualPeriodicity': generateArrayOrSingular( - itemOrFeed.accrualPeriodicities, - itemOrFeed.accrualPeriodicity, - generateCdataString, - ), - 'dcterms:accrualPolicy': generateArrayOrSingular( - itemOrFeed.accrualPolicies, - itemOrFeed.accrualPolicy, - generateCdataString, - ), - 'dcterms:alternative': generateArrayOrSingular( - itemOrFeed.alternatives, - itemOrFeed.alternative, - generateCdataString, - ), - 'dcterms:audience': generateArrayOrSingular( - itemOrFeed.audiences, - itemOrFeed.audience, - generateCdataString, - ), - 'dcterms:available': generateRfc3339Date(itemOrFeed.available), - 'dcterms:bibliographicCitation': generateArrayOrSingular( + 'dcterms:abstract': trimArray(itemOrFeed.abstracts, generateCdataString), + 'dcterms:accessRights': trimArray(itemOrFeed.accessRights, generateCdataString), + 'dcterms:accrualMethod': trimArray(itemOrFeed.accrualMethods, generateCdataString), + 'dcterms:accrualPeriodicity': trimArray(itemOrFeed.accrualPeriodicities, generateCdataString), + 'dcterms:accrualPolicy': trimArray(itemOrFeed.accrualPolicies, generateCdataString), + 'dcterms:alternative': trimArray(itemOrFeed.alternatives, generateCdataString), + 'dcterms:audience': trimArray(itemOrFeed.audiences, generateCdataString), + 'dcterms:available': trimArray(itemOrFeed.available, generateRfc3339Date), + 'dcterms:bibliographicCitation': trimArray( itemOrFeed.bibliographicCitations, - itemOrFeed.bibliographicCitation, - generateCdataString, - ), - 'dcterms:conformsTo': generateCdataString(itemOrFeed.conformsTo), - 'dcterms:contributor': generateArrayOrSingular( - itemOrFeed.contributors, - itemOrFeed.contributor, - generateCdataString, - ), - 'dcterms:coverage': generateArrayOrSingular( - itemOrFeed.coverages, - itemOrFeed.coverage, - generateCdataString, - ), - 'dcterms:created': generateRfc3339Date(itemOrFeed.created), - 'dcterms:creator': generateArrayOrSingular( - itemOrFeed.creators, - itemOrFeed.creator, - generateCdataString, - ), - 'dcterms:date': generateArrayOrSingular(itemOrFeed.dates, itemOrFeed.date, generateRfc3339Date), - 'dcterms:dateAccepted': generateRfc3339Date(itemOrFeed.dateAccepted), - 'dcterms:dateCopyrighted': generateRfc3339Date(itemOrFeed.dateCopyrighted), - 'dcterms:dateSubmitted': generateRfc3339Date(itemOrFeed.dateSubmitted), - 'dcterms:description': generateArrayOrSingular( - itemOrFeed.descriptions, - itemOrFeed.description, - generateCdataString, - ), - 'dcterms:educationLevel': generateArrayOrSingular( - itemOrFeed.educationLevels, - itemOrFeed.educationLevel, - generateCdataString, - ), - 'dcterms:extent': generateArrayOrSingular( - itemOrFeed.extents, - itemOrFeed.extent, - generateCdataString, - ), - 'dcterms:format': generateArrayOrSingular( - itemOrFeed.formats, - itemOrFeed.format, - generateCdataString, - ), - 'dcterms:hasFormat': generateArrayOrSingular( - itemOrFeed.hasFormats, - itemOrFeed.hasFormat, - generateCdataString, - ), - 'dcterms:hasPart': generateArrayOrSingular( - itemOrFeed.hasParts, - itemOrFeed.hasPart, - generateCdataString, - ), - 'dcterms:hasVersion': generateArrayOrSingular( - itemOrFeed.hasVersions, - itemOrFeed.hasVersion, - generateCdataString, - ), - 'dcterms:identifier': generateArrayOrSingular( - itemOrFeed.identifiers, - itemOrFeed.identifier, - generateCdataString, - ), - 'dcterms:instructionalMethod': generateArrayOrSingular( - itemOrFeed.instructionalMethods, - itemOrFeed.instructionalMethod, - generateCdataString, - ), - 'dcterms:isFormatOf': generateCdataString(itemOrFeed.isFormatOf), - 'dcterms:isPartOf': generateCdataString(itemOrFeed.isPartOf), - 'dcterms:isReferencedBy': generateCdataString(itemOrFeed.isReferencedBy), - 'dcterms:isReplacedBy': generateCdataString(itemOrFeed.isReplacedBy), - 'dcterms:isRequiredBy': generateCdataString(itemOrFeed.isRequiredBy), - 'dcterms:issued': generateRfc3339Date(itemOrFeed.issued), - 'dcterms:isVersionOf': generateCdataString(itemOrFeed.isVersionOf), - 'dcterms:language': generateArrayOrSingular( - itemOrFeed.languages, - itemOrFeed.language, - generateCdataString, - ), - 'dcterms:license': generateArrayOrSingular( - itemOrFeed.licenses, - itemOrFeed.license, - generateCdataString, - ), - 'dcterms:mediator': generateArrayOrSingular( - itemOrFeed.mediators, - itemOrFeed.mediator, - generateCdataString, - ), - 'dcterms:medium': generateArrayOrSingular( - itemOrFeed.mediums, - itemOrFeed.medium, - generateCdataString, - ), - 'dcterms:modified': generateRfc3339Date(itemOrFeed.modified), - 'dcterms:provenance': generateArrayOrSingular( - itemOrFeed.provenances, - itemOrFeed.provenance, - generateCdataString, - ), - 'dcterms:publisher': generateArrayOrSingular( - itemOrFeed.publishers, - itemOrFeed.publisher, - generateCdataString, - ), - 'dcterms:references': generateCdataString(itemOrFeed.references), - 'dcterms:relation': generateArrayOrSingular( - itemOrFeed.relations, - itemOrFeed.relation, - generateCdataString, - ), - 'dcterms:replaces': generateCdataString(itemOrFeed.replaces), - 'dcterms:requires': generateCdataString(itemOrFeed.requires), - 'dcterms:rights': generateCdataString(itemOrFeed.rights), - 'dcterms:rightsHolder': generateArrayOrSingular( - itemOrFeed.rightsHolders, - itemOrFeed.rightsHolder, - generateCdataString, - ), - 'dcterms:source': generateArrayOrSingular( - itemOrFeed.sources, - itemOrFeed.source, - generateCdataString, - ), - 'dcterms:spatial': generateArrayOrSingular( - itemOrFeed.spatials, - itemOrFeed.spatial, - generateCdataString, - ), - 'dcterms:subject': generateArrayOrSingular( - itemOrFeed.subjects, - itemOrFeed.subject, - generateCdataString, - ), - 'dcterms:tableOfContents': generateCdataString(itemOrFeed.tableOfContents), - 'dcterms:temporal': generateArrayOrSingular( - itemOrFeed.temporals, - itemOrFeed.temporal, - generateCdataString, - ), - 'dcterms:title': generateArrayOrSingular( - itemOrFeed.titles, - itemOrFeed.title, generateCdataString, ), - 'dcterms:type': generateArrayOrSingular(itemOrFeed.types, itemOrFeed.type, generateCdataString), - 'dcterms:valid': generateRfc3339Date(itemOrFeed.valid), + 'dcterms:conformsTo': trimArray(itemOrFeed.conformsTo, generateCdataString), + 'dcterms:contributor': trimArray(itemOrFeed.contributors, generateCdataString), + 'dcterms:coverage': trimArray(itemOrFeed.coverages, generateCdataString), + 'dcterms:created': trimArray(itemOrFeed.created, generateRfc3339Date), + 'dcterms:creator': trimArray(itemOrFeed.creators, generateCdataString), + 'dcterms:dateAccepted': trimArray(itemOrFeed.dateAccepted, generateRfc3339Date), + 'dcterms:dateCopyrighted': trimArray(itemOrFeed.dateCopyrighted, generateRfc3339Date), + 'dcterms:date': trimArray(itemOrFeed.dates, generateRfc3339Date), + 'dcterms:dateSubmitted': trimArray(itemOrFeed.dateSubmitted, generateRfc3339Date), + 'dcterms:description': trimArray(itemOrFeed.descriptions, generateCdataString), + 'dcterms:educationLevel': trimArray(itemOrFeed.educationLevels, generateCdataString), + 'dcterms:extent': trimArray(itemOrFeed.extents, generateCdataString), + 'dcterms:format': trimArray(itemOrFeed.formats, generateCdataString), + 'dcterms:hasFormat': trimArray(itemOrFeed.hasFormats, generateCdataString), + 'dcterms:hasPart': trimArray(itemOrFeed.hasParts, generateCdataString), + 'dcterms:hasVersion': trimArray(itemOrFeed.hasVersions, generateCdataString), + 'dcterms:identifier': trimArray(itemOrFeed.identifiers, generateCdataString), + 'dcterms:instructionalMethod': trimArray(itemOrFeed.instructionalMethods, generateCdataString), + 'dcterms:isFormatOf': trimArray(itemOrFeed.isFormatOf, generateCdataString), + 'dcterms:isPartOf': trimArray(itemOrFeed.isPartOf, generateCdataString), + 'dcterms:isReferencedBy': trimArray(itemOrFeed.isReferencedBy, generateCdataString), + 'dcterms:isReplacedBy': trimArray(itemOrFeed.isReplacedBy, generateCdataString), + 'dcterms:isRequiredBy': trimArray(itemOrFeed.isRequiredBy, generateCdataString), + 'dcterms:issued': trimArray(itemOrFeed.issued, generateRfc3339Date), + 'dcterms:isVersionOf': trimArray(itemOrFeed.isVersionOf, generateCdataString), + 'dcterms:language': trimArray(itemOrFeed.languages, generateCdataString), + 'dcterms:license': trimArray(itemOrFeed.licenses, generateCdataString), + 'dcterms:mediator': trimArray(itemOrFeed.mediators, generateCdataString), + 'dcterms:medium': trimArray(itemOrFeed.mediums, generateCdataString), + 'dcterms:modified': trimArray(itemOrFeed.modified, generateRfc3339Date), + 'dcterms:provenance': trimArray(itemOrFeed.provenances, generateCdataString), + 'dcterms:publisher': trimArray(itemOrFeed.publishers, generateCdataString), + 'dcterms:references': trimArray(itemOrFeed.references, generateCdataString), + 'dcterms:relation': trimArray(itemOrFeed.relations, generateCdataString), + 'dcterms:replaces': trimArray(itemOrFeed.replaces, generateCdataString), + 'dcterms:requires': trimArray(itemOrFeed.requires, generateCdataString), + 'dcterms:rights': trimArray(itemOrFeed.rights, generateCdataString), + 'dcterms:rightsHolder': trimArray(itemOrFeed.rightsHolders, generateCdataString), + 'dcterms:source': trimArray(itemOrFeed.sources, generateCdataString), + 'dcterms:spatial': trimArray(itemOrFeed.spatials, generateCdataString), + 'dcterms:subject': trimArray(itemOrFeed.subjects, generateCdataString), + 'dcterms:tableOfContents': trimArray(itemOrFeed.tableOfContents, generateCdataString), + 'dcterms:temporal': trimArray(itemOrFeed.temporals, generateCdataString), + 'dcterms:title': trimArray(itemOrFeed.titles, generateCdataString), + 'dcterms:type': trimArray(itemOrFeed.types, generateCdataString), + 'dcterms:valid': trimArray(itemOrFeed.valid, generateRfc3339Date), } return trimObject(value) diff --git a/src/namespaces/dcterms/parse/utils.test.ts b/src/namespaces/dcterms/parse/utils.test.ts index 1f576529..cc7f0906 100644 --- a/src/namespaces/dcterms/parse/utils.test.ts +++ b/src/namespaces/dcterms/parse/utils.test.ts @@ -4,16 +4,23 @@ import { retrieveItemOrFeed } from './utils.js' describe('retrieveItemOrFeed', () => { const expectedFull = { abstracts: ['Sample abstract content'], + accessRights: ['Open Access'], accrualMethods: ['Manual upload'], accrualPeriodicities: ['Annual'], accrualPolicies: ['Open submission'], alternatives: ['Alternative Title'], audiences: ['General Public'], + available: ['2023-06-01T00:00:00Z'], bibliographicCitations: ['Doe, J. (2023). Sample Work. Publisher.'], + conformsTo: ['Dublin Core Metadata Terms'], contributors: ['Jane Smith'], coverages: ['Global'], + created: ['2023-05-01T12:00:00Z'], creators: ['John Doe'], + dateAccepted: ['2023-05-10T09:00:00Z'], + dateCopyrighted: ['2023-05-15T00:00:00Z'], dates: ['2023-05-02T08:30:00Z'], + dateSubmitted: ['2023-05-05T14:30:00Z'], descriptions: ['A comprehensive description'], educationLevels: ['Graduate level'], extents: ['250 pages'], @@ -23,75 +30,34 @@ describe('retrieveItemOrFeed', () => { hasVersions: ['https://example.org/versions/v2'], identifiers: ['ISBN:978-0123456789'], instructionalMethods: ['Online learning'], + isFormatOf: ['https://example.org/original'], + isPartOf: ['https://example.org/collection'], + isReferencedBy: ['https://example.org/references/citation1'], + isReplacedBy: ['https://example.org/replacement'], + isRequiredBy: ['https://example.org/dependent'], + issued: ['2023-05-20T10:00:00Z'], + isVersionOf: ['https://example.org/original-work'], languages: ['en-US'], licenses: ['Creative Commons Attribution 4.0'], mediators: ['Library System'], mediums: ['Digital'], + modified: ['2023-05-25T16:45:00Z'], provenances: ['Digitized from original manuscript'], publishers: ['Academic Press'], + references: ['https://example.org/ref/source1'], relations: ['https://example.org/related'], + replaces: ['https://example.org/old-version'], + requires: ['https://example.org/prerequisite'], + rights: ['Copyright 2023'], rightsHolders: ['Example University'], sources: ['https://example.org/source'], spatials: ['Boston, MA, USA'], subjects: ['Academic Research'], + tableOfContents: ['Chapter 1, Chapter 2, Chapter 3'], temporals: ['2023'], titles: ['Sample Title'], types: ['Text'], - abstract: 'Sample abstract content', - accessRights: 'Open Access', - accrualMethod: 'Manual upload', - accrualPeriodicity: 'Annual', - accrualPolicy: 'Open submission', - alternative: 'Alternative Title', - audience: 'General Public', - available: '2023-06-01T00:00:00Z', - bibliographicCitation: 'Doe, J. (2023). Sample Work. Publisher.', - conformsTo: 'Dublin Core Metadata Terms', - contributor: 'Jane Smith', - coverage: 'Global', - created: '2023-05-01T12:00:00Z', - creator: 'John Doe', - date: '2023-05-02T08:30:00Z', - dateAccepted: '2023-05-10T09:00:00Z', - dateCopyrighted: '2023-05-15T00:00:00Z', - dateSubmitted: '2023-05-05T14:30:00Z', - description: 'A comprehensive description', - educationLevel: 'Graduate level', - extent: '250 pages', - format: 'application/pdf', - hasFormat: 'https://example.org/formats/pdf', - hasPart: 'https://example.org/parts/chapter1', - hasVersion: 'https://example.org/versions/v2', - identifier: 'ISBN:978-0123456789', - instructionalMethod: 'Online learning', - isFormatOf: 'https://example.org/original', - isPartOf: 'https://example.org/collection', - isReferencedBy: 'https://example.org/references/citation1', - isReplacedBy: 'https://example.org/replacement', - isRequiredBy: 'https://example.org/dependent', - issued: '2023-05-20T10:00:00Z', - isVersionOf: 'https://example.org/original-work', - language: 'en-US', - license: 'Creative Commons Attribution 4.0', - mediator: 'Library System', - medium: 'Digital', - modified: '2023-05-25T16:45:00Z', - provenance: 'Digitized from original manuscript', - publisher: 'Academic Press', - references: 'https://example.org/ref/source1', - relation: 'https://example.org/related', - replaces: 'https://example.org/old-version', - requires: 'https://example.org/prerequisite', - rights: 'Copyright 2023', - rightsHolder: 'Example University', - source: 'https://example.org/source', - spatial: 'Boston, MA, USA', - subject: 'Academic Research', - tableOfContents: 'Chapter 1, Chapter 2, Chapter 3', - temporal: '2023', - title: 'Sample Title', - type: 'Text', - valid: '2024-05-25T23:59:59Z', + valid: ['2024-05-25T23:59:59Z'], } it('should parse complete item or feed object with all properties (with #text)', () => { @@ -299,19 +265,26 @@ describe('retrieveItemOrFeed', () => { } const expected = { abstracts: ['Sample abstract content', 'Another abstract'], + accessRights: ['Open Access', 'Restricted Access'], accrualMethods: ['Manual upload', 'Automatic harvest'], accrualPeriodicities: ['Annual', 'Monthly'], accrualPolicies: ['Open submission', 'Moderated submission'], alternatives: ['Alternative Title', 'Secondary Title'], audiences: ['General Public', 'Researchers'], + available: ['2023-06-01T00:00:00Z', '2023-07-01T00:00:00Z'], bibliographicCitations: [ 'Doe, J. (2023). Sample Work. Publisher.', 'Smith, J. (2023). Another Work.', ], + conformsTo: ['Dublin Core Metadata Terms', 'ISO Standard'], contributors: ['Jane Smith', 'Bob Johnson'], coverages: ['Global', 'Europe'], + created: ['2023-05-01T12:00:00Z', '2023-05-02T12:00:00Z'], creators: ['John Doe', 'Jane Doe'], + dateAccepted: ['2023-05-10T09:00:00Z', '2023-05-11T09:00:00Z'], + dateCopyrighted: ['2023-05-15T00:00:00Z', '2023-05-16T00:00:00Z'], dates: ['2023-05-02T08:30:00Z', '2023-05-03T08:30:00Z'], + dateSubmitted: ['2023-05-05T14:30:00Z', '2023-05-06T14:30:00Z'], descriptions: ['A comprehensive description', 'Another description'], educationLevels: ['Graduate level', 'Undergraduate level'], extents: ['250 pages', '300 pages'], @@ -321,75 +294,37 @@ describe('retrieveItemOrFeed', () => { hasVersions: ['https://example.org/versions/v2', 'https://example.org/versions/v3'], identifiers: ['ISBN:978-0123456789', 'DOI:10.1234/example'], instructionalMethods: ['Online learning', 'In-person learning'], + isFormatOf: ['https://example.org/original', 'https://example.org/another-original'], + isPartOf: ['https://example.org/collection', 'https://example.org/series'], + isReferencedBy: [ + 'https://example.org/references/citation1', + 'https://example.org/references/citation2', + ], + isReplacedBy: ['https://example.org/replacement', 'https://example.org/new-replacement'], + isRequiredBy: ['https://example.org/dependent', 'https://example.org/another-dependent'], + issued: ['2023-05-20T10:00:00Z', '2023-05-21T10:00:00Z'], + isVersionOf: ['https://example.org/original-work', 'https://example.org/base-work'], languages: ['en-US', 'en-GB'], licenses: ['Creative Commons Attribution 4.0', 'MIT License'], mediators: ['Library System', 'Archive System'], mediums: ['Digital', 'Print'], + modified: ['2023-05-25T16:45:00Z', '2023-05-26T16:45:00Z'], provenances: ['Digitized from original manuscript', 'Scanned from print'], publishers: ['Academic Press', 'University Press'], + references: ['https://example.org/ref/source1', 'https://example.org/ref/source2'], relations: ['https://example.org/related', 'https://example.org/also-related'], + replaces: ['https://example.org/old-version', 'https://example.org/deprecated-version'], + requires: ['https://example.org/prerequisite', 'https://example.org/dependency'], + rights: ['Copyright 2023', 'All rights reserved'], rightsHolders: ['Example University', 'Example Foundation'], sources: ['https://example.org/source', 'https://example.org/origin'], spatials: ['Boston, MA, USA', 'Cambridge, MA, USA'], subjects: ['Academic Research', 'Scientific Study'], + tableOfContents: ['Chapter 1, Chapter 2, Chapter 3', 'Section 1, Section 2'], temporals: ['2023', '2022-2023'], titles: ['Sample Title', 'Alternative Sample Title'], types: ['Text', 'Dataset'], - abstract: 'Sample abstract content', - accessRights: 'Open Access', - accrualMethod: 'Manual upload', - accrualPeriodicity: 'Annual', - accrualPolicy: 'Open submission', - alternative: 'Alternative Title', - audience: 'General Public', - available: '2023-06-01T00:00:00Z', - bibliographicCitation: 'Doe, J. (2023). Sample Work. Publisher.', - conformsTo: 'Dublin Core Metadata Terms', - contributor: 'Jane Smith', - coverage: 'Global', - created: '2023-05-01T12:00:00Z', - creator: 'John Doe', - date: '2023-05-02T08:30:00Z', - dateAccepted: '2023-05-10T09:00:00Z', - dateCopyrighted: '2023-05-15T00:00:00Z', - dateSubmitted: '2023-05-05T14:30:00Z', - description: 'A comprehensive description', - educationLevel: 'Graduate level', - extent: '250 pages', - format: 'application/pdf', - hasFormat: 'https://example.org/formats/pdf', - hasPart: 'https://example.org/parts/chapter1', - hasVersion: 'https://example.org/versions/v2', - identifier: 'ISBN:978-0123456789', - instructionalMethod: 'Online learning', - isFormatOf: 'https://example.org/original', - isPartOf: 'https://example.org/collection', - isReferencedBy: 'https://example.org/references/citation1', - isReplacedBy: 'https://example.org/replacement', - isRequiredBy: 'https://example.org/dependent', - issued: '2023-05-20T10:00:00Z', - isVersionOf: 'https://example.org/original-work', - language: 'en-US', - license: 'Creative Commons Attribution 4.0', - mediator: 'Library System', - medium: 'Digital', - modified: '2023-05-25T16:45:00Z', - provenance: 'Digitized from original manuscript', - publisher: 'Academic Press', - references: 'https://example.org/ref/source1', - relation: 'https://example.org/related', - replaces: 'https://example.org/old-version', - requires: 'https://example.org/prerequisite', - rights: 'Copyright 2023', - rightsHolder: 'Example University', - source: 'https://example.org/source', - spatial: 'Boston, MA, USA', - subject: 'Academic Research', - tableOfContents: 'Chapter 1, Chapter 2, Chapter 3', - temporal: '2023', - title: 'Sample Title', - type: 'Text', - valid: '2024-05-25T23:59:59Z', + valid: ['2024-05-25T23:59:59Z', '2025-05-25T23:59:59Z'], } expect(retrieveItemOrFeed(value)).toEqual(expected) @@ -404,18 +339,15 @@ describe('retrieveItemOrFeed', () => { } const expected = { abstracts: ['Partial abstract'], + created: ['2023-05-01T12:00:00Z'], licenses: ['MIT License'], spatials: ['New York, NY'], - abstract: 'Partial abstract', - license: 'MIT License', - spatial: 'New York, NY', - created: '2023-05-01T12:00:00Z', } expect(retrieveItemOrFeed(value)).toEqual(expected) }) - it('should handle array of values and extract first one', () => { + it('should handle array of values and extract all', () => { const value = { 'dcterms:abstract': ['First abstract', 'Second abstract'], 'dcterms:license': [{ '#text': 'First license' }, { '#text': 'Second license' }], @@ -423,8 +355,6 @@ describe('retrieveItemOrFeed', () => { const expected = { abstracts: ['First abstract', 'Second abstract'], licenses: ['First license', 'Second license'], - abstract: 'First abstract', - license: 'First license', } expect(retrieveItemOrFeed(value)).toEqual(expected) @@ -438,7 +368,7 @@ describe('retrieveItemOrFeed', () => { 'dcterms:license': '', } const expected = { - created: '123456789', + created: ['123456789'], } expect(retrieveItemOrFeed(value)).toEqual(expected) @@ -464,10 +394,8 @@ describe('retrieveItemOrFeed', () => { } const expected = { abstracts: ['Valid abstract'], + created: ['invalid-date'], licenses: ['Valid license'], - abstract: 'Valid abstract', - license: 'Valid license', - created: 'invalid-date', } expect(retrieveItemOrFeed(value)).toEqual(expected) @@ -483,14 +411,10 @@ describe('retrieveItemOrFeed', () => { } const expected = { abstracts: ['First abstract', 'Second abstract'], + created: ['2023-01-15T00:00:00Z', '2023-01-16T00:00:00Z'], creators: ['John Doe', 'Jane Smith'], dates: ['2023-01-01T00:00:00Z', '2023-02-01T00:00:00Z'], licenses: ['MIT', 'Apache 2.0'], - abstract: 'First abstract', - created: '2023-01-15T00:00:00Z', - creator: 'John Doe', - date: '2023-01-01T00:00:00Z', - license: 'MIT', } expect(retrieveItemOrFeed(value)).toEqual(expected) diff --git a/src/namespaces/dcterms/parse/utils.ts b/src/namespaces/dcterms/parse/utils.ts index 2ba10269..c0a60fda 100644 --- a/src/namespaces/dcterms/parse/utils.ts +++ b/src/namespaces/dcterms/parse/utils.ts @@ -1,22 +1,27 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { DateAny, ParseMainOptions, ParseUtilPartial } from '../../../common/types.js' import { isObject, parseArrayOf, parseDate, - parseSingularOf, parseString, retrieveText, trimObject, } from '../../../common/utils.js' import type { DcTermsNs } from '../common/types.js' -export const retrieveItemOrFeed: ParsePartialUtil> = (value) => { +export const retrieveItemOrFeed: ParseUtilPartial< + DcTermsNs.ItemOrFeed, + ParseMainOptions +> = (value, options) => { if (!isObject(value)) { return } const itemOrFeed = { abstracts: parseArrayOf(value['dcterms:abstract'], (value) => parseString(retrieveText(value))), + accessRights: parseArrayOf(value['dcterms:accessrights'], (value) => + parseString(retrieveText(value)), + ), accrualMethods: parseArrayOf(value['dcterms:accrualmethod'], (value) => parseString(retrieveText(value)), ), @@ -30,15 +35,35 @@ export const retrieveItemOrFeed: ParsePartialUtil> parseString(retrieveText(value)), ), audiences: parseArrayOf(value['dcterms:audience'], (value) => parseString(retrieveText(value))), + available: parseArrayOf(value['dcterms:available'], (value) => + parseDate(retrieveText(value), options?.parseDateFn), + ), bibliographicCitations: parseArrayOf(value['dcterms:bibliographiccitation'], (value) => parseString(retrieveText(value)), ), + conformsTo: parseArrayOf(value['dcterms:conformsto'], (value) => + parseString(retrieveText(value)), + ), contributors: parseArrayOf(value['dcterms:contributor'], (value) => parseString(retrieveText(value)), ), coverages: parseArrayOf(value['dcterms:coverage'], (value) => parseString(retrieveText(value))), + created: parseArrayOf(value['dcterms:created'], (value) => + parseDate(retrieveText(value), options?.parseDateFn), + ), creators: parseArrayOf(value['dcterms:creator'], (value) => parseString(retrieveText(value))), - dates: parseArrayOf(value['dcterms:date'], (value) => parseDate(retrieveText(value))), + dateAccepted: parseArrayOf(value['dcterms:dateaccepted'], (value) => + parseDate(retrieveText(value), options?.parseDateFn), + ), + dateCopyrighted: parseArrayOf(value['dcterms:datecopyrighted'], (value) => + parseDate(retrieveText(value), options?.parseDateFn), + ), + dates: parseArrayOf(value['dcterms:date'], (value) => + parseDate(retrieveText(value), options?.parseDateFn), + ), + dateSubmitted: parseArrayOf(value['dcterms:datesubmitted'], (value) => + parseDate(retrieveText(value), options?.parseDateFn), + ), descriptions: parseArrayOf(value['dcterms:description'], (value) => parseString(retrieveText(value)), ), @@ -60,161 +85,60 @@ export const retrieveItemOrFeed: ParsePartialUtil> instructionalMethods: parseArrayOf(value['dcterms:instructionalmethod'], (value) => parseString(retrieveText(value)), ), - languages: parseArrayOf(value['dcterms:language'], (value) => parseString(retrieveText(value))), - licenses: parseArrayOf(value['dcterms:license'], (value) => parseString(retrieveText(value))), - mediators: parseArrayOf(value['dcterms:mediator'], (value) => parseString(retrieveText(value))), - mediums: parseArrayOf(value['dcterms:medium'], (value) => parseString(retrieveText(value))), - provenances: parseArrayOf(value['dcterms:provenance'], (value) => - parseString(retrieveText(value)), - ), - publishers: parseArrayOf(value['dcterms:publisher'], (value) => - parseString(retrieveText(value)), - ), - relations: parseArrayOf(value['dcterms:relation'], (value) => parseString(retrieveText(value))), - rightsHolders: parseArrayOf(value['dcterms:rightsholder'], (value) => - parseString(retrieveText(value)), - ), - sources: parseArrayOf(value['dcterms:source'], (value) => parseString(retrieveText(value))), - spatials: parseArrayOf(value['dcterms:spatial'], (value) => parseString(retrieveText(value))), - subjects: parseArrayOf(value['dcterms:subject'], (value) => parseString(retrieveText(value))), - temporals: parseArrayOf(value['dcterms:temporal'], (value) => parseString(retrieveText(value))), - titles: parseArrayOf(value['dcterms:title'], (value) => parseString(retrieveText(value))), - types: parseArrayOf(value['dcterms:type'], (value) => parseString(retrieveText(value))), - - // Deprecated fields for backward compatibility. - abstract: parseSingularOf(value['dcterms:abstract'], (value) => - parseString(retrieveText(value)), - ), - accrualMethod: parseSingularOf(value['dcterms:accrualmethod'], (value) => - parseString(retrieveText(value)), - ), - accrualPeriodicity: parseSingularOf(value['dcterms:accrualperiodicity'], (value) => - parseString(retrieveText(value)), - ), - accrualPolicy: parseSingularOf(value['dcterms:accrualpolicy'], (value) => - parseString(retrieveText(value)), - ), - alternative: parseSingularOf(value['dcterms:alternative'], (value) => - parseString(retrieveText(value)), - ), - audience: parseSingularOf(value['dcterms:audience'], (value) => - parseString(retrieveText(value)), - ), - bibliographicCitation: parseSingularOf(value['dcterms:bibliographiccitation'], (value) => - parseString(retrieveText(value)), - ), - contributor: parseSingularOf(value['dcterms:contributor'], (value) => + isFormatOf: parseArrayOf(value['dcterms:isformatof'], (value) => parseString(retrieveText(value)), ), - coverage: parseSingularOf(value['dcterms:coverage'], (value) => + isPartOf: parseArrayOf(value['dcterms:ispartof'], (value) => parseString(retrieveText(value))), + isReferencedBy: parseArrayOf(value['dcterms:isreferencedby'], (value) => parseString(retrieveText(value)), ), - creator: parseSingularOf(value['dcterms:creator'], (value) => parseString(retrieveText(value))), - date: parseSingularOf(value['dcterms:date'], (value) => parseDate(retrieveText(value))), - description: parseSingularOf(value['dcterms:description'], (value) => + isReplacedBy: parseArrayOf(value['dcterms:isreplacedby'], (value) => parseString(retrieveText(value)), ), - educationLevel: parseSingularOf(value['dcterms:educationlevel'], (value) => + isRequiredBy: parseArrayOf(value['dcterms:isrequiredby'], (value) => parseString(retrieveText(value)), ), - extent: parseSingularOf(value['dcterms:extent'], (value) => parseString(retrieveText(value))), - format: parseSingularOf(value['dcterms:format'], (value) => parseString(retrieveText(value))), - hasFormat: parseSingularOf(value['dcterms:hasformat'], (value) => - parseString(retrieveText(value)), - ), - hasPart: parseSingularOf(value['dcterms:haspart'], (value) => parseString(retrieveText(value))), - hasVersion: parseSingularOf(value['dcterms:hasversion'], (value) => - parseString(retrieveText(value)), + issued: parseArrayOf(value['dcterms:issued'], (value) => + parseDate(retrieveText(value), options?.parseDateFn), ), - identifier: parseSingularOf(value['dcterms:identifier'], (value) => + isVersionOf: parseArrayOf(value['dcterms:isversionof'], (value) => parseString(retrieveText(value)), ), - instructionalMethod: parseSingularOf(value['dcterms:instructionalmethod'], (value) => - parseString(retrieveText(value)), - ), - language: parseSingularOf(value['dcterms:language'], (value) => - parseString(retrieveText(value)), - ), - license: parseSingularOf(value['dcterms:license'], (value) => parseString(retrieveText(value))), - mediator: parseSingularOf(value['dcterms:mediator'], (value) => - parseString(retrieveText(value)), - ), - medium: parseSingularOf(value['dcterms:medium'], (value) => parseString(retrieveText(value))), - provenance: parseSingularOf(value['dcterms:provenance'], (value) => - parseString(retrieveText(value)), - ), - publisher: parseSingularOf(value['dcterms:publisher'], (value) => - parseString(retrieveText(value)), - ), - relation: parseSingularOf(value['dcterms:relation'], (value) => - parseString(retrieveText(value)), - ), - rightsHolder: parseSingularOf(value['dcterms:rightsholder'], (value) => - parseString(retrieveText(value)), - ), - source: parseSingularOf(value['dcterms:source'], (value) => parseString(retrieveText(value))), - spatial: parseSingularOf(value['dcterms:spatial'], (value) => parseString(retrieveText(value))), - subject: parseSingularOf(value['dcterms:subject'], (value) => parseString(retrieveText(value))), - temporal: parseSingularOf(value['dcterms:temporal'], (value) => - parseString(retrieveText(value)), - ), - title: parseSingularOf(value['dcterms:title'], (value) => parseString(retrieveText(value))), - type: parseSingularOf(value['dcterms:type'], (value) => parseString(retrieveText(value))), - - // Singular-only fields (no plural variants). - accessRights: parseSingularOf(value['dcterms:accessrights'], (value) => - parseString(retrieveText(value)), - ), - available: parseSingularOf(value['dcterms:available'], (value) => - parseDate(retrieveText(value)), - ), - conformsTo: parseSingularOf(value['dcterms:conformsto'], (value) => - parseString(retrieveText(value)), - ), - created: parseSingularOf(value['dcterms:created'], (value) => parseDate(retrieveText(value))), - dateAccepted: parseSingularOf(value['dcterms:dateaccepted'], (value) => - parseDate(retrieveText(value)), - ), - dateCopyrighted: parseSingularOf(value['dcterms:datecopyrighted'], (value) => - parseDate(retrieveText(value)), - ), - dateSubmitted: parseSingularOf(value['dcterms:datesubmitted'], (value) => - parseDate(retrieveText(value)), - ), - isFormatOf: parseSingularOf(value['dcterms:isformatof'], (value) => - parseString(retrieveText(value)), - ), - isPartOf: parseSingularOf(value['dcterms:ispartof'], (value) => - parseString(retrieveText(value)), - ), - isReferencedBy: parseSingularOf(value['dcterms:isreferencedby'], (value) => - parseString(retrieveText(value)), - ), - isReplacedBy: parseSingularOf(value['dcterms:isreplacedby'], (value) => - parseString(retrieveText(value)), + languages: parseArrayOf(value['dcterms:language'], (value) => parseString(retrieveText(value))), + licenses: parseArrayOf(value['dcterms:license'], (value) => parseString(retrieveText(value))), + mediators: parseArrayOf(value['dcterms:mediator'], (value) => parseString(retrieveText(value))), + mediums: parseArrayOf(value['dcterms:medium'], (value) => parseString(retrieveText(value))), + modified: parseArrayOf(value['dcterms:modified'], (value) => + parseDate(retrieveText(value), options?.parseDateFn), ), - isRequiredBy: parseSingularOf(value['dcterms:isrequiredby'], (value) => + provenances: parseArrayOf(value['dcterms:provenance'], (value) => parseString(retrieveText(value)), ), - issued: parseSingularOf(value['dcterms:issued'], (value) => parseDate(retrieveText(value))), - isVersionOf: parseSingularOf(value['dcterms:isversionof'], (value) => + publishers: parseArrayOf(value['dcterms:publisher'], (value) => parseString(retrieveText(value)), ), - modified: parseSingularOf(value['dcterms:modified'], (value) => parseDate(retrieveText(value))), - references: parseSingularOf(value['dcterms:references'], (value) => + references: parseArrayOf(value['dcterms:references'], (value) => parseString(retrieveText(value)), ), - replaces: parseSingularOf(value['dcterms:replaces'], (value) => + relations: parseArrayOf(value['dcterms:relation'], (value) => parseString(retrieveText(value))), + replaces: parseArrayOf(value['dcterms:replaces'], (value) => parseString(retrieveText(value))), + requires: parseArrayOf(value['dcterms:requires'], (value) => parseString(retrieveText(value))), + rights: parseArrayOf(value['dcterms:rights'], (value) => parseString(retrieveText(value))), + rightsHolders: parseArrayOf(value['dcterms:rightsholder'], (value) => parseString(retrieveText(value)), ), - requires: parseSingularOf(value['dcterms:requires'], (value) => + sources: parseArrayOf(value['dcterms:source'], (value) => parseString(retrieveText(value))), + spatials: parseArrayOf(value['dcterms:spatial'], (value) => parseString(retrieveText(value))), + subjects: parseArrayOf(value['dcterms:subject'], (value) => parseString(retrieveText(value))), + tableOfContents: parseArrayOf(value['dcterms:tableofcontents'], (value) => parseString(retrieveText(value)), ), - rights: parseSingularOf(value['dcterms:rights'], (value) => parseString(retrieveText(value))), - tableOfContents: parseSingularOf(value['dcterms:tableofcontents'], (value) => - parseString(retrieveText(value)), + temporals: parseArrayOf(value['dcterms:temporal'], (value) => parseString(retrieveText(value))), + titles: parseArrayOf(value['dcterms:title'], (value) => parseString(retrieveText(value))), + types: parseArrayOf(value['dcterms:type'], (value) => parseString(retrieveText(value))), + valid: parseArrayOf(value['dcterms:valid'], (value) => + parseDate(retrieveText(value), options?.parseDateFn), ), - valid: parseSingularOf(value['dcterms:valid'], (value) => parseDate(retrieveText(value))), } return trimObject(itemOrFeed) diff --git a/src/namespaces/feedpress/parse/utils.ts b/src/namespaces/feedpress/parse/utils.ts index e53d458c..99737d9f 100644 --- a/src/namespaces/feedpress/parse/utils.ts +++ b/src/namespaces/feedpress/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { ParseUtilPartial } from '../../../common/types.js' import { isObject, parseSingularOf, @@ -8,7 +8,7 @@ import { } from '../../../common/utils.js' import type { FeedPressNs } from '../common/types.js' -export const retrieveFeed: ParsePartialUtil = (value) => { +export const retrieveFeed: ParseUtilPartial = (value) => { if (!isObject(value)) { return } diff --git a/src/namespaces/geo/parse/utils.ts b/src/namespaces/geo/parse/utils.ts index 9a8c9c96..a8f384e4 100644 --- a/src/namespaces/geo/parse/utils.ts +++ b/src/namespaces/geo/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { ParseUtilPartial } from '../../../common/types.js' import { isObject, parseNumber, @@ -8,7 +8,7 @@ import { } from '../../../common/utils.js' import type { GeoNs } from '../common/types.js' -export const retrieveItemOrFeed: ParsePartialUtil = (value) => { +export const retrieveItemOrFeed: ParseUtilPartial = (value) => { if (!isObject(value)) { return } diff --git a/src/namespaces/georss/common/types.ts b/src/namespaces/georss/common/types.ts index 6423cffd..6f89de81 100644 --- a/src/namespaces/georss/common/types.ts +++ b/src/namespaces/georss/common/types.ts @@ -1,28 +1,42 @@ +import type { Requirable, Strict } from '../../../common/types.js' + // #region reference export namespace GeoRssNs { - export type Point = { - lat: number - lng: number - } + export type Point = Strict< + { + lat: Requirable // Required in spec. + lng: Requirable // Required in spec. + }, + TStrict + > - export type Line = { - points: Array - } + export type Line = Strict< + { + points: Requirable>> // Required in spec. + }, + TStrict + > - export type Polygon = { - points: Array - } + export type Polygon = Strict< + { + points: Requirable>> // Required in spec. + }, + TStrict + > - export type Box = { - lowerCorner: Point - upperCorner: Point - } + export type Box = Strict< + { + lowerCorner: Requirable> // Required in spec. + upperCorner: Requirable> // Required in spec. + }, + TStrict + > - export type ItemOrFeed = { - point?: Point - line?: Line - polygon?: Polygon - box?: Box + export type ItemOrFeed = { + point?: Point + line?: Line + polygon?: Polygon + box?: Box featureTypeTag?: string relationshipTag?: string featureName?: string diff --git a/src/namespaces/georss/generate/utils.test.ts b/src/namespaces/georss/generate/utils.test.ts index 9adeab0b..935d0bba 100644 --- a/src/namespaces/georss/generate/utils.test.ts +++ b/src/namespaces/georss/generate/utils.test.ts @@ -64,7 +64,6 @@ describe('generateLatLngPairs', () => { ] const expected = '45.256 -71.92 47 -70' - // @ts-expect-error: This is for testing purposes. expect(generateLatLngPairs(value)).toBe(expected) }) @@ -141,14 +140,12 @@ describe('generatePoint', () => { it('should return undefined for missing lat', () => { const value = { lng: -71.92 } - // @ts-expect-error: This is for testing purposes. expect(generatePoint(value)).toBeUndefined() }) it('should return undefined for missing lng', () => { const value = { lat: 45.256 } - // @ts-expect-error: This is for testing purposes. expect(generatePoint(value)).toBeUndefined() }) @@ -360,7 +357,6 @@ describe('generateBox', () => { upperCorner: { lat: 43.039, lng: -69.856 }, } - // @ts-expect-error: This is for testing purposes. expect(generateBox(value)).toBeUndefined() }) @@ -369,7 +365,6 @@ describe('generateBox', () => { lowerCorner: { lat: 42.943, lng: -71.032 }, } - // @ts-expect-error: This is for testing purposes. expect(generateBox(value)).toBeUndefined() }) diff --git a/src/namespaces/georss/generate/utils.ts b/src/namespaces/georss/generate/utils.ts index 5f52fb46..477499de 100644 --- a/src/namespaces/georss/generate/utils.ts +++ b/src/namespaces/georss/generate/utils.ts @@ -39,7 +39,7 @@ export const generatePoint: GenerateUtil = (point) => { } export const generateLine: GenerateUtil = (line) => { - if (!isObject(line)) { + if (!isObject(line) || !line.points) { return } @@ -47,7 +47,7 @@ export const generateLine: GenerateUtil = (line) => { } export const generatePolygon: GenerateUtil = (polygon) => { - if (!isObject(polygon)) { + if (!isObject(polygon) || !polygon.points) { return } diff --git a/src/namespaces/georss/parse/utils.ts b/src/namespaces/georss/parse/utils.ts index 0e40df97..01fd7c54 100644 --- a/src/namespaces/georss/parse/utils.ts +++ b/src/namespaces/georss/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParseExactUtil, ParsePartialUtil, Unreliable } from '../../../common/types.js' +import type { ParseUtilExact, ParseUtilPartial, Unreliable } from '../../../common/types.js' import { isNonEmptyString, isObject, @@ -52,11 +52,11 @@ export const parseLatLngPairs = ( return points.length > 0 ? points : undefined } -export const parsePoint: ParseExactUtil = (value) => { +export const parsePoint: ParseUtilExact = (value) => { return parseLatLngPairs(retrieveText(value), { min: 1, max: 1 })?.[0] } -export const parseLine: ParseExactUtil = (value) => { +export const parseLine: ParseUtilExact = (value) => { const points = parseLatLngPairs(retrieveText(value), { min: 2 }) if (isPresent(points)) { @@ -64,7 +64,7 @@ export const parseLine: ParseExactUtil = (value) => { } } -export const parsePolygon: ParseExactUtil = (value) => { +export const parsePolygon: ParseUtilExact = (value) => { const points = parseLatLngPairs(retrieveText(value), { min: 4 }) if (isPresent(points)) { @@ -72,7 +72,7 @@ export const parsePolygon: ParseExactUtil = (value) => { } } -export const parseBox: ParseExactUtil = (value) => { +export const parseBox: ParseUtilExact = (value) => { const points = parseLatLngPairs(retrieveText(value), { min: 2, max: 2 }) const lowerCorner = points?.[0] const upperCorner = points?.[1] @@ -82,7 +82,7 @@ export const parseBox: ParseExactUtil = (value) => { } } -export const retrieveItemOrFeed: ParsePartialUtil = (value) => { +export const retrieveItemOrFeed: ParseUtilPartial = (value) => { if (!isObject(value)) { return } diff --git a/src/namespaces/googleplay/common/types.ts b/src/namespaces/googleplay/common/types.ts index db214443..5fa42bd7 100644 --- a/src/namespaces/googleplay/common/types.ts +++ b/src/namespaces/googleplay/common/types.ts @@ -1,23 +1,28 @@ +import type { Requirable, Strict } from '../../../common/types.js' + // #region reference export namespace GooglePlayNs { - export type Image = { - href: string - } + export type Image = Strict< + { + href: Requirable // Required in spec. + }, + TStrict + > - export type Item = { + export type Item = { author?: string description?: string explicit?: boolean | 'clean' block?: boolean - image?: Image + image?: Image } - export type Feed = { + export type Feed = { author?: string description?: string explicit?: boolean | 'clean' block?: boolean - image?: Image + image?: Image newFeedUrl?: string email?: string categories?: Array diff --git a/src/namespaces/googleplay/parse/utils.ts b/src/namespaces/googleplay/parse/utils.ts index add26c1e..32aa5412 100644 --- a/src/namespaces/googleplay/parse/utils.ts +++ b/src/namespaces/googleplay/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { ParseUtilPartial } from '../../../common/types.js' import { isObject, parseArrayOf, @@ -10,7 +10,7 @@ import { } from '../../../common/utils.js' import type { GooglePlayNs } from '../common/types.js' -export const parseImage: ParsePartialUtil = (value) => { +export const parseImage: ParseUtilPartial = (value) => { if (isObject(value) && value['@href']) { const image = { href: parseString(value['@href']), @@ -29,7 +29,7 @@ export const parseImage: ParsePartialUtil = (value) => { } } -export const parseCategory: ParsePartialUtil = (value) => { +export const parseCategory: ParseUtilPartial = (value) => { if (isObject(value) && value['@text']) { return parseString(value['@text']) } @@ -37,7 +37,7 @@ export const parseCategory: ParsePartialUtil = (value) => { return parseString(retrieveText(value)) } -export const parseExplicit: ParsePartialUtil = (value) => { +export const parseExplicit: ParseUtilPartial = (value) => { const explicit = retrieveText(value)?.trim().toLowerCase() if (explicit === 'clean') { @@ -47,7 +47,7 @@ export const parseExplicit: ParsePartialUtil = (value) => { return parseYesNoBoolean(value) } -export const retrieveItem: ParsePartialUtil = (value) => { +export const retrieveItem: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -69,7 +69,7 @@ export const retrieveItem: ParsePartialUtil = (value) => { return trimObject(item) } -export const retrieveFeed: ParsePartialUtil = (value) => { +export const retrieveFeed: ParseUtilPartial = (value) => { if (!isObject(value)) { return } diff --git a/src/namespaces/itunes/common/types.ts b/src/namespaces/itunes/common/types.ts index dbba02ea..a793c587 100644 --- a/src/namespaces/itunes/common/types.ts +++ b/src/namespaces/itunes/common/types.ts @@ -1,8 +1,18 @@ +import type { Requirable, Strict } from '../../../common/types.js' + // #region reference export namespace ItunesNs { - export type Category = { - text: string - categories?: Array + // NOTE: BaseCategory contains non-recursive fields wrapped in Strict<>. + // Category extends it and adds recursive categories field separately. + export type BaseCategory = Strict< + { + text: Requirable // Required in spec. + }, + TStrict + > + + export type Category = BaseCategory & { + categories?: Array> } export type Owner = { @@ -28,9 +38,9 @@ export namespace ItunesNs { keywords?: Array } - export type Feed = { + export type Feed = { image?: string - categories?: Array + categories?: Array> explicit?: boolean author?: string title?: string diff --git a/src/namespaces/itunes/generate/utils.test.ts b/src/namespaces/itunes/generate/utils.test.ts index c4e47823..09af4287 100644 --- a/src/namespaces/itunes/generate/utils.test.ts +++ b/src/namespaces/itunes/generate/utils.test.ts @@ -99,8 +99,8 @@ describe('generateCategory', () => { }) it('should handle non-object inputs', () => { - // @ts-expect-error: This is for testing purposes. - expect(generateCategory('string')).toBeUndefined() + // @ts-expect-error: Testing with invalid input type. + expect(generateCategory(123)).toBeUndefined() expect(generateCategory(undefined)).toBeUndefined() }) }) diff --git a/src/namespaces/itunes/parse/utils.ts b/src/namespaces/itunes/parse/utils.ts index c98b03e9..17646439 100644 --- a/src/namespaces/itunes/parse/utils.ts +++ b/src/namespaces/itunes/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { ParseUtilPartial } from '../../../common/types.js' import { isNonEmptyStringOrNumber, isObject, @@ -17,7 +17,7 @@ import type { ItunesNs } from '../common/types.js' const explicitOrYesRegex = /^\p{White_Space}*(explicit|yes)\p{White_Space}*$/iu const durationRegex = /^(?:(\d+):)?(\d+):(\d+)$/ -export const parseCategory: ParsePartialUtil = (value) => { +export const parseCategory: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -30,7 +30,7 @@ export const parseCategory: ParsePartialUtil = (value) => { return trimObject(category) } -export const parseOwner: ParsePartialUtil = (value) => { +export const parseOwner: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -43,7 +43,7 @@ export const parseOwner: ParsePartialUtil = (value) => { return trimObject(owner) } -export const parseExplicit: ParsePartialUtil = (value) => { +export const parseExplicit: ParseUtilPartial = (value) => { const boolean = parseBoolean(value) if (boolean !== undefined) { @@ -56,7 +56,7 @@ export const parseExplicit: ParsePartialUtil = (value) => { } } -export const parseDuration: ParsePartialUtil = (value) => { +export const parseDuration: ParseUtilPartial = (value) => { const duration = parseNumber(value) if (duration !== undefined) { @@ -76,7 +76,7 @@ export const parseDuration: ParsePartialUtil = (value) => { } } -export const parseImage: ParsePartialUtil = (value) => { +export const parseImage: ParseUtilPartial = (value) => { // Support non-standard format of the image tag where href is not provided in the @href // attribute but rather provided as a node value. if (isNonEmptyStringOrNumber(value)) { @@ -90,7 +90,7 @@ export const parseImage: ParsePartialUtil = (value) => { return parseString(value['@href']) } -export const retrieveItem: ParsePartialUtil = (value) => { +export const retrieveItem: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -125,7 +125,7 @@ export const retrieveItem: ParsePartialUtil = (value) => { return trimObject(item) } -export const retrieveFeed: ParsePartialUtil = (value) => { +export const retrieveFeed: ParseUtilPartial = (value) => { if (!isObject(value)) { return } diff --git a/src/namespaces/media/common/types.ts b/src/namespaces/media/common/types.ts index ae7dc2a0..d14b95cc 100644 --- a/src/namespaces/media/common/types.ts +++ b/src/namespaces/media/common/types.ts @@ -1,68 +1,100 @@ +import type { Requirable, Strict } from '../../../common/types.js' + // #region reference export namespace MediaNs { - export type Rating = { - value: string - scheme?: string - } - - export type TitleOrDescription = { - value: string - type?: string - } - - export type Thumbnail = { - url: string - height?: number - width?: number - time?: string - } - - export type Category = { - name: string - scheme?: string - label?: string - } - - export type Hash = { - value: string - algo?: string - } - - export type Player = { - url: string - height?: number - width?: number - } - - export type Credit = { - value: string - role?: string - scheme?: string - } - - export type Copyright = { - value: string - url?: string - } - - export type Text = { - value: string - type?: string - lang?: string - start?: string - end?: string - } - - export type Restriction = { - value: string - relationship: string - type?: string - } - - export type Community = { + export type Rating = Strict< + { + value: Requirable // Required in spec. + scheme?: string + }, + TStrict + > + + export type TitleOrDescription = Strict< + { + value: Requirable // Required in spec. + type?: string + }, + TStrict + > + + export type Thumbnail = Strict< + { + url: Requirable // Required in spec. + height?: number + width?: number + time?: string + }, + TStrict + > + + export type Category = Strict< + { + name: Requirable // Required in spec. + scheme?: string + label?: string + }, + TStrict + > + + export type Hash = Strict< + { + value: Requirable // Required in spec. + algo?: string + }, + TStrict + > + + export type Player = Strict< + { + url: Requirable // Required in spec. + height?: number + width?: number + }, + TStrict + > + + export type Credit = Strict< + { + value: Requirable // Required in spec. + role?: string + scheme?: string + }, + TStrict + > + + export type Copyright = Strict< + { + value: Requirable // Required in spec. + url?: string + }, + TStrict + > + + export type Text = Strict< + { + value: Requirable // Required in spec. + type?: string + lang?: string + start?: string + end?: string + }, + TStrict + > + + export type Restriction = Strict< + { + value: Requirable // Required in spec. + relationship: Requirable // Required in spec. + type?: string + }, + TStrict + > + + export type Community = { starRating?: StarRating statistics?: Statistics - tags?: Array + tags?: Array> } export type StarRating = { @@ -77,27 +109,39 @@ export namespace MediaNs { favorites?: number } - export type Tag = { - name: string - weight?: number - } - - export type Embed = { - url: string - width?: number - height?: number - params?: Array - } - - export type Param = { - name: string - value: string - } - - export type Status = { - state: string - reason?: string - } + export type Tag = Strict< + { + name: Requirable // Required in spec. + weight?: number + }, + TStrict + > + + export type Embed = Strict< + { + url: Requirable // Required in spec. + width?: number + height?: number + params?: Array> + }, + TStrict + > + + export type Param = Strict< + { + name: Requirable // Required in spec. + value: Requirable // Required in spec. + }, + TStrict + > + + export type Status = Strict< + { + state: Requirable // Required in spec. + reason?: string + }, + TStrict + > export type Price = { type?: string @@ -106,22 +150,28 @@ export namespace MediaNs { currency?: string } - export type License = { - name?: string - type?: string - href?: string - } & ({ name: string } | { href: string }) - - export type SubTitle = { - type?: string - lang?: string - href: string - } - - export type PeerLink = { + export type License = { + name?: string // At least one of name or href is required in spec. type?: string - href: string - } + href?: string // At least one of name or href is required in spec. + } & (TStrict extends true ? { name: string } | { href: string } : unknown) + + export type SubTitle = Strict< + { + type?: string + lang?: string + href: Requirable // Required in spec. + }, + TStrict + > + + export type PeerLink = Strict< + { + type?: string + href: Requirable // Required in spec. + }, + TStrict + > export type Rights = { status?: string @@ -143,35 +193,35 @@ export namespace MediaNs { } /** @internal Shared elements available across Content, Group, ItemOrFeed types. */ - export type CommonElements = { - ratings?: Array - title?: TitleOrDescription - description?: TitleOrDescription + export type CommonElements = { + ratings?: Array> + title?: TitleOrDescription + description?: TitleOrDescription keywords?: Array - thumbnails?: Array - categories?: Array - hashes?: Array - player?: Player - credits?: Array - copyright?: Copyright - texts?: Array - restrictions?: Array - community?: Community + thumbnails?: Array> + categories?: Array> + hashes?: Array> + player?: Player + credits?: Array> + copyright?: Copyright + texts?: Array> + restrictions?: Array> + community?: Community comments?: Array - embed?: Embed + embed?: Embed responses?: Array backLinks?: Array - status?: Status + status?: Status prices?: Array - licenses?: Array - subTitles?: Array - peerLinks?: Array + licenses?: Array> + subTitles?: Array> + peerLinks?: Array> locations?: Array rights?: Rights scenes?: Array } - export type Content = { + export type Content = { url?: string fileSize?: number type?: string @@ -186,18 +236,15 @@ export namespace MediaNs { height?: number width?: number lang?: string - } & CommonElements - - export type Group = { - contents?: Array - } & CommonElements + } & CommonElements - export type ItemOrFeed = { - groups?: Array - contents?: Array + export type Group = { + contents?: Array> + } & CommonElements - /** @deprecated Use `groups` (array) instead. Multiple media:group elements are allowed per specification. */ - group?: Group - } & CommonElements + export type ItemOrFeed = { + groups?: Array> + contents?: Array> + } & CommonElements } // #endregion reference diff --git a/src/namespaces/media/generate/utils.test.ts b/src/namespaces/media/generate/utils.test.ts index 2c5ed10d..4099692c 100644 --- a/src/namespaces/media/generate/utils.test.ts +++ b/src/namespaces/media/generate/utils.test.ts @@ -1584,17 +1584,19 @@ describe('generateGroup', () => { describe('generateItemOrFeed', () => { it('should generate item with all properties', () => { const value = { - group: { - contents: [ - { - url: 'http://www.foo.com/video.mp4', - type: 'video/mp4', + groups: [ + { + contents: [ + { + url: 'http://www.foo.com/video.mp4', + type: 'video/mp4', + }, + ], + title: { + value: 'Video Group', }, - ], - title: { - value: 'Video Group', }, - }, + ], contents: [ { url: 'http://www.foo.com/audio.mp3', @@ -1722,17 +1724,19 @@ describe('generateItemOrFeed', () => { ], } const expected = { - 'media:group': { - 'media:content': [ - { - '@url': 'http://www.foo.com/video.mp4', - '@type': 'video/mp4', + 'media:group': [ + { + 'media:content': [ + { + '@url': 'http://www.foo.com/video.mp4', + '@type': 'video/mp4', + }, + ], + 'media:title': { + '#text': 'Video Group', }, - ], - 'media:title': { - '#text': 'Video Group', }, - }, + ], 'media:content': [ { '@url': 'http://www.foo.com/audio.mp3', diff --git a/src/namespaces/media/generate/utils.ts b/src/namespaces/media/generate/utils.ts index a373ee65..a13ff34c 100644 --- a/src/namespaces/media/generate/utils.ts +++ b/src/namespaces/media/generate/utils.ts @@ -1,6 +1,5 @@ import type { GenerateUtil } from '../../../common/types.js' import { - generateArrayOrSingular, generateCdataString, generateCsvOf, generateNumber, @@ -465,7 +464,7 @@ export const generateItemOrFeed: GenerateUtil = (itemOrFeed) } const value = { - 'media:group': generateArrayOrSingular(itemOrFeed.groups, itemOrFeed.group, generateGroup), + 'media:group': trimArray(itemOrFeed.groups, generateGroup), 'media:content': trimArray(itemOrFeed.contents, generateContent), ...generateCommonElements(itemOrFeed), } diff --git a/src/namespaces/media/parse/utils.test.ts b/src/namespaces/media/parse/utils.test.ts index c60edb1b..dbec4273 100644 --- a/src/namespaces/media/parse/utils.test.ts +++ b/src/namespaces/media/parse/utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test' -import type { ParsePartialUtil } from '../../../common/types.js' +import type { ParseUtilPartial } from '../../../common/types.js' import { parseBackLinks, parseCategory, @@ -38,7 +38,7 @@ import { const createSimpleArrayParserTests = ( functionName: string, - parserFunction: ParsePartialUtil>, + parserFunction: ParseUtilPartial>, propertyName: string, ) => { describe(functionName, () => { @@ -3571,21 +3571,6 @@ describe('retrieveItemOrFeed', () => { }, } const expected = { - group: { - contents: [ - { - url: 'https://example.com/video-hd.mp4', - type: 'video/mp4', - }, - { - url: 'https://example.com/video-sd.mp4', - type: 'video/mp4', - }, - ], - title: { - value: 'Group Title', - }, - }, groups: [ { contents: [ @@ -3641,17 +3626,6 @@ describe('retrieveItemOrFeed', () => { }, } const expected = { - group: { - contents: [ - { - url: 'https://example.com/video-hd.mp4', - type: 'video/mp4', - }, - ], - title: { - value: 'Group Title', - }, - }, groups: [ { contents: [ diff --git a/src/namespaces/media/parse/utils.ts b/src/namespaces/media/parse/utils.ts index fb9b8808..a096f2e6 100644 --- a/src/namespaces/media/parse/utils.ts +++ b/src/namespaces/media/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { ParseUtilPartial } from '../../../common/types.js' import { isNonEmptyStringOrNumber, isObject, @@ -13,7 +13,7 @@ import { } from '../../../common/utils.js' import type { MediaNs } from '../common/types.js' -export const parseRating: ParsePartialUtil = (value) => { +export const parseRating: ParseUtilPartial = (value) => { const rating = { value: ((value) => parseString(retrieveText(value)))(value), scheme: parseString(value?.['@scheme']), @@ -22,7 +22,7 @@ export const parseRating: ParsePartialUtil = (value) => { return trimObject(rating) } -export const retrieveRatings: ParsePartialUtil> = (value) => { +export const retrieveRatings: ParseUtilPartial> = (value) => { if (!isObject(value)) { return } @@ -46,7 +46,7 @@ export const retrieveRatings: ParsePartialUtil> = (value) } } -export const parseTitleOrDescription: ParsePartialUtil = (value) => { +export const parseTitleOrDescription: ParseUtilPartial = (value) => { const title = { value: ((value) => parseString(retrieveText(value)))(value), type: parseString(value?.['@type']), @@ -55,7 +55,7 @@ export const parseTitleOrDescription: ParsePartialUtil = (value) => { +export const parseThumbnail: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -70,7 +70,7 @@ export const parseThumbnail: ParsePartialUtil = (value) => { return trimObject(thumbnail) } -export const parseCategory: ParsePartialUtil = (value) => { +export const parseCategory: ParseUtilPartial = (value) => { const category = { name: ((value) => parseString(retrieveText(value)))(value), scheme: parseString(value?.['@scheme']), @@ -80,7 +80,7 @@ export const parseCategory: ParsePartialUtil = (value) => { return trimObject(category) } -export const parseHash: ParsePartialUtil = (value) => { +export const parseHash: ParseUtilPartial = (value) => { const hash = { value: ((value) => parseString(retrieveText(value)))(value), algo: parseString(value?.['@algo']), @@ -89,7 +89,7 @@ export const parseHash: ParsePartialUtil = (value) => { return trimObject(hash) } -export const parsePlayer: ParsePartialUtil = (value) => { +export const parsePlayer: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -103,7 +103,7 @@ export const parsePlayer: ParsePartialUtil = (value) => { return trimObject(player) } -export const parseCredit: ParsePartialUtil = (value) => { +export const parseCredit: ParseUtilPartial = (value) => { const credit = { value: ((value) => parseString(retrieveText(value)))(value), role: parseString(value?.['@role']), @@ -113,7 +113,7 @@ export const parseCredit: ParsePartialUtil = (value) => { return trimObject(credit) } -export const parseCopyright: ParsePartialUtil = (value) => { +export const parseCopyright: ParseUtilPartial = (value) => { const copyright = { value: ((value) => parseString(retrieveText(value)))(value), url: parseString(value?.['@url']), @@ -122,7 +122,7 @@ export const parseCopyright: ParsePartialUtil = (value) => { return trimObject(copyright) } -export const parseText: ParsePartialUtil = (value) => { +export const parseText: ParseUtilPartial = (value) => { const text = { value: ((value) => parseString(retrieveText(value)))(value), type: parseString(value?.['@type']), @@ -134,7 +134,7 @@ export const parseText: ParsePartialUtil = (value) => { return trimObject(text) } -export const parseRestriction: ParsePartialUtil = (value) => { +export const parseRestriction: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -148,7 +148,7 @@ export const parseRestriction: ParsePartialUtil = (value) = return trimObject(restriction) } -export const parseCommunity: ParsePartialUtil = (value) => { +export const parseCommunity: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -162,7 +162,7 @@ export const parseCommunity: ParsePartialUtil = (value) => { return trimObject(community) } -export const parseStarRating: ParsePartialUtil = (value) => { +export const parseStarRating: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -177,7 +177,7 @@ export const parseStarRating: ParsePartialUtil = (value) => return trimObject(starRating) } -export const parseStatistics: ParsePartialUtil = (value) => { +export const parseStatistics: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -190,7 +190,7 @@ export const parseStatistics: ParsePartialUtil = (value) => return trimObject(statistics) } -export const parseTags: ParsePartialUtil> = (value) => { +export const parseTags: ParseUtilPartial> = (value) => { return parseCsvOf(value, (segment) => { const split = segment.split(':') @@ -201,11 +201,11 @@ export const parseTags: ParsePartialUtil> = (value) => { }) } -export const parseComments: ParsePartialUtil> = (value) => { +export const parseComments: ParseUtilPartial> = (value) => { return parseArrayOf(value?.['media:comment'], (value) => parseString(retrieveText(value))) } -export const parseEmbed: ParsePartialUtil = (value) => { +export const parseEmbed: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -220,7 +220,7 @@ export const parseEmbed: ParsePartialUtil = (value) => { return trimObject(embed) } -export const parseParam: ParsePartialUtil = (value) => { +export const parseParam: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -233,15 +233,15 @@ export const parseParam: ParsePartialUtil = (value) => { return trimObject(param) } -export const parseResponses: ParsePartialUtil> = (value) => { +export const parseResponses: ParseUtilPartial> = (value) => { return parseArrayOf(value?.['media:response'], (value) => parseString(retrieveText(value))) } -export const parseBackLinks: ParsePartialUtil> = (value) => { +export const parseBackLinks: ParseUtilPartial> = (value) => { return parseArrayOf(value?.['media:backlink'], (value) => parseString(retrieveText(value))) } -export const parseStatus: ParsePartialUtil = (value) => { +export const parseStatus: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -254,7 +254,7 @@ export const parseStatus: ParsePartialUtil = (value) => { return trimObject(status) } -export const parsePrice: ParsePartialUtil = (value) => { +export const parsePrice: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -269,7 +269,7 @@ export const parsePrice: ParsePartialUtil = (value) => { return trimObject(price) } -export const parseLicense: ParsePartialUtil = (value) => { +export const parseLicense: ParseUtilPartial = (value) => { const license = { name: ((value) => parseString(retrieveText(value)))(value), type: parseString(value?.['@type']), @@ -279,7 +279,7 @@ export const parseLicense: ParsePartialUtil = (value) => { return trimObject(license) } -export const parseSubTitle: ParsePartialUtil = (value) => { +export const parseSubTitle: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -293,7 +293,7 @@ export const parseSubTitle: ParsePartialUtil = (value) => { return trimObject(subTitle) } -export const parsePeerLink: ParsePartialUtil = (value) => { +export const parsePeerLink: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -306,7 +306,7 @@ export const parsePeerLink: ParsePartialUtil = (value) => { return trimObject(peerLink) } -export const parseRights: ParsePartialUtil = (value) => { +export const parseRights: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -318,7 +318,7 @@ export const parseRights: ParsePartialUtil = (value) => { return trimObject(rights) } -export const parseScene: ParsePartialUtil = (value) => { +export const parseScene: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -335,11 +335,11 @@ export const parseScene: ParsePartialUtil = (value) => { return trimObject(scene) } -export const parseScenes: ParsePartialUtil> = (value) => { +export const parseScenes: ParseUtilPartial> = (value) => { return parseArrayOf(value?.['media:scene'], parseScene) } -export const parseLocation: ParsePartialUtil = (value) => { +export const parseLocation: ParseUtilPartial = (value) => { // For cases where the location is simply a string within the tag. if (isNonEmptyStringOrNumber(value) || isObject(value)) { const location = { @@ -353,7 +353,7 @@ export const parseLocation: ParsePartialUtil = (value) => { // https://www.rssboard.org/media-rss#media-peerlink after implementing GeoRSS GML support. } -export const retrieveCommonElements: ParsePartialUtil = (value) => { +export const retrieveCommonElements: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -391,7 +391,7 @@ export const retrieveCommonElements: ParsePartialUtil = return trimObject(commonElements) } -export const parseContent: ParsePartialUtil = (value) => { +export const parseContent: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -417,7 +417,7 @@ export const parseContent: ParsePartialUtil = (value) => { return trimObject(content) } -export const parseGroup: ParsePartialUtil = (value) => { +export const parseGroup: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -430,7 +430,7 @@ export const parseGroup: ParsePartialUtil = (value) => { return trimObject(group) } -export const retrieveItemOrFeed: ParsePartialUtil = (value) => { +export const retrieveItemOrFeed: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -439,9 +439,6 @@ export const retrieveItemOrFeed: ParsePartialUtil = (value) groups: parseArrayOf(value['media:group'], parseGroup), contents: parseArrayOf(value['media:content'], parseContent), ...retrieveCommonElements(value), - - // Deprecated field for backward compatibility. - group: parseSingularOf(value['media:group'], parseGroup), } return trimObject(itemOrFeed) diff --git a/src/namespaces/opensearch/common/types.ts b/src/namespaces/opensearch/common/types.ts index 26526d17..eb56ed03 100644 --- a/src/namespaces/opensearch/common/types.ts +++ b/src/namespaces/opensearch/common/types.ts @@ -1,21 +1,26 @@ +import type { Requirable, Strict } from '../../../common/types.js' + // #region reference export namespace OpenSearchNs { - export type Query = { - role: string - searchTerms?: string - count?: number - startIndex?: number - startPage?: number - language?: string - inputEncoding?: string - outputEncoding?: string - } + export type Query = Strict< + { + role: Requirable // Required in spec. + searchTerms?: string + count?: number + startIndex?: number + startPage?: number + language?: string + inputEncoding?: string + outputEncoding?: string + }, + TStrict + > - export type Feed = { + export type Feed = { totalResults?: number startIndex?: number itemsPerPage?: number - queries?: Array + queries?: Array> } } // #endregion reference diff --git a/src/namespaces/opensearch/generate/utils.test.ts b/src/namespaces/opensearch/generate/utils.test.ts index e5d2ffd5..e43c4f3c 100644 --- a/src/namespaces/opensearch/generate/utils.test.ts +++ b/src/namespaces/opensearch/generate/utils.test.ts @@ -70,7 +70,6 @@ describe('generateQuery', () => { it('should handle empty object', () => { const value = {} - // @ts-expect-error: This is for testing purposes. expect(generateQuery(value)).toBeUndefined() }) diff --git a/src/namespaces/opensearch/parse/utils.ts b/src/namespaces/opensearch/parse/utils.ts index 9f00f98a..c84359ce 100644 --- a/src/namespaces/opensearch/parse/utils.ts +++ b/src/namespaces/opensearch/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { ParseUtilPartial } from '../../../common/types.js' import { isObject, parseArrayOf, @@ -10,7 +10,7 @@ import { } from '../../../common/utils.js' import type { OpenSearchNs } from '../common/types.js' -export const parseQuery: ParsePartialUtil = (value) => { +export const parseQuery: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -29,7 +29,7 @@ export const parseQuery: ParsePartialUtil = (value) => { return trimObject(query) } -export const retrieveFeed: ParsePartialUtil = (value) => { +export const retrieveFeed: ParseUtilPartial = (value) => { if (!isObject(value)) { return } diff --git a/src/namespaces/pingback/parse/utils.ts b/src/namespaces/pingback/parse/utils.ts index 049180f4..d896a84f 100644 --- a/src/namespaces/pingback/parse/utils.ts +++ b/src/namespaces/pingback/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { ParseUtilPartial } from '../../../common/types.js' import { isObject, parseSingularOf, @@ -8,7 +8,7 @@ import { } from '../../../common/utils.js' import type { PingbackNs } from '../common/types.js' -export const retrieveItem: ParsePartialUtil = (value) => { +export const retrieveItem: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -25,7 +25,7 @@ export const retrieveItem: ParsePartialUtil = (value) => { return trimObject(item) } -export const retrieveFeed: ParsePartialUtil = (value) => { +export const retrieveFeed: ParseUtilPartial = (value) => { if (!isObject(value)) { return } diff --git a/src/namespaces/podcast/common/types.ts b/src/namespaces/podcast/common/types.ts index 9ab7299d..c099ae6b 100644 --- a/src/namespaces/podcast/common/types.ts +++ b/src/namespaces/podcast/common/types.ts @@ -1,262 +1,327 @@ -import type { DateLike } from '../../../common/types.js' +import type { Requirable, Strict } from '../../../common/types.js' // #region reference export namespace PodcastNs { /** @internal Common properties shared by Item and LiveItem. */ - export type BaseItem = { - transcripts?: Array - chapters?: Chapters - soundbites?: Array - persons?: Array - locations?: Array - season?: Season - episode?: Episode - license?: License - alternateEnclosures?: Array - values?: Array - images?: Array - socialInteracts?: Array - txts?: Array - chat?: Chat - - /** @deprecated Use `locations` (array) instead. Multiple podcast:location elements are allowed per specification. */ - location?: Location - /** @deprecated Use `values` (array) instead. Multiple podcast:value elements are allowed per specification. */ - value?: Value - /** @deprecated Use `chat` (singular) instead. Only one podcast:chat element is allowed per specification. */ - chats?: Array - } - - export type Transcript = { - url: string - type: string - language?: string - rel?: string - } - - export type Locked = { - value: boolean - owner?: string - } - - export type Funding = { - url: string - display?: string - } - - export type Chapters = { - url: string - type: string - } - - export type Soundbite = { - startTime: number - duration: number - display?: string - } - - export type Person = { - display: string - role?: string - group?: string - img?: string - href?: string - } - - export type Location = { - display: string - rel?: string - geo?: string - osm?: string - country?: string - } - - export type Season = { - number: number - name?: string - } - - export type Episode = { - number: number - display?: string - } - - export type Trailer = { - display: string - url: string - pubDate: TDate - length?: number - type?: string - season?: number - } - - export type License = { - display: string - url?: string - } - - export type AlternateEnclosure = { - type: string - length?: number - bitrate?: number - height?: number - lang?: string - title?: string - rel?: string - codecs?: string - default?: boolean - sources?: Array - integrity?: Integrity - } - - export type Source = { - uri: string - contentType?: string - } - - export type Integrity = { - type: string - value: string - } - - export type Value = { - type: string - method: string - suggested?: number - valueRecipients?: Array - valueTimeSplits?: Array - } - - export type ValueRecipient = { - name?: string - customKey?: string - customValue?: string - type: string - address: string - split: number - fee?: boolean - } + export type BaseItem = { + transcripts?: Array> + chapters?: Chapters + soundbites?: Array> + persons?: Array> + locations?: Array> + season?: Season + episode?: Episode + license?: License + alternateEnclosures?: Array> + values?: Array> + images?: Array> + socialInteracts?: Array> + txts?: Array> + chat?: Chat + } + + export type Transcript = Strict< + { + url: Requirable // Required in spec. + type: Requirable // Required in spec. + language?: string + rel?: string + }, + TStrict + > + + export type Locked = Strict< + { + value: Requirable // Required in spec. + owner?: string + }, + TStrict + > + + export type Funding = Strict< + { + url: Requirable // Required in spec. + display?: string + }, + TStrict + > + + export type Chapters = Strict< + { + url: Requirable // Required in spec. + type: Requirable // Required in spec. + }, + TStrict + > + + export type Soundbite = Strict< + { + startTime: Requirable // Required in spec. + duration: Requirable // Required in spec. + display?: string + }, + TStrict + > + + export type Person = Strict< + { + display: Requirable // Required in spec. + role?: string + group?: string + img?: string + href?: string + }, + TStrict + > + + export type Location = Strict< + { + display: Requirable // Required in spec. + rel?: string + geo?: string + osm?: string + country?: string + }, + TStrict + > + + export type Season = Strict< + { + number: Requirable // Required in spec. + name?: string + }, + TStrict + > + + export type Episode = Strict< + { + number: Requirable // Required in spec. + display?: string + }, + TStrict + > + + export type Trailer = Strict< + { + display: Requirable // Required in spec. + url: Requirable // Required in spec. + pubDate: Requirable // Required in spec. + length?: number + type?: string + season?: number + }, + TStrict + > + + export type License = Strict< + { + display: Requirable // Required in spec. + url?: string + }, + TStrict + > + + export type AlternateEnclosure = Strict< + { + type: Requirable // Required in spec. + length?: number + bitrate?: number + height?: number + lang?: string + title?: string + rel?: string + codecs?: string + default?: boolean + sources?: Array> + integrity?: Integrity + }, + TStrict + > + + export type Source = Strict< + { + uri: Requirable // Required in spec. + contentType?: string + }, + TStrict + > + + export type Integrity = Strict< + { + type: Requirable // Required in spec. + value: Requirable // Required in spec. + }, + TStrict + > + + export type Value = Strict< + { + type: Requirable // Required in spec. + method: Requirable // Required in spec. + suggested?: number + valueRecipients?: Array> + valueTimeSplits?: Array> + }, + TStrict + > + + export type ValueRecipient = Strict< + { + name?: string + customKey?: string + customValue?: string + type: Requirable // Required in spec. + address: Requirable // Required in spec. + split: Requirable // Required in spec. + fee?: boolean + }, + TStrict + > /** @internal Legacy type for parsing podcast:images with srcset attribute. */ export type Images = { srcset?: string } - export type Image = { - href: string - alt?: string - aspectRatio?: string - width?: number - height?: number - type?: string - purpose?: string - } - - export type LiveItem = BaseItem & { - status: string - start: TDate // Date: ISO 8601. - end?: TDate // Date: ISO 8601. - contentLinks?: Array - } - - export type ContentLink = { - href: string - display?: string - } - - export type SocialInteract = { - // INFO: The specification states that the `uri` is required. However, if the protocol is set - // to `disabled`, no other attributes are necessary. To bypass the protocol value check, which - // may be invalid, only the protocol is required to ensure consistent behavior. - uri?: string - protocol: string - accountId?: string - accountUrl?: string - priority?: number - } - - export type Chat = { - server: string - protocol: string - accountId?: string - space?: string - } - - export type Block = { - value: boolean - id?: string - } - - export type Txt = { - display: string - purpose?: string - } - - export type RemoteItem = { - feedGuid: string - feedUrl?: string - itemGuid?: string - medium?: string - title?: string - } - - export type Podroll = { - remoteItems?: Array - } - - export type UpdateFrequency = { - display: string - complete?: boolean - dtstart?: TDate - rrule?: string - } + export type Image = Strict< + { + href: Requirable // Required in spec. + alt?: string + aspectRatio?: string + width?: number + height?: number + type?: string + purpose?: string + }, + TStrict + > + + export type LiveItem = BaseItem & + Strict< + { + status: Requirable // Required in spec. + start: Requirable // Required in spec. Date: ISO 8601. + end?: TDate // Date: ISO 8601. + contentLinks?: Array> + }, + TStrict + > + + export type ContentLink = Strict< + { + href: Requirable // Required in spec. + display?: string + }, + TStrict + > + + export type SocialInteract = Strict< + { + // INFO: The specification states that the `uri` is required. However, if the protocol is set + // to `disabled`, no other attributes are necessary. To bypass the protocol value check, which + // may be invalid, only the protocol is required to ensure consistent behavior. + uri?: string + protocol: Requirable // Required in spec. + accountId?: string + accountUrl?: string + priority?: number + }, + TStrict + > + + export type Chat = Strict< + { + server: Requirable // Required in spec. + protocol: Requirable // Required in spec. + accountId?: string + space?: string + }, + TStrict + > + + export type Block = Strict< + { + value: Requirable // Required in spec. + id?: string + }, + TStrict + > + + export type Txt = Strict< + { + display: Requirable // Required in spec. + purpose?: string + }, + TStrict + > + + export type RemoteItem = Strict< + { + feedGuid: Requirable // Required in spec. + feedUrl?: string + itemGuid?: string + medium?: string + title?: string + }, + TStrict + > + + export type Podroll = { + remoteItems?: Array> + } + + export type UpdateFrequency = Strict< + { + display: Requirable // Required in spec. + complete?: boolean + dtstart?: TDate + rrule?: string + }, + TStrict + > export type Podping = { usesPodping?: boolean } - export type Publisher = { - remoteItem?: RemoteItem + export type Publisher = { + remoteItem?: RemoteItem } - export type ValueTimeSplit = { - startTime: number - duration: number - remoteStartTime?: number - remotePercentage?: number - remoteItem?: RemoteItem - valueRecipients?: Array - } + export type ValueTimeSplit = Strict< + { + startTime: Requirable // Required in spec. + duration: Requirable // Required in spec. + remoteStartTime?: number + remotePercentage?: number + remoteItem?: RemoteItem + valueRecipients?: Array> + }, + TStrict + > - export type Item = BaseItem + export type Item = BaseItem - export type Feed = { - locked?: Locked - fundings?: Array - persons?: Array - locations?: Array - trailers?: Array> - license?: License + export type Feed = { + locked?: Locked + fundings?: Array> + persons?: Array> + locations?: Array> + trailers?: Array> + license?: License guid?: string - values?: Array + values?: Array> medium?: string - images?: Array - liveItems?: Array> - blocks?: Array - txts?: Array - remoteItems?: Array - podroll?: Podroll - updateFrequency?: UpdateFrequency + images?: Array> + liveItems?: Array> + blocks?: Array> + txts?: Array> + remoteItems?: Array> + podroll?: Podroll + updateFrequency?: UpdateFrequency podping?: Podping - chat?: Chat - publisher?: Publisher - - /** @deprecated Use `locations` (array) instead. Multiple podcast:location elements are allowed per specification. */ - location?: Location - /** @deprecated Use `values` (array) instead. Multiple podcast:value elements are allowed per specification. */ - value?: Value - /** @deprecated Use `chat` (singular) instead. Only one podcast:chat element is allowed per specification. */ - chats?: Array + chat?: Chat + publisher?: Publisher } } // #endregion reference diff --git a/src/namespaces/podcast/generate/utils.test.ts b/src/namespaces/podcast/generate/utils.test.ts index 3b209f53..678b6b95 100644 --- a/src/namespaces/podcast/generate/utils.test.ts +++ b/src/namespaces/podcast/generate/utils.test.ts @@ -1076,9 +1076,11 @@ describe('generateBaseItem', () => { role: 'host', }, ], - location: { - display: 'Austin, TX', - }, + locations: [ + { + display: 'Austin, TX', + }, + ], season: { number: 2, }, @@ -1093,10 +1095,12 @@ describe('generateBaseItem', () => { type: 'audio/mpeg', }, ], - value: { - type: 'lightning', - method: 'keysend', - }, + values: [ + { + type: 'lightning', + method: 'keysend', + }, + ], images: [ { href: 'https://example.com/image.jpg', @@ -1136,9 +1140,11 @@ describe('generateBaseItem', () => { '@role': 'host', }, ], - 'podcast:location': { - '#text': 'Austin, TX', - }, + 'podcast:location': [ + { + '#text': 'Austin, TX', + }, + ], 'podcast:season': { '#text': 2, }, @@ -1153,10 +1159,12 @@ describe('generateBaseItem', () => { '@type': 'audio/mpeg', }, ], - 'podcast:value': { - '@type': 'lightning', - '@method': 'keysend', - }, + 'podcast:value': [ + { + '@type': 'lightning', + '@method': 'keysend', + }, + ], 'podcast:image': [ { '@href': 'https://example.com/image.jpg', @@ -1323,9 +1331,11 @@ describe('generateFeed', () => { role: 'host', }, ], - location: { - display: 'Austin, TX', - }, + locations: [ + { + display: 'Austin, TX', + }, + ], trailers: [ { display: 'Season 2 Trailer', @@ -1337,10 +1347,12 @@ describe('generateFeed', () => { display: 'Creative Commons', }, guid: 'feed-guid-123', - value: { - type: 'lightning', - method: 'keysend', - }, + values: [ + { + type: 'lightning', + method: 'keysend', + }, + ], medium: 'podcast', images: [ { @@ -1400,9 +1412,11 @@ describe('generateFeed', () => { '@role': 'host', }, ], - 'podcast:location': { - '#text': 'Austin, TX', - }, + 'podcast:location': [ + { + '#text': 'Austin, TX', + }, + ], 'podcast:trailer': [ { '#text': 'Season 2 Trailer', @@ -1414,10 +1428,12 @@ describe('generateFeed', () => { '#text': 'Creative Commons', }, 'podcast:guid': 'feed-guid-123', - 'podcast:value': { - '@type': 'lightning', - '@method': 'keysend', - }, + 'podcast:value': [ + { + '@type': 'lightning', + '@method': 'keysend', + }, + ], 'podcast:medium': 'podcast', 'podcast:image': [ { diff --git a/src/namespaces/podcast/generate/utils.ts b/src/namespaces/podcast/generate/utils.ts index 8eac1740..4b4f9b06 100644 --- a/src/namespaces/podcast/generate/utils.ts +++ b/src/namespaces/podcast/generate/utils.ts @@ -1,13 +1,11 @@ import type { DateLike, GenerateUtil } from '../../../common/types.js' import { - generateArrayOrSingular, generateBoolean, generateCdataString, generateNumber, generatePlainString, generateRfc822Date, generateRfc3339Date, - generateSingularOrArray, generateTextOrCdataString, generateYesNoBoolean, isObject, @@ -26,11 +24,7 @@ export const generateBaseItem: GenerateUtil = (baseItem) => 'podcast:chapters': generateChapters(baseItem.chapters), 'podcast:soundbite': trimArray(baseItem.soundbites, generateSoundbite), 'podcast:person': trimArray(baseItem.persons, generatePerson), - 'podcast:location': generateArrayOrSingular( - baseItem.locations, - baseItem.location, - generateLocation, - ), + 'podcast:location': trimArray(baseItem.locations, generateLocation), 'podcast:season': generateSeason(baseItem.season), 'podcast:episode': generateEpisode(baseItem.episode), 'podcast:license': generateLicense(baseItem.license), @@ -38,11 +32,11 @@ export const generateBaseItem: GenerateUtil = (baseItem) => baseItem.alternateEnclosures, generateAlternateEnclosure, ), - 'podcast:value': generateArrayOrSingular(baseItem.values, baseItem.value, generateValue), + 'podcast:value': trimArray(baseItem.values, generateValue), 'podcast:image': trimArray(baseItem.images, generateImage), 'podcast:socialInteract': trimArray(baseItem.socialInteracts, generateSocialInteract), 'podcast:txt': trimArray(baseItem.txts, generateTxt), - 'podcast:chat': generateSingularOrArray(baseItem.chat, baseItem.chats, generateChat), + 'podcast:chat': generateChat(baseItem.chat), } return trimObject(value) @@ -495,11 +489,11 @@ export const generateFeed: GenerateUtil> = (feed) => { 'podcast:locked': generateLocked(feed.locked), 'podcast:funding': trimArray(feed.fundings, generateFunding), 'podcast:person': trimArray(feed.persons, generatePerson), - 'podcast:location': generateArrayOrSingular(feed.locations, feed.location, generateLocation), + 'podcast:location': trimArray(feed.locations, generateLocation), 'podcast:trailer': trimArray(feed.trailers, generateTrailer), 'podcast:license': generateLicense(feed.license), 'podcast:guid': generateCdataString(feed.guid), - 'podcast:value': generateArrayOrSingular(feed.values, feed.value, generateValue), + 'podcast:value': trimArray(feed.values, generateValue), 'podcast:medium': generateCdataString(feed.medium), 'podcast:image': trimArray(feed.images, generateImage), 'podcast:liveItem': trimArray(feed.liveItems, generateLiveItem), @@ -509,7 +503,7 @@ export const generateFeed: GenerateUtil> = (feed) => { 'podcast:podroll': generatePodroll(feed.podroll), 'podcast:updateFrequency': generateUpdateFrequency(feed.updateFrequency), 'podcast:podping': generatePodping(feed.podping), - 'podcast:chat': generateSingularOrArray(feed.chat, feed.chats, generateChat), + 'podcast:chat': generateChat(feed.chat), 'podcast:publisher': generatePublisher(feed.publisher), } diff --git a/src/namespaces/podcast/parse/utils.test.ts b/src/namespaces/podcast/parse/utils.test.ts index 83cda0d1..3a6ef7fb 100644 --- a/src/namespaces/podcast/parse/utils.test.ts +++ b/src/namespaces/podcast/parse/utils.test.ts @@ -2318,10 +2318,6 @@ describe('parseLiveItem', () => { geo: '40.7128,-74.0060', }, ], - location: { - display: 'New York, NY', - geo: '40.7128,-74.0060', - }, } expect(parseLiveItem(value)).toEqual(expected) @@ -3738,22 +3734,6 @@ describe('retrieveItem', () => { purpose: 'description', }, ], - location: { - display: 'New York, NY', - geo: '40.7128,-74.0060', - }, - value: { - type: 'lightning', - method: 'keysend', - suggested: 0.00000005, - valueRecipients: [ - { - type: 'node', - address: '02d5c1bf8b940dc9cadca86d1b0a3c37fbe39cee4c7e839e33bef9174531d27f52', - split: 100, - }, - ], - }, } it('should parse a complete item with all podcast namespace elements', () => { @@ -4186,10 +4166,6 @@ describe('retrieveFeed', () => { geo: '37.7749,-122.4194', }, ], - location: { - display: 'San Francisco, CA', - geo: '37.7749,-122.4194', - }, trailers: [ { display: 'Season 2 Trailer', @@ -4218,18 +4194,6 @@ describe('retrieveFeed', () => { ], }, ], - value: { - type: 'lightning', - method: 'keysend', - suggested: 0.00000005, - valueRecipients: [ - { - type: 'node', - address: '02d5c1bf8b940dc9cadca86d1b0a3c37fbe39cee4c7e839e33bef9174531d27f52', - split: 100, - }, - ], - }, medium: 'podcast', images: [ { diff --git a/src/namespaces/podcast/parse/utils.ts b/src/namespaces/podcast/parse/utils.ts index 76fd8f11..02192607 100644 --- a/src/namespaces/podcast/parse/utils.ts +++ b/src/namespaces/podcast/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { DateAny, ParseMainOptions, ParseUtilPartial } from '../../../common/types.js' import { isObject, parseArrayOf, @@ -17,7 +17,7 @@ import type { PodcastNs } from '../common/types.js' const whitespaceRegex = /\s+/ const trailingWRegex = /w$/ -export const parseTranscript: ParsePartialUtil = (value) => { +export const parseTranscript: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -32,7 +32,7 @@ export const parseTranscript: ParsePartialUtil = (value) = return trimObject(transcript) } -export const parseLocked: ParsePartialUtil = (value) => { +export const parseLocked: ParseUtilPartial = (value) => { const locked = { value: parseYesNoBoolean(retrieveText(value)), owner: parseString(value?.['@owner']), @@ -41,7 +41,7 @@ export const parseLocked: ParsePartialUtil = (value) => { return trimObject(locked) } -export const parseFunding: ParsePartialUtil = (value) => { +export const parseFunding: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -54,7 +54,7 @@ export const parseFunding: ParsePartialUtil = (value) => { return trimObject(funding) } -export const parseChapters: ParsePartialUtil = (value) => { +export const parseChapters: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -67,7 +67,7 @@ export const parseChapters: ParsePartialUtil = (value) => { return trimObject(chapters) } -export const parseSoundbite: ParsePartialUtil = (value) => { +export const parseSoundbite: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -81,7 +81,7 @@ export const parseSoundbite: ParsePartialUtil = (value) => return trimObject(soundbite) } -export const parsePerson: ParsePartialUtil = (value) => { +export const parsePerson: ParseUtilPartial = (value) => { const person = { display: parseString(retrieveText(value)), role: parseString(value?.['@role']), @@ -93,7 +93,7 @@ export const parsePerson: ParsePartialUtil = (value) => { return trimObject(person) } -export const parseLocation: ParsePartialUtil = (value) => { +export const parseLocation: ParseUtilPartial = (value) => { const location = { display: parseString(retrieveText(value)), rel: parseString(value?.['@rel']), @@ -105,7 +105,7 @@ export const parseLocation: ParsePartialUtil = (value) => { return trimObject(location) } -export const parseSeason: ParsePartialUtil = (value) => { +export const parseSeason: ParseUtilPartial = (value) => { const season = { number: parseNumber(retrieveText(value)), name: parseString(value?.['@name']), @@ -114,7 +114,7 @@ export const parseSeason: ParsePartialUtil = (value) => { return trimObject(season) } -export const parseEpisode: ParsePartialUtil = (value) => { +export const parseEpisode: ParseUtilPartial = (value) => { const episode = { number: parseNumber(retrieveText(value)), display: parseString(value?.['@display']), @@ -123,7 +123,10 @@ export const parseEpisode: ParsePartialUtil = (value) => { return trimObject(episode) } -export const parseTrailer: ParsePartialUtil> = (value) => { +export const parseTrailer: ParseUtilPartial< + PodcastNs.Trailer, + ParseMainOptions +> = (value, options) => { if (!isObject(value)) { return } @@ -131,7 +134,7 @@ export const parseTrailer: ParsePartialUtil> = (value) const trailer = { display: parseString(retrieveText(value)), url: parseString(value['@url']), - pubDate: parseDate(value['@pubdate']), + pubDate: parseDate(value['@pubdate'], options?.parseDateFn), length: parseNumber(value['@length']), type: parseString(value['@type']), season: parseNumber(value['@season']), @@ -140,7 +143,7 @@ export const parseTrailer: ParsePartialUtil> = (value) return trimObject(trailer) } -export const parseLicense: ParsePartialUtil = (value) => { +export const parseLicense: ParseUtilPartial = (value) => { const license = { display: parseString(retrieveText(value)), url: parseString(value?.['@url']), @@ -149,7 +152,7 @@ export const parseLicense: ParsePartialUtil = (value) => { return trimObject(license) } -export const parseAlternateEnclosure: ParsePartialUtil = (value) => { +export const parseAlternateEnclosure: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -171,7 +174,7 @@ export const parseAlternateEnclosure: ParsePartialUtil = (value) => { +export const parseSource: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -184,7 +187,7 @@ export const parseSource: ParsePartialUtil = (value) => { return trimObject(source) } -export const parseIntegrity: ParsePartialUtil = (value) => { +export const parseIntegrity: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -197,7 +200,7 @@ export const parseIntegrity: ParsePartialUtil = (value) => return trimObject(integrity) } -export const parseValue: ParsePartialUtil = (value) => { +export const parseValue: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -213,7 +216,7 @@ export const parseValue: ParsePartialUtil = (value) => { return trimObject(parsed) } -export const parseValueRecipient: ParsePartialUtil = (value) => { +export const parseValueRecipient: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -231,7 +234,7 @@ export const parseValueRecipient: ParsePartialUtil = ( return trimObject(valueRecipient) } -export const parseImage: ParsePartialUtil = (value) => { +export const parseImage: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -249,7 +252,7 @@ export const parseImage: ParsePartialUtil = (value) => { return trimObject(image) } -export const parseImages: ParsePartialUtil> = (value) => { +export const parseImages: ParseUtilPartial> = (value) => { const srcset = parseString(value?.['@srcset']) if (!srcset) { @@ -273,14 +276,17 @@ export const parseImages: ParsePartialUtil> = (value) => return trimArray(images, trimObject) } -export const retrieveImages: ParsePartialUtil> = (value) => { +export const retrieveImages: ParseUtilPartial> = (value) => { return ( parseArrayOf(value['podcast:image'], parseImage) ?? parseSingularOf(value['podcast:images'], parseImages) ) } -export const parseLiveItem: ParsePartialUtil> = (value) => { +export const parseLiveItem: ParseUtilPartial< + PodcastNs.LiveItem, + ParseMainOptions +> = (value, options) => { if (!isObject(value)) { return } @@ -288,15 +294,15 @@ export const parseLiveItem: ParsePartialUtil> = (valu const liveItem = { ...retrieveItem(value), status: parseString(value['@status']), - start: parseDate(value['@start']), - end: parseDate(value['@end']), + start: parseDate(value['@start'], options?.parseDateFn), + end: parseDate(value['@end'], options?.parseDateFn), contentLinks: parseArrayOf(value['podcast:contentlink'], parseContentLink), } return trimObject(liveItem) } -export const parseContentLink: ParsePartialUtil = (value) => { +export const parseContentLink: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -309,7 +315,7 @@ export const parseContentLink: ParsePartialUtil = (value) return trimObject(contentLink) } -export const parseSocialInteract: ParsePartialUtil = (value) => { +export const parseSocialInteract: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -325,7 +331,7 @@ export const parseSocialInteract: ParsePartialUtil = ( return trimObject(socialInteract) } -export const parseChat: ParsePartialUtil = (value) => { +export const parseChat: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -340,7 +346,7 @@ export const parseChat: ParsePartialUtil = (value) => { return trimObject(chat) } -export const parseBlock: ParsePartialUtil = (value) => { +export const parseBlock: ParseUtilPartial = (value) => { const block = { value: parseYesNoBoolean(retrieveText(value)), id: parseString(value?.['@id']), @@ -349,7 +355,7 @@ export const parseBlock: ParsePartialUtil = (value) => { return trimObject(block) } -export const parseTxt: ParsePartialUtil = (value) => { +export const parseTxt: ParseUtilPartial = (value) => { const txt = { display: parseString(retrieveText(value)), purpose: parseString(value?.['@purpose']), @@ -358,7 +364,7 @@ export const parseTxt: ParsePartialUtil = (value) => { return trimObject(txt) } -export const parseRemoteItem: ParsePartialUtil = (value) => { +export const parseRemoteItem: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -374,7 +380,7 @@ export const parseRemoteItem: ParsePartialUtil = (value) = return trimObject(remoteItem) } -export const parsePodroll: ParsePartialUtil = (value) => { +export const parsePodroll: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -386,20 +392,21 @@ export const parsePodroll: ParsePartialUtil = (value) => { return trimObject(podroll) } -export const parseUpdateFrequency: ParsePartialUtil> = ( - value, -) => { +export const parseUpdateFrequency: ParseUtilPartial< + PodcastNs.UpdateFrequency, + ParseMainOptions +> = (value, options) => { const updateFrequency = { display: parseString(retrieveText(value)), complete: parseBoolean(value?.['@complete']), - dtstart: parseDate(value?.['@dtstart']), + dtstart: parseDate(value?.['@dtstart'], options?.parseDateFn), rrule: parseString(value?.['@rrule']), } return trimObject(updateFrequency) } -export const parsePodping: ParsePartialUtil = (value) => { +export const parsePodping: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -411,7 +418,7 @@ export const parsePodping: ParsePartialUtil = (value) => { return trimObject(podping) } -export const parsePublisher: ParsePartialUtil = (value) => { +export const parsePublisher: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -423,7 +430,7 @@ export const parsePublisher: ParsePartialUtil = (value) => return trimObject(publisher) } -export const parseValueTimeSplit: ParsePartialUtil = (value) => { +export const parseValueTimeSplit: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -440,7 +447,7 @@ export const parseValueTimeSplit: ParsePartialUtil = ( return trimObject(valueTimeSplit) } -export const retrieveItem: ParsePartialUtil = (value) => { +export const retrieveItem: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -460,17 +467,15 @@ export const retrieveItem: ParsePartialUtil = (value) => { socialInteracts: parseArrayOf(value['podcast:socialinteract'], parseSocialInteract), txts: parseArrayOf(value['podcast:txt'], parseTxt), chat: parseSingularOf(value['podcast:chat'], parseChat), - - // Deprecated fields for backward compatibility. - location: parseSingularOf(value['podcast:location'], parseLocation), - value: parseSingularOf(value['podcast:value'], parseValue), - chats: parseArrayOf(value['podcast:chat'], parseChat), } return trimObject(item) } -export const retrieveFeed: ParsePartialUtil> = (value) => { +export const retrieveFeed: ParseUtilPartial, ParseMainOptions> = ( + value, + options, +) => { if (!isObject(value)) { return } @@ -480,26 +485,23 @@ export const retrieveFeed: ParsePartialUtil> = (value) => fundings: parseArrayOf(value['podcast:funding'], parseFunding), persons: parseArrayOf(value['podcast:person'], parsePerson), locations: parseArrayOf(value['podcast:location'], parseLocation), - trailers: parseArrayOf(value['podcast:trailer'], parseTrailer), + trailers: parseArrayOf(value['podcast:trailer'], (value) => parseTrailer(value, options)), license: parseSingularOf(value['podcast:license'], parseLicense), guid: parseSingularOf(value['podcast:guid'], (value) => parseString(retrieveText(value))), values: parseArrayOf(value['podcast:value'], parseValue), medium: parseSingularOf(value['podcast:medium'], (value) => parseString(retrieveText(value))), images: retrieveImages(value), - liveItems: parseArrayOf(value['podcast:liveitem'], parseLiveItem), + liveItems: parseArrayOf(value['podcast:liveitem'], (value) => parseLiveItem(value, options)), blocks: parseArrayOf(value['podcast:block'], parseBlock), txts: parseArrayOf(value['podcast:txt'], parseTxt), remoteItems: parseArrayOf(value['podcast:remoteitem'], parseRemoteItem), podroll: parseSingularOf(value['podcast:podroll'], parsePodroll), - updateFrequency: parseSingularOf(value['podcast:updatefrequency'], parseUpdateFrequency), + updateFrequency: parseSingularOf(value['podcast:updatefrequency'], (value) => + parseUpdateFrequency(value, options), + ), podping: parseSingularOf(value['podcast:podping'], parsePodping), chat: parseSingularOf(value['podcast:chat'], parseChat), publisher: parseSingularOf(value['podcast:publisher'], parsePublisher), - - // Deprecated fields for backward compatibility. - location: parseSingularOf(value['podcast:location'], parseLocation), - value: parseSingularOf(value['podcast:value'], parseValue), - chats: parseArrayOf(value['podcast:chat'], parseChat), } return trimObject(feed) diff --git a/src/namespaces/prism/common/types.ts b/src/namespaces/prism/common/types.ts index ef0854d9..df8999e8 100644 --- a/src/namespaces/prism/common/types.ts +++ b/src/namespaces/prism/common/types.ts @@ -1,8 +1,6 @@ -import type { DateLike } from '../../../common/types.js' - // #region reference export namespace PrismNs { - export type Feed = { + export type Feed = { publicationName?: string issn?: string eIssn?: string @@ -86,7 +84,7 @@ export namespace PrismNs { rightsAgent?: string } - export type Item = { + export type Item = { publicationName?: string issn?: string eIssn?: string diff --git a/src/namespaces/prism/parse/utils.ts b/src/namespaces/prism/parse/utils.ts index 0e35c71a..09e9f4d4 100644 --- a/src/namespaces/prism/parse/utils.ts +++ b/src/namespaces/prism/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { DateAny, ParseMainOptions, ParseUtilPartial } from '../../../common/types.js' import { isObject, parseArrayOf, @@ -11,7 +11,10 @@ import { } from '../../../common/utils.js' import type { PrismNs } from '../common/types.js' -export const retrieveFeed: ParsePartialUtil> = (value) => { +export const retrieveFeed: ParseUtilPartial, ParseMainOptions> = ( + value, + options, +) => { if (!isObject(value)) { return } @@ -44,31 +47,37 @@ export const retrieveFeed: ParsePartialUtil> = (value) => { aggregationType: parseSingularOf(value['prism:aggregationtype'], (value) => parseString(retrieveText(value)), ), - coverDate: parseSingularOf(value['prism:coverdate'], (value) => parseDate(retrieveText(value))), + coverDate: parseSingularOf(value['prism:coverdate'], (value) => + parseDate(retrieveText(value), options?.parseDateFn), + ), coverDisplayDate: parseSingularOf(value['prism:coverdisplaydate'], (value) => parseString(retrieveText(value)), ), publicationDates: parseArrayOf(value['prism:publicationdate'], (value) => - parseDate(retrieveText(value)), + parseDate(retrieveText(value), options?.parseDateFn), ), publicationDisplayDates: parseArrayOf(value['prism:publicationdisplaydate'], (value) => parseString(retrieveText(value)), ), creationDate: parseSingularOf(value['prism:creationdate'], (value) => - parseDate(retrieveText(value)), + parseDate(retrieveText(value), options?.parseDateFn), ), modificationDate: parseSingularOf(value['prism:modificationdate'], (value) => - parseDate(retrieveText(value)), + parseDate(retrieveText(value), options?.parseDateFn), ), dateReceived: parseSingularOf(value['prism:datereceived'], (value) => - parseDate(retrieveText(value)), + parseDate(retrieveText(value), options?.parseDateFn), + ), + onSaleDates: parseArrayOf(value['prism:onsaledate'], (value) => + parseDate(retrieveText(value), options?.parseDateFn), ), - onSaleDates: parseArrayOf(value['prism:onsaledate'], (value) => parseDate(retrieveText(value))), onSaleDays: parseArrayOf(value['prism:onsaleday'], (value) => parseString(retrieveText(value))), offSaleDates: parseArrayOf(value['prism:offsaledate'], (value) => - parseDate(retrieveText(value)), + parseDate(retrieveText(value), options?.parseDateFn), + ), + killDate: parseSingularOf(value['prism:killdate'], (value) => + parseDate(retrieveText(value), options?.parseDateFn), ), - killDate: parseSingularOf(value['prism:killdate'], (value) => parseDate(retrieveText(value))), copyrightYears: parseArrayOf(value['prism:copyrightyear'], (value) => parseString(retrieveText(value)), ), @@ -179,13 +188,13 @@ export const retrieveFeed: ParsePartialUtil> = (value) => { ), sport: parseSingularOf(value['prism:sport'], (value) => parseString(retrieveText(value))), embargoDate: parseSingularOf(value['prism:embargodate'], (value) => - parseDate(retrieveText(value)), + parseDate(retrieveText(value), options?.parseDateFn), ), copyright: parseSingularOf(value['prism:copyright'], (value) => parseString(retrieveText(value)), ), expirationDate: parseSingularOf(value['prism:expirationdate'], (value) => - parseDate(retrieveText(value)), + parseDate(retrieveText(value), options?.parseDateFn), ), rightsAgent: parseSingularOf(value['prism:rightsagent'], (value) => parseString(retrieveText(value)), @@ -195,7 +204,10 @@ export const retrieveFeed: ParsePartialUtil> = (value) => { return trimObject(feed) } -export const retrieveItem: ParsePartialUtil> = (value) => { +export const retrieveItem: ParseUtilPartial, ParseMainOptions> = ( + value, + options, +) => { if (!isObject(value)) { return } @@ -231,21 +243,23 @@ export const retrieveItem: ParsePartialUtil> = (value) => { parseString(retrieveText(value)), ), publicationDates: parseArrayOf(value['prism:publicationdate'], (value) => - parseDate(retrieveText(value)), + parseDate(retrieveText(value), options?.parseDateFn), ), publicationDisplayDates: parseArrayOf(value['prism:publicationdisplaydate'], (value) => parseString(retrieveText(value)), ), creationDate: parseSingularOf(value['prism:creationdate'], (value) => - parseDate(retrieveText(value)), + parseDate(retrieveText(value), options?.parseDateFn), ), modificationDate: parseSingularOf(value['prism:modificationdate'], (value) => - parseDate(retrieveText(value)), + parseDate(retrieveText(value), options?.parseDateFn), ), dateReceived: parseSingularOf(value['prism:datereceived'], (value) => - parseDate(retrieveText(value)), + parseDate(retrieveText(value), options?.parseDateFn), + ), + killDate: parseSingularOf(value['prism:killdate'], (value) => + parseDate(retrieveText(value), options?.parseDateFn), ), - killDate: parseSingularOf(value['prism:killdate'], (value) => parseDate(retrieveText(value))), copyrightYears: parseArrayOf(value['prism:copyrightyear'], (value) => parseString(retrieveText(value)), ), @@ -322,13 +336,13 @@ export const retrieveItem: ParsePartialUtil> = (value) => { ), tickers: parseArrayOf(value['prism:ticker'], (value) => parseString(retrieveText(value))), embargoDate: parseSingularOf(value['prism:embargodate'], (value) => - parseDate(retrieveText(value)), + parseDate(retrieveText(value), options?.parseDateFn), ), copyright: parseSingularOf(value['prism:copyright'], (value) => parseString(retrieveText(value)), ), expirationDate: parseSingularOf(value['prism:expirationdate'], (value) => - parseDate(retrieveText(value)), + parseDate(retrieveText(value), options?.parseDateFn), ), rightsAgent: parseSingularOf(value['prism:rightsagent'], (value) => parseString(retrieveText(value)), diff --git a/src/namespaces/psc/common/types.ts b/src/namespaces/psc/common/types.ts index 74a331f4..ee892f49 100644 --- a/src/namespaces/psc/common/types.ts +++ b/src/namespaces/psc/common/types.ts @@ -1,14 +1,19 @@ +import type { Requirable, Strict } from '../../../common/types.js' + // #region reference export namespace PscNs { - export type Chapter = { - start: string - title: string - href?: string - image?: string - } + export type Chapter = Strict< + { + start: Requirable // Required in spec. + title: Requirable // Required in spec. + href?: string + image?: string + }, + TStrict + > - export type Item = { - chapters?: Array + export type Item = { + chapters?: Array> } } // #endregion reference diff --git a/src/namespaces/psc/parse/utils.ts b/src/namespaces/psc/parse/utils.ts index bcd326cc..2e0ccfe8 100644 --- a/src/namespaces/psc/parse/utils.ts +++ b/src/namespaces/psc/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { ParseUtilPartial } from '../../../common/types.js' import { isObject, parseArrayOf, @@ -8,7 +8,7 @@ import { } from '../../../common/utils.js' import type { PscNs } from '../common/types.js' -export const parseChapter: ParsePartialUtil = (value) => { +export const parseChapter: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -23,11 +23,11 @@ export const parseChapter: ParsePartialUtil = (value) => { return trimObject(chapter) } -export const parseChapters: ParsePartialUtil> = (value) => { +export const parseChapters: ParseUtilPartial> = (value) => { return parseArrayOf(value?.['psc:chapter'], parseChapter) } -export const retrieveItem: ParsePartialUtil = (value) => { +export const retrieveItem: ParseUtilPartial = (value) => { if (!isObject(value)) { return } diff --git a/src/namespaces/rawvoice/common/types.ts b/src/namespaces/rawvoice/common/types.ts index def41e19..0a2310cf 100644 --- a/src/namespaces/rawvoice/common/types.ts +++ b/src/namespaces/rawvoice/common/types.ts @@ -1,4 +1,4 @@ -import type { DateLike } from '../../../common/types.js' +import type { Requirable, Strict } from '../../../common/types.js' // #region reference export namespace RawVoiceNs { @@ -8,22 +8,31 @@ export namespace RawVoiceNs { movie?: string } - export type LiveStream = { - url?: string - schedule?: TDate - duration?: string - type?: string - } + export type LiveStream = Strict< + { + url?: string + schedule: Requirable // Required in spec. + duration: Requirable // Required in spec. + type?: string + }, + TStrict + > - export type Poster = { - url?: string - } + export type Poster = Strict< + { + url: Requirable // Required in spec. + }, + TStrict + > - export type AlternateEnclosure = { - src?: string - type?: string - length?: number - } + export type AlternateEnclosure = Strict< + { + src: Requirable // Required in spec. + type?: string + length?: number + }, + TStrict + > export type Subscribe = Record @@ -35,31 +44,34 @@ export namespace RawVoiceNs { value?: string } - export type Donate = { - href: string - value?: string - } + export type Donate = Strict< + { + href: Requirable // Required in spec. + value?: string + }, + TStrict + > - export type Feed = { + export type Feed = { rating?: Rating liveEmbed?: string - flashLiveStream?: LiveStream - httpLiveStream?: LiveStream - shoutcastLiveStream?: LiveStream - liveStream?: LiveStream + flashLiveStream?: LiveStream + httpLiveStream?: LiveStream + shoutcastLiveStream?: LiveStream + liveStream?: LiveStream location?: string frequency?: string mycast?: boolean subscribe?: Subscribe - donate?: Donate + donate?: Donate } - export type Item = { - poster?: Poster + export type Item = { + poster?: Poster isHd?: boolean embed?: string - webm?: AlternateEnclosure - mp4?: AlternateEnclosure + webm?: AlternateEnclosure + mp4?: AlternateEnclosure metamarks?: Array } } diff --git a/src/namespaces/rawvoice/parse/utils.ts b/src/namespaces/rawvoice/parse/utils.ts index 10b2fd83..b835d2e5 100644 --- a/src/namespaces/rawvoice/parse/utils.ts +++ b/src/namespaces/rawvoice/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { DateAny, ParseMainOptions, ParseUtilPartial } from '../../../common/types.js' import { isObject, parseArrayOf, @@ -12,7 +12,7 @@ import { } from '../../../common/utils.js' import type { RawVoiceNs } from '../common/types.js' -export const parseRating: ParsePartialUtil = (value) => { +export const parseRating: ParseUtilPartial = (value) => { const rating = { value: parseString(retrieveText(value)), tv: parseString(value?.['@tv']), @@ -22,10 +22,13 @@ export const parseRating: ParsePartialUtil = (value) => { return trimObject(rating) } -export const parseLiveStream: ParsePartialUtil> = (value) => { +export const parseLiveStream: ParseUtilPartial< + RawVoiceNs.LiveStream, + ParseMainOptions +> = (value, options) => { const liveStream = { url: parseString(retrieveText(value)), - schedule: parseDate(value?.['@schedule']), + schedule: parseDate(value?.['@schedule'], options?.parseDateFn), duration: parseString(value?.['@duration']), type: parseString(value?.['@type']), } @@ -33,7 +36,7 @@ export const parseLiveStream: ParsePartialUtil> = return trimObject(liveStream) } -export const parsePoster: ParsePartialUtil = (value) => { +export const parsePoster: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -45,7 +48,7 @@ export const parsePoster: ParsePartialUtil = (value) => { return trimObject(poster) } -export const parseAlternateEnclosure: ParsePartialUtil = (value) => { +export const parseAlternateEnclosure: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -59,7 +62,7 @@ export const parseAlternateEnclosure: ParsePartialUtil = (value) => { +export const parseSubscribe: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -79,7 +82,7 @@ export const parseSubscribe: ParsePartialUtil = (value) => return trimObject(subscribe) } -export const parseMetamark: ParsePartialUtil = (value) => { +export const parseMetamark: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -95,7 +98,7 @@ export const parseMetamark: ParsePartialUtil = (value) => { return trimObject(metamark) } -export const parseDonate: ParsePartialUtil = (value) => { +export const parseDonate: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -108,7 +111,7 @@ export const parseDonate: ParsePartialUtil = (value) => { return trimObject(donate) } -export const retrieveItem: ParsePartialUtil = (value) => { +export const retrieveItem: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -127,7 +130,10 @@ export const retrieveItem: ParsePartialUtil = (value) => { return trimObject(item) } -export const retrieveFeed: ParsePartialUtil> = (value) => { +export const retrieveFeed: ParseUtilPartial, ParseMainOptions> = ( + value, + options, +) => { if (!isObject(value)) { return } @@ -137,10 +143,18 @@ export const retrieveFeed: ParsePartialUtil> = (value) = liveEmbed: parseSingularOf(value['rawvoice:liveembed'], (value) => parseString(retrieveText(value)), ), - flashLiveStream: parseSingularOf(value['rawvoice:flashlivestream'], parseLiveStream), - httpLiveStream: parseSingularOf(value['rawvoice:httplivestream'], parseLiveStream), - shoutcastLiveStream: parseSingularOf(value['rawvoice:shoutcastlivestream'], parseLiveStream), - liveStream: parseSingularOf(value['rawvoice:livestream'], parseLiveStream), + flashLiveStream: parseSingularOf(value['rawvoice:flashlivestream'], (value) => + parseLiveStream(value, options), + ), + httpLiveStream: parseSingularOf(value['rawvoice:httplivestream'], (value) => + parseLiveStream(value, options), + ), + shoutcastLiveStream: parseSingularOf(value['rawvoice:shoutcastlivestream'], (value) => + parseLiveStream(value, options), + ), + liveStream: parseSingularOf(value['rawvoice:livestream'], (value) => + parseLiveStream(value, options), + ), location: parseSingularOf(value['rawvoice:location'], (value) => parseString(retrieveText(value)), ), diff --git a/src/namespaces/rdf/parse/utils.ts b/src/namespaces/rdf/parse/utils.ts index dff4bf36..3a07e116 100644 --- a/src/namespaces/rdf/parse/utils.ts +++ b/src/namespaces/rdf/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { ParseUtilPartial } from '../../../common/types.js' import { isObject, parseArrayOf, @@ -10,7 +10,7 @@ import { } from '../../../common/utils.js' import type { RdfNs } from '../common/types.js' -export const retrieveAbout: ParsePartialUtil = (value) => { +export const retrieveAbout: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -23,7 +23,7 @@ export const retrieveAbout: ParsePartialUtil = (value) => { } /** @internal General RDF element kept for potential future use when all RDF data is needed. */ -export const retrieveElement: ParsePartialUtil = (value) => { +export const retrieveElement: ParseUtilPartial = (value) => { if (!isObject(value)) { return } diff --git a/src/namespaces/slash/parse/utils.ts b/src/namespaces/slash/parse/utils.ts index 206f547c..496faa48 100644 --- a/src/namespaces/slash/parse/utils.ts +++ b/src/namespaces/slash/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { ParseUtilPartial } from '../../../common/types.js' import { isObject, parseCsvOf, @@ -10,11 +10,11 @@ import { } from '../../../common/utils.js' import type { SlashNs } from '../common/types.js' -export const parseHitParade: ParsePartialUtil = (value) => { +export const parseHitParade: ParseUtilPartial = (value) => { return parseCsvOf(value, parseNumber) } -export const retrieveItem: ParsePartialUtil = (value) => { +export const retrieveItem: ParseUtilPartial = (value) => { if (!isObject(value)) { return } diff --git a/src/namespaces/source/common/types.ts b/src/namespaces/source/common/types.ts index c80c48e4..0613d3e9 100644 --- a/src/namespaces/source/common/types.ts +++ b/src/namespaces/source/common/types.ts @@ -1,31 +1,45 @@ +import type { Requirable, Strict } from '../../../common/types.js' + // #region reference export namespace SourceNs { - export type Account = { - service: string - value?: string - } + export type Account = Strict< + { + service: Requirable // Required in spec. + value?: string + }, + TStrict + > - export type Likes = { - server: string - } + export type Likes = Strict< + { + server: Requirable // Required in spec. + }, + TStrict + > - export type Archive = { - url: string - startDay: string - endDay?: string - filename?: string - } + export type Archive = Strict< + { + url: Requirable // Required in spec. + startDay: Requirable // Required in spec. + endDay?: string + filename?: string + }, + TStrict + > - export type SubscriptionList = { - url: string - value?: string - } + export type SubscriptionList = Strict< + { + url: Requirable // Required in spec. + value?: string + }, + TStrict + > - export type Feed = { - accounts?: Array - likes?: Likes - archive?: Archive - subscriptionLists?: Array + export type Feed = { + accounts?: Array> + likes?: Likes + archive?: Archive + subscriptionLists?: Array> cloud?: string blogroll?: string self?: string diff --git a/src/namespaces/source/generate/utils.test.ts b/src/namespaces/source/generate/utils.test.ts index 371cda1f..d35f6a18 100644 --- a/src/namespaces/source/generate/utils.test.ts +++ b/src/namespaces/source/generate/utils.test.ts @@ -78,7 +78,6 @@ describe('generateLikes', () => { it('should return undefined when server is missing', () => { const value = {} - // @ts-expect-error: This is for testing purposes. expect(generateLikes(value)).toBeUndefined() }) }) @@ -120,7 +119,6 @@ describe('generateArchive', () => { endDay: '2023-12-31', } - // @ts-expect-error: This is for testing purposes. expect(generateArchive(value)).toBeUndefined() }) @@ -130,7 +128,6 @@ describe('generateArchive', () => { endDay: '2023-12-31', } - // @ts-expect-error: This is for testing purposes. expect(generateArchive(value)).toBeUndefined() }) }) @@ -165,7 +162,6 @@ describe('generateSubscriptionList', () => { value: 'follows', } - // @ts-expect-error: This is for testing purposes. expect(generateSubscriptionList(value)).toBeUndefined() }) }) diff --git a/src/namespaces/source/parse/utils.ts b/src/namespaces/source/parse/utils.ts index 36af3821..c0196966 100644 --- a/src/namespaces/source/parse/utils.ts +++ b/src/namespaces/source/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { ParseUtilPartial } from '../../../common/types.js' import { isObject, parseArrayOf, @@ -9,7 +9,7 @@ import { } from '../../../common/utils.js' import type { SourceNs } from '../common/types.js' -export const parseAccount: ParsePartialUtil = (value) => { +export const parseAccount: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -22,7 +22,7 @@ export const parseAccount: ParsePartialUtil = (value) => { return trimObject(account) } -export const parseLikes: ParsePartialUtil = (value) => { +export const parseLikes: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -34,7 +34,7 @@ export const parseLikes: ParsePartialUtil = (value) => { return trimObject(likes) } -export const parseArchive: ParsePartialUtil = (value) => { +export const parseArchive: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -53,7 +53,7 @@ export const parseArchive: ParsePartialUtil = (value) => { return trimObject(archive) } -export const parseSubscriptionList: ParsePartialUtil = (value) => { +export const parseSubscriptionList: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -66,7 +66,7 @@ export const parseSubscriptionList: ParsePartialUtil return trimObject(subscriptionList) } -export const retrieveFeed: ParsePartialUtil = (value) => { +export const retrieveFeed: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -86,7 +86,7 @@ export const retrieveFeed: ParsePartialUtil = (value) => { return trimObject(feed) } -export const retrieveItem: ParsePartialUtil = (value) => { +export const retrieveItem: ParseUtilPartial = (value) => { if (!isObject(value)) { return } diff --git a/src/namespaces/spotify/common/types.ts b/src/namespaces/spotify/common/types.ts index d6bb6e4a..6f3608a1 100644 --- a/src/namespaces/spotify/common/types.ts +++ b/src/namespaces/spotify/common/types.ts @@ -1,38 +1,49 @@ +import type { Requirable, Strict } from '../../../common/types.js' + // #region reference export namespace SpotifyNs { export type Limit = { recentCount?: number } - export type Partner = { - id: string - } + export type Partner = Strict< + { + id: Requirable // Required in spec. + }, + TStrict + > - export type Sandbox = { - enabled: boolean - } + export type Sandbox = Strict< + { + enabled: Requirable // Required in spec. + }, + TStrict + > - export type FeedAccess = { - partner?: Partner - sandbox?: Sandbox + export type FeedAccess = { + partner?: Partner + sandbox?: Sandbox } - export type Entitlement = { - name: string - } + export type Entitlement = Strict< + { + name: Requirable // Required in spec. + }, + TStrict + > - export type ItemAccess = { - entitlement?: Entitlement + export type ItemAccess = { + entitlement?: Entitlement } - export type Feed = { + export type Feed = { limit?: Limit countryOfOrigin?: string - access?: FeedAccess + access?: FeedAccess } - export type Item = { - access?: ItemAccess + export type Item = { + access?: ItemAccess } } // #endregion reference diff --git a/src/namespaces/spotify/parse/utils.ts b/src/namespaces/spotify/parse/utils.ts index c0eb3b5c..608ca0d3 100644 --- a/src/namespaces/spotify/parse/utils.ts +++ b/src/namespaces/spotify/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { ParseUtilPartial } from '../../../common/types.js' import { isObject, parseBoolean, @@ -10,7 +10,7 @@ import { } from '../../../common/utils.js' import type { SpotifyNs } from '../common/types.js' -export const parseLimit: ParsePartialUtil = (value) => { +export const parseLimit: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -22,7 +22,7 @@ export const parseLimit: ParsePartialUtil = (value) => { return trimObject(limit) } -export const parsePartner: ParsePartialUtil = (value) => { +export const parsePartner: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -32,7 +32,7 @@ export const parsePartner: ParsePartialUtil = (value) => { return id ? { id } : undefined } -export const parseSandbox: ParsePartialUtil = (value) => { +export const parseSandbox: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -42,7 +42,7 @@ export const parseSandbox: ParsePartialUtil = (value) => { return enabled !== undefined ? { enabled } : undefined } -export const parseFeedAccess: ParsePartialUtil = (value) => { +export const parseFeedAccess: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -55,7 +55,7 @@ export const parseFeedAccess: ParsePartialUtil = (value) = return trimObject(access) } -export const parseEntitlement: ParsePartialUtil = (value) => { +export const parseEntitlement: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -65,7 +65,7 @@ export const parseEntitlement: ParsePartialUtil = (value) return name ? { name } : undefined } -export const parseItemAccess: ParsePartialUtil = (value) => { +export const parseItemAccess: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -77,7 +77,7 @@ export const parseItemAccess: ParsePartialUtil = (value) = return trimObject(access) } -export const retrieveFeed: ParsePartialUtil = (value) => { +export const retrieveFeed: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -93,7 +93,7 @@ export const retrieveFeed: ParsePartialUtil = (value) => { return trimObject(feed) } -export const retrieveItem: ParsePartialUtil = (value) => { +export const retrieveItem: ParseUtilPartial = (value) => { if (!isObject(value)) { return } diff --git a/src/namespaces/sy/common/types.ts b/src/namespaces/sy/common/types.ts index b76bf7d8..d01a837b 100644 --- a/src/namespaces/sy/common/types.ts +++ b/src/namespaces/sy/common/types.ts @@ -1,8 +1,6 @@ -import type { DateLike } from '../../../common/types.js' - // #region reference export namespace SyNs { - export type Feed = { + export type Feed = { updatePeriod?: string updateFrequency?: number updateBase?: TDate diff --git a/src/namespaces/sy/parse/utils.ts b/src/namespaces/sy/parse/utils.ts index 65e5565e..c4d6a46c 100644 --- a/src/namespaces/sy/parse/utils.ts +++ b/src/namespaces/sy/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { DateAny, ParseMainOptions, ParseUtilPartial } from '../../../common/types.js' import { isObject, parseDate, @@ -10,7 +10,10 @@ import { } from '../../../common/utils.js' import type { SyNs } from '../common/types.js' -export const retrieveFeed: ParsePartialUtil> = (value) => { +export const retrieveFeed: ParseUtilPartial, ParseMainOptions> = ( + value, + options, +) => { if (!isObject(value)) { return } @@ -22,7 +25,9 @@ export const retrieveFeed: ParsePartialUtil> = (value) => { updateFrequency: parseSingularOf(value['sy:updatefrequency'], (value) => parseNumber(retrieveText(value)), ), - updateBase: parseSingularOf(value['sy:updatebase'], (value) => parseDate(retrieveText(value))), + updateBase: parseSingularOf(value['sy:updatebase'], (value) => + parseDate(retrieveText(value), options?.parseDateFn), + ), } return trimObject(feed) diff --git a/src/namespaces/thr/common/types.ts b/src/namespaces/thr/common/types.ts index b7966555..74a844ab 100644 --- a/src/namespaces/thr/common/types.ts +++ b/src/namespaces/thr/common/types.ts @@ -1,22 +1,25 @@ -import type { DateLike } from '../../../common/types.js' +import type { Requirable, Strict } from '../../../common/types.js' // #region reference export namespace ThrNs { - export type InReplyTo = { - ref: string - href?: string - type?: string - source?: string - } + export type InReplyTo = Strict< + { + ref: Requirable // Required in spec. + href?: string + type?: string + source?: string + }, + TStrict + > - export type Link = { + export type Link = { count?: number updated?: TDate } - export type Item = { + export type Item = { total?: number - inReplyTos?: Array + inReplyTos?: Array> } } // #endregion reference diff --git a/src/namespaces/thr/generate/utils.test.ts b/src/namespaces/thr/generate/utils.test.ts index 2a218a5e..e4fbc622 100644 --- a/src/namespaces/thr/generate/utils.test.ts +++ b/src/namespaces/thr/generate/utils.test.ts @@ -104,7 +104,6 @@ describe('generateItem', () => { ], } - // @ts-expect-error: This is for testing purposes. expect(generateItem(value)).toEqual(expected) }) diff --git a/src/namespaces/thr/parse/utils.ts b/src/namespaces/thr/parse/utils.ts index 67dca011..d577ca8b 100644 --- a/src/namespaces/thr/parse/utils.ts +++ b/src/namespaces/thr/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { DateAny, ParseMainOptions, ParseUtilPartial } from '../../../common/types.js' import { isObject, parseArrayOf, @@ -11,7 +11,7 @@ import { } from '../../../common/utils.js' import type { ThrNs } from '../common/types.js' -export const parseInReplyTo: ParsePartialUtil = (value) => { +export const parseInReplyTo: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -26,20 +26,23 @@ export const parseInReplyTo: ParsePartialUtil = (value) => { return trimObject(inReplyTo) } -export const retrieveLink: ParsePartialUtil> = (value) => { +export const retrieveLink: ParseUtilPartial, ParseMainOptions> = ( + value, + options, +) => { if (!isObject(value)) { return } const link = { count: parseNumber(value['@thr:count']), - updated: parseDate(value['@thr:updated']), + updated: parseDate(value['@thr:updated'], options?.parseDateFn), } return trimObject(link) } -export const retrieveItem: ParsePartialUtil = (value) => { +export const retrieveItem: ParseUtilPartial = (value) => { if (!isObject(value)) { return } diff --git a/src/namespaces/trackback/parse/utils.ts b/src/namespaces/trackback/parse/utils.ts index 4521f9c9..f5ef233a 100644 --- a/src/namespaces/trackback/parse/utils.ts +++ b/src/namespaces/trackback/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { ParseUtilPartial } from '../../../common/types.js' import { isObject, parseArrayOf, @@ -9,7 +9,7 @@ import { } from '../../../common/utils.js' import type { TrackbackNs } from '../common/types.js' -export const retrieveItem: ParsePartialUtil = (value) => { +export const retrieveItem: ParseUtilPartial = (value) => { if (!isObject(value)) { return } diff --git a/src/namespaces/wfw/parse/utils.ts b/src/namespaces/wfw/parse/utils.ts index 5c77b7fe..fc632bb9 100644 --- a/src/namespaces/wfw/parse/utils.ts +++ b/src/namespaces/wfw/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { ParseUtilPartial } from '../../../common/types.js' import { isObject, parseSingularOf, @@ -8,7 +8,7 @@ import { } from '../../../common/utils.js' import type { WfwNs } from '../common/types.js' -export const retrieveItem: ParsePartialUtil = (value) => { +export const retrieveItem: ParseUtilPartial = (value) => { if (!isObject(value)) { return } diff --git a/src/namespaces/xml/common/types.ts b/src/namespaces/xml/common/types.ts new file mode 100644 index 00000000..9bd53e49 --- /dev/null +++ b/src/namespaces/xml/common/types.ts @@ -0,0 +1,10 @@ +// #region reference +export namespace XmlNs { + export type ItemOrFeed = { + lang?: string + base?: string + space?: string + id?: string + } +} +// #endregion reference diff --git a/src/namespaces/xml/generate/utils.test.ts b/src/namespaces/xml/generate/utils.test.ts new file mode 100644 index 00000000..d87062de --- /dev/null +++ b/src/namespaces/xml/generate/utils.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'bun:test' +import { generateItemOrFeed } from './utils.js' + +describe('generateItemOrFeed', () => { + it('should generate valid XML namespace object with all properties', () => { + const value = { + lang: 'en-US', + base: 'http://example.org/base/', + space: 'preserve', + id: 'unique-xml-id', + } + const expected = { + '@xml:lang': 'en-US', + '@xml:base': 'http://example.org/base/', + '@xml:space': 'preserve', + '@xml:id': 'unique-xml-id', + } + + expect(generateItemOrFeed(value)).toEqual(expected) + }) + + it('should generate XML namespace with minimal properties', () => { + const value = { + lang: 'fr-FR', + } + const expected = { + '@xml:lang': 'fr-FR', + } + + expect(generateItemOrFeed(value)).toEqual(expected) + }) + + it('should filter out empty string values', () => { + const value = { + lang: '', + base: 'http://example.org/', + space: '', + id: 'valid-id', + } + const expected = { + '@xml:base': 'http://example.org/', + '@xml:id': 'valid-id', + } + + expect(generateItemOrFeed(value)).toEqual(expected) + }) + + it('should filter out null values', () => { + const value = { + lang: null, + base: 'http://example.org/', + space: undefined, + id: 'valid-id', + } + const expected = { + '@xml:base': 'http://example.org/', + '@xml:id': 'valid-id', + } + + // @ts-expect-error: This is for testing purposes. + expect(generateItemOrFeed(value)).toEqual(expected) + }) + + it('should handle object with all properties having null/empty values', () => { + const value = { + lang: null, + base: '', + space: undefined, + id: '', + } + + // @ts-expect-error: This is for testing purposes. + expect(generateItemOrFeed(value)).toBeUndefined() + }) + + it('should handle object with only undefined/empty properties', () => { + const value = { + lang: undefined, + base: undefined, + space: undefined, + id: undefined, + } + + expect(generateItemOrFeed(value)).toBeUndefined() + }) + + it('should handle empty object', () => { + const value = {} + + expect(generateItemOrFeed(value)).toBeUndefined() + }) + + it('should handle non-object inputs gracefully', () => { + expect(generateItemOrFeed(undefined)).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generateItemOrFeed(null)).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generateItemOrFeed('not an object')).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generateItemOrFeed([])).toBeUndefined() + }) +}) diff --git a/src/namespaces/xml/generate/utils.ts b/src/namespaces/xml/generate/utils.ts new file mode 100644 index 00000000..0906f992 --- /dev/null +++ b/src/namespaces/xml/generate/utils.ts @@ -0,0 +1,18 @@ +import type { GenerateUtil } from '../../../common/types.js' +import { generatePlainString, isObject, trimObject } from '../../../common/utils.js' +import type { XmlNs } from '../common/types.js' + +export const generateItemOrFeed: GenerateUtil = (itemOrFeed) => { + if (!isObject(itemOrFeed)) { + return + } + + const value = { + '@xml:lang': generatePlainString(itemOrFeed.lang), + '@xml:base': generatePlainString(itemOrFeed.base), + '@xml:space': generatePlainString(itemOrFeed.space), + '@xml:id': generatePlainString(itemOrFeed.id), + } + + return trimObject(value) +} diff --git a/src/namespaces/xml/parse/utils.test.ts b/src/namespaces/xml/parse/utils.test.ts new file mode 100644 index 00000000..a815f608 --- /dev/null +++ b/src/namespaces/xml/parse/utils.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'bun:test' +import { retrieveItemOrFeed } from './utils.js' + +describe('retrieveItemOrFeed', () => { + it('should parse complete XML namespace object with all properties', () => { + const value = { + '@xml:lang': 'en-US', + '@xml:base': 'http://example.org/base/', + '@xml:space': 'preserve', + '@xml:id': 'unique-xml-id', + } + const expected = { + lang: 'en-US', + base: 'http://example.org/base/', + space: 'preserve', + id: 'unique-xml-id', + } + + expect(retrieveItemOrFeed(value)).toEqual(expected) + }) + + it('should parse object with only lang property', () => { + const value = { + '@xml:lang': 'fr-FR', + } + const expected = { + lang: 'fr-FR', + } + + expect(retrieveItemOrFeed(value)).toEqual(expected) + }) + + it('should parse object with only base property', () => { + const value = { + '@xml:base': 'https://example.com/path/', + } + const expected = { + base: 'https://example.com/path/', + } + + expect(retrieveItemOrFeed(value)).toEqual(expected) + }) + + it('should parse object with only space property', () => { + const value = { + '@xml:space': 'default', + } + const expected = { + space: 'default', + } + + expect(retrieveItemOrFeed(value)).toEqual(expected) + }) + + it('should parse object with only id property', () => { + const value = { + '@xml:id': 'test-element-id', + } + const expected = { + id: 'test-element-id', + } + + expect(retrieveItemOrFeed(value)).toEqual(expected) + }) + + it('should ignore non-XML namespace attributes', () => { + const value = { + '@xml:lang': 'en-US', + '@href': 'http://example.com', + '@title': 'Some title', + '@rel': 'alternate', + '@xml:base': 'http://example.org/', + '@type': 'text/html', + } + const expected = { + lang: 'en-US', + base: 'http://example.org/', + } + + expect(retrieveItemOrFeed(value)).toEqual(expected) + }) + + it('should handle coercible values', () => { + const value = { + '@xml:lang': 'en', + '@xml:id': 123, + '@xml:space': true, + } + const expected = { + lang: 'en', + id: '123', + } + + expect(retrieveItemOrFeed(value)).toEqual(expected) + }) + + it('should handle boolean-like values', () => { + const value = { + '@xml:lang': false, + '@xml:base': true, + '@xml:space': 0, + '@xml:id': 1, + } + const expected = { + space: '0', + id: '1', + } + + expect(retrieveItemOrFeed(value)).toEqual(expected) + }) + + it('should handle objects with empty string values', () => { + const value = { + '@xml:lang': '', + '@xml:base': 'http://example.org/', + '@xml:space': '', + '@xml:id': 'valid-id', + } + const expected = { + base: 'http://example.org/', + id: 'valid-id', + } + + expect(retrieveItemOrFeed(value)).toEqual(expected) + }) + + it('should handle null values in properties', () => { + const value = { + '@xml:lang': null, + '@xml:base': 'http://example.org/', + '@xml:space': undefined, + '@xml:id': 'valid-id', + } + const expected = { + base: 'http://example.org/', + id: 'valid-id', + } + + expect(retrieveItemOrFeed(value)).toEqual(expected) + }) + + it('should handle object with all properties having null/empty values', () => { + const value = { + '@xml:lang': null, + '@xml:base': '', + '@xml:space': undefined, + '@xml:id': '', + } + + expect(retrieveItemOrFeed(value)).toBeUndefined() + }) + + it('should return undefined for empty object', () => { + const value = {} + + expect(retrieveItemOrFeed(value)).toBeUndefined() + }) + + it('should return undefined when no XML namespace properties can be parsed', () => { + const value = { + 'other:property': 'value', + 'unknown:field': 'data', + '@href': 'http://example.com', + '@title': 'Not XML namespace', + } + + expect(retrieveItemOrFeed(value)).toBeUndefined() + }) + + it('should return undefined for non-object input', () => { + expect(retrieveItemOrFeed('not an object')).toBeUndefined() + expect(retrieveItemOrFeed(undefined)).toBeUndefined() + expect(retrieveItemOrFeed(null)).toBeUndefined() + expect(retrieveItemOrFeed([])).toBeUndefined() + }) +}) diff --git a/src/namespaces/xml/parse/utils.ts b/src/namespaces/xml/parse/utils.ts new file mode 100644 index 00000000..d02ec0f5 --- /dev/null +++ b/src/namespaces/xml/parse/utils.ts @@ -0,0 +1,18 @@ +import type { ParseUtilPartial } from '../../../common/types.js' +import { isObject, parseString, trimObject } from '../../../common/utils.js' +import type { XmlNs } from '../common/types.js' + +export const retrieveItemOrFeed: ParseUtilPartial = (value) => { + if (!isObject(value)) { + return + } + + const itemOrFeed = { + lang: parseString(value['@xml:lang']), + base: parseString(value['@xml:base']), + space: parseString(value['@xml:space']), + id: parseString(value['@xml:id']), + } + + return trimObject(itemOrFeed) +} diff --git a/src/namespaces/yt/parse/utils.ts b/src/namespaces/yt/parse/utils.ts index 2a4ab9f5..57a945ea 100644 --- a/src/namespaces/yt/parse/utils.ts +++ b/src/namespaces/yt/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../../common/types.js' +import type { ParseUtilPartial } from '../../../common/types.js' import { isObject, parseSingularOf, @@ -8,7 +8,7 @@ import { } from '../../../common/utils.js' import type { YtNs } from '../common/types.js' -export const retrieveItem: ParsePartialUtil = (value) => { +export const retrieveItem: ParseUtilPartial = (value) => { if (!isObject(value)) { return } @@ -21,7 +21,7 @@ export const retrieveItem: ParsePartialUtil = (value) => { return trimObject(item) } -export const retrieveFeed: ParsePartialUtil = (value) => { +export const retrieveFeed: ParseUtilPartial = (value) => { if (!isObject(value)) { return } diff --git a/src/opml/common/types.ts b/src/opml/common/types.ts index b09c353f..a0591304 100644 --- a/src/opml/common/types.ts +++ b/src/opml/common/types.ts @@ -1,32 +1,65 @@ -import type { DateLike, ExtraFields, ParseOptions } from '../../common/types.js' +import type { + GenerateUtil as BaseGenerateUtil, + ParseMainOptions as BaseParseMainOptions, + ParseUtilPartial as BaseParseUtilPartial, + DateAny, + ExtraFields, + Requirable, + Strict, +} from '../../common/types.js' -export type MainOptions = ReadonlyArray> = ParseOptions & { - extraOutlineAttributes?: A +export type ParseMainOptions< + TDate, + TExtra extends ReadonlyArray = ReadonlyArray, +> = BaseParseMainOptions & { + extraOutlineAttributes?: TExtra } +export type GenerateMainOptions = ReadonlyArray> = { + extraOutlineAttributes?: TExtra +} + +export type ParseUtilPartial = BaseParseUtilPartial> + +export type GenerateUtil = BaseGenerateUtil + // #region reference export namespace Opml { + // NOTE: BaseOutline contains non-recursive fields wrapped in Strict<>. + // Outline extends it and adds recursive outlines field separately. + export type BaseOutline< + TDate, + TExtra extends ReadonlyArray = ReadonlyArray, + TStrict extends boolean = false, + > = Strict< + { + text: Requirable // Required in spec. + type?: string + isComment?: boolean + isBreakpoint?: boolean + created?: TDate + category?: string + description?: string + xmlUrl?: string + htmlUrl?: string + language?: string + title?: string + version?: string + url?: string + }, + TStrict + > & + ExtraFields + export type Outline< - TDate extends DateLike, - A extends ReadonlyArray = ReadonlyArray, - > = { - text: string - type?: string - isComment?: boolean - isBreakpoint?: boolean - created?: TDate - category?: string - description?: string - xmlUrl?: string - htmlUrl?: string - language?: string - title?: string - version?: string - url?: string - outlines?: Array> - } & ExtraFields + TDate, + TExtra extends ReadonlyArray = ReadonlyArray, + TStrict extends boolean = false, + > = BaseOutline & { + outlines?: Array> + } - export type Head = { + export type Head = { title?: string dateCreated?: TDate dateModified?: TDate @@ -43,18 +76,20 @@ export namespace Opml { } export type Body< - TDate extends DateLike, - A extends ReadonlyArray = ReadonlyArray, + TDate, + TExtra extends ReadonlyArray = ReadonlyArray, + TStrict extends boolean = false, > = { - outlines?: Array> + outlines?: Array> } export type Document< - TDate extends DateLike, - A extends ReadonlyArray = ReadonlyArray, + TDate, + TExtra extends ReadonlyArray = ReadonlyArray, + TStrict extends boolean = false, > = { head?: Head - body?: Body + body?: Body } } // #endregion reference diff --git a/src/opml/generate/index.test.ts b/src/opml/generate/index.test.ts index 1f664ea3..36c0149d 100644 --- a/src/opml/generate/index.test.ts +++ b/src/opml/generate/index.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'bun:test' +import { locales } from '../../common/config.js' +import { GenerateError } from '../../common/errors.js' import { generate } from './index.js' describe('generate', () => { @@ -216,14 +218,18 @@ describe('generate', () => { expect(generate(value)).toEqual(expected) }) - it('should throw error for invalid OPML structure', () => { - const value = { - head: { - title: 'Invalid OPML', - }, - } + describe('error types', () => { + it('should throw GenerateError for invalid OPML structure', () => { + const value = { + head: { + title: 'Invalid OPML', + }, + } + const throwing = () => generate(value) - expect(() => generate(value)).toThrow() + expect(throwing).toThrow(GenerateError) + expect(throwing).toThrow(locales.invalidInputOpml) + }) }) it('should properly encode special characters in attributes', () => { @@ -270,8 +276,74 @@ describe('generate', () => { }) }) -describe('generate with lenient mode', () => { - it('should accept partial feeds with lenient: true', () => { +describe('strict mode', () => { + it('should require outline text in strict mode', () => { + generate( + { + body: { + // @ts-expect-error: This is for testing purposes. + outlines: [{ type: 'rss', xmlUrl: 'https://example.com/feed.xml' }], + }, + }, + { strict: true }, + ) + }) + + it('should accept outlines with text in strict mode', () => { + generate( + { + body: { + outlines: [{ text: 'Feed', type: 'rss', xmlUrl: 'https://example.com/feed.xml' }], + }, + }, + { strict: true }, + ) + }) + + it('should require nested outline text in strict mode', () => { + generate( + { + body: { + outlines: [ + { + text: 'Category', + // @ts-expect-error: This is for testing purposes. + outlines: [{ type: 'rss', xmlUrl: 'https://example.com/feed.xml' }], + }, + ], + }, + }, + { strict: true }, + ) + }) + + it('should accept nested outlines with text in strict mode', () => { + generate( + { + body: { + outlines: [ + { + text: 'Category', + outlines: [{ text: 'Feed', type: 'rss', xmlUrl: 'https://example.com/feed.xml' }], + }, + ], + }, + }, + { strict: true }, + ) + }) + + it('should accept partial document in lenient mode', () => { + generate({ + body: { + outlines: [{ type: 'rss', xmlUrl: 'https://example.com/feed.xml' }], + }, + }) + }) +}) + +describe('generate with partial and string dates', () => { + it('should accept partial documents', () => { const value = { body: { outlines: [{ text: 'Test Outline' }], @@ -285,10 +357,10 @@ describe('generate with lenient mode', () => { ` - expect(generate(value, { lenient: true })).toEqual(expected) + expect(generate(value)).toEqual(expected) }) - it('should accept feeds with string dates in lenient mode', () => { + it('should accept string dates', () => { const value = { head: { title: 'Test OPML', @@ -312,10 +384,10 @@ describe('generate with lenient mode', () => { ` - expect(generate(value, { lenient: true })).toEqual(expected) + expect(generate(value)).toEqual(expected) }) - it('should preserve invalid date strings in lenient mode', () => { + it('should preserve invalid date strings', () => { const value = { head: { title: 'Test OPML', @@ -339,7 +411,7 @@ describe('generate with lenient mode', () => { ` - expect(generate(value, { lenient: true })).toEqual(expected) + expect(generate(value)).toEqual(expected) }) describe('custom attributes', () => { diff --git a/src/opml/generate/index.ts b/src/opml/generate/index.ts index 13c07f93..16d51bb8 100644 --- a/src/opml/generate/index.ts +++ b/src/opml/generate/index.ts @@ -1,18 +1,19 @@ import { locales } from '../../common/config.js' -import type { DateLike, DeepPartial, XmlGenerateOptions } from '../../common/types.js' +import { GenerateError } from '../../common/errors.js' +import type { DateLike, GenerateMainXmlOptions } from '../../common/types.js' import { generateXml } from '../../common/utils.js' -import type { MainOptions, Opml } from '../common/types.js' +import type { GenerateMainOptions, Opml } from '../common/types.js' import { builder } from './config.js' import { generateDocument } from './utils.js' -export const generate = = [], F extends boolean = false>( - value: F extends true ? DeepPartial> : Opml.Document, - options?: XmlGenerateOptions, F>, +export const generate = = [], S extends boolean = false>( + value: S extends true ? Opml.Document : Opml.Document, + options?: GenerateMainXmlOptions, S>, ): string => { - const generated = generateDocument(value as Opml.Document, options) + const generated = generateDocument(value, options) if (!generated) { - throw new Error(locales.invalidInputOpml) + throw new GenerateError(locales.invalidInputOpml) } return generateXml(builder, generated, options) diff --git a/src/opml/generate/utils.test.ts b/src/opml/generate/utils.test.ts index 4188f21e..a7802101 100644 --- a/src/opml/generate/utils.test.ts +++ b/src/opml/generate/utils.test.ts @@ -133,7 +133,6 @@ describe('generateOutline', () => { outlines: [{}], } - // @ts-expect-error: This is for testing purposes. expect(generateOutline(value)).toBeUndefined() }) @@ -143,12 +142,10 @@ describe('generateOutline', () => { xmlUrl: undefined, } - // @ts-expect-error: This is for testing purposes. expect(generateOutline(value)).toBeUndefined() }) it('should handle empty object', () => { - // @ts-expect-error: This is for testing purposes. expect(generateOutline({})).toBeUndefined() }) diff --git a/src/opml/generate/utils.ts b/src/opml/generate/utils.ts index 95881118..69d1980d 100644 --- a/src/opml/generate/utils.ts +++ b/src/opml/generate/utils.ts @@ -1,4 +1,4 @@ -import type { DateLike, GenerateUtil } from '../../common/types.js' +import type { DateLike } from '../../common/types.js' import { generateBoolean, generateCdataString, @@ -11,12 +11,9 @@ import { trimArray, trimObject, } from '../../common/utils.js' -import type { MainOptions, Opml } from '../common/types.js' +import type { GenerateUtil, Opml } from '../common/types.js' -export const generateOutline: GenerateUtil, MainOptions> = ( - outline, - options, -) => { +export const generateOutline: GenerateUtil> = (outline, options) => { if (!isObject(outline)) { return } @@ -77,7 +74,7 @@ export const generateHead: GenerateUtil> = (head) => { return trimObject(value) } -export const generateBody: GenerateUtil, MainOptions> = (body, options) => { +export const generateBody: GenerateUtil> = (body, options) => { if (!isObject(body)) { return } @@ -89,10 +86,7 @@ export const generateBody: GenerateUtil, MainOptions> = (bod return trimObject(value) } -export const generateDocument: GenerateUtil, MainOptions> = ( - opml, - options, -) => { +export const generateDocument: GenerateUtil> = (opml, options) => { if (!isObject(opml)) { return } diff --git a/src/opml/parse/index.test.ts b/src/opml/parse/index.test.ts index d25fa90f..1b84319d 100644 --- a/src/opml/parse/index.test.ts +++ b/src/opml/parse/index.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'bun:test' +import { locales } from '../../common/config.js' +import { MalformedError, ParseError } from '../../common/errors.js' import { parse } from './index.js' describe('parse', () => { @@ -120,6 +122,31 @@ describe('parse', () => { expect(() => parse(value)).toThrow() }) + describe('error types', () => { + it('should throw MalformedError for invalid XML', () => { + const value = ` + + + + Test + + ` + const throwing = () => parse(value) + + expect(throwing).toThrow(MalformedError) + expect(throwing).toThrow(locales.invalidOpmlFormat) + }) + + it('should throw ParseError for valid XML with invalid structure', () => { + const value = '' + const throwing = () => parse(value) + + expect(throwing).toThrow(ParseError) + expect(throwing).toThrow(locales.invalidOpmlFormat) + }) + }) + describe('custom attributes', () => { it('should parse OPML with custom attributes when specified in options', () => { const opmlString = ` @@ -258,4 +285,36 @@ describe('parse', () => { expect(parse(commonValue, { maxItems: undefined })).toEqual(expected) }) }) + + describe('parseDateFn', () => { + it('should apply custom parseDateFn to head and outline dates', () => { + const value = ` + + + + Wed, 15 Mar 2023 12:00:00 GMT + + + + + + ` + const expected = { + head: { + dateCreated: new Date('Wed, 15 Mar 2023 12:00:00 GMT'), + }, + body: { + outlines: [ + { + text: 'Feed', + created: new Date('Fri, 17 Mar 2023 12:00:00 GMT'), + }, + ], + }, + } + const result = parse(value, { parseDateFn: (raw) => new Date(raw) }) + + expect(result).toEqual(expected) + }) + }) }) diff --git a/src/opml/parse/index.ts b/src/opml/parse/index.ts index a2b00126..7adcbc9f 100644 --- a/src/opml/parse/index.ts +++ b/src/opml/parse/index.ts @@ -1,19 +1,30 @@ import { locales } from '../../common/config.js' -import type { DeepPartial } from '../../common/types.js' -import type { MainOptions, Opml } from '../common/types.js' +import { MalformedError, ParseError } from '../../common/errors.js' +import type { Unreliable } from '../../common/types.js' +import type { Opml, ParseMainOptions } from '../common/types.js' import { parser } from './config.js' import { parseDocument } from './utils.js' -export const parse = = ReadonlyArray>( +export const parse = < + TDate = string, + const TExtra extends ReadonlyArray = ReadonlyArray, +>( value: string, - options?: MainOptions, -): DeepPartial> => { - const object = parser.parse(value) + options?: ParseMainOptions, +): Opml.Document => { + let object: Unreliable + + try { + object = parser.parse(value) + } catch { + throw new MalformedError(locales.invalidOpmlFormat) + } + const parsed = parseDocument(object, options) if (!parsed) { - throw new Error(locales.invalidOpmlFormat) + throw new ParseError(locales.invalidOpmlFormat) } - return parsed as DeepPartial> + return parsed as Opml.Document } diff --git a/src/opml/parse/utils.ts b/src/opml/parse/utils.ts index 3f8af908..37634138 100644 --- a/src/opml/parse/utils.ts +++ b/src/opml/parse/utils.ts @@ -1,4 +1,4 @@ -import type { ParsePartialUtil } from '../../common/types.js' +import type { DateAny } from '../../common/types.js' import { isObject, isPresent, @@ -12,12 +12,9 @@ import { retrieveText, trimObject, } from '../../common/utils.js' -import type { MainOptions, Opml } from '../common/types.js' +import type { Opml, ParseUtilPartial } from '../common/types.js' -export const parseOutline: ParsePartialUtil, MainOptions> = ( - value, - options, -) => { +export const parseOutline: ParseUtilPartial> = (value, options) => { if (!isObject(value)) { return } @@ -27,7 +24,7 @@ export const parseOutline: ParsePartialUtil, MainOptions> = type: parseString(value['@type']), isComment: parseBoolean(value['@iscomment']), isBreakpoint: parseBoolean(value['@isbreakpoint']), - created: parseDate(value['@created']), + created: parseDate(value['@created'], options?.parseDateFn), category: parseString(value['@category']), description: parseString(value['@description']), xmlUrl: parseString(value['@xmlurl']), @@ -54,15 +51,19 @@ export const parseOutline: ParsePartialUtil, MainOptions> = return trimObject(outline) } -export const parseHead: ParsePartialUtil> = (value) => { +export const parseHead: ParseUtilPartial> = (value, options) => { if (!isObject(value)) { return } const head = { title: parseSingularOf(value.title, (value) => parseString(retrieveText(value))), - dateCreated: parseSingularOf(value.datecreated, (value) => parseDate(retrieveText(value))), - dateModified: parseSingularOf(value.datemodified, (value) => parseDate(retrieveText(value))), + dateCreated: parseSingularOf(value.datecreated, (value) => + parseDate(retrieveText(value), options?.parseDateFn), + ), + dateModified: parseSingularOf(value.datemodified, (value) => + parseDate(retrieveText(value), options?.parseDateFn), + ), ownerName: parseSingularOf(value.ownername, (value) => parseString(retrieveText(value))), ownerEmail: parseSingularOf(value.owneremail, (value) => parseString(retrieveText(value))), ownerId: parseSingularOf(value.ownerid, (value) => parseString(retrieveText(value))), @@ -82,7 +83,7 @@ export const parseHead: ParsePartialUtil> = (value) => { return trimObject(head) } -export const parseBody: ParsePartialUtil, MainOptions> = (value, options) => { +export const parseBody: ParseUtilPartial> = (value, options) => { if (!isObject(value)) { return } @@ -98,16 +99,13 @@ export const parseBody: ParsePartialUtil, MainOptions> = (valu return trimObject(body) } -export const parseDocument: ParsePartialUtil, MainOptions> = ( - value, - options, -) => { +export const parseDocument: ParseUtilPartial> = (value, options) => { if (!isObject(value?.opml)) { return } const opml = { - head: parseHead(value.opml.head), + head: parseHead(value.opml.head, options), body: parseBody(value.opml.body, options), } diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 6f0a8c6d..00000000 --- a/src/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type { DeepPartial } from './common/types.js' -export type { Atom } from './feeds/atom/common/types.js' -export type { Json } from './feeds/json/common/types.js' -export type { Rdf } from './feeds/rdf/common/types.js' -export type { Rss } from './feeds/rss/common/types.js' -export type { Opml } from './opml/common/types.js'