Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1e222ec
Add DeMu-style educational XML comments to generated feeds
claude Jun 15, 2026
8d3628d
Reference DeMu feed template in generated feed comments
claude Jun 15, 2026
5523882
Add MSP 2.0 feed template reference file
claude Jun 15, 2026
a9c5efe
Make educational comments medium-aware; add comment tests
ChadFarrow Jun 15, 2026
addfbdc
Use DeMu template's verbatim wording for feed comments
ChadFarrow Jun 15, 2026
1453505
Add "Show comments" toggle to the View Feed window
ChadFarrow Jun 15, 2026
350404a
Move View Feed 'Show comments' toggle into the footer
ChadFarrow Jun 15, 2026
1997d7d
Reword <language> comment to "the language the music is in"
ChadFarrow Jun 15, 2026
fa23d96
Expand language list and reword language info icon to match onboardin…
ChadFarrow Jun 15, 2026
d854673
Reword podcast:guid comment: MSP mints the GUID automatically
ChadFarrow Jun 15, 2026
fd76822
Stop emitting managingEditor/webMaster (DeMu leftovers, not MSP fields)
ChadFarrow Jun 15, 2026
b2b9886
Trim playlist guidance from podcast:medium comment
ChadFarrow Jun 15, 2026
5ad4d41
Document npub attribute in podcast:person comment
ChadFarrow Jun 16, 2026
8055d27
Reword valueRecipient comment: lnaddress support + app-managed
ChadFarrow Jun 16, 2026
2a81df8
Reword item <guid> comment: MSP mints per-track GUIDs automatically
ChadFarrow Jun 16, 2026
20cd937
Drop Fountain Radio from itunes:duration comment
ChadFarrow Jun 16, 2026
4b61d4c
Consistency pass: reword enclosure + lastBuildDate comments
ChadFarrow Jun 16, 2026
ab2b6f1
Add blank-line grouping to generated feeds for readability
ChadFarrow Jun 16, 2026
3446615
Serve msp-feed-template.xml as a static asset
ChadFarrow Jun 16, 2026
cfd2d22
Document educational feed comments + template in CLAUDE.md
ChadFarrow Jun 16, 2026
b8634f9
Add comments-free reference template variant
ChadFarrow Jun 16, 2026
40e9d9e
Swap template names: clean is the default, commented is -with-comments
ChadFarrow Jun 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,17 @@ The modern singular `<podcast:image>` tag (supersedes the deprecated plural `<po
- **UI** (`PodcastImagesList.tsx`, mounted in `Editor.tsx` at album/video feed + track level, and in `PublisherEditor/PublisherArtworkSection.tsx` for publisher feeds): artists paste a URL; `detectImageMetadata()` (`utils/imageMetadata.ts`) loads the image to auto-fill width/height/aspect-ratio/MIME and `suggestPurpose()` pre-selects a purpose. `detectImageMetadata` never rejects and has a 10s timeout (mirrors `audioUtils.getAudioDuration`). The blur handler only writes fields it actually detected (a failed/timed-out re-detect never erases prior values), bails if the row was removed or its URL changed during load, and skips re-detecting an unchanged URL (per-row `detectedUrls` ref). State flows through existing `UPDATE_ALBUM`/`UPDATE_TRACK`/`UPDATE_PUBLISHER_FEED` actions (no new reducer action) via the list's `onChange(images)` which replaces the whole array. Known follow-up: the legacy `trackArtWidth`/`trackArtHeight` parser fields are now written-but-unread by the generator.
- Tests in `xmlGenerator.test.ts`, `xmlParser.test.ts` (incl. round-trip) and `imageMetadata.test.ts` (pure helpers) cover the feature.

#### Educational XML comments in generated feeds
`xmlGenerator.ts` emits a `<!-- ... -->` 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 `<guid>`, `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 `<item>` 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
Expand Down
143 changes: 143 additions & 0 deletions public/msp-feed-template-with-comments.xml

Large diffs are not rendered by default.

91 changes: 91 additions & 0 deletions public/msp-feed-template.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:podcast="https://podcastindex.org/namespace/1.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0">
<channel>

<title>My Album Title</title>
<itunes:author>My Band Name</itunes:author>
<description>
A brief description of your album. Tell listeners what it&apos;s about, the vibe, the story behind it.
</description>
<link>https://mybandwebsite.com</link>
<language>en</language>

<generator>MSP 2.0 - Music Side Project Studio</generator>
<pubDate>Tue, 16 Jun 2026 00:46:58 GMT</pubDate>
<lastBuildDate>Tue, 16 Jun 2026 00:46:58 GMT</lastBuildDate>

<podcast:locked owner="you@example.com">yes</podcast:locked>
<podcast:guid>21377651-b449-5585-ac5d-4b70f2ede0f6</podcast:guid>

<itunes:category text="Music" />
<itunes:keywords>rock, indie, alternative</itunes:keywords>

<image>
<url>https://mybandwebsite.com/album-art.jpg</url>
<title>My Album Title cover art</title>
<link>https://mybandwebsite.com</link>
<description>My Band Name - My Album Title album art</description>
</image>
<itunes:image href="https://mybandwebsite.com/album-art.jpg" />

<podcast:medium>music</podcast:medium>
<itunes:explicit>false</itunes:explicit>

<itunes:owner>
<itunes:name>My Band Name</itunes:name>
<itunes:email>you@example.com</itunes:email>
</itunes:owner>

<podcast:person href="https://mybandwebsite.com" img="https://mybandwebsite.com/photo.jpg" group="music" role="band">Band Member Name</podcast:person>

<podcast:value type="lightning" method="lnaddress" suggested="0.000033333">
<podcast:valueRecipient name="My Band Name" address="myband@getalby.com" split="95" type="lnaddress" />
<podcast:valueRecipient name="Podcast Index" address="03ae9f91a0cb8ff43840e3c322c4c61f019d8c1c3cea15a25cfc425ac605e61a4a" split="1" type="node" />
<podcast:valueRecipient name="MSP 2.0" address="chadf@getalby.com" split="4" type="lnaddress" />
</podcast:value>

<item>
<title>First Song</title>
<description>First Song - My Band Name</description>
<pubDate>Tue, 16 Jun 2026 00:46:58 GMT</pubDate>
<guid isPermaLink="false">3b77cf04-4256-42d4-804e-24df710d3d91</guid>

<itunes:image href="https://mybandwebsite.com/album-art.jpg" />

<enclosure url="https://mybandwebsite.com/audio/track01.mp3" length="6029312" type="audio/mpeg"/>
<itunes:duration>00:04:12</itunes:duration>

<podcast:season>1</podcast:season>
<podcast:episode>1</podcast:episode>
<itunes:explicit>false</itunes:explicit>

<podcast:value type="lightning" method="lnaddress" suggested="0.000033333">
<podcast:valueRecipient name="My Band Name" address="myband@getalby.com" split="95" type="lnaddress" />
<podcast:valueRecipient name="Podcast Index" address="03ae9f91a0cb8ff43840e3c322c4c61f019d8c1c3cea15a25cfc425ac605e61a4a" split="1" type="node" />
<podcast:valueRecipient name="MSP 2.0" address="chadf@getalby.com" split="4" type="lnaddress" />
</podcast:value>
</item>

<item>
<title>Second Song</title>
<description>Second Song - My Band Name</description>
<pubDate>Tue, 16 Jun 2026 00:46:58 GMT</pubDate>
<guid isPermaLink="false">3b77cf04-4256-42d4-804e-24df710d3d92</guid>

<itunes:image href="https://mybandwebsite.com/album-art.jpg" />

<enclosure url="https://mybandwebsite.com/audio/track02.mp3" length="7340032" type="audio/mpeg"/>
<itunes:duration>00:05:18</itunes:duration>

<podcast:season>1</podcast:season>
<podcast:episode>2</podcast:episode>
<itunes:explicit>false</itunes:explicit>

<podcast:value type="lightning" method="lnaddress" suggested="0.000033333">
<podcast:valueRecipient name="My Band Name" address="myband@getalby.com" split="95" type="lnaddress" />
<podcast:valueRecipient name="Podcast Index" address="03ae9f91a0cb8ff43840e3c322c4c61f019d8c1c3cea15a25cfc425ac605e61a4a" split="1" type="node" />
<podcast:valueRecipient name="MSP 2.0" address="chadf@getalby.com" split="4" type="lnaddress" />
</podcast:value>
</item>
</channel>
</rss>
26 changes: 24 additions & 2 deletions src/components/modals/PreviewModal.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -189,6 +194,23 @@ export function PreviewModal({ onClose, album, publisherFeed, feedType = 'album'
className="preview-modal"
footer={
<>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '0.875rem',
cursor: 'pointer',
userSelect: 'none'
}}
>
<input
type="checkbox"
checked={showComments}
onChange={(e) => setShowComments(e.target.checked)}
/>
Show comments
</label>
<button className="btn btn-secondary" onClick={handleCopy}>
Copy to Clipboard
</button>
Expand Down
2 changes: 1 addition & 1 deletion src/data/fieldInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
32 changes: 26 additions & 6 deletions src/types/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,13 +610,33 @@ export const PERSON_ROLES: Record<PersonGroup, { value: string; label: string }[
// Language codes
export const LANGUAGES = [
{ value: 'en', label: 'English' },
{ value: 'es', label: 'Spanish' },
{ value: 'de', label: 'German' },
{ value: 'ar', label: 'Arabic' },
{ value: 'bn', label: 'Bengali' },
{ value: 'zh', label: 'Chinese' },
{ value: 'cs', label: 'Czech' },
{ value: 'da', label: 'Danish' },
{ value: 'nl', label: 'Dutch' },
{ value: 'fi', label: 'Finnish' },
{ value: 'fr', label: 'French' },
{ value: 'pt', label: 'Portuguese' },
{ value: 'de', label: 'German' },
{ value: 'el', label: 'Greek' },
{ value: 'he', label: 'Hebrew' },
{ value: 'hi', label: 'Hindi' },
{ value: 'hu', label: 'Hungarian' },
{ value: 'id', label: 'Indonesian' },
{ value: 'it', label: 'Italian' },
{ value: 'ja', label: 'Japanese' },
{ value: 'zh', label: 'Chinese' },
{ value: 'ko', label: 'Korean' },
{ value: 'it', label: 'Italian' },
{ value: 'nl', label: 'Dutch' }
{ value: 'no', label: 'Norwegian' },
{ value: 'fa', label: 'Persian' },
{ value: 'pl', label: 'Polish' },
{ value: 'pt', label: 'Portuguese' },
{ value: 'ro', label: 'Romanian' },
{ value: 'ru', label: 'Russian' },
{ value: 'es', label: 'Spanish' },
{ value: 'sv', label: 'Swedish' },
{ value: 'th', label: 'Thai' },
{ value: 'tr', label: 'Turkish' },
{ value: 'uk', label: 'Ukrainian' },
{ value: 'vi', label: 'Vietnamese' }
];
119 changes: 117 additions & 2 deletions src/utils/xmlGenerator.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { generateRssFeed } from './xmlGenerator';
import { generateRssFeed, generatePublisherRssFeed, stripXmlComments } 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', () => {
Expand Down Expand Up @@ -284,3 +284,118 @@ describe('podcast:image generation', () => {
expect(xml).not.toContain('<podcast:images');
});
});

describe('DeMu-style educational comments', () => {
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('<!-- The "title" tag will contain the name of your album. -->');
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('<!-- The "title" tag will contain the name of your publisher or label catalog. -->');
expect(xml).toContain('<!-- The "itunes:author" tag describes the label or publisher name. -->');
});

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(/<!--[\s\S]*?-->/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('<!--'); // sanity: the feed has comments
const commentLines = (xml.match(/^[ \t]*<!--.*?-->[ \t]*$/gm) ?? []).length;

const stripped = stripXmlComments(xml);
expect(stripped).not.toContain('<!--');
expect(stripped).not.toContain('-->');
// Tags and content survive
expect(stripped).toContain('<title>Strip Test</title>');
expect(stripped).toContain('<rss');
expect(stripped).toContain('</rss>');
// 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(/<!--/g) ?? []).length;
expect(count(xml2)).toBe(count(xml1));
});

it('groups tags with single blank lines — never two blank lines in a row (comments on OR off)', () => {
const album = createEmptyAlbum();
album.title = 'Spacing Test';
album.author = 'Artist';
album.description = 'A test album';
album.link = 'https://example.com';
album.keywords = 'rock';
album.imageUrl = 'https://example.com/cover.jpg';
album.ownerName = 'Owner';
album.persons = [{ name: 'P', roles: [{ group: 'music', role: 'band' }] }];
album.value = { type: 'lightning', method: 'keysend', suggested: '0', recipients: [{ name: 'N', address: 'n@getalby.com', split: 100, type: 'lnaddress' }] };
album.tracks = [
{ ...album.tracks[0], title: 'One', enclosureUrl: 'https://example.com/1.mp3' },
{ ...album.tracks[0], title: 'Two', enclosureUrl: 'https://example.com/2.mp3' },
];

const xml = generateRssFeed(album);
const doubleBlank = /\n[ \t]*\n[ \t]*\n/;
// Has blank-line grouping at all
expect(xml).toMatch(/\n\n/);
// ...but never two consecutive blank lines, with comments on or off
expect(xml).not.toMatch(doubleBlank);
expect(stripXmlComments(xml)).not.toMatch(doubleBlank);
// No leading/trailing blank lines
expect(xml.startsWith('\n')).toBe(false);
expect(xml.endsWith('\n')).toBe(false);
});
});
Loading