Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link
rel="alternate"
type="application/rss+xml"
title="Alice Alexandra Moore"
href="/rss.xml"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
Expand Down
184 changes: 184 additions & 0 deletions src/lib/server/rss.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { XMLParser } from 'fast-xml-parser';
import { describe, expect, it } from 'vitest';
import {
absolutizeHtmlUrls,
applySubAndSuperMarkers,
escapeXml,
makeCdataSafe,
serializeRssFeed,
toRfc822,
wrapCdata
} from './rss';

// fast-xml-parser represents adjacent CDATA sections (which our escaping
// technique produces when content contains "]]>") as an array of chunks
// rather than a single concatenated string.
function readCdata(value: unknown): string {
if (Array.isArray(value)) return value.join('');
if (typeof value === 'object' && value !== null && '__cdata' in value) {
return readCdata((value as { __cdata: unknown }).__cdata);
}
return String(value);
}

describe('escapeXml', () => {
it('escapes the five XML special characters', () => {
expect(escapeXml(`Tom & Jerry's "Big" <Adventure>`)).toBe(
'Tom &amp; Jerry&apos;s &quot;Big&quot; &lt;Adventure&gt;'
);
});

it('leaves ordinary text untouched', () => {
expect(escapeXml('a plain title')).toBe('a plain title');
});
});

describe('toRfc822', () => {
it('formats an ISO calendar date as RFC-822 at UTC midnight', () => {
expect(toRfc822('2026-09-10')).toBe('Thu, 10 Sep 2026 00:00:00 GMT');
});

it('handles single-digit days correctly', () => {
expect(toRfc822('2023-02-10')).toBe('Fri, 10 Feb 2023 00:00:00 GMT');
});
});

describe('makeCdataSafe / wrapCdata', () => {
it('splits an embedded ]]> so it cannot terminate the CDATA section', () => {
const input = 'before ]]> after';
expect(makeCdataSafe(input)).toBe('before ]]]]><![CDATA[> after');
});

it('wraps content in a CDATA section', () => {
expect(wrapCdata('<p>hi</p>')).toBe('<![CDATA[<p>hi</p>]]>');
});

it('round-trips embedded ]]> through a real XML parser without truncation', () => {
const malicious = ']]><script>alert(1)</script><![CDATA[';
const xml = `<root>${wrapCdata(malicious)}</root>`;
const parsed = new XMLParser({ cdataPropName: '__cdata' }).parse(xml);
expect(readCdata(parsed.root)).toBe(malicious);
});
});

describe('applySubAndSuperMarkers', () => {
it('replaces {super:x} and {sub:x} markers with real tags', () => {
const html = '<p>Espresso Getting Shit Done{super:TM} is real{sub:ish}.</p>';
expect(applySubAndSuperMarkers(html)).toBe(
'<p>Espresso Getting Shit Done<sup>TM</sup> is real<sub>ish</sub>.</p>'
);
});

it('handles multiple markers of the same kind', () => {
const html = '{super:superscript} and {sub:subscript}';
expect(applySubAndSuperMarkers(html)).toBe('<sup>superscript</sup> and <sub>subscript</sub>');
});

it('leaves text without markers untouched', () => {
expect(applySubAndSuperMarkers('<p>no markers here</p>')).toBe('<p>no markers here</p>');
});
});

describe('absolutizeHtmlUrls', () => {
const origin = 'https://example.com';

it('makes root-relative src attributes absolute', () => {
expect(absolutizeHtmlUrls('<img src="/images/foo.webp" />', origin)).toBe(
'<img src="https://example.com/images/foo.webp" />'
);
});

it('makes root-relative href attributes absolute', () => {
expect(absolutizeHtmlUrls('<a href="/about#connect">contact</a>', origin)).toBe(
'<a href="https://example.com/about#connect">contact</a>'
);
});

it('leaves already-absolute URLs untouched', () => {
const html = '<img src="https://cdn.example.org/a.webp" /><a href="https://other.com">x</a>';
expect(absolutizeHtmlUrls(html, origin)).toBe(html);
});

it('leaves protocol-relative URLs untouched', () => {
const html = '<img src="//cdn.example.org/a.webp" />';
expect(absolutizeHtmlUrls(html, origin)).toBe(html);
});
});

describe('serializeRssFeed', () => {
const baseFeed = {
title: 'Alice Alexandra Moore',
link: 'https://www.alicealexandra.com/blog',
description: "Blog entries and writing that doesn't quite fit anywhere else.",
feedUrl: 'https://www.alicealexandra.com/rss.xml',
language: 'en',
items: [
{
slug: 'were-all-right-here',
title: "We're all right here",
description: 'On white despair and wanting more of our lives back.',
contentHtml: '<p>Body with {super:TM} and <img src="/images/a.webp" /></p>',
publicationDate: '2026-09-10',
category: 'Lyric'
},
{
slug: 'coming-soon',
title: 'Coming soon',
description: 'A placeholder.',
contentHtml: '<p>]]> danger</p>',
publicationDate: '2023-02-10'
}
]
};

it('produces well-formed XML with the required RSS 2.0 elements', () => {
const xml = serializeRssFeed(baseFeed);
const parsed = new XMLParser({
ignoreAttributes: false,
cdataPropName: '__cdata'
}).parse(xml);

const channel = parsed.rss.channel;
expect(channel.title).toBe('Alice Alexandra Moore');
expect(channel.link).toBe('https://www.alicealexandra.com/blog');
expect(channel.description).toContain("doesn't quite fit anywhere else");
expect(channel.language).toBe('en');
expect(channel['atom:link']['@_href']).toBe('https://www.alicealexandra.com/rss.xml');
expect(channel['atom:link']['@_rel']).toBe('self');
expect(channel.lastBuildDate).toBe(toRfc822('2026-09-10'));

const items = Array.isArray(channel.item) ? channel.item : [channel.item];
expect(items).toHaveLength(2);
expect(items[0].title).toBe("We're all right here");
expect(items[0].link).toBe('https://www.alicealexandra.com/blog/were-all-right-here');
expect(items[0].guid['#text']).toBe('https://www.alicealexandra.com/blog/were-all-right-here');
expect(items[0].guid['@_isPermaLink']).toBe('true');
expect(items[0].category).toBe('Lyric');
expect(items[0].pubDate).toBe(toRfc822('2026-09-10'));
});

it('absolutizes URLs and replaces sub/super markers inside content:encoded', () => {
const xml = serializeRssFeed(baseFeed);
expect(xml).toContain('<sup>TM</sup>');
expect(xml).not.toMatch(/\{super:|\{sub:/);
expect(xml).toContain('src="https://www.alicealexandra.com/images/a.webp"');
});

it('keeps embedded ]]> from breaking the CDATA section', () => {
const xml = serializeRssFeed(baseFeed);
const parsed = new XMLParser({ cdataPropName: '__cdata' }).parse(xml);
// If parsing succeeds and content:encoded contains our marker text, the
// CDATA was not truncated early by the embedded "]]>".
const items = Array.isArray(parsed.rss.channel.item)
? parsed.rss.channel.item
: [parsed.rss.channel.item];
const text = readCdata(items[1]['content:encoded']);
expect(text).toContain('danger');
expect(text).toBe('<p>]]> danger</p>');
});

it('falls back to the epoch when there are no items', () => {
const xml = serializeRssFeed({ ...baseFeed, items: [] });
expect(xml).toContain(`<lastBuildDate>${new Date(0).toUTCString()}</lastBuildDate>`);
});
});
127 changes: 127 additions & 0 deletions src/lib/server/rss.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { CANONICAL_ORIGIN } from './agent-readiness';

export interface RssFeedItemInput {
slug: string;
title: string;
description: string;
contentHtml: string;
/** ISO calendar date, e.g. "2026-09-10". Treated as UTC midnight. */
publicationDate: string;
category?: string;
}

export interface RssFeedInput {
title: string;
link: string;
description: string;
feedUrl: string;
language?: string;
items: RssFeedItemInput[];
}

/**
* Escapes text for safe inclusion in XML element/attribute content.
*/
export function escapeXml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}

/**
* Replaces the site's custom `{super:x}` / `{sub:x}` markers with real
* `<sup>`/`<sub>` tags. This is the feed-safe (pure string) equivalent of
* `subAndSuper` in `$lib/notion/utils/blog-helpers.ts`, which mutates the
* DOM in the browser. Feed readers never run that browser code, so raw
* markers would otherwise leak into `content:encoded`.
*/
export function applySubAndSuperMarkers(html: string): string {
return html
.replace(/\{super:([^}]*)\}/g, '<sup>$1</sup>')
.replace(/\{sub:([^}]*)\}/g, '<sub>$1</sub>');
}

/**
* Rewrites root-relative `src="/..."` and `href="/..."` attributes to
* absolute URLs against `origin`. Protocol-relative (`//...`) and already
* absolute URLs are left untouched.
*/
export function absolutizeHtmlUrls(html: string, origin: string = CANONICAL_ORIGIN): string {
return html.replace(/((?:src|href)=")\/(?!\/)/g, `$1${origin}/`);
}

/**
* Makes arbitrary text safe to place inside a CDATA section by splitting
* any embedded `]]>` sequence so it can't terminate the section early.
*/
export function makeCdataSafe(content: string): string {
return content.split(']]>').join(']]]]><![CDATA[>');
}

/**
* Wraps content in a CDATA section, guarding against embedded `]]>`.
*/
export function wrapCdata(content: string): string {
return `<![CDATA[${makeCdataSafe(content)}]]>`;
}

/**
* Formats an ISO calendar date (`YYYY-MM-DD`) as an RFC-822 date at UTC
* midnight, suitable for RSS `pubDate`/`lastBuildDate` elements.
*/
export function toRfc822(dateOnly: string): string {
return new Date(`${dateOnly}T00:00:00.000Z`).toUTCString();
}

function buildItemXml(item: RssFeedItemInput, origin: string): string {
const link = `${origin}/blog/${item.slug}`;
const processedHtml = absolutizeHtmlUrls(applySubAndSuperMarkers(item.contentHtml), origin);
const categoryXml = item.category
? ` <category>${escapeXml(item.category)}</category>`
: null;

return [
' <item>',
` <title>${escapeXml(item.title)}</title>`,
` <link>${escapeXml(link)}</link>`,
` <guid isPermaLink="true">${escapeXml(link)}</guid>`,
` <pubDate>${toRfc822(item.publicationDate)}</pubDate>`,
categoryXml,
` <description>${escapeXml(item.description)}</description>`,
` <content:encoded>${wrapCdata(processedHtml)}</content:encoded>`,
' </item>'
]
.filter((line): line is string => line !== null)
.join('\n');
}

/**
* Serializes an RSS 2.0 feed (with the `content` and `atom` namespaces)
* from plain data. Pure and testable: no filesystem or network access.
*/
export function serializeRssFeed(feed: RssFeedInput, origin: string = CANONICAL_ORIGIN): string {
const language = feed.language ?? 'en';
const newestPublicationDate = feed.items[0]?.publicationDate;
const lastBuildDate = newestPublicationDate
? toRfc822(newestPublicationDate)
: new Date(0).toUTCString();

const itemsXml = feed.items.map((item) => buildItemXml(item, origin)).join('\n');

return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(feed.title)}</title>
<link>${escapeXml(feed.link)}</link>
<description>${escapeXml(feed.description)}</description>
<language>${escapeXml(language)}</language>
<atom:link href="${escapeXml(feed.feedUrl)}" rel="self" type="application/rss+xml" />
<lastBuildDate>${lastBuildDate}</lastBuildDate>
${itemsXml}
</channel>
</rss>
`;
}
48 changes: 48 additions & 0 deletions src/routes/rss.xml/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { renderBlogMarkdown } from '$lib/blog/render-markdown';
import { loadPostBySlug, loadPostsMeta, type BlogPost } from '$lib/content/blog';
import { CANONICAL_ORIGIN } from '$lib/server/agent-readiness';
import { serializeRssFeed, type RssFeedItemInput } from '$lib/server/rss';
import type { RequestHandler } from './$types';

export const prerender = true;

// Kept in sync with the meta description on the blog index page
// (src/routes/(landing-pages)/blog/+page.svelte).
const BLOG_DESCRIPTION =
"Blog entries and writing that doesn't quite fit anywhere else, from Alice Alexandra Moore.";

function toRssItem(post: BlogPost): RssFeedItemInput {
return {
slug: post.slug,
title: post.title,
description: post.summary || post.ogDescription || post.subtitle,
contentHtml: renderBlogMarkdown(post.content),
publicationDate: post.publicationDate,
category: post.category
};
}

export const GET: RequestHandler = async () => {
const postsMeta = await loadPostsMeta();
const posts = await Promise.all(postsMeta.map((meta) => loadPostBySlug(meta.slug)));

const items = posts
.filter((post): post is BlogPost => post !== null)
.sort((a, b) => new Date(b.publicationDate).getTime() - new Date(a.publicationDate).getTime())
.map(toRssItem);

const xml = serializeRssFeed({
title: 'Alice Alexandra Moore',
link: `${CANONICAL_ORIGIN}/blog`,
description: BLOG_DESCRIPTION,
feedUrl: `${CANONICAL_ORIGIN}/rss.xml`,
language: 'en',
items
});

return new Response(xml, {
headers: {
'Content-Type': 'application/rss+xml; charset=utf-8'
}
});
};
5 changes: 5 additions & 0 deletions static/robots.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
User-agent: *
Disallow: /api/
Disallow: /owner

Sitemap: https://www.alicealexandra.com/sitemap.xml
Loading