diff options
feat: an RSS
| -rw-r--r-- | src/lib/index.ts | 4 | ||||
| -rw-r--r-- | src/lib/vendor/rss/rss.example.md | 110 | ||||
| -rw-r--r-- | src/lib/vendor/rss/rss.ts | 231 | ||||
| -rw-r--r-- | src/lib/vendor/rss/xml.example.md | 12 | ||||
| -rw-r--r-- | src/lib/vendor/rss/xml.ts | 9 | ||||
| -rw-r--r-- | src/routes/blog/+page.svelte | 26 | ||||
| -rw-r--r-- | src/routes/blog/feed.xml/+server.ts | 41 | ||||
| -rw-r--r-- | src/routes/blog/posts/LICENSE | 2 |
8 files changed, 425 insertions, 10 deletions
diff --git a/src/lib/index.ts b/src/lib/index.ts index c83493b..46be9a2 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,3 +1,6 @@ +import { dev } from '$app/environment'; +import { env } from '$env/dynamic/public'; + /* Copyright (C) 2024-2026 memdmp @@ -10,3 +13,4 @@ export * as styles from "./styles.ts"; export const forceTrailingSlash = (v: string) => v.endsWith('/') ? v : `${v}/`; +export const RSS_ROOT = env.PUBLIC_RSS_ROOT ?? (dev ? 'http://localhost:5173/~mem/blog/' : 'https://estrogen.zone/~mem/blog/') diff --git a/src/lib/vendor/rss/rss.example.md b/src/lib/vendor/rss/rss.example.md new file mode 100644 index 0000000..4e532fd --- /dev/null +++ b/src/lib/vendor/rss/rss.example.md @@ -0,0 +1,110 @@ +```ts +const doc = new XMLDocumentRoot().child( + new XMLDeclaration().version().encoding(), + new RSSRootElement() + .channel( + new RSSChannelElement( + 'Latest blog posts for 7222e800', + 'Some Description Here', + 'https://estrogen.zone/~mem/blog/' + ) + .pubDate(new Date('2026-01-14T15:53:57Z') /* When the last item was published */) + .lastBuildDate(new Date() /* When this file was last updated - usually when your build process last ran */) + .language('en') + .child(new XMLElement('nonStandardElement').attribute('non-standard', 'true')) + .items( + new RSSItemElement( + 'Some Fancy Blog Post Title', + 'Imagine some crazy fun thing here that is guaranteed to get the reader hooked. A really good blurb would be here in practice.', + 'https://estrogen.zone/~mem/blog/1234567890-your-fancy-post-slug/', + ) + .author('7222e800') + .guid('https://estrogen.zone/~mem/blog/1234567890', true) + .pubDate(new Date('2026-01-14T15:53:57Z')), + new RSSItemElement( + 'Domesticated Catgirl Transport', + 'Smuggling multiple thousands of catgirls over borders isn\'t an easy feat. Here\'s how we did it.', + 'https://estrogen.zone/~mem/blog/1234566789-catgirl-smuggling-operation/', + ) + .author('CatgirlSmuggler9000') + .author('7222e800') + .guid('https://estrogen.zone/~mem/blog/1234566789', true) + .pubDate(new Date('2026-01-14T15:53:57Z')), + ) + ) +); +const xml = doc.toString(); +``` + +will output + +```xml +<?xml version="1.0" encoding="UTF-8" ?> +<rss version="2.0" + xmlns:content="http://purl.org/rss/1.0/modules/content/"> + <channel> + <title> + Latest blog posts for 7222e800 + </title> + <description> + Some Description Here + </description> + <link> + https://estrogen.zone/~mem/blog/ + </link> + <pubDate> + Wed, 14 Jan 2026 15:53:57 GMT + </pubDate> + <lastBuildDate> + Mon, 26 Jan 2026 04:45:27 GMT + </lastBuildDate> + <language> + en + </language> + <nonStandardElement non-standard="true" /> + <item> + <title> + Some Fancy Blog Post Title + </title> + <description> + Imagine some crazy fun thing here that is guaranteed to get the reader hooked. A really good blurb would be here in practice. + </description> + <link> + https://estrogen.zone/~mem/blog/1234567890-your-fancy-post-slug/ + </link> + <author> + 7222e800 + </author> + <guid isPermaLink="true"> + https://estrogen.zone/~mem/blog/1234567890 + </guid> + <pubDate> + Wed, 14 Jan 2026 15:53:57 GMT + </pubDate> + </item> + <item> + <title> + Domesticated Catgirl Transport + </title> + <description> + Smuggling multiple thousands of catgirls over borders isn't an easy feat. Here's how we did it. + </description> + <link> + https://estrogen.zone/~mem/blog/1234566789-catgirl-smuggling-operation/ + </link> + <author> + CatgirlSmuggler9000 + </author> + <author> + 7222e800 + </author> + <guid isPermaLink="true"> + https://estrogen.zone/~mem/blog/1234566789 + </guid> + <pubDate> + Wed, 14 Jan 2026 15:32:57 GMT + </pubDate> + </item> + </channel> +</rss> +``` diff --git a/src/lib/vendor/rss/rss.ts b/src/lib/vendor/rss/rss.ts new file mode 100644 index 0000000..f44a6d2 --- /dev/null +++ b/src/lib/vendor/rss/rss.ts @@ -0,0 +1,231 @@ +import { XMLElement, XMLRootElement, XMLTagType, XMLText } from './xml'; + +class XMLElementWithRSSUtil extends XMLElement { + protected replaceChildByTagName(tag: string) { + const existingEl = this.findElementChild(v => v.tagName === tag); + if (existingEl) { + existingEl.children.length = 0; + existingEl.attributes.clear(); + } + const el = existingEl ?? new XMLElement(tag); + if (!existingEl) + this.child(el) + return el + } +} +export class RSSItemElement extends XMLElementWithRSSUtil { + public constructor(title: string | null, description: string | null, link: string | URL | null) { + super("item"); + if (title !== null) + this.title(title); + if (description !== null) + this.description(description) + if (link !== null) + this.link(link) + } + /** Replaces the title of the item */ + public title(title: string) { + this.replaceChildByTagName('title').child(new XMLText(title)) + return this; + } + /** Replaces the link to the item */ + public link(link: string | URL) { + this.replaceChildByTagName('link').child(new XMLText(typeof link === 'string' ? link : link.href)) + return this; + } + /** Sets the description of the item, replacing it if it already exists */ + public description(description: string) { + this.replaceChildByTagName('description').child(new XMLText(description)) + return this; + } + /** Sets the URL for the comment section of an item, replacing it if it already exists */ + public comments(comments: string | URL) { + return this.child(new XMLElement('comments').child(new XMLText(typeof comments === 'string' ? comments : comments.href))); + } + /** Sets the timestamp where the most recent item in the channel was published, replacing it if it already exists */ + public pubDate(pubDate: Date) { + this.replaceChildByTagName('pubDate').child(new XMLText(pubDate.toUTCString())) + return this; + } + /** Specifies a third-party source for the item. */ + public source(sourceName: string, sourceUrl: string | URL) { + return this.child(new XMLElement('source').attribute('url', typeof sourceUrl === 'string' ? sourceUrl : sourceUrl.href).child(new XMLText(sourceName))) + } + /** Adds a category to the post */ + public category( + category: string, + ) { + return this.child(new XMLElement('category').child(new XMLText(category))); + } + /** Adds a author to the post */ + public author( + author: string, + ) { + return this.child(new XMLElement('author').child(new XMLText(author))); + } + /** Adds a unique ID to the post. This ID must be globally unique, but has no format specifications. */ + public guid( + id: string, + /** If this is a permanent link to the post. If true, this must be a URL that permanently points to this item. */ + isPermaLink: boolean + ) { + return this.child(new XMLElement('guid').child(new XMLText(id)).attribute('isPermaLink', isPermaLink ? 'true' : 'false')); + } + /** Adds a media file to be included with an item */ + public enclosure( + url: string, + mimeType: string, + /** in bytes */ + length: number + ) { + return this.child( + new XMLElement('enclosure') + .attribute('url', url) + .attribute('length', length.toString()) + .attribute('type', mimeType) + ) + } +} +export class RSSChannelElement extends XMLElementWithRSSUtil { + public constructor(title: string | null, description: string | null, link: URL | string | null) { + super("channel"); + if (title !== null) + this.title(title); + if (description !== null) + this.description(description) + if (link !== null) + this.link(link) + } + /** Replaces the title of the channel */ + public title(title: string) { + this.replaceChildByTagName('title').child(new XMLText(title)) + return this; + } + /** Replaces the link to the channel */ + public link(link: string | URL) { + this.replaceChildByTagName('link').child(new XMLText(typeof link === 'string' ? link : link.href)) + return this; + } + /** Sets the description of the channel, replacing it if it already exists */ + public description(description: string) { + this.replaceChildByTagName('description').child(new XMLText(description)) + return this; + } + /** Sets the timestamp where the most recent item in the channel was published, replacing it if it already exists */ + public pubDate(pubDate: Date) { + this.replaceChildByTagName('pubDate').child(new XMLText(pubDate.toUTCString())) + return this; + } + /** Sets the timestamp where the RSS file was last modified in any way, replacing it if it already exists */ + public lastBuildDate(lastBuildDate: Date) { + this.replaceChildByTagName('lastBuildDate').child(new XMLText(lastBuildDate.toUTCString())) + return this; + } + /** Sets the time to live for the channel - the maximum time until clients should re-fetch it, replacing it if it already exists */ + public ttl(ttl: number) { + this.replaceChildByTagName('ttl').child(new XMLText(ttl.toString())) + return this; + } + /** Adds the copyright information of the channel */ + public copyright(copyright: string) { + return this.child(new XMLElement('copyright').child(new XMLText(copyright))) + } + /** Adds the generator used for this RSS feed - we recommend calling this and keeping it at .rsstrogen() - Can be called multiple times */ + public generator(generator: string = 'rsstrogen') { + return this.child(new XMLElement('generator').child(new XMLText(generator))); + } + /** Adds the webmaster managing this feed's email address */ + public webmaster(webmaster: string) { + return this.child(new XMLElement('webMaster').child(new XMLText(webmaster))) + } + /** Adds the managing editor managing this feed's email address */ + public managingEditor(managingEditor: string) { + return this.child(new XMLElement('managingEditor').child(new XMLText(managingEditor))) + } + /** + * Adds a URL of an image to be displayed when aggregators present a feed + * + * **Note:** The image must be of type GIF, JPEG or PNG. + */ + public image(image: { + /** Defines the hyperlink to the website that offers the channel */ + link: string; + /** Defines the text to display if the image could not be shown and/or for screen readers */ + alt: string; + /** Specifies the URL to the image */ + url: URL | string; + /** Specifies the text in the HTML title attribute of the link around the image. Defaults to the value of alt */ + description?: string | null; + /** The resolution of the image. Per Spec: Default is 88x31, Max is 144x400. */ + resolution?: [width: number, height: number] + }) { + const img = new XMLElement('image').child( + new XMLElement('link').child(new XMLText(image.link)), + new XMLElement('title').child(new XMLText(image.alt)), + new XMLElement('url').child(new XMLText(typeof image.url === 'string' ? image.url : image.url.href)), + ); + if (image.description !== null) + img.child( + new XMLElement('description').child(new XMLText(image.description ?? image.alt)), + ); + if (image.resolution) + img.child( + new XMLElement('width').child(new XMLText(image.resolution[0].toString())), + new XMLElement('height').child(new XMLText(image.resolution[1].toString())), + ); + return this.child(img); + } + /** Sets (or specifies an additional) language for the feed */ + public language(language: RSSLanguageCode) { + return this.child(new XMLElement('language').child(new XMLText(language))); + } + /** Specifies a search engine/text input should be specified with the feed */ + public search(engine: { + /** @default "Search" */ + title?: string, + /** @example "Search Neobot Systems" */ + description: string, + /** @example "https://search.example.com/search?" */ + link: string, + /** @example "query" */ + param: string, + }) { + this.replaceChildByTagName('textInput').child( + new XMLElement('title').child(new XMLText(engine.title ?? 'Search')), + new XMLElement('description').child(new XMLText(engine.description)), + new XMLElement('link').child(new XMLText(engine.link)), + new XMLElement('name').child(new XMLText(engine.param)), + ); + return this; + } + /** Adds a category to the feed */ + public category( + category: string, + /** Optional - A string or URL that identifies a categorization taxonomy. */ + domain?: string + ) { + const cat = new XMLElement('category'); + if (domain !== undefined) cat.attribute('domain', domain) + this.child(cat.child(new XMLText(category))) + return this; + } + // TODO: add more tags + /** Adds items to the RSS feed */ + public items(...items: RSSItemElement[]) { + return this.child(...items); + } +} +export class RSSRootElement extends XMLRootElement { + public constructor() { + super("rss") + this + .attribute("version", "2.0") + .xmlns("content", "http://purl.org/rss/1.0/modules/content/") + } + public channel(...channels: RSSChannelElement[]) { + return this.child(...channels) + } +} + +/** @link https://www.rssboard.org/rss-language-codes */ +export type RSSLanguageCode = 'af' | 'sq' | 'eu' | 'be' | 'bg' | 'ca' | 'zh-cn' | 'zh-tw' | 'hr' | 'cs' | 'da' | 'nl' | 'nl-be' | 'nl-nl' | 'en' | 'en-au' | 'en-bz' | 'en-ca' | 'en-ie' | 'en-jm' | 'en-nz' | 'en-ph' | 'en-za' | 'en-tt' | 'en-gb' | 'en-us' | 'en-zw' | ' et' | 'fo' | 'fi' | 'fr' | 'fr-be' | 'fr-ca' | 'fr-fr' | 'fr-lu' | 'fr-mc' | 'fr-ch' | 'gl' | 'gd' | 'de' | 'de-at' | 'de-de' | 'de-li' | 'de-lu' | 'de-ch' | 'el' | 'haw' | 'hu' | 'is' | 'in' | 'ga' | 'it' | 'it-it' | 'it-ch' | 'ja' | 'ko' | 'mk' | 'no' | 'pl' | 'pt' | 'pt-br' | 'pt-pt' | 'ro' | 'ro-mo' | 'ro-ro' | 'ru' | 'ru-mo' | 'ru-ru' | 'sr' | 'sk' | 'sl' | 'es' | 'es-ar' | 'es-bo' | 'es-cl' | 'es-co' | 'es-cr' | 'es-do' | 'es-ec' | 'es-sv' | 'es-gt' | 'es-hn' | 'es-mx' | 'es-ni' | 'es-pa' | 'es-py' | 'es-pe' | 'es-pr' | 'es-es' | 'es-uy' | 'es-ve' | 'sv' | 'sv-fi' | 'sv-se' | 'tr' | 'uk' diff --git a/src/lib/vendor/rss/xml.example.md b/src/lib/vendor/rss/xml.example.md index ba006e2..591904a 100644 --- a/src/lib/vendor/rss/xml.example.md +++ b/src/lib/vendor/rss/xml.example.md @@ -6,10 +6,10 @@ If you like directly working with XML, here's an example of how to for this: const posts = [ { title: 'Launching SSH during early boot with mkinitfs', - url: 'https://estrogen.zone/~mem/blog/1768406136', + url: 'https://estrogen.zone/~mem/blog/1768406136-alpine-ssh-early-initfs-disk-decryption/', blurb: 'Replacing the early init with our own script to launch SSH, killing it in early userspace, and allowing remote disk decryption in the mean time', author: '7222e800', - guid: '1768406136', + guid: 'https://estrogen.zone/~mem/blog/1768406136', published: new Date('2026-01-14T15:53:57Z').toUTCString() } ]; @@ -131,7 +131,7 @@ XMLDocumentRoot { children: [ XMLText { tagType: '#text', - text: 'https://estrogen.zone/~mem/blog/1768406136' + text: 'https://estrogen.zone/~mem/blog/1768406136-alpine-ssh-early-initfs-disk-decryption/' } ], tagType: '#element', @@ -164,7 +164,7 @@ XMLDocumentRoot { children: [ XMLText { tagType: '#text', - text: '1768406136' + text: 'https://estrogen.zone/~mem/blog/1768406136' } ], tagType: '#element', @@ -222,7 +222,7 @@ and this RSS: Launching SSH during early boot with mkinitfs </title> <link> - https://estrogen.zone/~mem/blog/1768406136 + https://estrogen.zone/~mem/blog/1768406136-alpine-ssh-early-initfs-disk-decryption/ </link> <description> Replacing the early init with our own script to launch SSH, killing it in early userspace, and allowing remote disk decryption in the mean time @@ -231,7 +231,7 @@ and this RSS: 7222e800 </author> <guid> - 1768406136 + https://estrogen.zone/~mem/blog/1768406136 </guid> <published> Wed, 14 Jan 2026 15:53:57 GMT diff --git a/src/lib/vendor/rss/xml.ts b/src/lib/vendor/rss/xml.ts index 30618c0..8a0d2c4 100644 --- a/src/lib/vendor/rss/xml.ts +++ b/src/lib/vendor/rss/xml.ts @@ -52,11 +52,11 @@ export abstract class XMLTagWithAttributes extends BaseXMLTag { } } export abstract class XMLTagWithChildrenAndAttributes extends XMLTagWithAttributes { - public children: BaseXMLTag[] = []; + public children: XMLTag[] = []; protected get contentString() { return this.children.map(v => v.toString()).join('\n'); } - public child(...children: BaseXMLTag[]) { + public child(...children: XMLTag[]) { this.children.push(...children); return this; } @@ -82,6 +82,9 @@ export class XMLDeclaration extends XMLTagWithChildrenAndAttributes { export class XMLElement extends XMLTagWithChildrenAndAttributes { public readonly tagType = XMLTagType.Element as const; public constructor(public tagName: string) { super() } + protected findElementChild(query: (element: XMLElement) => boolean) { + return this.children.find(el => el.tagType === XMLTagType.Element && query(el)) as XMLElement + } public toString(): string { return `<${this.tagName}${this.attributeString}${this.children.length ? `> ${indent(this.contentString, 2)} @@ -138,4 +141,4 @@ export class XMLCData extends BaseXMLTag { return `<![CDATA[${this.content.replace(/\]\]>/gu, ']]]]><![CDATA[>')}]]>`; } } -export type XMLTag = XMLDocumentRoot | XMLDeclaration | XMLElement | XMLComment; +export type XMLTag = XMLDocumentRoot | XMLDeclaration | XMLElement | XMLText | XMLComment | XMLCData; diff --git a/src/routes/blog/+page.svelte b/src/routes/blog/+page.svelte index fb4592a..bd6e806 100644 --- a/src/routes/blog/+page.svelte +++ b/src/routes/blog/+page.svelte @@ -59,15 +59,39 @@ </div> </div> {/each} + <div class="flex gap-2"> + <div class="flex flex-col items-end"> + <span class="h-0 opacity-0 select-none">short</span> + <a href={resolve('/blog/feed.xml')} class="quicklink">rss</a> + </div> + <div class="flex flex-col"> + <table> + <tbody> + <tr> + <td + >content under cc-by-nc-sa 4.0 international unless + otherwise noted.</td + > + </tr> + </tbody> + </table> + </div> + </div> </div> {:else} <h2 class="font-space-grotesk text-5xl mt-8 mb-4"> <span class="text-accent-primary select-none">zsh: </span>no matches found<span class="text-accent-primary select-none">.</span> </h2> - <p class="mt-1.5"> + <p class="my-1.5"> Feel free to check back once some posts are published. </p> + <p class="my-1.5"> + Want posts to show up on an RSS reader once they exist? Here's a <a + href={resolve('/blog/feed.xml')} + class="quicklink">feed.xml</a + >. + </p> {/if} </div> </div> diff --git a/src/routes/blog/feed.xml/+server.ts b/src/routes/blog/feed.xml/+server.ts new file mode 100644 index 0000000..a2a80f5 --- /dev/null +++ b/src/routes/blog/feed.xml/+server.ts @@ -0,0 +1,41 @@ +import { RSS_ROOT } from '$/lib'; +import { RSSChannelElement, RSSItemElement, RSSRootElement } from '$/lib/vendor/rss/rss'; +import { XMLDeclaration, XMLDocumentRoot, XMLElement, XMLRootElement, XMLText } from '$/lib/vendor/rss/xml' +import { dev } from '$app/environment'; +import posts from '../posts'; + +export const GET = async (req) => { + const doc = new XMLDocumentRoot().child( + new XMLDeclaration().version().encoding(), + new RSSRootElement() + .channel( + new RSSChannelElement( + 'Latest blog posts for 7222e800', + 'Some Fancy Description Here', + RSS_ROOT + ) + .pubDate(new Date('2026-01-14T15:53:57Z') /* When the last item was published */) + .lastBuildDate(new Date() /* When this file was last updated - usually when your build process last ran */) + .language('en') + .child(new XMLElement('nonStandardElement').attribute('non-standard', 'true')) + .generator() + .items( + ...(await Promise.all(Object.values(posts))) + .filter(v => v.metadata.published === true || (dev && req.url.searchParams.has('include-unpublished'))) + .map(({ metadata }) => { + const item = new RSSItemElement(`${metadata.published === true ? '' : metadata.published ? '🚧 ' : '🛑 '}${metadata.title}`, metadata.blurb, new URL(`${metadata.id}-${metadata.slug}`, RSS_ROOT)); + if (metadata.author) + item.author(metadata.author); + item.pubDate(new Date(metadata.created)); + item.guid(new URL(`${metadata.id}`, RSS_ROOT).href, true) + return item; + }) + ), + ) + ) + return new Response(doc.toString(), { + headers: { + 'Content-Type': 'application/rss+xml;charset=utf-8' + } + }) +} diff --git a/src/routes/blog/posts/LICENSE b/src/routes/blog/posts/LICENSE new file mode 100644 index 0000000..e349d1c --- /dev/null +++ b/src/routes/blog/posts/LICENSE @@ -0,0 +1,2 @@ +All content in this directory is under the license CC-BY-NC-SA 4.0 International. +This does not apply to the code outside of it, just the mdx data inside of it. |