Skip to content

Commit ff89fdf

Browse files
McKnight, Eric D.Copilot
authored andcommitted
feat(rss): render HTML descriptions and support stable rss_guid
Feed item descriptions previously carried the raw markdown excerpt, so readers showed unrendered syntax (e.g. leaked "[text](file.md){target=_blank}") instead of formatted text. Build the description from the post's rendered HTML intro instead: the content before the <!-- more --> marker (mirroring the on-site excerpt), or the first paragraph when there's no marker. The leading tag nav and H1 are stripped (the item already has a title), and relative links/images are absolutized so they resolve in any reader. Also add an `rss_guid` front-matter override. The guid identifies each item, and defaults to the post permalink, so changing a slug/URL changes the guid and makes readers re-surface the post as new. Pinning `rss_guid` keeps a post's identity stable across such a move (emitted with isPermaLink="false", and used as the Atom entry id too). This lets a site backfill stable guids before a URL cutover. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ef3da03 commit ff89fdf

3 files changed

Lines changed: 165 additions & 7 deletions

File tree

docs-site/docs/plugins/rss.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,26 @@ the derived defaults:
3939
| `link` / `id` | `site_url` + post URL ||
4040
| `pubDate` / `published`, `updated` | Post creation date ||
4141
| `category` (RSS) / `category term` (Atom) | Blog `categories` ||
42-
| `description` / `summary` | Post excerpt | `rss_description` |
42+
| `description` / `summary` | Rendered HTML of the post intro (content before `<!-- more -->`, or the first paragraph) | `rss_description` |
4343
| `content:encoded` / `content` | Rendered HTML *(only when `full_content: true`)* ||
4444
| `enclosure` (RSS image) | Front-matter `image`, else the first `<img>` in the post | `image` |
45+
| `guid` (RSS) / `id` (Atom) | `site_url` + post URL (`isPermaLink="true"`) | `rss_guid` |
4546

4647
Relative image paths are resolved against the post URL; a leading `/` is resolved against
47-
`site_url`; absolute (`http(s)://`) URLs are used as-is.
48+
`site_url`; absolute (`http(s)://`) URLs are used as-is. Links and images inside the rendered
49+
`description` are absolutized the same way so they resolve in any reader.
50+
51+
### Stable GUIDs across URL changes
52+
53+
The `guid` (RSS) / `id` (Atom) uniquely identifies each item; readers use it to tell new posts
54+
from ones they've already shown. By default it's the post's permalink, so **changing a post's
55+
slug or URL changes its guid**, and readers re-surface it as new. To keep a post's identity
56+
stable across such a move, pin an explicit `rss_guid` in front matter (any stable string — a URN,
57+
a UUID, or the post's original URL). When set, the guid is emitted with `isPermaLink="false"`:
58+
59+
```yaml
60+
rss_guid: https://static.xtremeownage.com/blog/2024/01/04/power-consumption-versus-price/
61+
```
4862
4963
Example front matter using the overrides:
5064
@@ -53,6 +67,7 @@ Example front matter using the overrides:
5367
title: My Post
5468
rss_title: A snappier title just for feed readers
5569
rss_description: A custom one-line summary for the feed.
70+
rss_guid: urn:uuid:1e5f2c9a-0b3d-4a1e-9c2f-8b7a6d5e4c3b
5671
image: hero.png
5772
---
5873
```

src/Netdocs.Plugins/RssPlugin.cs

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,15 @@ private FeedItem ToItem(BlogPost post, string siteUrl)
9898
var page = post.Page;
9999
var url = $"{siteUrl}/{page.Url}";
100100
var title = FrontMatterString(page, "rss_title") ?? page.Title;
101-
var description = FrontMatterString(page, "rss_description") ?? post.Excerpt;
101+
var description = FrontMatterString(page, "rss_description")
102+
?? BuildDescriptionHtml(page, url)
103+
?? post.Excerpt;
104+
// A stable guid overrides the (URL-derived) permalink guid so a later slug/URL change
105+
// doesn't make readers re-surface every post as new. Backfill `rss_guid` in front matter.
106+
var guid = FrontMatterString(page, "rss_guid");
102107
var image = ResolveImage(page, url, siteUrl);
103108
var content = _fullContent && page.HtmlContent.Length > 0 ? page.HtmlContent : null;
104-
return new FeedItem(title, url, post.Date, post.Categories, description, image, content);
109+
return new FeedItem(title, url, guid, post.Date, post.Categories, description, image, content);
105110
}
106111

107112
private string BuildRss(SiteContext site, string siteUrl, List<FeedItem> items)
@@ -152,8 +157,16 @@ private string BuildRss(SiteContext site, string siteUrl, List<FeedItem> items)
152157
writer.WriteElementString("title", item.Title);
153158
writer.WriteElementString("link", item.Url);
154159
writer.WriteStartElement("guid");
155-
writer.WriteAttributeString("isPermaLink", "true");
156-
writer.WriteString(item.Url);
160+
if (item.Guid is { Length: > 0 } guid)
161+
{
162+
writer.WriteAttributeString("isPermaLink", "false");
163+
writer.WriteString(guid);
164+
}
165+
else
166+
{
167+
writer.WriteAttributeString("isPermaLink", "true");
168+
writer.WriteString(item.Url);
169+
}
157170
writer.WriteEndElement();
158171
writer.WriteElementString("pubDate", item.Date.ToString("r"));
159172
foreach (var category in item.Categories)
@@ -204,7 +217,7 @@ private string BuildAtom(SiteContext site, string siteUrl, List<FeedItem> items)
204217
{
205218
writer.WriteStartElement("entry");
206219
writer.WriteElementString("title", item.Title);
207-
writer.WriteElementString("id", item.Url);
220+
writer.WriteElementString("id", item.Guid is { Length: > 0 } aid ? aid : item.Url);
208221
writer.WriteElementString("updated", item.Date.ToString("yyyy-MM-ddTHH:mm:sszzz"));
209222
writer.WriteElementString("published", item.Date.ToString("yyyy-MM-ddTHH:mm:sszzz"));
210223
writer.WriteStartElement("link");
@@ -292,9 +305,76 @@ private static string MimeForImage(string url) =>
292305
[GeneratedRegex("""<img[^>]+src=["']([^"']+)["']""", RegexOptions.IgnoreCase)]
293306
private static partial Regex FirstImage();
294307

308+
/// <summary>
309+
/// Builds a rendered-HTML description from the post's content so feed readers show formatted
310+
/// text instead of raw markdown. Uses the intro region — everything before the
311+
/// <c>&lt;!-- more --&gt;</c> marker, mirroring the on-site excerpt — or the first paragraph
312+
/// when the post has no marker. Leading tag navigation and the H1 (the item already carries a
313+
/// title) are stripped, and relative links/images are absolutized so they resolve in a reader.
314+
/// </summary>
315+
private static string? BuildDescriptionHtml(Page page, string postUrl)
316+
{
317+
var html = page.HtmlContent;
318+
if (string.IsNullOrWhiteSpace(html)) return null;
319+
320+
var moreIdx = html.IndexOf("<!-- more -->", StringComparison.OrdinalIgnoreCase);
321+
string region;
322+
if (moreIdx >= 0)
323+
{
324+
region = html[..moreIdx];
325+
}
326+
else
327+
{
328+
var first = FirstParagraph().Match(html);
329+
if (!first.Success) return null;
330+
region = first.Value;
331+
}
332+
333+
region = LeadingTagsNav().Replace(region, "");
334+
region = LeadingHeading().Replace(region, "");
335+
region = AbsolutizeUrls(region.Trim(), postUrl);
336+
return region.Length > 0 ? region : null;
337+
}
338+
339+
/// <summary>Rewrites relative <c>href</c>/<c>src</c> values to absolute URLs, resolved against
340+
/// the post's canonical URL, so links and images in a feed description work in any reader.</summary>
341+
private static string AbsolutizeUrls(string html, string postUrl)
342+
{
343+
if (!Uri.TryCreate(postUrl.EndsWith('/') ? postUrl : postUrl + "/", UriKind.Absolute, out var baseUri))
344+
return html;
345+
346+
return HrefOrSrc().Replace(html, match =>
347+
{
348+
var attr = match.Groups[1].Value;
349+
var value = match.Groups[2].Value;
350+
if (value.Length == 0 || value.StartsWith('#') || value.Contains("://") ||
351+
value.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase) ||
352+
value.StartsWith("tel:", StringComparison.OrdinalIgnoreCase) ||
353+
value.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
354+
return match.Value;
355+
356+
return Uri.TryCreate(baseUri, value, out var abs)
357+
? $"{attr}=\"{abs}\""
358+
: match.Value;
359+
});
360+
}
361+
362+
[GeneratedRegex("""<p\b[^>]*>.*?</p>""", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
363+
private static partial Regex FirstParagraph();
364+
365+
[GeneratedRegex("""^\s*<nav\b[^>]*class="[^"]*md-tags[^"]*"[^>]*>.*?</nav>""", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
366+
private static partial Regex LeadingTagsNav();
367+
368+
[GeneratedRegex("""^\s*<h1\b[^>]*>.*?</h1>""", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
369+
private static partial Regex LeadingHeading();
370+
371+
[GeneratedRegex("\\b(href|src)\\s*=\\s*\"([^\"]*)\"", RegexOptions.IgnoreCase)]
372+
private static partial Regex HrefOrSrc();
373+
295374
private sealed record FeedItem(
296375
string Title,
297376
string Url,
377+
string? Guid,
298378
DateTimeOffset Date,
299379
IReadOnlyList<string> Categories,
300380
string Description,

tests/Netdocs.Core.Tests/RssPluginTests.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,69 @@ public async Task RssChannel_IncludesStandardMetadata()
103103
Assert.Contains("<pubDate>", xml);
104104
}
105105

106+
[Fact]
107+
public async Task Guid_DefaultsToPermalinkUrl()
108+
{
109+
var site = Site(Post("Hello", "blog/hello", DateTimeOffset.UtcNow));
110+
await Run(new Dictionary<string, object?>(), site);
111+
112+
var xml = File.ReadAllText(Path.Combine(_root, "feed_rss_created.xml"));
113+
Assert.Contains("<guid isPermaLink=\"true\">https://example.com/blog/hello/</guid>", xml);
114+
}
115+
116+
[Fact]
117+
public async Task RssGuidFrontMatter_OverridesPermalinkGuid()
118+
{
119+
var fm = new Dictionary<string, object?> { ["rss_guid"] = "urn:uuid:stable-123" };
120+
var site = Site(Post("Renamed", "blog/new-slug", DateTimeOffset.UtcNow, frontMatter: fm));
121+
await Run(new Dictionary<string, object?>(), site);
122+
123+
var xml = File.ReadAllText(Path.Combine(_root, "feed_rss_created.xml"));
124+
Assert.Contains("<guid isPermaLink=\"false\">urn:uuid:stable-123</guid>", xml);
125+
// The stable id also anchors the Atom entry.
126+
await Run(new Dictionary<string, object?> { ["atom"] = true }, site);
127+
var atom = File.ReadAllText(Path.Combine(_root, "feed_atom_created.xml"));
128+
Assert.Contains("<id>urn:uuid:stable-123</id>", atom);
129+
}
130+
131+
[Fact]
132+
public async Task Description_UsesRenderedHtmlBeforeMoreMarker()
133+
{
134+
var html = "<h1 id=\"t\">Title</h1>\n<p>Intro <strong>bold</strong>.</p>\n<!-- more -->\n<p>Rest</p>";
135+
var site = Site(Post("Post", "blog/p", DateTimeOffset.UtcNow, html: html));
136+
await Run(new Dictionary<string, object?>(), site);
137+
138+
var xml = File.ReadAllText(Path.Combine(_root, "feed_rss_created.xml"));
139+
// HTML is escaped inside <description>; the intro paragraph (with formatting) is present,
140+
// the leading H1 is stripped, and content after <!-- more --> is excluded.
141+
Assert.Contains("&lt;p&gt;Intro &lt;strong&gt;bold&lt;/strong&gt;.&lt;/p&gt;", xml);
142+
Assert.DoesNotContain("Title", xml.Split("<description>")[1].Split("</description>")[0]);
143+
Assert.DoesNotContain("Rest", xml);
144+
}
145+
146+
[Fact]
147+
public async Task Description_AbsolutizesRelativeLinks()
148+
{
149+
var html = "<p>See <a href=\"../other/\">this</a>.</p>\n<!-- more -->\n<p>x</p>";
150+
var site = Site(Post("Post", "blog/2024/p", DateTimeOffset.UtcNow, html: html));
151+
await Run(new Dictionary<string, object?>(), site);
152+
153+
var xml = File.ReadAllText(Path.Combine(_root, "feed_rss_created.xml"));
154+
Assert.Contains("https://example.com/blog/2024/other/", xml);
155+
}
156+
157+
[Fact]
158+
public async Task RssDescriptionFrontMatter_WinsOverRenderedHtml()
159+
{
160+
var fm = new Dictionary<string, object?> { ["rss_description"] = "Custom summary" };
161+
var html = "<p>Intro</p>\n<!-- more -->\n<p>Rest</p>";
162+
var site = Site(Post("Post", "blog/p", DateTimeOffset.UtcNow, html: html, frontMatter: fm));
163+
await Run(new Dictionary<string, object?>(), site);
164+
165+
var xml = File.ReadAllText(Path.Combine(_root, "feed_rss_created.xml"));
166+
Assert.Contains("<description>Custom summary</description>", xml);
167+
}
168+
106169
[Fact]
107170
public async Task PerPostTitleOverride_WinsOverPageTitle()
108171
{

0 commit comments

Comments
 (0)