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..c9063fc
--- /dev/null
+++ b/src/parser/phase/__test__/phase-8.test.ts
@@ -0,0 +1,126 @@
+/* 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:follow", () => {
+ const supportedName = "follow";
+
+ it("skips missing tag", () => {
+ const result = helpers.parseValidFeed(feed);
+
+ expect(result).not.toHaveProperty("podcastFollow");
+ expect(helpers.getPhaseSupport(result, phase)).not.toContain(supportedName);
+ });
+
+ it("ignores missing url", () => {
+ const xml = helpers.spliceFeed(feed, ``);
+ const result = helpers.parseValidFeed(xml);
+
+ expect(result).not.toHaveProperty("podcastFollow");
+ expect(helpers.getPhaseSupport(result, phase)).not.toContain(supportedName);
+ });
+
+ it("extracts a PRX follow url", () => {
+ const xml = helpers.spliceFeed(
+ feed,
+ ``
+ );
+ const result = helpers.parseValidFeed(xml);
+
+ expect(result).toHaveProperty("podcastFollow");
+ expect(result.podcastFollow).toHaveProperty(
+ "url",
+ "https://f.prxu.org/72/subscribelinks.json"
+ );
+ expect(helpers.getPhaseSupport(result, phase)).toContain(supportedName);
+ });
+
+ it("extracts a Podnews follow url", () => {
+ const xml = helpers.spliceFeed(
+ feed,
+ ``
+ );
+ const result = helpers.parseValidFeed(xml);
+
+ expect(result).toHaveProperty("podcastFollow");
+ expect(result.podcastFollow).toHaveProperty(
+ "url",
+ "https://podnews.net/podcast/i8xe9/follow.json"
+ );
+ expect(helpers.getPhaseSupport(result, phase)).toContain(supportedName);
+ });
+
+ it("extracts an insecure HTTP follow url", () => {
+ const xml = helpers.spliceFeed(
+ feed,
+ ``
+ );
+ const result = helpers.parseValidFeed(xml);
+
+ expect(result).toHaveProperty("podcastFollow");
+ expect(result.podcastFollow).toHaveProperty("url", "http://example.com/follow.json");
+ expect(helpers.getPhaseSupport(result, phase)).toContain(supportedName);
+ });
+
+ it("handles multiple follow tags by taking the first one", () => {
+ const xml = helpers.spliceFeed(
+ feed,
+ `
+ `
+ );
+ const result = helpers.parseValidFeed(xml);
+
+ expect(result).toHaveProperty("podcastFollow");
+ expect(result.podcastFollow).toHaveProperty("url", "https://example.com/first.json");
+ expect(helpers.getPhaseSupport(result, phase)).toContain(supportedName);
+ });
+
+ it("handles multiple follow tags by taking the first valid one", () => {
+ const xml = helpers.spliceFeed(
+ feed,
+ `
+
+ `
+ );
+ const result = helpers.parseValidFeed(xml);
+
+ expect(result).toHaveProperty("podcastFollow");
+ expect(result.podcastFollow).toHaveProperty("url", "https://example.com/valid.json");
+ expect(helpers.getPhaseSupport(result, phase)).toContain(supportedName);
+ });
+
+ it("ignores empty or whitespace-only url values", () => {
+ const empty = helpers.spliceFeed(feed, ``);
+ const whitespace = helpers.spliceFeed(feed, ``);
+
+ expect(helpers.parseValidFeed(empty)).not.toHaveProperty("podcastFollow");
+ expect(helpers.parseValidFeed(whitespace)).not.toHaveProperty("podcastFollow");
+ });
+
+ it("ignores malformed and unsupported url values", () => {
+ const malformed = helpers.spliceFeed(feed, ``);
+ const unsupported = helpers.spliceFeed(
+ feed,
+ ``
+ );
+
+ expect(helpers.parseValidFeed(malformed)).not.toHaveProperty("podcastFollow");
+ expect(helpers.getPhaseSupport(helpers.parseValidFeed(malformed), phase)).not.toContain(
+ supportedName
+ );
+ expect(helpers.parseValidFeed(unsupported)).not.toHaveProperty("podcastFollow");
+ expect(helpers.getPhaseSupport(helpers.parseValidFeed(unsupported), phase)).not.toContain(
+ supportedName
+ );
+ });
+ });
+});
diff --git a/src/parser/phase/index.ts b/src/parser/phase/index.ts
index 5432152..ae8e980 100644
--- a/src/parser/phase/index.ts
+++ b/src/parser/phase/index.ts
@@ -16,6 +16,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";
@@ -99,6 +100,8 @@ const feeds: FeedUpdate[] = [
phase7.podcastChat,
phase7.podcastPublisher,
+ phase8.podcastFollow,
+
pending.metaBoost,
pending.id,
pending.social,
diff --git a/src/parser/phase/phase-8.ts b/src/parser/phase/phase-8.ts
new file mode 100644
index 0000000..fd6aad6
--- /dev/null
+++ b/src/parser/phase/phase-8.ts
@@ -0,0 +1,33 @@
+import { ensureArray, getAttribute, normalizeHttpUrl } from "../shared";
+import type { XmlNode } from "../types";
+
+export type Phase8Follow = {
+ url: string;
+};
+
+function getFollowUrl(node: XmlNode): string | null {
+ const url = getAttribute(node, "url");
+ return url ? normalizeHttpUrl(url, true) : null;
+}
+
+export const podcastFollow = {
+ phase: 8,
+ name: "follow",
+ tag: "podcast:follow",
+ nodeTransform: (node: XmlNode | XmlNode[]): XmlNode | undefined =>
+ ensureArray(node).find((n) => getFollowUrl(n)),
+ supportCheck: (node: XmlNode): boolean => Boolean(getFollowUrl(node)),
+ fn(node: XmlNode): { podcastFollow: Phase8Follow } {
+ const url = getFollowUrl(node);
+
+ if (!url) {
+ throw new Error("Unable to extract phase 8 podcastFollow; supportCheck needs to be updated");
+ }
+
+ return {
+ podcastFollow: {
+ url,
+ },
+ };
+ },
+};
diff --git a/src/parser/types.ts b/src/parser/types.ts
index 2134a65..dcfe370 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 { Phase8Follow } from "./phase/phase-8";
import {
PhasePendingPodcastId,
PhasePendingSocial,
@@ -193,6 +194,11 @@ export interface FeedObject extends BasicFeed {
chat?: Phase7Chat;
podcastPublisher?: Phase7Publisher;
+ // #region Phase 8
+ /** PENDING Phase 8: URL to a JSON document containing podcast follow links. */
+ podcastFollow?: Phase8Follow;
+ // #endregion
+
/** Channel-level podcast:socialInteract (same shape as item podcastSocialInteraction). */
channelPodcastSocialInteract?: Phase5SocialInteract[];
// #region Pending Phase