Blogging platform with advanced tools for arts and sciences.
6

Configure Feed

Select the types of activity you want to include in your feed.

Add support for Atom feeds

lemma (Jun 7, 2026, 3:36 PM -0500) 230bf1af 8e81d92e

+109
+97
infra/api/src/index.ts
··· 209 209 return { statusCode: 200, headers: { 'Content-Type': 'text/plain' }, body: result.Item.pubAtUri as string }; 210 210 } 211 211 212 + function escXml(s: string): string { 213 + return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); 214 + } 215 + 216 + async function getPublicationFeed(rawPath: string): Promise<APIGatewayProxyResultV2> { 217 + if (!SITE_DOMAIN) { 218 + return { statusCode: 404, headers: { 'Content-Type': 'text/plain' }, body: 'Not found' }; 219 + } 220 + 221 + const feedSuffix = '/feed.xml'; 222 + const rawPubPath = rawPath.slice(0, rawPath.length - feedSuffix.length); 223 + const pubPath = decodeURIComponent(rawPubPath); 224 + const pubUrl = `https://${SITE_DOMAIN}${pubPath}`; 225 + 226 + const urlLookup = await ddb.send(new GetCommand({ 227 + TableName: TABLE, 228 + Key: { PK: `url#${pubUrl}`, SK: 'info' }, 229 + })); 230 + if (!urlLookup.Item?.pubAtUri) { 231 + return { statusCode: 404, headers: { 'Content-Type': 'text/plain' }, body: 'Not found' }; 232 + } 233 + 234 + const pubAtUri = urlLookup.Item.pubAtUri as string; 235 + const atParts = pubAtUri.slice('at://'.length).split('/'); 236 + const did = atParts[0]; 237 + const pubRkey = atParts[2]; 238 + 239 + const [pubResult, postsResult, authorHandle] = await Promise.all([ 240 + ddb.send(new GetCommand({ TableName: TABLE, Key: { PK: `u#${did}#pub`, SK: `pub#${pubRkey}` } })), 241 + ddb.send(new QueryCommand({ 242 + TableName: TABLE, 243 + KeyConditionExpression: 'PK = :pk', 244 + FilterExpression: 'visibility = :vis AND site = :site', 245 + ExpressionAttributeValues: { ':pk': `u#${did}#post`, ':vis': 'published', ':site': pubAtUri }, 246 + ScanIndexForward: false, 247 + Limit: 100, 248 + })), 249 + resolveAuthorHandle(did), 250 + ]); 251 + 252 + if (!pubResult.Item) { 253 + return { statusCode: 404, headers: { 'Content-Type': 'text/plain' }, body: 'Not found' }; 254 + } 255 + 256 + const pub = pubResult.Item; 257 + const pubName = pub.name as string; 258 + const pubSiteUrl = (pub.url as string).replace(/\/$/, ''); 259 + const pubDescription = pub.description as string | undefined; 260 + const posts = (postsResult.Items ?? []).slice(0, 20); 261 + const selfUrl = `https://${SITE_DOMAIN}${rawPath}`; 262 + const updatedAt = posts.length > 0 263 + ? ((posts[0].updatedAt ?? posts[0].publishedAt) as string) 264 + : new Date().toISOString(); 265 + 266 + const entryXml = posts.map((post) => { 267 + const base = (post.resolvedUrl as string | undefined ?? pubSiteUrl).replace(/\/$/, ''); 268 + const postUrl = `${base}${post.path as string | undefined ?? ''}`; 269 + const summary = (post.description ?? post.textContent) as string | undefined; 270 + const lines = [ 271 + ' <entry>', 272 + ` <id>${escXml(post.atUri as string)}</id>`, 273 + ` <title>${escXml(post.title as string)}</title>`, 274 + ` <link href="${escXml(postUrl)}" rel="alternate" type="text/html"/>`, 275 + ` <published>${post.publishedAt as string}</published>`, 276 + ` <updated>${(post.updatedAt ?? post.publishedAt) as string}</updated>`, 277 + ]; 278 + if (summary) lines.push(` <summary type="text">${escXml(summary)}</summary>`); 279 + lines.push(' </entry>'); 280 + return lines.join('\n'); 281 + }); 282 + 283 + const feedLines = [ 284 + '<?xml version="1.0" encoding="UTF-8"?>', 285 + '<feed xmlns="http://www.w3.org/2005/Atom">', 286 + ` <id>${escXml(pubAtUri)}</id>`, 287 + ` <title>${escXml(pubName)}</title>`, 288 + ]; 289 + if (pubDescription) feedLines.push(` <subtitle>${escXml(pubDescription)}</subtitle>`); 290 + feedLines.push( 291 + ` <link href="${escXml(pubSiteUrl)}" rel="alternate" type="text/html"/>`, 292 + ` <link href="${escXml(selfUrl)}" rel="self" type="application/atom+xml"/>`, 293 + ` <updated>${updatedAt}</updated>`, 294 + ' <author>', 295 + ` <name>${escXml(authorHandle)}</name>`, 296 + ' </author>', 297 + ...entryXml, 298 + '</feed>', 299 + ); 300 + 301 + return { 302 + statusCode: 200, 303 + headers: { 'Content-Type': 'application/atom+xml; charset=utf-8', 'Cache-Control': 'public, max-age=300' }, 304 + body: feedLines.join('\n'), 305 + }; 306 + } 307 + 212 308 async function resolvePublicationMeta(atUri: string): Promise<{ url: string; name: string }> { 213 309 const parts = atUri.slice('at://'.length).split('/') 214 310 const pubDid = parts[0] ··· 886 982 887 983 // Public endpoints — no auth required. 888 984 if (method === 'GET' && path.endsWith('/.well-known/site.standard.publication')) return verifyPublication(path); 985 + if (method === 'GET' && path.endsWith('/feed.xml')) return getPublicationFeed(path); 889 986 if (method === 'GET' && path === '/feed') return getFeed(event); 890 987 if (method === 'GET' && path === '/iframely') return getIframelyEmbed(event); 891 988 if (method === 'GET' && path === '/posts/content') return getPublicPostContent(event);
+12
infra/cf/cloudfront.yaml
··· 154 154 OriginSSLProtocols: 155 155 - TLSv1.2 156 156 CacheBehaviors: 157 + - PathPattern: '*/feed.xml' 158 + TargetOriginId: ApiGatewayOrigin 159 + ViewerProtocolPolicy: redirect-to-https 160 + AllowedMethods: 161 + - GET 162 + - HEAD 163 + CachedMethods: 164 + - GET 165 + - HEAD 166 + # CachingDisabled — every request reaches the Lambda 167 + CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad 168 + Compress: true 157 169 - PathPattern: '*/.well-known/site.standard.publication' 158 170 TargetOriginId: ApiGatewayOrigin 159 171 ViewerProtocolPolicy: redirect-to-https