Skip to content

Commit 83236a1

Browse files
yusuf-gundogduyusuf-gundogdu
andauthored
Give each engine archive its own feed, and make the feeds validate (#59)
* fix(rss): put the author's name where RSS 2.0 allows it The site feed wrote the author's name into <author>, which RSS 2.0 defines as an email address. The W3C feed validator refuses the feed over it, once per item, so https://libredb.org/rss.xml does not validate today. The name moves to <dc:creator>, which is what readers and planet aggregators already read, and the channel gains the atom:link rel="self" the validator asks for. Both live behind namespace declarations on <rss>, so removing either declaration leaves every feed on the site not well-formed XML and every reader rejecting it. Nothing in the suite noticed that. dist-smoke now parses each built feed, checks that every prefix it uses is declared, and pins the escaping of a creator name carrying an ampersand, which is the one we ship. * feat(blog): give each engine archive its own feed The blog groups posts by engine, but it publishes one feed for all of them, so an aggregator that wants a single engine has to filter us from the outside. The only thing an outside service can filter on is the title, and our titles name the behaviour rather than the engine, so it drops posts. Planet for the MySQL Community reads us through such a filter today and misses one. Each engine archive now serves the feed for its own posts at /blog/engine/<engine>/rss.xml, built from the post set the archive page lists, with the engine as the first category on every item. The archive page links it in the body and declares it in the head ahead of the site feed, which stays advertised so the whole blog is still reachable from there. The grouping and its threshold already existed three times: the archive route, the sibling links on an archive, and the engine index on /blog. The feed would have been a fourth, so all four now read engineArchives() in lib/posts and the threshold is one constant. The fixture behind the archive tests counted every post file on disk while the routes count published ones, so saving a draft turned the suite red with nothing wrong in the site. It filters on status now, which also settles nine older tests that shared it. --------- Co-authored-by: yusuf-gundogdu <devgundogdu86@gmail.com>
1 parent 666da5f commit 83236a1

9 files changed

Lines changed: 298 additions & 46 deletions

File tree

‎src/layouts/BaseLayout.astro‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,21 @@ import SiteHeader from '../components/layout/SiteHeader.astro';
1515
import SiteFooter from '../components/layout/SiteFooter.astro';
1616
import CookieConsent from '../components/layout/CookieConsent.astro';
1717
import { buildSeo, type SeoInput } from '../lib/seo';
18-
import { site } from '../lib/site';
18+
import { canonicalUrl, site } from '../lib/site';
1919
2020
interface Props extends SeoInput {
2121
/** JSON-LD graph nodes appended to the page */
2222
schema?: unknown[];
2323
bodyClass?: string;
24+
/**
25+
* The feed this page is a view of, when it is not the site-wide one. An
26+
* engine archive that advertises /rss.xml subscribes the reader to all 104
27+
* posts, which is the opposite of what they clicked.
28+
*/
29+
feed?: { href: string; title: string };
2430
}
2531
26-
const { schema = [], bodyClass, ...seoInput } = Astro.props;
32+
const { schema = [], bodyClass, feed, ...seoInput } = Astro.props;
2733
const seo = buildSeo(seoInput);
2834
2935
const jsonLd = {
@@ -87,7 +93,8 @@ const jsonLd = {
8793
<link rel="apple-touch-icon" href="/brand/lb-icon.svg" />
8894
<link rel="manifest" href="/site.webmanifest" />
8995
<meta name="theme-color" content="#0b0d18" />
90-
<link rel="alternate" type="application/rss+xml" title={`${site.name} — blog`} href={`${site.url}/rss.xml`} />
96+
{feed && <link rel="alternate" type="application/rss+xml" title={feed.title} href={canonicalUrl(feed.href)} />}
97+
<link rel="alternate" type="application/rss+xml" title={`${site.name} — blog`} href={canonicalUrl('/rss.xml')} />
9198

9299
<script is:inline>
93100
// Runs before first paint: no theme flash, and `html.js` is the switch the

‎src/lib/feed.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { canonicalUrl } from './site';
2+
3+
/**
4+
* The two namespaces and the self link every feed on this site declares.
5+
*
6+
* RSS 2.0 defines <author> as an email address, so a bare name there fails the
7+
* W3C feed validator: the live site feed answers "Invalid email address" once
8+
* per item. The author's name is Dublin Core's job. rel="self" is the
9+
* validator's one standing recommendation for a feed that does not say where
10+
* it lives.
11+
*
12+
* Both are declared on the channel, so dropping either one leaves every feed
13+
* on the site not well-formed. tests/dist-smoke.test.ts holds them down.
14+
*/
15+
export const feedXmlns = {
16+
dc: 'http://purl.org/dc/elements/1.1/',
17+
atom: 'http://www.w3.org/2005/Atom',
18+
} as const;
19+
20+
/**
21+
* `<dc:creator>` for a feed item.
22+
*
23+
* @astrojs/rss parses item customData as XML and re-serialises it, so a bare
24+
* `&` would survive on its own. `<` would not: it opens an element and the
25+
* name after it is lost. Escaping all three is cheaper than depending on which
26+
* ones the library happens to handle.
27+
*/
28+
export function feedCreator(name: string): string {
29+
const safe = name.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
30+
return `<dc:creator>${safe}</dc:creator>`;
31+
}
32+
33+
/** `<atom:link rel="self">` for the feed served at `path`. */
34+
export function feedSelfLink(path: string): string {
35+
return `<atom:link href="${canonicalUrl(path)}" rel="self" type="application/rss+xml"/>`;
36+
}

‎src/lib/posts.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,40 @@ export function engineName(engineId: string): string | undefined {
2828

2929
type Datable = { id: string; data: { publishedAt: Date } };
3030

31+
/**
32+
* The engine archives the blog actually offers: engine id to its posts, newest
33+
* first, for engines with at least two posts.
34+
*
35+
* The threshold and the grouping live here because two routes need the same
36+
* answer — the archive page and its feed. When they each carried their own copy
37+
* of the loop, the only thing keeping a feed from existing without its page was
38+
* that both happened to say `>= 2`. One edit to either and they part silently,
39+
* which is the drift this repo keeps writing rules against.
40+
*
41+
* Two is the floor because one post is the post, not an archive of it.
42+
*/
43+
/** The shape both engine routes hand each other: a list of published posts. */
44+
export type PublishedPosts = Awaited<ReturnType<typeof import('astro:content').getCollection<'posts'>>>;
45+
46+
/** One post is the post, not an archive of it. */
47+
const ARCHIVE_MIN = 2;
48+
49+
export function engineArchives<T extends Datable>(posts: T[]): Map<string, T[]> {
50+
const byEngine = new Map<string, T[]>();
51+
for (const post of posts) {
52+
const id = postEngine(post.id);
53+
if (!id) continue;
54+
byEngine.set(id, [...(byEngine.get(id) ?? []), post]);
55+
}
56+
57+
const byDate = (a: T, b: T) => b.data.publishedAt.getTime() - a.data.publishedAt.getTime();
58+
return new Map(
59+
[...byEngine.entries()]
60+
.filter(([, list]) => list.length >= ARCHIVE_MIN)
61+
.map(([id, list]) => [id, [...list].sort(byDate)]),
62+
);
63+
}
64+
3165
/**
3266
* Posts to offer at the end of `post`: same engine first, newest first, topped
3367
* up with recent posts when that engine has too few to fill the block.

‎src/pages/blog/engine/[engine].astro‎

Lines changed: 20 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { GetStaticPaths } from 'astro';
33
import BaseLayout from '../../../layouts/BaseLayout.astro';
44
import PostCard from '../../../components/blog/PostCard.astro';
55
import { pagePath, site } from '../../../lib/site';
6-
import { engineName, postEngine } from '../../../lib/posts';
6+
import { engineArchives, engineName, type PublishedPosts } from '../../../lib/posts';
77
import { engines } from '../../../data/engines';
88
99
/**
@@ -23,27 +23,16 @@ import { engines } from '../../../data/engines';
2323
*/
2424
export const getStaticPaths = (async () => {
2525
const { getCollection } = await import('astro:content');
26-
const { postEngine } = await import('../../../lib/posts');
2726
2827
const posts = await getCollection('posts', ({ data }) => data.status === 'published');
29-
const byEngine = new Map<string, typeof posts>();
30-
for (const post of posts) {
31-
const id = postEngine(post.id);
32-
if (!id) continue;
33-
byEngine.set(id, [...(byEngine.get(id) ?? []), post]);
34-
}
3528
36-
return [...byEngine.entries()]
37-
.filter(([, list]) => list.length >= 2)
38-
.map(([engine, list]) => ({
39-
params: { engine },
40-
props: {
41-
posts: list.sort((a, b) => b.data.publishedAt.getTime() - a.data.publishedAt.getTime()),
42-
},
43-
}));
29+
return [...engineArchives(posts).entries()].map(([engine, list]) => ({
30+
params: { engine },
31+
props: { posts: list },
32+
}));
4433
}) satisfies GetStaticPaths;
4534
46-
type Props = { posts: Awaited<ReturnType<typeof import('astro:content').getCollection<'posts'>>> };
35+
type Props = { posts: PublishedPosts };
4736
const { engine } = Astro.params;
4837
const { posts } = Astro.props;
4938
@@ -53,20 +42,11 @@ const engineData = engines.find((e) => e.id === engine);
5342
// Sibling archives, so a reader comparing two engines can cross over without
5443
// going back to the flat list. Built from what actually shipped, not from the
5544
// full engine list — an archive that does not exist must not be linked.
56-
const siblings = (await import('astro:content'))
57-
.getCollection('posts', ({ data }) => data.status === 'published')
58-
.then((all) => {
59-
const counts = new Map<string, number>();
60-
for (const p of all) {
61-
const id = postEngine(p.id);
62-
if (id) counts.set(id, (counts.get(id) ?? 0) + 1);
63-
}
64-
return [...counts.entries()]
65-
.filter(([id, n]) => n >= 2 && id !== engine)
66-
.map(([id]) => ({ id, name: engineName(id) ?? id }))
67-
.sort((a, b) => a.name.localeCompare(b.name));
68-
});
69-
const others = await siblings;
45+
const all = await (await import('astro:content')).getCollection('posts', ({ data }) => data.status === 'published');
46+
const others = [...engineArchives(all).keys()]
47+
.filter((id) => id !== engine)
48+
.map((id) => ({ id, name: engineName(id) ?? id }))
49+
.sort((a, b) => a.name.localeCompare(b.name));
7050
7151
const description = `Every LibreDB Studio write-up about ${name}: what the provider does, where it stops, and why. ${posts.length} posts.`;
7252
@@ -98,7 +78,13 @@ const schema = [
9878
];
9979
---
10080

101-
<BaseLayout path={`/blog/engine/${engine}`} title={`${name} posts`} description={description} schema={schema}>
81+
<BaseLayout
82+
path={`/blog/engine/${engine}`}
83+
title={`${name} posts`}
84+
description={description}
85+
schema={schema}
86+
feed={{ href: `/blog/engine/${engine}/rss.xml`, title: `${site.name} — ${name} posts` }}
87+
>
10288
<section class="earc" aria-labelledby="earc-title">
10389
<div class="u-wash earc__wash" aria-hidden="true"></div>
10490
<div class="earc__inner u-container">
@@ -126,6 +112,8 @@ const schema = [
126112
<p class="earc__links">
127113
<a href={pagePath('/databases')}>{name} support in Studio →</a>
128114
<span aria-hidden="true">·</span>
115+
<a href={pagePath(`/blog/engine/${engine}/rss.xml`)}>{name} RSS feed →</a>
116+
<span aria-hidden="true">·</span>
129117
<a href={pagePath('/blog')}>All posts</a>
130118
</p>
131119
</header>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import rss from '@astrojs/rss';
2+
import type { APIContext, GetStaticPaths } from 'astro';
3+
import { canonicalUrl, pagePath, site } from '../../../../lib/site';
4+
import { feedCreator, feedSelfLink, feedXmlns } from '../../../../lib/feed';
5+
import { engineName, type PublishedPosts } from '../../../../lib/posts';
6+
7+
/**
8+
* One feed per engine archive, alongside the archive page itself.
9+
*
10+
* The site-wide feed carries every post, so an aggregator that wants one
11+
* engine has to filter it downstream. Planet for the MySQL Community asks for
12+
* the opposite in the notes at the top of its own planet.ini
13+
* (github.com/oursqlcommunity-org/planet): "If your blog contains a mix MySQL
14+
* and non-MySQL content, please consider submitting a category or tag feed
15+
* instead of a default feed." Without one, our entry there runs the site feed
16+
* through siftrss on a title regex, which misses posts whose title does not
17+
* name the engine. mysql-wire-compatible-engines-one-provider is titled "Nine
18+
* servers, one connection type, different answers" and is exactly that case.
19+
*
20+
* The grouping is not new: /blog/engine/<id> already lists these posts. Both
21+
* routes read it from engineArchives(), so the threshold lives in one place.
22+
*/
23+
export const getStaticPaths = (async () => {
24+
const { getCollection } = await import('astro:content');
25+
const { engineArchives } = await import('../../../../lib/posts');
26+
27+
const posts = await getCollection('posts', ({ data }) => data.status === 'published');
28+
29+
return [...engineArchives(posts).entries()].map(([engine, list]) => ({
30+
params: { engine },
31+
props: { posts: list },
32+
}));
33+
}) satisfies GetStaticPaths;
34+
35+
type Props = { posts: PublishedPosts };
36+
37+
export function GET(context: APIContext) {
38+
const engine = context.params.engine!;
39+
const { posts } = context.props as Props;
40+
const name = engineName(engine) ?? engine;
41+
42+
return rss({
43+
title: `${site.name} — ${name} posts`,
44+
description: `Posts about ${name} from the ${site.name} blog.`,
45+
// The archive, not the home page: a reader who follows the feed back should
46+
// land on the list it was cut from.
47+
site: canonicalUrl(`/blog/engine/${engine}`),
48+
trailingSlash: true,
49+
xmlns: feedXmlns,
50+
items: posts.map((post) => ({
51+
title: post.data.title,
52+
description: post.data.description,
53+
pubDate: post.data.publishedAt,
54+
link: pagePath(`/blog/${post.id}`),
55+
// The engine first, so an aggregator filtering on category has the name
56+
// it asked for. Deduped because a post is free to carry the engine as a
57+
// tag too, and two identical <category> elements help nobody.
58+
categories: [name, ...post.data.tags.map((t) => t.label)],
59+
customData: feedCreator(post.data.author.name || site.name),
60+
})),
61+
customData: `<language>en</language>${feedSelfLink(`/blog/engine/${engine}/rss.xml`)}`,
62+
});
63+
}

‎src/pages/blog/index.astro‎

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { getCollection } from 'astro:content';
33
import BaseLayout from '../../layouts/BaseLayout.astro';
44
import PostCard from '../../components/blog/PostCard.astro';
55
import { pagePath, site } from '../../lib/site';
6-
import { engineName, postEngine } from '../../lib/posts';
6+
import { engineArchives, engineName } from '../../lib/posts';
77
88
const posts = (await getCollection('posts', ({ data }) => data.status === 'published')).sort(
99
(a, b) => b.data.publishedAt.getTime() - a.data.publishedAt.getTime(),
@@ -12,14 +12,8 @@ const posts = (await getCollection('posts', ({ data }) => data.status === 'publi
1212
// A way into 104 posts that is not "scroll". These are plain links to the
1313
// archives rather than a client-side filter: the grouping is then crawlable,
1414
// linkable and survives with JavaScript off, and the work is already done.
15-
const byEngine = new Map<string, number>();
16-
for (const post of posts) {
17-
const id = postEngine(post.id);
18-
if (id) byEngine.set(id, (byEngine.get(id) ?? 0) + 1);
19-
}
20-
const archives = [...byEngine.entries()]
21-
.filter(([, n]) => n >= 2)
22-
.map(([id, n]) => ({ id, n, name: engineName(id) ?? id }))
15+
const archives = [...engineArchives(posts).entries()]
16+
.map(([id, list]) => ({ id, n: list.length, name: engineName(id) ?? id }))
2317
.sort((a, b) => b.n - a.n || a.name.localeCompare(b.name));
2418
---
2519

‎src/pages/rss.xml.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import rss from '@astrojs/rss';
22
import { getCollection } from 'astro:content';
33
import { pagePath, site } from '../lib/site';
4+
import { feedCreator, feedSelfLink, feedXmlns } from '../lib/feed';
45
import type { APIContext } from 'astro';
56

67
export async function GET(context: APIContext) {
@@ -13,14 +14,17 @@ export async function GET(context: APIContext) {
1314
description: site.description,
1415
site: context.site ?? site.url,
1516
trailingSlash: true,
17+
xmlns: feedXmlns,
1618
items: posts.map((post) => ({
1719
title: post.data.title,
1820
description: post.data.description,
1921
pubDate: post.data.publishedAt,
2022
link: pagePath(`/blog/${post.id}`),
2123
categories: post.data.tags.map((t) => t.label),
22-
author: post.data.author.name || site.name,
24+
// Not <author>: RSS 2.0 defines that element as an email address, and a
25+
// bare name there fails the W3C validator. The name belongs in dc:creator.
26+
customData: feedCreator(post.data.author.name || site.name),
2327
})),
24-
customData: '<language>en</language>',
28+
customData: `<language>en</language>${feedSelfLink('/rss.xml')}`,
2529
});
2630
}

‎tests/dist-smoke.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,16 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
33
import { parseHTML } from 'linkedom';
44
import site from '../site.config.json' with { type: 'json' };
55
import { engines } from '../src/data/engines';
6+
import { feedCreator } from '../src/lib/feed';
67

78
const page = (path: string) => parseHTML(readFileSync(path, 'utf8')).document;
9+
/** Every per-engine feed that was actually built. */
10+
const engineFeeds = () =>
11+
existsSync('dist/blog/engine')
12+
? readdirSync('dist/blog/engine', { withFileTypes: true })
13+
.filter((e) => e.isDirectory() && existsSync(`dist/blog/engine/${e.name}/rss.xml`))
14+
.map((e) => `dist/blog/engine/${e.name}/rss.xml`)
15+
: [];
816

917
/** Every stylesheet the homepage links, concatenated. Astro splits them per
1018
* component, so which chunk the hero lands in is not something to assert on. */
@@ -169,6 +177,39 @@ describe('SEO surface', () => {
169177
expect(items).toBe(postDirs.length);
170178
expect(rss).toContain(`${site.url}/blog/the-tool-goes-to-the-data/`);
171179
expect(rss).toContain('<language>en</language>');
180+
});
181+
182+
it('serves feeds that parse as XML at all, with the author in dc:creator', () => {
183+
// Every feed puts the author's name in <dc:creator> and declares where it
184+
// lives, because RSS 2.0 defines <author> as an email address and the W3C
185+
// validator rejects a name there. Both depend on the namespaces declared on
186+
// <rss>: drop that one line and every feed on the site stops being
187+
// well-formed XML, which nothing here used to notice.
188+
const feeds = ['dist/rss.xml', ...engineFeeds()];
189+
expect(feeds.length, 'no feeds were built').toBeGreaterThanOrEqual(1);
190+
191+
for (const path of feeds) {
192+
const xml = readFileSync(path, 'utf8');
193+
expect(xml, `${path}: dc namespace`).toContain('xmlns:dc="http://purl.org/dc/elements/1.1/"');
194+
expect(xml, `${path}: atom namespace`).toContain('xmlns:atom="http://www.w3.org/2005/Atom"');
195+
expect(xml, `${path}: author must not be a bare name`).not.toContain('<author>');
196+
expect(xml, `${path}: creator`).toContain('<dc:creator>');
197+
expect(xml, `${path}: self link`).toMatch(/<atom:link href="[^"]+" rel="self" type="application\/rss\+xml"\/>/);
198+
// Every prefix used in the body must be one of the declared namespaces.
199+
for (const prefix of new Set([...xml.matchAll(/<([a-z]+):[a-z]+/g)].map((m) => m[1]))) {
200+
expect(xml, `${path}: ${prefix} prefix used but not declared`).toContain(`xmlns:${prefix}=`);
201+
}
202+
}
203+
});
204+
205+
it('escapes a creator name that carries XML syntax', () => {
206+
// The site feed's author name contains an ampersand, the case that breaks a
207+
// feed silently: a reader stops at the parse error and shows nothing.
208+
expect(feedCreator('a & b')).toBe('<dc:creator>a &amp; b</dc:creator>');
209+
expect(feedCreator('<i>x</i>')).toBe('<dc:creator>&lt;i&gt;x&lt;/i&gt;</dc:creator>');
210+
expect(readFileSync('dist/rss.xml', 'utf8')).toContain(
211+
'<dc:creator>Cevheri &amp; LibreDB Engineering</dc:creator>',
212+
);
172213

173214
const sitemap = readFileSync('dist/sitemap-0.xml', 'utf8');
174215
expect(sitemap).toContain(`${site.url}/`);

0 commit comments

Comments
 (0)