diff --git a/src/parser/__test__/fixtures/podcast-image-example.xml b/src/parser/__test__/fixtures/podcast-image-example.xml new file mode 100644 index 0000000..3fc8248 --- /dev/null +++ b/src/parser/__test__/fixtures/podcast-image-example.xml @@ -0,0 +1,84 @@ + + + + Podcast with Phase 8 Image Tag + This is an example feed demonstrating the new podcast:image tag from Phase 8. + http://example.com/podcast + http://blogs.law.harvard.edu/tech/rss + en-US + Phase 8 Example + Fri, 09 Oct 2020 04:30:38 GMT + Fri, 09 Oct 2020 04:30:38 GMT + john@example.com (John Doe) + support@example.com (Tech Support) + + + + + + + + + + John Doe + no + episodic + + + + https://example.com/images/pci_avatar-massive.jpg + + + Episode 1 - Introduction to Phase 8 + <p>This episode introduces the new podcast:image tag from Phase 8!</p> + https://example.com/podcast/ep0001 + https://example.com/ep0001 + Fri, 09 Oct 2020 04:30:38 GMT + John Doe (john@example.com) + https://example.com/ep0001/artMd.jpg + no + + + + + + + + + diff --git a/src/parser/phase/__test__/phase-8.test.ts b/src/parser/phase/__test__/phase-8.test.ts new file mode 100644 index 0000000..06e68d8 --- /dev/null +++ b/src/parser/phase/__test__/phase-8.test.ts @@ -0,0 +1,145 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import * as helpers from "../../__test__/helpers"; + +const phase = 8; + +describe("phase 8", () => { + let feed; + beforeAll(async () => { + feed = await helpers.loadSimple(); + }); + + describe("podcast:image", () => { + const supportedName = "image"; + + it("correctly identifies a basic feed", () => { + const result = helpers.parseValidFeed(feed); + + expect(result).not.toHaveProperty("podcastImage"); + expect(helpers.getPhaseSupport(result, phase)).not.toContain(supportedName); + }); + + it("ignores missing href", () => { + const xml = helpers.spliceFeed( + feed, + // missing href, not valid + `` + ); + const result = helpers.parseValidFeed(xml); + + expect(result).not.toHaveProperty("podcastImage"); + expect(helpers.getPhaseSupport(result, phase)).not.toContain(supportedName); + }); + + it("extracts a basic image with only href", () => { + const xml = helpers.spliceFeed( + feed, + `` + ); + const result = helpers.parseValidFeed(xml); + + expect(result).toHaveProperty("podcastImage"); + expect(result.podcastImage).toHaveProperty("href", "https://example.com/image.jpg"); + expect(result.podcastImage).not.toHaveProperty("type"); + expect(result.podcastImage).not.toHaveProperty("width"); + expect(result.podcastImage).not.toHaveProperty("height"); + expect(helpers.getPhaseSupport(result, phase)).toContain(supportedName); + }); + + it("extracts an image with all attributes", () => { + const xml = helpers.spliceFeed( + feed, + `` + ); + const result = helpers.parseValidFeed(xml); + + expect(result).toHaveProperty("podcastImage"); + expect(result.podcastImage).toHaveProperty("href", "https://example.com/image.jpg"); + expect(result.podcastImage).toHaveProperty("alt", "Test image"); + expect(result.podcastImage).toHaveProperty("aspectRatio", "1/1"); + expect(result.podcastImage).toHaveProperty("type", "image/jpeg"); + expect(result.podcastImage).toHaveProperty("width", 1400); + expect(result.podcastImage).toHaveProperty("height", 1400); + expect(result.podcastImage).toHaveProperty("purpose", "artwork"); + expect(helpers.getPhaseSupport(result, phase)).toContain(supportedName); + }); + + it("extracts an image with partial attributes", () => { + const xml = helpers.spliceFeed( + feed, + `` + ); + const result = helpers.parseValidFeed(xml); + + expect(result).toHaveProperty("podcastImage"); + expect(result.podcastImage).toHaveProperty("href", "https://example.com/image.png"); + expect(result.podcastImage).toHaveProperty("type", "image/png"); + expect(result.podcastImage).toHaveProperty("width", 800); + expect(result.podcastImage).not.toHaveProperty("height"); + expect(helpers.getPhaseSupport(result, phase)).toContain(supportedName); + }); + + it("handles numeric width and height as integers", () => { + const xml = helpers.spliceFeed( + feed, + `` + ); + const result = helpers.parseValidFeed(xml); + + expect(result).toHaveProperty("podcastImage"); + expect(result.podcastImage).toHaveProperty("width", 1920); + expect(result.podcastImage).toHaveProperty("height", 1080); + expect(typeof result.podcastImage?.width).toBe("number"); + expect(typeof result.podcastImage?.height).toBe("number"); + }); + + it("extracts aspect-ratio attribute", () => { + const xml = helpers.spliceFeed( + feed, + `` + ); + const result = helpers.parseValidFeed(xml); + + expect(result).toHaveProperty("podcastImage"); + expect(result.podcastImage).toHaveProperty("aspectRatio", "16/9"); + }); + + it("extracts purpose attribute", () => { + const xml = helpers.spliceFeed( + feed, + `` + ); + const result = helpers.parseValidFeed(xml); + + expect(result).toHaveProperty("podcastImage"); + expect(result.podcastImage).toHaveProperty("purpose", "artwork social"); + }); + + it("extracts alt attribute for accessibility", () => { + const xml = helpers.spliceFeed( + feed, + `` + ); + const result = helpers.parseValidFeed(xml); + + expect(result).toHaveProperty("podcastImage"); + expect(result.podcastImage).toHaveProperty("alt", "An antenna emanating signal waves"); + }); + + it("handles video type with aspect-ratio", () => { + const xml = helpers.spliceFeed( + feed, + `` + ); + const result = helpers.parseValidFeed(xml); + + expect(result).toHaveProperty("podcastImage"); + expect(result.podcastImage).toHaveProperty("href", "https://example.com/video.mp4"); + expect(result.podcastImage).toHaveProperty("type", "video/mp4"); + expect(result.podcastImage).toHaveProperty("aspectRatio", "9/16"); + expect(result.podcastImage).toHaveProperty("width", 1200); + }); + }); +}); diff --git a/src/parser/phase/index.ts b/src/parser/phase/index.ts index bd05e09..e08d0dd 100644 --- a/src/parser/phase/index.ts +++ b/src/parser/phase/index.ts @@ -15,6 +15,7 @@ import * as phase4 from "./phase-4"; import * as phase5 from "./phase-5"; import * as phase6 from "./phase-6"; import * as phase7 from "./phase-7"; +import * as phase8 from "./phase-8"; import * as pending from "./phase-pending"; import { XmlNodeSource } from "./types"; @@ -92,6 +93,9 @@ const feeds: FeedUpdate[] = [ phase7.podcastChat, phase7.podcastPublisher, + // Phase 8 + phase8.podcastImage, + pending.id, pending.social, pending.podcastRecommendations, @@ -119,6 +123,8 @@ const items: ItemUpdate[] = [ phase7.podcastChat, + phase8.podcastImage, + pending.podcastRecommendations, pending.podcastGateway, ]; diff --git a/src/parser/phase/phase-8.ts b/src/parser/phase/phase-8.ts new file mode 100644 index 0000000..1e70243 --- /dev/null +++ b/src/parser/phase/phase-8.ts @@ -0,0 +1,41 @@ +import { + ensureArray, + extractOptionalIntegerAttribute, + extractOptionalStringAttribute, + getAttribute, + getKnownAttribute, +} from "../shared"; +import type { XmlNode } from "../types"; + +export type Phase8PodcastImage = { + href: string; + alt?: string; + aspectRatio?: string; + width?: number; + height?: number; + type?: string; + purpose?: string; +}; + +export const podcastImage = { + phase: 8, + name: "image", + tag: "podcast:image", + nodeTransform: (node: XmlNode | XmlNode[]): XmlNode => + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + ensureArray(node).find((n) => getAttribute(n, "href")), + supportCheck: (node: XmlNode): boolean => Boolean(getAttribute(node, "href")), + fn(node: XmlNode): { podcastImage: Phase8PodcastImage } { + return { + podcastImage: { + href: getKnownAttribute(node, "href"), + ...extractOptionalStringAttribute(node, "alt"), + ...extractOptionalStringAttribute(node, "aspect-ratio", "aspectRatio"), + ...extractOptionalIntegerAttribute(node, "width"), + ...extractOptionalIntegerAttribute(node, "height"), + ...extractOptionalStringAttribute(node, "type"), + ...extractOptionalStringAttribute(node, "purpose"), + }, + }; + }, +}; diff --git a/src/parser/types.ts b/src/parser/types.ts index c5119f6..a39d096 100644 --- a/src/parser/types.ts +++ b/src/parser/types.ts @@ -22,6 +22,7 @@ import type { import type { Phase5Blocked, Phase5BlockedPlatforms, Phase5SocialInteract } from "./phase/phase-5"; import type { Phase6RemoteItem, Phase6TxtEntry } from "./phase/phase-6"; import type { Phase7Chat, Phase7Publisher } from "./phase/phase-7"; +import type { Phase8PodcastImage } from "./phase/phase-8"; import { PhasePendingPodcastId, PhasePendingSocial, @@ -197,6 +198,8 @@ export interface FeedObject extends BasicFeed { /** PENDING AND LIKELY TO CHANGE This tag tells the an application what the content contained within the feed IS, as opposed to what the content is ABOUT in the case of a category. */ medium?: Phase4Medium; podcastImages?: Phase4PodcastImage[]; + /** Phase 8: specifies a single image for a podcast at the channel level */ + podcastImage?: Phase8PodcastImage; podcastRecommendations?: PhasePendingPodcastRecommendation[]; // #endregion } @@ -264,6 +267,8 @@ export interface Episode { // #region Pending Phase podcastImages?: Phase4PodcastImage[]; + /** Phase 8: specifies a single image for a podcast at the episode level */ + podcastImage?: Phase8PodcastImage; podcastRecommendations?: PhasePendingPodcastRecommendation[]; podcastGateway?: PhasePendingGateway; // #endregion