From dcad343674f9a78fe43ac0344e1df06fe4a35a58 Mon Sep 17 00:00:00 2001 From: Nathan Gathright Date: Sat, 6 Sep 2025 13:48:29 -0500 Subject: [PATCH 1/2] feat: add Phase 8 structure foundation - Create phase-8.ts module with placeholder structure - Add Phase 8 section to phase index with placeholder comment - Prepare infrastructure for future Phase 8 tag implementations - No specific tags implemented yet - ready for development --- src/parser/phase/index.ts | 3 +++ src/parser/phase/phase-8.ts | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 src/parser/phase/phase-8.ts diff --git a/src/parser/phase/index.ts b/src/parser/phase/index.ts index bd05e09..aca2975 100644 --- a/src/parser/phase/index.ts +++ b/src/parser/phase/index.ts @@ -92,6 +92,9 @@ const feeds: FeedUpdate[] = [ phase7.podcastChat, phase7.podcastPublisher, + // Phase 8 - Currently no tags implemented + // phase8.* tags will be added here as they are implemented + pending.id, pending.social, pending.podcastRecommendations, diff --git a/src/parser/phase/phase-8.ts b/src/parser/phase/phase-8.ts new file mode 100644 index 0000000..d5c90ea --- /dev/null +++ b/src/parser/phase/phase-8.ts @@ -0,0 +1,23 @@ +// Phase 8 - Podcast Namespace +// This phase will contain podcast namespace tags that are confirmed for Phase 8 +// Currently no tags are implemented in this phase + +// Placeholder for future Phase 8 implementations +// Example structure for future tags: +// export const exampleTag = { +// phase: 8, +// name: "example", +// tag: "podcast:example", +// nodeTransform: firstIfArray, +// supportCheck: (node: XmlNode): boolean => Boolean(getAttribute(node, "requiredAttr")), +// fn(node: XmlNode): { exampleTag: ExampleType } { +// return { +// exampleTag: { +// // extracted properties +// }, +// }; +// }, +// }; + +// Export empty object to make this a valid module +export {}; From f3f2974b62694e0a8218b227848ae81faf31dfe2 Mon Sep 17 00:00:00 2001 From: Nathan Gathright Date: Sat, 6 Sep 2025 18:13:56 -0500 Subject: [PATCH 2/2] feat: implement podcast:image tag for Phase 8 - Add Phase8PodcastImage type with all required attributes: - href (required): Image URL - alt (recommended): Accessibility text - aspect-ratio (recommended): CSS aspect ratio syntax - width (recommended): Width in pixels - height (optional): Height in pixels - type (optional): MIME type (supports video/mp4, image/jpeg, etc.) - purpose (optional): Space-separated tokens for use cases - Implement podcast:image parser in phase-8.ts with proper validation - Add podcastImage field to FeedObject and Episode interfaces - Register tag in phase system for both feed and item processing - Add comprehensive test coverage (10 test cases) - Create example fixture demonstrating real-world usage - Support multiple images per channel/item as per spec - Support purpose tokens for indicating intended use cases - Support video content via type attribute - Support accessibility via alt attribute Closes: Phase 8 podcast:image implementation --- .../fixtures/podcast-image-example.xml | 84 ++++++++++ src/parser/phase/__test__/phase-8.test.ts | 145 ++++++++++++++++++ src/parser/phase/index.ts | 7 +- src/parser/phase/phase-8.ts | 60 +++++--- src/parser/types.ts | 5 + 5 files changed, 278 insertions(+), 23 deletions(-) create mode 100644 src/parser/__test__/fixtures/podcast-image-example.xml create mode 100644 src/parser/phase/__test__/phase-8.test.ts 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 aca2975..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,8 +93,8 @@ const feeds: FeedUpdate[] = [ phase7.podcastChat, phase7.podcastPublisher, - // Phase 8 - Currently no tags implemented - // phase8.* tags will be added here as they are implemented + // Phase 8 + phase8.podcastImage, pending.id, pending.social, @@ -122,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 index d5c90ea..1e70243 100644 --- a/src/parser/phase/phase-8.ts +++ b/src/parser/phase/phase-8.ts @@ -1,23 +1,41 @@ -// Phase 8 - Podcast Namespace -// This phase will contain podcast namespace tags that are confirmed for Phase 8 -// Currently no tags are implemented in this phase +import { + ensureArray, + extractOptionalIntegerAttribute, + extractOptionalStringAttribute, + getAttribute, + getKnownAttribute, +} from "../shared"; +import type { XmlNode } from "../types"; -// Placeholder for future Phase 8 implementations -// Example structure for future tags: -// export const exampleTag = { -// phase: 8, -// name: "example", -// tag: "podcast:example", -// nodeTransform: firstIfArray, -// supportCheck: (node: XmlNode): boolean => Boolean(getAttribute(node, "requiredAttr")), -// fn(node: XmlNode): { exampleTag: ExampleType } { -// return { -// exampleTag: { -// // extracted properties -// }, -// }; -// }, -// }; +export type Phase8PodcastImage = { + href: string; + alt?: string; + aspectRatio?: string; + width?: number; + height?: number; + type?: string; + purpose?: string; +}; -// Export empty object to make this a valid module -export {}; +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