diff --git a/CLAUDE.md b/CLAUDE.md index f81fe14..8e1f8cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -209,6 +209,17 @@ 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. +- **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 - [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 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 new file mode 100644 index 0000000..0df2a41 --- /dev/null +++ b/public/msp-feed-template.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 + + + + + + + + + diff --git a/src/components/modals/PreviewModal.tsx b/src/components/modals/PreviewModal.tsx index 6c33274..2d840a9 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 { @@ -189,6 +194,23 @@ export function PreviewModal({ onClose, album, publisherFeed, feedType = 'album' className="preview-modal" footer={ <> + 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 { it('includes podcast:publisher tag when publisher is set', () => { @@ -284,3 +284,118 @@ 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('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'); + }); + + 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('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'; + 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(/`); value.recipients.forEach(r => lines.push(generateRecipientXml(r, level + 1))); lines.push(`${indent(level)}`); @@ -257,103 +258,152 @@ 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[] = []; + // This function is shared by album/video feeds and publisher (label/catalog) feeds. + // Comments that describe an album-centric concept are reworded for publisher feeds. + const isPublisher = medium === 'publisher'; + + // Insert a single blank line between logical groups (no leading or double blanks). + const sep = () => { if (lines.length && lines[lines.length - 1] !== '') lines.push(''); }; + // Title + lines.push(isPublisher + ? `${indent(level)}` + : `${indent(level)}`); lines.push(`${indent(level)}${escapeXml(data.title)}`); // Author + lines.push(isPublisher + ? `${indent(level)}` + : `${indent(level)}`); lines.push(`${indent(level)}${escapeXml(data.author)}`); // Description + lines.push(isPublisher + ? `${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)}${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 + sep(); + 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)}`); + // 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)}`); 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) + sep(); 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)}${escapeXml(data.managingEditor)}`); - } - if (data.webMaster) { - lines.push(`${indent(level)}${escapeXml(data.webMaster)}`); - } + // 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)}`); 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)}`); - } - - // 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}`); - }); + 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)}`); lines.push(`${indent(level)}${medium}`); // Explicit + lines.push(`${indent(level)}`); lines.push(`${indent(level)}${data.explicit ? 'true' : 'false'}`); // Owner if (data.ownerName || data.ownerEmail) { + sep(); + lines.push(`${indent(level)}`); lines.push(`${indent(level)}`); if (data.ownerName) { lines.push(`${indent(level + 1)}${escapeXml(data.ownerName)}`); @@ -365,18 +415,26 @@ const generateCommonChannelElements = (data: BaseChannelData, medium: string, le } // Persons - data.persons.forEach(p => lines.push(generatePersonXml(p, level))); + 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)); } // 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) { + sep(); + lines.push(`${indent(level)}`); + fundingLines.forEach(f => lines.push(f as string)); + } return lines; }; @@ -393,74 +451,90 @@ 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[] = []; + // 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)}`); 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) { + 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.podcastImages || []).forEach(img => { - const tag = generatePodcastImageXml(img); - if (tag) lines.push(`${indent(level + 1)}${tag}`); - }); + + // Podcasting 2.0 additional images (track level) + 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; + sep(); + 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) + sep(); + 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) { + sep(); + 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) { + sep(); + if (track.overrideValue && track.value) { + lines.push(`${indent(level + 1)}`); + } else { + lines.push(`${indent(level + 1)}`); + } lines.push(generateValueXml(value, level + 1)); } @@ -489,10 +563,14 @@ 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(``); // Channel + lines.push(`${indent(1)}`); lines.push(`${indent(1)}`); + lines.push(''); // Common channel elements lines.push(...generateCommonChannelElements(album, album.medium, 2)); @@ -500,17 +578,31 @@ 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(''); + lines.push(`${indent(2)}`); + lines.push(publisherXml); + } } // 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 - album.tracks.forEach(track => lines.push(generateTrackXml(track, album, 2))); + if (album.tracks.length > 0) { + lines.push(''); + lines.push(`${indent(2)}`); + album.tracks.forEach((track, i) => { + if (i > 0) lines.push(''); + lines.push(generateTrackXml(track, album, 2)); + }); + } // Close channel and rss lines.push(`${indent(1)}`); @@ -536,16 +628,22 @@ 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(``); // 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)); }); @@ -554,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 @@ -564,6 +665,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' });