From 1e222eccfa736d49312447855ee32e2b854c9687 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 14:00:13 +0000 Subject: [PATCH 01/22] Add DeMu-style educational XML comments to generated feeds Every tag in the channel and each item now has an inline XML comment explaining what it does and linking to the relevant Podcasting 2.0 spec. Mirrors the approach from the DeMu feed template (de-mu/demu-feed-template) so artists can open the raw XML and understand what each field means. Tag ordering already matched DeMu; this commit adds the annotations. All 134 tests pass. Co-Authored-By: Claude https://claude.ai/code/session_011LfxWfu9deiuRidiyZSa5y --- src/utils/xmlGenerator.ts | 124 +++++++++++++++++++++++++++----------- 1 file changed, 89 insertions(+), 35 deletions(-) diff --git a/src/utils/xmlGenerator.ts b/src/utils/xmlGenerator.ts index 1e92340..9d44d42 100644 --- a/src/utils/xmlGenerator.ts +++ b/src/utils/xmlGenerator.ts @@ -213,6 +213,7 @@ const generateValueXml = (value: ValueBlock, level: number): string => { if (value.suggested) attrs.push(`suggested="${value.suggested}"`); lines.push(`${indent(level)}`); + lines.push(`${indent(level + 1)}`); value.recipients.forEach(r => lines.push(generateRecipientXml(r, level + 1))); lines.push(`${indent(level)}`); @@ -257,72 +258,101 @@ const generatePublisherXml = (publisher: PublisherReference, level: number): str return lines.join('\n'); }; +// Generate a single element (without indentation). Returns null when href is empty. +const generatePodcastImageXml = (image: PodcastImage): string | null => { + if (!image.href) return null; + const attrs = [`href="${escapeXml(image.href)}"`]; + if (image.purpose) attrs.push(`purpose="${escapeXml(image.purpose)}"`); + if (image.alt) attrs.push(`alt="${escapeXml(image.alt)}"`); + if (image.aspectRatio) attrs.push(`aspect-ratio="${escapeXml(image.aspectRatio)}"`); + if (image.width) attrs.push(`width="${image.width}"`); + if (image.height) attrs.push(`height="${image.height}"`); + if (image.type) attrs.push(`type="${escapeXml(image.type)}"`); + return ``; +}; + // Generate common channel elements shared between Album and PublisherFeed const generateCommonChannelElements = (data: BaseChannelData, medium: string, level: number): string[] => { const lines: string[] = []; // Title + lines.push(`${indent(level)}`); lines.push(`${indent(level)}${escapeXml(data.title)}`); // Author + lines.push(`${indent(level)}`); lines.push(`${indent(level)}${escapeXml(data.author)}`); // Description + lines.push(`${indent(level)}`); lines.push(`${indent(level)}`); lines.push(`${indent(level + 1)}${escapeXml(data.description)}`); lines.push(`${indent(level)}`); // Link if (data.link) { + lines.push(`${indent(level)}`); lines.push(`${indent(level)}${escapeXml(data.link)}`); } // Language + lines.push(`${indent(level)}`); lines.push(`${indent(level)}${data.language}`); - // Generator - always use MSP 2.0 since we're generating the feed + // Generator + lines.push(`${indent(level)}`); lines.push(`${indent(level)}MSP 2.0 - Music Side Project Studio`); // Dates + lines.push(`${indent(level)}`); lines.push(`${indent(level)}${formatRFC822Date(data.pubDate)}`); + lines.push(`${indent(level)}`); lines.push(`${indent(level)}${formatRFC822Date(data.lastBuildDate)}`); // Locked if (data.locked && data.lockedOwner) { + lines.push(`${indent(level)}`); lines.push(`${indent(level)}yes`); } // GUID if (data.podcastGuid) { + lines.push(`${indent(level)}`); lines.push(`${indent(level)}${escapeXml(data.podcastGuid)}`); } // Artist Npub (only for Album feeds) if ((data as Album).artistNpub) { + lines.push(`${indent(level)}`); lines.push(`${indent(level)}${escapeXml((data as Album).artistNpub!)}`); } // Categories (default to Music for music feeds) const categories = data.categories.length > 0 ? data.categories : ['Music']; + lines.push(`${indent(level)}`); categories.forEach(cat => { lines.push(`${indent(level)}`); }); // Keywords if (data.keywords) { + lines.push(`${indent(level)}`); lines.push(`${indent(level)}${escapeXml(data.keywords)}`); } // Contact if (data.managingEditor) { + lines.push(`${indent(level)}`); lines.push(`${indent(level)}${escapeXml(data.managingEditor)}`); } if (data.webMaster) { + lines.push(`${indent(level)}`); lines.push(`${indent(level)}${escapeXml(data.webMaster)}`); } // Image if (data.imageUrl) { + lines.push(`${indent(level)}`); lines.push(`${indent(level)}`); lines.push(`${indent(level + 1)}${escapeXml(data.imageUrl)}`); lines.push(`${indent(level + 1)}${escapeXml(data.imageTitle || data.title)}`); @@ -333,27 +363,28 @@ const generateCommonChannelElements = (data: BaseChannelData, medium: string, le lines.push(`${indent(level + 1)}${escapeXml(data.imageDescription)}`); } lines.push(`${indent(level)}`); - } - - // iTunes image - if (data.imageUrl) { + lines.push(`${indent(level)}`); lines.push(`${indent(level)}`); } // Podcasting 2.0 additional images - (data.podcastImages || []).forEach(img => { - const tag = generatePodcastImageXml(img); - if (tag) lines.push(`${indent(level)}${tag}`); - }); + const podcastImgTags = (data.podcastImages || []).map(img => generatePodcastImageXml(img)).filter((t): t is string => t !== null); + if (podcastImgTags.length > 0) { + lines.push(`${indent(level)}`); + podcastImgTags.forEach(tag => lines.push(`${indent(level)}${tag}`)); + } // Medium + lines.push(`${indent(level)}`); lines.push(`${indent(level)}${medium}`); // Explicit + lines.push(`${indent(level)}`); lines.push(`${indent(level)}${data.explicit ? 'true' : 'false'}`); // Owner if (data.ownerName || data.ownerEmail) { + lines.push(`${indent(level)}`); lines.push(`${indent(level)}`); if (data.ownerName) { lines.push(`${indent(level + 1)}${escapeXml(data.ownerName)}`); @@ -365,18 +396,23 @@ const generateCommonChannelElements = (data: BaseChannelData, medium: string, le } // Persons - data.persons.forEach(p => lines.push(generatePersonXml(p, level))); + if (data.persons.length > 0) { + lines.push(`${indent(level)}`); + data.persons.forEach(p => lines.push(generatePersonXml(p, level))); + } // Value block if (data.value.recipients.length > 0) { + lines.push(`${indent(level)}`); lines.push(generateValueXml(data.value, level)); } // Funding - (data.funding || []).forEach(f => { - const fundingXml = generateFundingXml(f, level); - if (fundingXml) lines.push(fundingXml); - }); + const fundingLines = (data.funding || []).map(f => generateFundingXml(f, level)).filter(Boolean); + if (fundingLines.length > 0) { + lines.push(`${indent(level)}`); + fundingLines.forEach(f => lines.push(f as string)); + } return lines; }; @@ -393,74 +429,81 @@ const applyOp3Prefix = (url: string, podcastGuid?: string): string => { return `https://op3.dev/e${pgParam}/${urlWithoutProtocol}`; }; -// Generate a single element (without indentation). Returns null when href is empty. -const generatePodcastImageXml = (image: PodcastImage): string | null => { - if (!image.href) return null; - const attrs = [`href="${escapeXml(image.href)}"`]; - if (image.purpose) attrs.push(`purpose="${escapeXml(image.purpose)}"`); - if (image.alt) attrs.push(`alt="${escapeXml(image.alt)}"`); - if (image.aspectRatio) attrs.push(`aspect-ratio="${escapeXml(image.aspectRatio)}"`); - if (image.width) attrs.push(`width="${image.width}"`); - if (image.height) attrs.push(`height="${image.height}"`); - if (image.type) attrs.push(`type="${escapeXml(image.type)}"`); - return ``; -}; - // Generate track/item XML const generateTrackXml = (track: Track, album: Album, level: number): string => { const lines: string[] = []; lines.push(`${indent(level)}`); + + lines.push(`${indent(level + 1)}`); lines.push(`${indent(level + 1)}${escapeXml(track.title)}`); if (track.description) { + lines.push(`${indent(level + 1)}`); lines.push(`${indent(level + 1)}${escapeXml(track.description)}`); } + lines.push(`${indent(level + 1)}`); lines.push(`${indent(level + 1)}${formatRFC822Date(track.pubDate)}`); + + lines.push(`${indent(level + 1)}`); lines.push(`${indent(level + 1)}${escapeXml(track.guid)}`); if (track.transcriptUrl) { + lines.push(`${indent(level + 1)}`); lines.push(`${indent(level + 1)}`); } // Track artwork (falls back to album) const artUrl = track.trackArtUrl || album.imageUrl; if (artUrl) { + lines.push(`${indent(level + 1)}`); lines.push(`${indent(level + 1)}`); } - // Podcasting 2.0 additional images - (track.podcastImages || []).forEach(img => { - const tag = generatePodcastImageXml(img); - if (tag) lines.push(`${indent(level + 1)}${tag}`); - }); + + // Podcasting 2.0 additional images (track level) + const trackImgTags = (track.podcastImages || []).map(img => generatePodcastImageXml(img)).filter((t): t is string => t !== null); + if (trackImgTags.length > 0) { + lines.push(`${indent(level + 1)}`); + trackImgTags.forEach(tag => lines.push(`${indent(level + 1)}${tag}`)); + } // Enclosure (audio file) const fileLength = track.enclosureLength || '0'; const enclosureUrl = album.op3 ? applyOp3Prefix(track.enclosureUrl, album.podcastGuid) : track.enclosureUrl; + lines.push(`${indent(level + 1)}`); lines.push(`${indent(level + 1)}`); - // Duration + lines.push(`${indent(level + 1)}`); lines.push(`${indent(level + 1)}${track.duration}`); // Season (always 1) + lines.push(`${indent(level + 1)}`); lines.push(`${indent(level + 1)}1`); // Episode number (use track.episode if set, otherwise trackNumber) + lines.push(`${indent(level + 1)}`); lines.push(`${indent(level + 1)}${track.episode ?? track.trackNumber}`); // Explicit + lines.push(`${indent(level + 1)}`); lines.push(`${indent(level + 1)}${track.explicit ? 'true' : 'false'}`); // Persons (only output at item level when overriding album persons) if (track.overridePersons) { + lines.push(`${indent(level + 1)}`); track.persons.forEach(p => lines.push(generatePersonXml(p, level + 1))); } // Value block (override or inherit from album) const value = track.overrideValue && track.value ? track.value : album.value; if (value.recipients.length > 0) { + if (track.overrideValue && track.value) { + lines.push(`${indent(level + 1)}`); + } else { + lines.push(`${indent(level + 1)}`); + } lines.push(generateValueXml(value, level + 1)); } @@ -489,9 +532,11 @@ export const generateRssFeed = (album: Album): string => { // RSS root with namespaces const baseNs = 'xmlns:podcast="https://podcastindex.org/namespace/1.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"'; const rssAttrs = additionalNsDecl ? `${baseNs} ${additionalNsDecl}` : baseNs; + lines.push(``); lines.push(``); // Channel + lines.push(`${indent(1)}`); lines.push(`${indent(1)}`); // Common channel elements @@ -500,7 +545,10 @@ export const generateRssFeed = (album: Album): string => { // Publisher reference (if this album belongs to a publisher) if (album.publisher) { const publisherXml = generatePublisherXml(album.publisher, 2); - if (publisherXml) lines.push(publisherXml); + if (publisherXml) { + lines.push(`${indent(2)}`); + lines.push(publisherXml); + } } // Unknown/unsupported channel elements (preserved from import) @@ -510,7 +558,10 @@ export const generateRssFeed = (album: Album): string => { } // Tracks - album.tracks.forEach(track => lines.push(generateTrackXml(track, album, 2))); + if (album.tracks.length > 0) { + lines.push(`${indent(2)}`); + album.tracks.forEach(track => lines.push(generateTrackXml(track, album, 2))); + } // Close channel and rss lines.push(`${indent(1)}`); @@ -536,9 +587,11 @@ export const generatePublisherRssFeed = (publisher: PublisherFeed): string => { // RSS root with namespaces const baseNs = 'xmlns:podcast="https://podcastindex.org/namespace/1.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"'; const rssAttrs = additionalNsDecl ? `${baseNs} ${additionalNsDecl}` : baseNs; + lines.push(``); lines.push(``); // Channel + lines.push(`${indent(1)}`); lines.push(`${indent(1)}`); // Common channel elements (medium is always "publisher" for publisher feeds) @@ -546,6 +599,7 @@ export const generatePublisherRssFeed = (publisher: PublisherFeed): string => { // Remote items - the feeds this publisher owns if (publisher.remoteItems.length > 0) { + lines.push(`${indent(2)}`); publisher.remoteItems.forEach(item => { lines.push(generateRemoteItemXml(item, 2)); }); From 8d3628d4d8a1bb2458e4e5e2727bd7b3fb808c1d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 14:03:19 +0000 Subject: [PATCH 02/22] Reference DeMu feed template in generated feed comments Adds a comment at the top of every generated feed pointing readers to https://github.com/de-mu/demu-feed-template for the original template and documentation. Co-Authored-By: Claude https://claude.ai/code/session_011LfxWfu9deiuRidiyZSa5y --- src/utils/xmlGenerator.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/utils/xmlGenerator.ts b/src/utils/xmlGenerator.ts index 9d44d42..c8cec3f 100644 --- a/src/utils/xmlGenerator.ts +++ b/src/utils/xmlGenerator.ts @@ -532,6 +532,7 @@ export const generateRssFeed = (album: Album): string => { // RSS root with namespaces const baseNs = 'xmlns:podcast="https://podcastindex.org/namespace/1.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"'; const rssAttrs = additionalNsDecl ? `${baseNs} ${additionalNsDecl}` : baseNs; + lines.push(``); lines.push(``); lines.push(``); @@ -587,6 +588,7 @@ export const generatePublisherRssFeed = (publisher: PublisherFeed): string => { // RSS root with namespaces const baseNs = 'xmlns:podcast="https://podcastindex.org/namespace/1.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"'; const rssAttrs = additionalNsDecl ? `${baseNs} ${additionalNsDecl}` : baseNs; + lines.push(``); lines.push(``); lines.push(``); From 5523882537594341eac0f743f96c19c5db1eec5e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 14:06:11 +0000 Subject: [PATCH 03/22] Add MSP 2.0 feed template reference file msp-feed-template.xml is a fully annotated example RSS feed showing all MSP 2.0 features: channel metadata, value splits, person credits, lyrics (podcast:transcript), and a featured-artist track with a per-track value override. Mirrors the DeMu feed template approach but covers the full MSP 2.0 tag set. Co-Authored-By: Claude https://claude.ai/code/session_011LfxWfu9deiuRidiyZSa5y --- msp-feed-template.xml | 164 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 msp-feed-template.xml diff --git a/msp-feed-template.xml b/msp-feed-template.xml new file mode 100644 index 0000000..562fb99 --- /dev/null +++ b/msp-feed-template.xml @@ -0,0 +1,164 @@ + + + + + + + + My Album Title + + My Band Name + + + A brief description of your album. Tell listeners what it's about, the vibe, the story behind it. + + + https://mybandwebsite.com + + en + + MSP 2.0 - Music Side Project Studio + + Mon, 01 Jan 2024 00:00:00 GMT + + Mon, 01 Jan 2024 00:00:00 GMT + + no + + xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + + + + rock, indie, alternative + + you@example.com + + you@example.com + + + https://mybandwebsite.com/album-art.jpg + My Album Title cover art + https://mybandwebsite.com + My Album Title by My Band Name + + + + + music + + false + + + My Band Name + you@example.com + + + My Band Name + Singer Name + Guitarist Name + Producer Name + + + + + + + + + Support My Band + + + + Track One + + The opening track of the album. + + Mon, 01 Jan 2024 00:00:00 GMT + + aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa + + + + + + 00:03:45 + + 1 + + 1 + + false + + + + + + + + + + + Track Two (with Lyrics) + + This track includes a time-coded lyrics file via podcast:transcript. + + Mon, 01 Jan 2024 00:01:00 GMT + + bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb + + + + + + + + 00:04:32 + + 1 + + 2 + + false + + + + + + + + + + + Track Three (Featured Artist) + + This track features a guest artist with their own value split at the item level. + + Mon, 01 Jan 2024 00:02:00 GMT + + cccccccc-cccc-cccc-cccc-cccccccccccc + + + + + + 00:05:18 + + 1 + + 3 + + false + + Singer Name + Guest Artist Name + + + + + + + + + + + From a9c5efec602fb385cc5a169074fde140777bffa1 Mon Sep 17 00:00:00 2001 From: Chad Date: Mon, 15 Jun 2026 18:54:41 -0400 Subject: [PATCH 04/22] Make educational comments medium-aware; add comment tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publisher feeds share generateCommonChannelElements, so they were emitting album-centric comments — most notably the podcast:medium comment claiming "this feed contains music" for medium=publisher. Reword title/author/description/medium comments based on medium. Add tests covering album wording, publisher rewording, the no-"--" XML-comment invariant, and comment-stripping on round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/utils/xmlGenerator.test.ts | 64 ++++++++++++++++++++++++++++++++-- src/utils/xmlGenerator.ts | 20 ++++++++--- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/utils/xmlGenerator.test.ts b/src/utils/xmlGenerator.test.ts index 56cd809..9f411d3 100644 --- a/src/utils/xmlGenerator.test.ts +++ b/src/utils/xmlGenerator.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { generateRssFeed } from './xmlGenerator'; +import { generateRssFeed, generatePublisherRssFeed } from './xmlGenerator'; import { parseRssFeed } from './xmlParser'; -import { createEmptyAlbum } from '../types/feed'; +import { createEmptyAlbum, createEmptyPublisherFeed } from '../types/feed'; describe('xmlGenerator publisher reference', () => { it('includes podcast:publisher tag when publisher is set', () => { @@ -284,3 +284,63 @@ describe('podcast:image generation', () => { expect(xml).not.toContain(' { + it('emits the DeMu template attribution and per-tag comments on album feeds', () => { + const album = createEmptyAlbum(); + album.title = 'Test Album'; + album.author = 'Test Artist'; + album.description = 'Test description'; + + const xml = generateRssFeed(album); + + expect(xml).toContain('This feed follows the Demu feed template format.'); + // Album-centric wording for shared channel comments + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain('this feed contains music'); + }); + + it('rewords album-centric comments for publisher feeds (no "music"/"album" wording)', () => { + const publisher = createEmptyPublisherFeed(); + publisher.title = 'Test Label'; + publisher.author = 'Test Label'; + publisher.description = 'A record label'; + + const xml = generatePublisherRssFeed(publisher); + + // The medium comment must NOT claim the publisher feed contains music + expect(xml).not.toContain('this feed contains music'); + expect(xml).toContain('identifies this as a publisher feed'); + expect(xml).toContain(''); + expect(xml).toContain(''); + }); + + it('never emits an illegal double-hyphen inside a comment body', () => { + const album = createEmptyAlbum(); + album.title = 'Test Album'; + album.tracks[0].title = 'Song'; + const xml = generateRssFeed(album); + + // XML forbids "--" inside comment bodies; only the closing "-->" may contain it. + for (const comment of xml.match(//g) ?? []) { + expect(comment.slice(4, -3)).not.toContain('--'); + } + }); + + it('drops comments on round-trip (parser does not capture or re-emit them)', () => { + const album = createEmptyAlbum(); + album.title = 'Roundtrip Album'; + album.author = 'Artist'; + album.tracks[0].title = 'Track One'; + album.tracks[0].enclosureUrl = 'https://example.com/track1.mp3'; + + const xml1 = generateRssFeed(album); + const reparsed = parseRssFeed(xml1); + const xml2 = generateRssFeed(reparsed); + + // No comment should be duplicated on re-export (parser must not capture them as unknown elements) + const count = (s: string) => (s.match(/`); + lines.push(isPublisher + ? `${indent(level)}` + : `${indent(level)}`); lines.push(`${indent(level)}${escapeXml(data.title)}`); // Author - lines.push(`${indent(level)}`); + lines.push(isPublisher + ? `${indent(level)}` + : `${indent(level)}`); lines.push(`${indent(level)}${escapeXml(data.author)}`); // Description - lines.push(`${indent(level)}`); + lines.push(isPublisher + ? `${indent(level)}` + : `${indent(level)}`); lines.push(`${indent(level)}`); lines.push(`${indent(level + 1)}${escapeXml(data.description)}`); lines.push(`${indent(level)}`); @@ -375,7 +385,9 @@ const generateCommonChannelElements = (data: BaseChannelData, medium: string, le } // Medium - lines.push(`${indent(level)}`); + lines.push(isPublisher + ? `${indent(level)}` + : `${indent(level)}`); lines.push(`${indent(level)}${medium}`); // Explicit From addfbdc365e051627958358955a68d9bca5712fa Mon Sep 17 00:00:00 2001 From: Chad Date: Mon, 15 Jun 2026 19:17:05 -0400 Subject: [PATCH 05/22] Use DeMu template's verbatim wording for feed comments Replace the paraphrased educational comments with the exact text from DeMu's feed-with-comments.xml wherever a tag overlaps, so MSP feeds read identically to the canonical DeMu template. Adapt the handful of DeMu-specific lines (generator "Handcrafted" instruction, "Demu release/album", the template author's "Wolf" recipient) to neutral wording, and fix DeMu's typos (descripe, meduim, name spaces, necessarilly). Add DeMu's image child-tag comments (url/title/link/ description). Publisher-feed comments and MSP-only tags keep their own wording. Regenerate msp-feed-template.xml from the generator so the reference file stays in sync. Co-Authored-By: Claude Opus 4.8 (1M context) --- msp-feed-template.xml | 153 +++++++++++++-------------------- src/utils/xmlGenerator.test.ts | 2 +- src/utils/xmlGenerator.ts | 65 +++++++------- 3 files changed, 93 insertions(+), 127 deletions(-) diff --git a/msp-feed-template.xml b/msp-feed-template.xml index 562fb99..96ee463 100644 --- a/msp-feed-template.xml +++ b/msp-feed-template.xml @@ -1,49 +1,52 @@ - + - + My Album Title - + My Band Name - + - A brief description of your album. Tell listeners what it's about, the vibe, the story behind it. + A brief description of your album. Tell listeners what it's about, the vibe, the story behind it. - + https://mybandwebsite.com - + en - + MSP 2.0 - Music Side Project Studio - - Mon, 01 Jan 2024 00:00:00 GMT - - Mon, 01 Jan 2024 00:00:00 GMT - - no - - xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - + + Mon, 15 Jun 2026 23:16:31 GMT + + Mon, 15 Jun 2026 23:16:31 GMT + + yes + + 21377651-b449-5585-ac5d-4b70f2ede0f6 + rock, indie, alternative - + you@example.com - you@example.com - + + https://mybandwebsite.com/album-art.jpg + My Album Title cover art + https://mybandwebsite.com - My Album Title by My Band Name + + My Band Name - My Album Title album art - + music false @@ -52,45 +55,40 @@ My Band Name you@example.com - - My Band Name - Singer Name - Guitarist Name - Producer Name - + + Band Member Name + - + - - Support My Band - + - Track One - - The opening track of the album. - - Mon, 01 Jan 2024 00:00:00 GMT - - aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa - + First Song + + First Song - My Band Name + + Mon, 15 Jun 2026 23:16:31 GMT + + 3b77cf04-4256-42d4-804e-24df710d3d91 + - - - - 00:03:45 + + + + 00:04:12 1 - + 1 false - + @@ -98,67 +96,32 @@ - Track Two (with Lyrics) - - This track includes a time-coded lyrics file via podcast:transcript. - - Mon, 01 Jan 2024 00:01:00 GMT - - bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb - - - + Second Song + + Second Song - My Band Name + + Mon, 15 Jun 2026 23:16:31 GMT + + 3b77cf04-4256-42d4-804e-24df710d3d92 + - - - - 00:04:32 + + + + 00:05:18 1 - + 2 false - + - - - Track Three (Featured Artist) - - This track features a guest artist with their own value split at the item level. - - Mon, 01 Jan 2024 00:02:00 GMT - - cccccccc-cccc-cccc-cccc-cccccccccccc - - - - - - 00:05:18 - - 1 - - 3 - - false - - Singer Name - Guest Artist Name - - - - - - - - - diff --git a/src/utils/xmlGenerator.test.ts b/src/utils/xmlGenerator.test.ts index 9f411d3..96a9ef8 100644 --- a/src/utils/xmlGenerator.test.ts +++ b/src/utils/xmlGenerator.test.ts @@ -297,7 +297,7 @@ describe('DeMu-style educational comments', () => { expect(xml).toContain('This feed follows the Demu feed template format.'); // Album-centric wording for shared channel comments expect(xml).toContain(''); - expect(xml).toContain(''); + expect(xml).toContain('describes the author of the content in the feed. For a music release, we put the album\'s artist here.'); expect(xml).toContain('this feed contains music'); }); diff --git a/src/utils/xmlGenerator.ts b/src/utils/xmlGenerator.ts index 7fbca08..f58c930 100644 --- a/src/utils/xmlGenerator.ts +++ b/src/utils/xmlGenerator.ts @@ -213,7 +213,7 @@ const generateValueXml = (value: ValueBlock, level: number): string => { if (value.suggested) attrs.push(`suggested="${value.suggested}"`); lines.push(`${indent(level)}`); - lines.push(`${indent(level + 1)}`); + lines.push(`${indent(level + 1)}`); value.recipients.forEach(r => lines.push(generateRecipientXml(r, level + 1))); lines.push(`${indent(level)}`); @@ -288,46 +288,46 @@ const generateCommonChannelElements = (data: BaseChannelData, medium: string, le // Author lines.push(isPublisher ? `${indent(level)}` - : `${indent(level)}`); + : `${indent(level)}`); lines.push(`${indent(level)}${escapeXml(data.author)}`); // Description lines.push(isPublisher ? `${indent(level)}` - : `${indent(level)}`); + : `${indent(level)}`); lines.push(`${indent(level)}`); lines.push(`${indent(level + 1)}${escapeXml(data.description)}`); lines.push(`${indent(level)}`); // Link if (data.link) { - lines.push(`${indent(level)}`); + lines.push(`${indent(level)}`); lines.push(`${indent(level)}${escapeXml(data.link)}`); } // Language - lines.push(`${indent(level)}`); + lines.push(`${indent(level)}`); lines.push(`${indent(level)}${data.language}`); // Generator - lines.push(`${indent(level)}`); + lines.push(`${indent(level)}`); lines.push(`${indent(level)}MSP 2.0 - Music Side Project Studio`); // Dates - lines.push(`${indent(level)}`); + lines.push(`${indent(level)}`); lines.push(`${indent(level)}${formatRFC822Date(data.pubDate)}`); - lines.push(`${indent(level)}`); + lines.push(`${indent(level)}`); lines.push(`${indent(level)}${formatRFC822Date(data.lastBuildDate)}`); // Locked if (data.locked && data.lockedOwner) { - lines.push(`${indent(level)}`); + lines.push(`${indent(level)}`); lines.push(`${indent(level)}yes`); } // GUID if (data.podcastGuid) { - lines.push(`${indent(level)}`); + lines.push(`${indent(level)}`); lines.push(`${indent(level)}${escapeXml(data.podcastGuid)}`); } @@ -339,7 +339,7 @@ const generateCommonChannelElements = (data: BaseChannelData, medium: string, le // Categories (default to Music for music feeds) const categories = data.categories.length > 0 ? data.categories : ['Music']; - lines.push(`${indent(level)}`); + lines.push(`${indent(level)}`); categories.forEach(cat => { lines.push(`${indent(level)}`); }); @@ -352,24 +352,27 @@ const generateCommonChannelElements = (data: BaseChannelData, medium: string, le // Contact if (data.managingEditor) { - lines.push(`${indent(level)}`); + lines.push(`${indent(level)}`); lines.push(`${indent(level)}${escapeXml(data.managingEditor)}`); } if (data.webMaster) { - lines.push(`${indent(level)}`); lines.push(`${indent(level)}${escapeXml(data.webMaster)}`); } // Image if (data.imageUrl) { - lines.push(`${indent(level)}`); + lines.push(`${indent(level)}`); lines.push(`${indent(level)}`); + lines.push(`${indent(level + 1)}`); lines.push(`${indent(level + 1)}${escapeXml(data.imageUrl)}`); + lines.push(`${indent(level + 1)}`); lines.push(`${indent(level + 1)}${escapeXml(data.imageTitle || data.title)}`); if (data.imageLink) { + lines.push(`${indent(level + 1)}`); lines.push(`${indent(level + 1)}${escapeXml(data.imageLink)}`); } if (data.imageDescription) { + lines.push(`${indent(level + 1)}`); lines.push(`${indent(level + 1)}${escapeXml(data.imageDescription)}`); } lines.push(`${indent(level)}`); @@ -387,7 +390,7 @@ const generateCommonChannelElements = (data: BaseChannelData, medium: string, le // Medium lines.push(isPublisher ? `${indent(level)}` - : `${indent(level)}`); + : `${indent(level)}`); lines.push(`${indent(level)}${medium}`); // Explicit @@ -409,13 +412,13 @@ const generateCommonChannelElements = (data: BaseChannelData, medium: string, le // Persons if (data.persons.length > 0) { - lines.push(`${indent(level)}`); + lines.push(`${indent(level)}`); data.persons.forEach(p => lines.push(generatePersonXml(p, level))); } // Value block if (data.value.recipients.length > 0) { - lines.push(`${indent(level)}`); + lines.push(`${indent(level)}`); lines.push(generateValueXml(data.value, level)); } @@ -451,25 +454,25 @@ const generateTrackXml = (track: Track, album: Album, level: number): string => lines.push(`${indent(level + 1)}${escapeXml(track.title)}`); if (track.description) { - lines.push(`${indent(level + 1)}`); + lines.push(`${indent(level + 1)}`); lines.push(`${indent(level + 1)}${escapeXml(track.description)}`); } - lines.push(`${indent(level + 1)}`); + lines.push(`${indent(level + 1)}`); lines.push(`${indent(level + 1)}${formatRFC822Date(track.pubDate)}`); - lines.push(`${indent(level + 1)}`); + lines.push(`${indent(level + 1)}`); lines.push(`${indent(level + 1)}${escapeXml(track.guid)}`); if (track.transcriptUrl) { - lines.push(`${indent(level + 1)}`); + lines.push(`${indent(level + 1)}`); lines.push(`${indent(level + 1)}`); } // Track artwork (falls back to album) const artUrl = track.trackArtUrl || album.imageUrl; if (artUrl) { - lines.push(`${indent(level + 1)}`); + lines.push(`${indent(level + 1)}`); lines.push(`${indent(level + 1)}`); } @@ -483,11 +486,11 @@ const generateTrackXml = (track: Track, album: Album, level: number): string => // Enclosure (audio file) const fileLength = track.enclosureLength || '0'; const enclosureUrl = album.op3 ? applyOp3Prefix(track.enclosureUrl, album.podcastGuid) : track.enclosureUrl; - lines.push(`${indent(level + 1)}`); + lines.push(`${indent(level + 1)}`); lines.push(`${indent(level + 1)}`); // Duration - lines.push(`${indent(level + 1)}`); + lines.push(`${indent(level + 1)}`); lines.push(`${indent(level + 1)}${track.duration}`); // Season (always 1) @@ -495,7 +498,7 @@ const generateTrackXml = (track: Track, album: Album, level: number): string => lines.push(`${indent(level + 1)}1`); // Episode number (use track.episode if set, otherwise trackNumber) - lines.push(`${indent(level + 1)}`); + lines.push(`${indent(level + 1)}`); lines.push(`${indent(level + 1)}${track.episode ?? track.trackNumber}`); // Explicit @@ -504,7 +507,7 @@ const generateTrackXml = (track: Track, album: Album, level: number): string => // Persons (only output at item level when overriding album persons) if (track.overridePersons) { - lines.push(`${indent(level + 1)}`); + lines.push(`${indent(level + 1)}`); track.persons.forEach(p => lines.push(generatePersonXml(p, level + 1))); } @@ -512,7 +515,7 @@ const generateTrackXml = (track: Track, album: Album, level: number): string => const value = track.overrideValue && track.value ? track.value : album.value; if (value.recipients.length > 0) { if (track.overrideValue && track.value) { - lines.push(`${indent(level + 1)}`); + lines.push(`${indent(level + 1)}`); } else { lines.push(`${indent(level + 1)}`); } @@ -545,11 +548,11 @@ export const generateRssFeed = (album: Album): string => { const baseNs = 'xmlns:podcast="https://podcastindex.org/namespace/1.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"'; const rssAttrs = additionalNsDecl ? `${baseNs} ${additionalNsDecl}` : baseNs; lines.push(``); - lines.push(``); + lines.push(``); lines.push(``); // Channel - lines.push(`${indent(1)}`); + lines.push(`${indent(1)}`); lines.push(`${indent(1)}`); // Common channel elements @@ -572,7 +575,7 @@ export const generateRssFeed = (album: Album): string => { // Tracks if (album.tracks.length > 0) { - lines.push(`${indent(2)}`); + lines.push(`${indent(2)}`); album.tracks.forEach(track => lines.push(generateTrackXml(track, album, 2))); } @@ -601,7 +604,7 @@ export const generatePublisherRssFeed = (publisher: PublisherFeed): string => { const baseNs = 'xmlns:podcast="https://podcastindex.org/namespace/1.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"'; const rssAttrs = additionalNsDecl ? `${baseNs} ${additionalNsDecl}` : baseNs; lines.push(``); - lines.push(``); + lines.push(``); lines.push(``); // Channel From 1453505d4aea24ed27fa04d2b6d362ddff4d58ec Mon Sep 17 00:00:00 2001 From: Chad Date: Mon, 15 Jun 2026 19:21:37 -0400 Subject: [PATCH 06/22] Add "Show comments" toggle to the View Feed window PreviewModal gains a checkbox (default on) that hides the educational comment lines from the on-screen preview. Display-only: Copy and Download still export the full commented feed. Add an exported stripXmlComments() helper (whole-comment-line removal) with a unit test. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/modals/PreviewModal.tsx | 28 +++++++++++++++++++++++-- src/utils/xmlGenerator.test.ts | 29 +++++++++++++++++++++++++- src/utils/xmlGenerator.ts | 7 +++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/components/modals/PreviewModal.tsx b/src/components/modals/PreviewModal.tsx index 6c33274..40856a2 100644 --- a/src/components/modals/PreviewModal.tsx +++ b/src/components/modals/PreviewModal.tsx @@ -1,5 +1,5 @@ import { useState, useMemo } from 'react'; -import { generateRssFeed, generatePublisherRssFeed, downloadXml, copyToClipboard } from '../../utils/xmlGenerator'; +import { generateRssFeed, generatePublisherRssFeed, downloadXml, copyToClipboard, stripXmlComments } from '../../utils/xmlGenerator'; import type { Album, PublisherFeed } from '../../types/feed'; import type { FeedType } from '../../store/feedStore'; import { ModalWrapper } from './ModalWrapper'; @@ -143,6 +143,7 @@ interface PreviewModalProps { export function PreviewModal({ onClose, album, publisherFeed, feedType = 'album' }: PreviewModalProps) { const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const [showComments, setShowComments] = useState(true); const isPublisherMode = feedType === 'publisher'; // Generate XML for current feed type @@ -157,8 +158,12 @@ export function PreviewModal({ onClose, album, publisherFeed, feedType = 'album' const xml = generateCurrentFeedXml(); + // What's shown on screen follows the "Show comments" toggle. + // Copy/Download always use the full `xml` (with comments) — display only. + const displayXml = showComments ? xml : stripXmlComments(xml); + // Memoize highlighted XML for performance - const highlightedXml = useMemo(() => highlightXml(xml), [xml]); + const highlightedXml = useMemo(() => highlightXml(displayXml), [displayXml]); const handleCopy = async () => { try { @@ -201,6 +206,25 @@ export function PreviewModal({ onClose, album, publisherFeed, feedType = 'album' } > + +
 {
     }
   });
 
+  it('stripXmlComments removes every comment line but leaves tags and content intact', () => {
+    const album = createEmptyAlbum();
+    album.title = 'Strip Test';
+    album.author = 'Artist';
+    album.description = 'A test album';
+    album.tracks[0].title = 'Song';
+    album.tracks[0].enclosureUrl = 'https://example.com/track1.mp3';
+
+    const xml = generateRssFeed(album);
+    expect(xml).toContain('[ \t]*$/gm) ?? []).length;
+
+    const stripped = stripXmlComments(xml);
+    expect(stripped).not.toContain('');
+    // Tags and content survive
+    expect(stripped).toContain('Strip Test');
+    expect(stripped).toContain('');
+    // Still parses back to the same album
+    const reparsed = parseRssFeed(stripped);
+    expect(reparsed.title).toBe('Strip Test');
+    expect(reparsed.tracks[0].title).toBe('Song');
+    // Exactly the comment lines were removed — no blank lines left behind
+    expect(stripped.split('\n').length).toBe(xml.split('\n').length - commentLines);
+  });
+
   it('drops comments on round-trip (parser does not capture or re-emit them)', () => {
     const album = createEmptyAlbum();
     album.title = 'Roundtrip Album';
diff --git a/src/utils/xmlGenerator.ts b/src/utils/xmlGenerator.ts
index f58c930..d4d1dbf 100644
--- a/src/utils/xmlGenerator.ts
+++ b/src/utils/xmlGenerator.ts
@@ -635,6 +635,13 @@ export const generatePublisherRssFeed = (publisher: PublisherFeed): string => {
   return lines.join('\n');
 };
 
+// Remove the educational  comment lines from generated XML.
+// Each comment is emitted on its own line, so dropping whole comment lines
+// leaves the surrounding tags untouched. Used for the View Feed "Show comments" toggle.
+export const stripXmlComments = (xml: string): string => {
+  return xml.replace(/^[ \t]*[ \t]*\n/gm, '');
+};
+
 // Download XML as file
 export const downloadXml = (xml: string, filename: string = 'feed.xml'): void => {
   const blob = new Blob([xml], { type: 'application/xml' });

From 350404a32d17072df12e9969c65cf444d4513746 Mon Sep 17 00:00:00 2001
From: Chad 
Date: Mon, 15 Jun 2026 19:23:35 -0400
Subject: [PATCH 07/22] Move View Feed 'Show comments' toggle into the footer

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 src/components/modals/PreviewModal.tsx | 36 ++++++++++++--------------
 1 file changed, 17 insertions(+), 19 deletions(-)

diff --git a/src/components/modals/PreviewModal.tsx b/src/components/modals/PreviewModal.tsx
index 40856a2..2d840a9 100644
--- a/src/components/modals/PreviewModal.tsx
+++ b/src/components/modals/PreviewModal.tsx
@@ -194,6 +194,23 @@ export function PreviewModal({ onClose, album, publisherFeed, feedType = 'album'
       className="preview-modal"
       footer={
         <>
+          
           
@@ -206,25 +223,6 @@ export function PreviewModal({ onClose, album, publisherFeed, feedType = 'album'
         
       }
     >
-      
-
       
Date: Mon, 15 Jun 2026 19:45:30 -0400
Subject: [PATCH 08/22] Reword  comment to "the language the music is
 in"

Match the onboarding V2 framing. Diverges from DeMu's verbatim text
(which says "the language the feed is written in", per the RSS 2.0 spec)
in favor of the more artist-friendly content-oriented wording.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 msp-feed-template.xml     | 2 +-
 src/utils/xmlGenerator.ts | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/msp-feed-template.xml b/msp-feed-template.xml
index 96ee463..66915d7 100644
--- a/msp-feed-template.xml
+++ b/msp-feed-template.xml
@@ -14,7 +14,7 @@
         
         
         https://mybandwebsite.com
-        
+        
         en
         
         MSP 2.0 - Music Side Project Studio
diff --git a/src/utils/xmlGenerator.ts b/src/utils/xmlGenerator.ts
index d4d1dbf..53c18bb 100644
--- a/src/utils/xmlGenerator.ts
+++ b/src/utils/xmlGenerator.ts
@@ -306,7 +306,7 @@ const generateCommonChannelElements = (data: BaseChannelData, medium: string, le
   }
 
   // Language
-  lines.push(`${indent(level)}`);
+  lines.push(`${indent(level)}`);
   lines.push(`${indent(level)}${data.language}`);
 
   // Generator

From fa23d9689d8f639409be0afb8125c0ac4a1bf68f Mon Sep 17 00:00:00 2001
From: Chad 
Date: Mon, 15 Jun 2026 19:47:09 -0400
Subject: [PATCH 09/22] Expand language list and reword language info icon to
 match onboarding V2

- LANGUAGES: 10 -> 30 entries (matches new-onboarding-v2); all prior
  values retained so existing feeds stay valid. Used by both the album
  and publisher language dropdowns.
- fieldInfo.language: now 'The primary language spoken on your release
  (e.g. song lyrics).' matching onboarding V2.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 src/data/fieldInfo.ts |  2 +-
 src/types/feed.ts     | 32 ++++++++++++++++++++++++++------
 2 files changed, 27 insertions(+), 7 deletions(-)

diff --git a/src/data/fieldInfo.ts b/src/data/fieldInfo.ts
index de8423b..66ee775 100644
--- a/src/data/fieldInfo.ts
+++ b/src/data/fieldInfo.ts
@@ -8,7 +8,7 @@ export const FIELD_INFO = {
   artistNpub: "Nostr public key (npub1...) for the primary artist. Enables Nostr-based identity and discovery.",
   description: "A brief description of the album, band members, recording info, etc.",
   link: "The main website you want listeners to visit (usually a band website).",
-  language: "The language the feed is written in. See rssboard.org/rss-language-codes for codes.",
+  language: "The primary language spoken on your release (e.g. song lyrics).",
   podcastGuid: "A Globally Unique ID used to identify your feed across platforms and services.",
   keywords: "Comma-separated keywords for search and discovery (e.g., rock, indie, guitar).",
   ownerName: "The feed owner's name. Used for podcast directory contact info.",
diff --git a/src/types/feed.ts b/src/types/feed.ts
index e63172f..686374b 100644
--- a/src/types/feed.ts
+++ b/src/types/feed.ts
@@ -610,13 +610,33 @@ export const PERSON_ROLES: Record
Date: Mon, 15 Jun 2026 19:49:15 -0400
Subject: [PATCH 10/22] Reword podcast:guid comment: MSP mints the GUID
 automatically

The DeMu text told artists to generate the GUID themselves (UUIDv5
derived from the feed URL via RSS Blue). MSP generates it automatically
as a random UUID (crypto.randomUUID()) at feed creation; the URL-seeded
approach was only used to seed early feeds and is no longer how GUIDs
are made. Update the comment to say so.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 msp-feed-template.xml     | 2 +-
 src/utils/xmlGenerator.ts | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/msp-feed-template.xml b/msp-feed-template.xml
index 66915d7..0c81378 100644
--- a/msp-feed-template.xml
+++ b/msp-feed-template.xml
@@ -24,7 +24,7 @@
         Mon, 15 Jun 2026 23:16:31 GMT
         
         yes
-        
+        
         21377651-b449-5585-ac5d-4b70f2ede0f6
         
         
diff --git a/src/utils/xmlGenerator.ts b/src/utils/xmlGenerator.ts
index 53c18bb..e0a2532 100644
--- a/src/utils/xmlGenerator.ts
+++ b/src/utils/xmlGenerator.ts
@@ -327,7 +327,7 @@ const generateCommonChannelElements = (data: BaseChannelData, medium: string, le
 
   // GUID
   if (data.podcastGuid) {
-    lines.push(`${indent(level)}`);
+    lines.push(`${indent(level)}`);
     lines.push(`${indent(level)}${escapeXml(data.podcastGuid)}`);
   }
 

From fd7682247dfcb737aab259639351eac32c4e9bc9 Mon Sep 17 00:00:00 2001
From: Chad 
Date: Mon, 15 Jun 2026 19:55:43 -0400
Subject: [PATCH 11/22] Stop emitting managingEditor/webMaster (DeMu leftovers,
 not MSP fields)

MSP has no form field for managing editor / web master; the only contact
field is the owner email (emitted as itunes:owner). These tags only ever
came from test data or imported feeds. Remove the sole emission point in
generateCommonChannelElements (comment + both tags) so they never appear
in any generated feed, and drop the stale lines from msp-feed-template.xml.

Type fields, parser, and KNOWN_CHANNEL_KEYS entries are intentionally kept
so imported instances stay recognized (not resurrected as unknown elements).

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 msp-feed-template.xml     | 3 ---
 src/utils/xmlGenerator.ts | 9 ---------
 2 files changed, 12 deletions(-)

diff --git a/msp-feed-template.xml b/msp-feed-template.xml
index 0c81378..829491d 100644
--- a/msp-feed-template.xml
+++ b/msp-feed-template.xml
@@ -30,9 +30,6 @@
         
         
         rock, indie, alternative
-        
-        you@example.com
-        you@example.com
         
         
             
diff --git a/src/utils/xmlGenerator.ts b/src/utils/xmlGenerator.ts
index e0a2532..c727169 100644
--- a/src/utils/xmlGenerator.ts
+++ b/src/utils/xmlGenerator.ts
@@ -350,15 +350,6 @@ const generateCommonChannelElements = (data: BaseChannelData, medium: string, le
     lines.push(`${indent(level)}${escapeXml(data.keywords)}`);
   }
 
-  // Contact
-  if (data.managingEditor) {
-    lines.push(`${indent(level)}`);
-    lines.push(`${indent(level)}${escapeXml(data.managingEditor)}`);
-  }
-  if (data.webMaster) {
-    lines.push(`${indent(level)}${escapeXml(data.webMaster)}`);
-  }
-
   // Image
   if (data.imageUrl) {
     lines.push(`${indent(level)}`);

From b2b98863f6734696cac89d765ebe3bd1a26825e6 Mon Sep 17 00:00:00 2001
From: Chad 
Date: Mon, 15 Jun 2026 19:58:22 -0400
Subject: [PATCH 12/22] Trim playlist guidance from podcast:medium comment

MSP only publishes music albums/videos, never playlists ('list mediums'),
so drop the 'album/single -> leave unchanged, playlist -> add capital L'
sentences. Keep the tag definition and the spec link.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 msp-feed-template.xml     | 2 +-
 src/utils/xmlGenerator.ts | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/msp-feed-template.xml b/msp-feed-template.xml
index 829491d..c7be30d 100644
--- a/msp-feed-template.xml
+++ b/msp-feed-template.xml
@@ -43,7 +43,7 @@
         
         
         
-        
+        
         music
         
         false
diff --git a/src/utils/xmlGenerator.ts b/src/utils/xmlGenerator.ts
index c727169..cc31b2d 100644
--- a/src/utils/xmlGenerator.ts
+++ b/src/utils/xmlGenerator.ts
@@ -381,7 +381,7 @@ const generateCommonChannelElements = (data: BaseChannelData, medium: string, le
   // Medium
   lines.push(isPublisher
     ? `${indent(level)}`
-    : `${indent(level)}`);
+    : `${indent(level)}`);
   lines.push(`${indent(level)}${medium}`);
 
   // Explicit

From 5ad4d41d1188971236127d6f02b089e3fba6ff3b Mon Sep 17 00:00:00 2001
From: Chad 
Date: Mon, 15 Jun 2026 20:02:43 -0400
Subject: [PATCH 13/22] Document npub attribute in podcast:person comment

MSP's person tags carry a 5th attribute, npub (the person's Nostr public
key), beyond DeMu's href/img/group/role. Update the comment to describe
all 5 attributes.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 msp-feed-template.xml     | 2 +-
 src/utils/xmlGenerator.ts | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/msp-feed-template.xml b/msp-feed-template.xml
index c7be30d..c7a0446 100644
--- a/msp-feed-template.xml
+++ b/msp-feed-template.xml
@@ -52,7 +52,7 @@
             My Band Name
             you@example.com
         
-        
+        
         Band Member Name
         
         
diff --git a/src/utils/xmlGenerator.ts b/src/utils/xmlGenerator.ts
index cc31b2d..b8e1b68 100644
--- a/src/utils/xmlGenerator.ts
+++ b/src/utils/xmlGenerator.ts
@@ -403,7 +403,7 @@ const generateCommonChannelElements = (data: BaseChannelData, medium: string, le
 
   // Persons
   if (data.persons.length > 0) {
-    lines.push(`${indent(level)}`);
+    lines.push(`${indent(level)}`);
     data.persons.forEach(p => lines.push(generatePersonXml(p, level)));
   }
 

From 8055d27ab0007a278b3022bb9f0277b6daa3fa00 Mon Sep 17 00:00:00 2001
From: Chad 
Date: Mon, 15 Jun 2026 20:07:18 -0400
Subject: [PATCH 14/22] Reword valueRecipient comment: lnaddress support +
 app-managed

DeMu's text only described node pubkeys and manual wallet setup. MSP
supports both Lightning addresses (type=lnaddress) and node pubkeys
(type=node), auto-detected, and builds the whole value block (recipients,
splits, community support) from the app's Value section. Update the
comment to say so.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 msp-feed-template.xml     | 6 +++---
 src/utils/xmlGenerator.ts | 2 +-
 2 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/msp-feed-template.xml b/msp-feed-template.xml
index c7a0446..95a5beb 100644
--- a/msp-feed-template.xml
+++ b/msp-feed-template.xml
@@ -56,7 +56,7 @@
         Band Member Name
         
         
-            
+            
             
             
             
@@ -85,7 +85,7 @@
             false
             
             
-                
+                
                 
                 
                 
@@ -114,7 +114,7 @@
             false
             
             
-                
+                
                 
                 
                 
diff --git a/src/utils/xmlGenerator.ts b/src/utils/xmlGenerator.ts
index b8e1b68..78f50bb 100644
--- a/src/utils/xmlGenerator.ts
+++ b/src/utils/xmlGenerator.ts
@@ -213,7 +213,7 @@ const generateValueXml = (value: ValueBlock, level: number): string => {
   if (value.suggested) attrs.push(`suggested="${value.suggested}"`);
 
   lines.push(`${indent(level)}`);
-  lines.push(`${indent(level + 1)}`);
+  lines.push(`${indent(level + 1)}`);
   value.recipients.forEach(r => lines.push(generateRecipientXml(r, level + 1)));
   lines.push(`${indent(level)}`);
 

From 2a81df8178b6832602294ce5fb494c5328da1ef3 Mon Sep 17 00:00:00 2001
From: Chad 
Date: Mon, 15 Jun 2026 20:12:12 -0400
Subject: [PATCH 15/22] Reword item  comment: MSP mints per-track GUIDs
 automatically

The item-level guid (one per track) is generated by MSP via
crypto.randomUUID() at track creation, so DeMu's 'use guidgenerator.com
to make them yourself' instruction doesn't apply. Keep the explanation
that the GUID must stay unique/stable and is what routes boosts
(remoteItems, valueTimeSplit) to the right track.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 msp-feed-template.xml     | 4 ++--
 src/utils/xmlGenerator.ts | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/msp-feed-template.xml b/msp-feed-template.xml
index 95a5beb..7a13e12 100644
--- a/msp-feed-template.xml
+++ b/msp-feed-template.xml
@@ -69,7 +69,7 @@
             First Song - My Band Name
             
             Mon, 15 Jun 2026 23:16:31 GMT
-            
+            
             3b77cf04-4256-42d4-804e-24df710d3d91
             
             
@@ -98,7 +98,7 @@
             Second Song - My Band Name
             
             Mon, 15 Jun 2026 23:16:31 GMT
-            
+            
             3b77cf04-4256-42d4-804e-24df710d3d92
             
             
diff --git a/src/utils/xmlGenerator.ts b/src/utils/xmlGenerator.ts
index 78f50bb..8e8f45e 100644
--- a/src/utils/xmlGenerator.ts
+++ b/src/utils/xmlGenerator.ts
@@ -452,7 +452,7 @@ const generateTrackXml = (track: Track, album: Album, level: number): string =>
   lines.push(`${indent(level + 1)}`);
   lines.push(`${indent(level + 1)}${formatRFC822Date(track.pubDate)}`);
 
-  lines.push(`${indent(level + 1)}`);
+  lines.push(`${indent(level + 1)}`);
   lines.push(`${indent(level + 1)}${escapeXml(track.guid)}`);
 
   if (track.transcriptUrl) {

From 20cd937a8d7e3f1cc6b3a03ae1bcc25502713eb9 Mon Sep 17 00:00:00 2001
From: Chad 
Date: Mon, 15 Jun 2026 20:14:47 -0400
Subject: [PATCH 16/22] Drop Fountain Radio from itunes:duration comment

Fountain Radio no longer exists, so remove the 'required for Fountain
Radio' guidance. Note MSP auto-fills duration from the audio file and
that the tag is recommended for broad app compatibility.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 msp-feed-template.xml     | 4 ++--
 src/utils/xmlGenerator.ts | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/msp-feed-template.xml b/msp-feed-template.xml
index 7a13e12..1360582 100644
--- a/msp-feed-template.xml
+++ b/msp-feed-template.xml
@@ -75,7 +75,7 @@
             
             
             
-            
+            
             00:04:12
             
             1
@@ -104,7 +104,7 @@
             
             
             
-            
+            
             00:05:18
             
             1
diff --git a/src/utils/xmlGenerator.ts b/src/utils/xmlGenerator.ts
index 8e8f45e..4bd3fb3 100644
--- a/src/utils/xmlGenerator.ts
+++ b/src/utils/xmlGenerator.ts
@@ -481,7 +481,7 @@ const generateTrackXml = (track: Track, album: Album, level: number): string =>
   lines.push(`${indent(level + 1)}`);
 
   // Duration
-  lines.push(`${indent(level + 1)}`);
+  lines.push(`${indent(level + 1)}`);
   lines.push(`${indent(level + 1)}${track.duration}`);
 
   // Season (always 1)

From 4b61d4cdbdcfcb490dc9e2474a856c948da9f529 Mon Sep 17 00:00:00 2001
From: Chad 
Date: Mon, 15 Jun 2026 20:23:43 -0400
Subject: [PATCH 17/22] Consistency pass: reword enclosure + lastBuildDate
 comments

- enclosure: drop the casual 'true magic' voice and the manual 'edit the
  mp3 url/length yourself' instruction (which contradicted MSP's automatic
  handling and the adjacent duration comment). MSP sets the url and MIME
  type from the audio/video you add; length is described but not claimed
  auto (it's a placeholder).
- lastBuildDate: fix the 'also should be in RFC-822' fragment and note MSP
  auto-updates it on every generate.

Decentralized-music phrasing in episode/channel intentionally kept (MSP is
a demu tool). Generator<->template parity verified intact.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 msp-feed-template.xml     | 6 +++---
 src/utils/xmlGenerator.ts | 4 ++--
 2 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/msp-feed-template.xml b/msp-feed-template.xml
index 1360582..3757276 100644
--- a/msp-feed-template.xml
+++ b/msp-feed-template.xml
@@ -20,7 +20,7 @@
         MSP 2.0 - Music Side Project Studio
         
         Mon, 15 Jun 2026 23:16:31 GMT
-        
+        
         Mon, 15 Jun 2026 23:16:31 GMT
         
         yes
@@ -73,7 +73,7 @@
             3b77cf04-4256-42d4-804e-24df710d3d91
             
             
-            
+            
             
             
             00:04:12
@@ -102,7 +102,7 @@
             3b77cf04-4256-42d4-804e-24df710d3d92
             
             
-            
+            
             
             
             00:05:18
diff --git a/src/utils/xmlGenerator.ts b/src/utils/xmlGenerator.ts
index 4bd3fb3..cd0d36e 100644
--- a/src/utils/xmlGenerator.ts
+++ b/src/utils/xmlGenerator.ts
@@ -316,7 +316,7 @@ const generateCommonChannelElements = (data: BaseChannelData, medium: string, le
   // Dates
   lines.push(`${indent(level)}`);
   lines.push(`${indent(level)}${formatRFC822Date(data.pubDate)}`);
-  lines.push(`${indent(level)}`);
+  lines.push(`${indent(level)}`);
   lines.push(`${indent(level)}${formatRFC822Date(data.lastBuildDate)}`);
 
   // Locked
@@ -477,7 +477,7 @@ const generateTrackXml = (track: Track, album: Album, level: number): string =>
   // Enclosure (audio file)
   const fileLength = track.enclosureLength || '0';
   const enclosureUrl = album.op3 ? applyOp3Prefix(track.enclosureUrl, album.podcastGuid) : track.enclosureUrl;
-  lines.push(`${indent(level + 1)}`);
+  lines.push(`${indent(level + 1)}`);
   lines.push(`${indent(level + 1)}`);
 
   // Duration

From ab2b6f164f927870845501e3689a9354072f9814 Mon Sep 17 00:00:00 2001
From: Chad 
Date: Mon, 15 Jun 2026 20:47:55 -0400
Subject: [PATCH 18/22] Add blank-line grouping to generated feeds for
 readability
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Separate logical groups of tags with single blank lines (and blank lines
between  blocks), matching the spacing of a reference feed the user
liked. Per-tag comments and 4-space indentation are unchanged — only blank
lines are added.

- generateCommonChannelElements / generateTrackXml: a dedup-guarded sep()
  helper inserts one blank between groups (basic metadata, dates,
  identifiers, discovery, artwork, classification, owner, persons, value,
  funding; item: core, transcript, artwork, enclosure+duration,
  season/episode/explicit, persons, value). No leading/trailing/double blanks.
- Wrappers: blank after , before publisher/tracks sections, and
  between items (between only, so stripping comments leaves no double blank).
- New test asserts the feed has blank-line grouping but never two blank
  lines in a row, with comments ON or OFF (works with the View Feed toggle).

Regenerated msp-feed-template.xml; generator<->template parity intact.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 msp-feed-template.xml          | 27 +++++++++++++++---
 src/utils/xmlGenerator.test.ts | 28 +++++++++++++++++++
 src/utils/xmlGenerator.ts      | 51 ++++++++++++++++++++++++++++++----
 3 files changed, 96 insertions(+), 10 deletions(-)

diff --git a/msp-feed-template.xml b/msp-feed-template.xml
index 3757276..65629cf 100644
--- a/msp-feed-template.xml
+++ b/msp-feed-template.xml
@@ -4,6 +4,7 @@
 
     
     
+
         
         My Album Title
         
@@ -16,20 +17,24 @@
         https://mybandwebsite.com
         
         en
+
         
         MSP 2.0 - Music Side Project Studio
         
-        Mon, 15 Jun 2026 23:16:31 GMT
+        Tue, 16 Jun 2026 00:46:58 GMT
         
-        Mon, 15 Jun 2026 23:16:31 GMT
+        Tue, 16 Jun 2026 00:46:58 GMT
+
         
         yes
         
         21377651-b449-5585-ac5d-4b70f2ede0f6
+
         
         
         
         rock, indie, alternative
+
         
         
             
@@ -43,17 +48,21 @@
         
         
         
+
         
         music
         
         false
+
         
         
             My Band Name
             you@example.com
         
+
         
         Band Member Name
+
         
         
             
@@ -61,6 +70,7 @@
             
             
         
+
         
         
             
@@ -68,21 +78,25 @@
             
             First Song - My Band Name
             
-            Mon, 15 Jun 2026 23:16:31 GMT
+            Tue, 16 Jun 2026 00:46:58 GMT
             
             3b77cf04-4256-42d4-804e-24df710d3d91
+
             
             
+
             
             
             
             00:04:12
+
             
             1
             
             1
             
             false
+
             
             
                 
@@ -91,27 +105,32 @@
                 
             
         
+
         
             
             Second Song
             
             Second Song - My Band Name
             
-            Mon, 15 Jun 2026 23:16:31 GMT
+            Tue, 16 Jun 2026 00:46:58 GMT
             
             3b77cf04-4256-42d4-804e-24df710d3d92
+
             
             
+
             
             
             
             00:05:18
+
             
             1
             
             2
             
             false
+
             
             
                 
diff --git a/src/utils/xmlGenerator.test.ts b/src/utils/xmlGenerator.test.ts
index 01484a5..d94f8ab 100644
--- a/src/utils/xmlGenerator.test.ts
+++ b/src/utils/xmlGenerator.test.ts
@@ -370,4 +370,32 @@ describe('DeMu-style educational comments', () => {
     const count = (s: string) => (s.match(/`
@@ -310,6 +313,7 @@ const generateCommonChannelElements = (data: BaseChannelData, medium: string, le
   lines.push(`${indent(level)}${data.language}`);
 
   // Generator
+  sep();
   lines.push(`${indent(level)}`);
   lines.push(`${indent(level)}MSP 2.0 - Music Side Project Studio`);
 
@@ -319,6 +323,9 @@ const generateCommonChannelElements = (data: BaseChannelData, medium: string, le
   lines.push(`${indent(level)}`);
   lines.push(`${indent(level)}${formatRFC822Date(data.lastBuildDate)}`);
 
+  // Identifiers (locked / guid / npub) — one blank before the first one present
+  if ((data.locked && data.lockedOwner) || data.podcastGuid || (data as Album).artistNpub) sep();
+
   // Locked
   if (data.locked && data.lockedOwner) {
     lines.push(`${indent(level)}`);
@@ -338,6 +345,7 @@ const generateCommonChannelElements = (data: BaseChannelData, medium: string, le
   }
 
   // Categories (default to Music for music feeds)
+  sep();
   const categories = data.categories.length > 0 ? data.categories : ['Music'];
   lines.push(`${indent(level)}`);
   categories.forEach(cat => {
@@ -350,6 +358,10 @@ const generateCommonChannelElements = (data: BaseChannelData, medium: string, le
     lines.push(`${indent(level)}${escapeXml(data.keywords)}`);
   }
 
+  // Artwork (RSS image + itunes:image + additional podcast:image)
+  const podcastImgTags = (data.podcastImages || []).map(img => generatePodcastImageXml(img)).filter((t): t is string => t !== null);
+  if (data.imageUrl || podcastImgTags.length > 0) sep();
+
   // Image
   if (data.imageUrl) {
     lines.push(`${indent(level)}`);
@@ -372,13 +384,13 @@ const generateCommonChannelElements = (data: BaseChannelData, medium: string, le
   }
 
   // Podcasting 2.0 additional images
-  const podcastImgTags = (data.podcastImages || []).map(img => generatePodcastImageXml(img)).filter((t): t is string => t !== null);
   if (podcastImgTags.length > 0) {
     lines.push(`${indent(level)}`);
     podcastImgTags.forEach(tag => lines.push(`${indent(level)}${tag}`));
   }
 
   // Medium
+  sep();
   lines.push(isPublisher
     ? `${indent(level)}`
     : `${indent(level)}`);
@@ -390,6 +402,7 @@ const generateCommonChannelElements = (data: BaseChannelData, medium: string, le
 
   // Owner
   if (data.ownerName || data.ownerEmail) {
+    sep();
     lines.push(`${indent(level)}`);
     lines.push(`${indent(level)}`);
     if (data.ownerName) {
@@ -403,12 +416,14 @@ const generateCommonChannelElements = (data: BaseChannelData, medium: string, le
 
   // Persons
   if (data.persons.length > 0) {
+    sep();
     lines.push(`${indent(level)}`);
     data.persons.forEach(p => lines.push(generatePersonXml(p, level)));
   }
 
   // Value block
   if (data.value.recipients.length > 0) {
+    sep();
     lines.push(`${indent(level)}`);
     lines.push(generateValueXml(data.value, level));
   }
@@ -416,6 +431,7 @@ const generateCommonChannelElements = (data: BaseChannelData, medium: string, le
   // Funding
   const fundingLines = (data.funding || []).map(f => generateFundingXml(f, level)).filter(Boolean);
   if (fundingLines.length > 0) {
+    sep();
     lines.push(`${indent(level)}`);
     fundingLines.forEach(f => lines.push(f as string));
   }
@@ -439,6 +455,9 @@ const applyOp3Prefix = (url: string, podcastGuid?: string): string => {
 const generateTrackXml = (track: Track, album: Album, level: number): string => {
   const lines: string[] = [];
 
+  // Insert a single blank line between logical groups inside the item (no leading/double blanks).
+  const sep = () => { if (lines.length && lines[lines.length - 1] !== '') lines.push(''); };
+
   lines.push(`${indent(level)}`);
 
   lines.push(`${indent(level + 1)}`);
@@ -456,19 +475,21 @@ const generateTrackXml = (track: Track, album: Album, level: number): string =>
   lines.push(`${indent(level + 1)}${escapeXml(track.guid)}`);
 
   if (track.transcriptUrl) {
+    sep();
     lines.push(`${indent(level + 1)}`);
     lines.push(`${indent(level + 1)}`);
   }
 
-  // Track artwork (falls back to album)
+  // Track artwork (item-level itunes:image + additional podcast:image; falls back to album)
   const artUrl = track.trackArtUrl || album.imageUrl;
+  const trackImgTags = (track.podcastImages || []).map(img => generatePodcastImageXml(img)).filter((t): t is string => t !== null);
+  if (artUrl || trackImgTags.length > 0) sep();
   if (artUrl) {
     lines.push(`${indent(level + 1)}`);
     lines.push(`${indent(level + 1)}`);
   }
 
   // Podcasting 2.0 additional images (track level)
-  const trackImgTags = (track.podcastImages || []).map(img => generatePodcastImageXml(img)).filter((t): t is string => t !== null);
   if (trackImgTags.length > 0) {
     lines.push(`${indent(level + 1)}`);
     trackImgTags.forEach(tag => lines.push(`${indent(level + 1)}${tag}`));
@@ -477,6 +498,7 @@ const generateTrackXml = (track: Track, album: Album, level: number): string =>
   // Enclosure (audio file)
   const fileLength = track.enclosureLength || '0';
   const enclosureUrl = album.op3 ? applyOp3Prefix(track.enclosureUrl, album.podcastGuid) : track.enclosureUrl;
+  sep();
   lines.push(`${indent(level + 1)}`);
   lines.push(`${indent(level + 1)}`);
 
@@ -485,6 +507,7 @@ const generateTrackXml = (track: Track, album: Album, level: number): string =>
   lines.push(`${indent(level + 1)}${track.duration}`);
 
   // Season (always 1)
+  sep();
   lines.push(`${indent(level + 1)}`);
   lines.push(`${indent(level + 1)}1`);
 
@@ -498,6 +521,7 @@ const generateTrackXml = (track: Track, album: Album, level: number): string =>
 
   // Persons (only output at item level when overriding album persons)
   if (track.overridePersons) {
+    sep();
     lines.push(`${indent(level + 1)}`);
     track.persons.forEach(p => lines.push(generatePersonXml(p, level + 1)));
   }
@@ -505,6 +529,7 @@ const generateTrackXml = (track: Track, album: Album, level: number): string =>
   // Value block (override or inherit from album)
   const value = track.overrideValue && track.value ? track.value : album.value;
   if (value.recipients.length > 0) {
+    sep();
     if (track.overrideValue && track.value) {
       lines.push(`${indent(level + 1)}`);
     } else {
@@ -545,6 +570,7 @@ export const generateRssFeed = (album: Album): string => {
   // Channel
   lines.push(`${indent(1)}`);
   lines.push(`${indent(1)}`);
+  lines.push('');
 
   // Common channel elements
   lines.push(...generateCommonChannelElements(album, album.medium, 2));
@@ -553,6 +579,7 @@ export const generateRssFeed = (album: Album): string => {
   if (album.publisher) {
     const publisherXml = generatePublisherXml(album.publisher, 2);
     if (publisherXml) {
+      lines.push('');
       lines.push(`${indent(2)}`);
       lines.push(publisherXml);
     }
@@ -561,13 +588,20 @@ export const generateRssFeed = (album: Album): string => {
   // Unknown/unsupported channel elements (preserved from import)
   if (album.unknownChannelElements) {
     const unknownXml = generateUnknownXml(album.unknownChannelElements, 2);
-    if (unknownXml) lines.push(unknownXml);
+    if (unknownXml) {
+      lines.push('');
+      lines.push(unknownXml);
+    }
   }
 
   // Tracks
   if (album.tracks.length > 0) {
+    lines.push('');
     lines.push(`${indent(2)}`);
-    album.tracks.forEach(track => lines.push(generateTrackXml(track, album, 2)));
+    album.tracks.forEach((track, i) => {
+      if (i > 0) lines.push('');
+      lines.push(generateTrackXml(track, album, 2));
+    });
   }
 
   // Close channel and rss
@@ -601,12 +635,14 @@ export const generatePublisherRssFeed = (publisher: PublisherFeed): string => {
   // Channel
   lines.push(`${indent(1)}`);
   lines.push(`${indent(1)}`);
+  lines.push('');
 
   // Common channel elements (medium is always "publisher" for publisher feeds)
   lines.push(...generateCommonChannelElements(publisher, 'publisher', 2));
 
   // Remote items - the feeds this publisher owns
   if (publisher.remoteItems.length > 0) {
+    lines.push('');
     lines.push(`${indent(2)}`);
     publisher.remoteItems.forEach(item => {
       lines.push(generateRemoteItemXml(item, 2));
@@ -616,7 +652,10 @@ export const generatePublisherRssFeed = (publisher: PublisherFeed): string => {
   // Unknown/unsupported channel elements (preserved from import)
   if (publisher.unknownChannelElements) {
     const unknownXml = generateUnknownXml(publisher.unknownChannelElements, 2);
-    if (unknownXml) lines.push(unknownXml);
+    if (unknownXml) {
+      lines.push('');
+      lines.push(unknownXml);
+    }
   }
 
   // Close channel and rss

From 34466157d67c52b25fdc560c101b86bfda23ba86 Mon Sep 17 00:00:00 2001
From: Chad 
Date: Mon, 15 Jun 2026 20:53:28 -0400
Subject: [PATCH 19/22] Serve msp-feed-template.xml as a static asset

Move the reference template from the repo root into public/ so Vite/Vercel
serve it at the site root. Viewable in a browser at
https://msp.podtards.com/msp-feed-template.xml (and /msp-feed-template.xml
on the dev server). No code referenced the old root path.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 msp-feed-template.xml => public/msp-feed-template.xml | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 rename msp-feed-template.xml => public/msp-feed-template.xml (100%)

diff --git a/msp-feed-template.xml b/public/msp-feed-template.xml
similarity index 100%
rename from msp-feed-template.xml
rename to public/msp-feed-template.xml

From cfd2d22a8a712ba2e462b9658341121fef7cc463 Mon Sep 17 00:00:00 2001
From: Chad 
Date: Mon, 15 Jun 2026 21:01:18 -0400
Subject: [PATCH 20/22] Document educational feed comments + template in
 CLAUDE.md

Add an XML Handling subsection covering: DeMu-derived but MSP-accurate
per-tag comments, publisher-aware comment branching, blank-line grouping
with the sep() helper, stripXmlComments + the View Feed 'Show comments'
toggle, and the served public/msp-feed-template.xml reference (regenerate
from the generator; keep comment parity).

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 CLAUDE.md | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/CLAUDE.md b/CLAUDE.md
index f81fe14..ad52401 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -209,6 +209,14 @@ The modern singular `` tag (supersedes the deprecated plural `` explanatory comment before (nearly) every tag, modeled on the [DeMu feed template](https://github.com/de-mu/demu-feed-template) but **reworded to be accurate to MSP** — e.g. feed `podcast:guid`, item ``, `itunes:duration` and `enclosure` url/type are described as auto-generated by MSP (`crypto.randomUUID()` / audio detection), `valueRecipient` covers `lnaddress` and node types, `podcast:person` documents the MSP-added `npub` attribute, and `managingEditor`/`webMaster` are **not emitted** (no MSP field for them — owner email is the contact). The two top-of-feed comments include a DeMu attribution line.
+- **Publisher feeds**: `generateCommonChannelElements` is shared by album/video AND publisher feeds and branches on `medium === 'publisher'` (`isPublisher`) to reword album-centric comments (title/author/description/medium). When adding a comment to a shared element, give it a publisher-aware variant or keep it generic.
+- **Blank-line grouping**: logical groups of tags are separated by single blank lines (and a blank line between `` blocks) for readability. A dedup-guarded `sep()` helper (`if (lines.length && lines[lines.length-1] !== '') lines.push('')`) inside `generateCommonChannelElements`/`generateTrackXml` guarantees no leading/trailing/double blanks. The wrappers add blanks *between* items only (not before the first) so stripping comments can't collapse two blanks together. A test asserts the feed has grouping but **never two blank lines in a row, comments on OR off**.
+- **`stripXmlComments(xml)`** (exported from `xmlGenerator.ts`) removes whole comment lines (`/^[ \t]*[ \t]*\n/gm`). The View Feed modal (`PreviewModal.tsx`) has a footer **"Show comments" toggle** that hides them in the on-screen preview only — Copy/Download always export the full commented feed.
+- **`public/msp-feed-template.xml`** is a served reference snapshot of generator output (browsable at `/msp-feed-template.xml`; use `view-source:` for the line-numbered raw view). It is **generated from `generateRssFeed()`, not hand-authored** — regenerate it after any comment change. Invariant: generator↔template comment parity (extract `` from both, `comm -23 template generator` → empty). Comments are dropped on import (parser sets no `commentPropName`), so they never round-trip into `unknown*Elements`.
+- Tests in `xmlGenerator.test.ts` cover comment presence/wording, the no-`--`-in-comment-body invariant, comment stripping + round-trip, and blank-line grouping.
+
 ### OP3 Analytics
 - [OP3](https://op3.dev/) (Open Podcast Prefix Project) provides open, privacy-respecting download stats
 - Toggle in Album Info enables/disables OP3 prefix on enclosure URLs

From b8634f9412bcfad6bbf89d0ff19aa522aa786bba Mon Sep 17 00:00:00 2001
From: Chad 
Date: Mon, 15 Jun 2026 21:03:42 -0400
Subject: [PATCH 21/22] Add comments-free reference template variant

public/msp-feed-template-no-comments.xml is the same example feed with the
educational comments stripped (via stripXmlComments), keeping the blank-line
grouping. Lets users grab a clean template; the commented version remains at
public/msp-feed-template.xml.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 public/msp-feed-template-no-comments.xml | 91 ++++++++++++++++++++++++
 1 file changed, 91 insertions(+)
 create mode 100644 public/msp-feed-template-no-comments.xml

diff --git a/public/msp-feed-template-no-comments.xml b/public/msp-feed-template-no-comments.xml
new file mode 100644
index 0000000..0df2a41
--- /dev/null
+++ b/public/msp-feed-template-no-comments.xml
@@ -0,0 +1,91 @@
+
+
+    
+
+        My Album Title
+        My Band Name
+        
+            A brief description of your album. Tell listeners what it's about, the vibe, the story behind it.
+        
+        https://mybandwebsite.com
+        en
+
+        MSP 2.0 - Music Side Project Studio
+        Tue, 16 Jun 2026 00:46:58 GMT
+        Tue, 16 Jun 2026 00:46:58 GMT
+
+        yes
+        21377651-b449-5585-ac5d-4b70f2ede0f6
+
+        
+        rock, indie, alternative
+
+        
+            https://mybandwebsite.com/album-art.jpg
+            My Album Title cover art
+            https://mybandwebsite.com
+            My Band Name - My Album Title album art
+        
+        
+
+        music
+        false
+
+        
+            My Band Name
+            you@example.com
+        
+
+        Band Member Name
+
+        
+            
+            
+            
+        
+
+        
+            First Song
+            First Song - My Band Name
+            Tue, 16 Jun 2026 00:46:58 GMT
+            3b77cf04-4256-42d4-804e-24df710d3d91
+
+            
+
+            
+            00:04:12
+
+            1
+            1
+            false
+
+            
+                
+                
+                
+            
+        
+
+        
+            Second Song
+            Second Song - My Band Name
+            Tue, 16 Jun 2026 00:46:58 GMT
+            3b77cf04-4256-42d4-804e-24df710d3d92
+
+            
+
+            
+            00:05:18
+
+            1
+            2
+            false
+
+            
+                
+                
+                
+            
+        
+    
+

From 40e9d9ece8e4182083fdf206263b3dc05208821f Mon Sep 17 00:00:00 2001
From: Chad 
Date: Mon, 15 Jun 2026 21:05:21 -0400
Subject: [PATCH 22/22] Swap template names: clean is the default, commented is
 -with-comments

public/msp-feed-template.xml is now the comments-free default; the full
commented snapshot moves to public/msp-feed-template-with-comments.xml.
Update the CLAUDE.md note accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 CLAUDE.md                                  |   5 +-
 public/msp-feed-template-no-comments.xml   |  91 -------------
 public/msp-feed-template-with-comments.xml | 143 +++++++++++++++++++++
 public/msp-feed-template.xml               |  52 --------
 4 files changed, 147 insertions(+), 144 deletions(-)
 delete mode 100644 public/msp-feed-template-no-comments.xml
 create mode 100644 public/msp-feed-template-with-comments.xml

diff --git a/CLAUDE.md b/CLAUDE.md
index ad52401..8e1f8cb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -214,7 +214,10 @@ The modern singular `` tag (supersedes the deprecated plural `` blocks) for readability. A dedup-guarded `sep()` helper (`if (lines.length && lines[lines.length-1] !== '') lines.push('')`) inside `generateCommonChannelElements`/`generateTrackXml` guarantees no leading/trailing/double blanks. The wrappers add blanks *between* items only (not before the first) so stripping comments can't collapse two blanks together. A test asserts the feed has grouping but **never two blank lines in a row, comments on OR off**.
 - **`stripXmlComments(xml)`** (exported from `xmlGenerator.ts`) removes whole comment lines (`/^[ \t]*[ \t]*\n/gm`). The View Feed modal (`PreviewModal.tsx`) has a footer **"Show comments" toggle** that hides them in the on-screen preview only — Copy/Download always export the full commented feed.
-- **`public/msp-feed-template.xml`** is a served reference snapshot of generator output (browsable at `/msp-feed-template.xml`; use `view-source:` for the line-numbered raw view). It is **generated from `generateRssFeed()`, not hand-authored** — regenerate it after any comment change. Invariant: generator↔template comment parity (extract `` from both, `comm -23 template generator` → empty). Comments are dropped on import (parser sets no `commentPropName`), so they never round-trip into `unknown*Elements`.
+- **Served reference templates** (browsable under `public/`; use `view-source:` for the line-numbered raw view). Both are **generated from `generateRssFeed()`, not hand-authored**:
+  - `public/msp-feed-template-with-comments.xml` — the full commented snapshot (direct generator output). Invariant: generator↔this-template comment parity (extract `` from both, `comm -23 template generator` → empty).
+  - `public/msp-feed-template.xml` — the **default/clean** template: same feed with comments removed via `stripXmlComments`, keeping the blank-line grouping.
+  - Regenerate after any comment change: render the commented one from `generateRssFeed()`, then `stripXmlComments` it to produce the clean one (so both share identical data/dates). Comments are dropped on import (parser sets no `commentPropName`), so they never round-trip into `unknown*Elements`.
 - Tests in `xmlGenerator.test.ts` cover comment presence/wording, the no-`--`-in-comment-body invariant, comment stripping + round-trip, and blank-line grouping.
 
 ### OP3 Analytics
diff --git a/public/msp-feed-template-no-comments.xml b/public/msp-feed-template-no-comments.xml
deleted file mode 100644
index 0df2a41..0000000
--- a/public/msp-feed-template-no-comments.xml
+++ /dev/null
@@ -1,91 +0,0 @@
-
-
-    
-
-        My Album Title
-        My Band Name
-        
-            A brief description of your album. Tell listeners what it's about, the vibe, the story behind it.
-        
-        https://mybandwebsite.com
-        en
-
-        MSP 2.0 - Music Side Project Studio
-        Tue, 16 Jun 2026 00:46:58 GMT
-        Tue, 16 Jun 2026 00:46:58 GMT
-
-        yes
-        21377651-b449-5585-ac5d-4b70f2ede0f6
-
-        
-        rock, indie, alternative
-
-        
-            https://mybandwebsite.com/album-art.jpg
-            My Album Title cover art
-            https://mybandwebsite.com
-            My Band Name - My Album Title album art
-        
-        
-
-        music
-        false
-
-        
-            My Band Name
-            you@example.com
-        
-
-        Band Member Name
-
-        
-            
-            
-            
-        
-
-        
-            First Song
-            First Song - My Band Name
-            Tue, 16 Jun 2026 00:46:58 GMT
-            3b77cf04-4256-42d4-804e-24df710d3d91
-
-            
-
-            
-            00:04:12
-
-            1
-            1
-            false
-
-            
-                
-                
-                
-            
-        
-
-        
-            Second Song
-            Second Song - My Band Name
-            Tue, 16 Jun 2026 00:46:58 GMT
-            3b77cf04-4256-42d4-804e-24df710d3d92
-
-            
-
-            
-            00:05:18
-
-            1
-            2
-            false
-
-            
-                
-                
-                
-            
-        
-    
-
diff --git a/public/msp-feed-template-with-comments.xml b/public/msp-feed-template-with-comments.xml
new file mode 100644
index 0000000..65629cf
--- /dev/null
+++ b/public/msp-feed-template-with-comments.xml
@@ -0,0 +1,143 @@
+
+
+
+
+    
+    
+
+        
+        My Album Title
+        
+        My Band Name
+        
+        
+            A brief description of your album. Tell listeners what it's about, the vibe, the story behind it.
+        
+        
+        https://mybandwebsite.com
+        
+        en
+
+        
+        MSP 2.0 - Music Side Project Studio
+        
+        Tue, 16 Jun 2026 00:46:58 GMT
+        
+        Tue, 16 Jun 2026 00:46:58 GMT
+
+        
+        yes
+        
+        21377651-b449-5585-ac5d-4b70f2ede0f6
+
+        
+        
+        
+        rock, indie, alternative
+
+        
+        
+            
+            https://mybandwebsite.com/album-art.jpg
+            
+            My Album Title cover art
+            
+            https://mybandwebsite.com
+            
+            My Band Name - My Album Title album art
+        
+        
+        
+
+        
+        music
+        
+        false
+
+        
+        
+            My Band Name
+            you@example.com
+        
+
+        
+        Band Member Name
+
+        
+        
+            
+            
+            
+            
+        
+
+        
+        
+            
+            First Song
+            
+            First Song - My Band Name
+            
+            Tue, 16 Jun 2026 00:46:58 GMT
+            
+            3b77cf04-4256-42d4-804e-24df710d3d91
+
+            
+            
+
+            
+            
+            
+            00:04:12
+
+            
+            1
+            
+            1
+            
+            false
+
+            
+            
+                
+                
+                
+                
+            
+        
+
+        
+            
+            Second Song
+            
+            Second Song - My Band Name
+            
+            Tue, 16 Jun 2026 00:46:58 GMT
+            
+            3b77cf04-4256-42d4-804e-24df710d3d92
+
+            
+            
+
+            
+            
+            
+            00:05:18
+
+            
+            1
+            
+            2
+            
+            false
+
+            
+            
+                
+                
+                
+                
+            
+        
+    
+
diff --git a/public/msp-feed-template.xml b/public/msp-feed-template.xml
index 65629cf..0df2a41 100644
--- a/public/msp-feed-template.xml
+++ b/public/msp-feed-template.xml
@@ -1,105 +1,65 @@
 
-
-
 
-    
     
 
-        
         My Album Title
-        
         My Band Name
-        
         
             A brief description of your album. Tell listeners what it's about, the vibe, the story behind it.
         
-        
         https://mybandwebsite.com
-        
         en
 
-        
         MSP 2.0 - Music Side Project Studio
-        
         Tue, 16 Jun 2026 00:46:58 GMT
-        
         Tue, 16 Jun 2026 00:46:58 GMT
 
-        
         yes
-        
         21377651-b449-5585-ac5d-4b70f2ede0f6
 
-        
         
-        
         rock, indie, alternative
 
-        
         
-            
             https://mybandwebsite.com/album-art.jpg
-            
             My Album Title cover art
-            
             https://mybandwebsite.com
-            
             My Band Name - My Album Title album art
         
-        
         
 
-        
         music
-        
         false
 
-        
         
             My Band Name
             you@example.com
         
 
-        
         Band Member Name
 
-        
         
-            
             
             
             
         
 
-        
         
-            
             First Song
-            
             First Song - My Band Name
-            
             Tue, 16 Jun 2026 00:46:58 GMT
-            
             3b77cf04-4256-42d4-804e-24df710d3d91
 
-            
             
 
-            
             
-            
             00:04:12
 
-            
             1
-            
             1
-            
             false
 
-            
             
-                
                 
                 
                 
@@ -107,33 +67,21 @@
         
 
         
-            
             Second Song
-            
             Second Song - My Band Name
-            
             Tue, 16 Jun 2026 00:46:58 GMT
-            
             3b77cf04-4256-42d4-804e-24df710d3d92
 
-            
             
 
-            
             
-            
             00:05:18
 
-            
             1
-            
             2
-            
             false
 
-            
             
-