aboutsummaryrefslogtreecommitdiffstats
path: root/src/lib/vendor
diff options
context:
space:
mode:
authorLibravatarLarge Libravatar memdmp <memdmpestrogenzone>2026-01-26 06:20:31 +0100
committerLibravatarLarge Libravatar memdmp <memdmpestrogenzone>2026-01-26 06:20:31 +0100
commit663e797954721016625142c6a38d2915c0a6a698 (patch)
treece3213bf5ee3d39b2d2e80432c62634448786c95 /src/lib/vendor
parent7a3a7eb9c8e1012a75e6956ba476f13beb414f4c (diff)
downloadmem-estrogen-zone-663e797954721016625142c6a38d2915c0a6a698.tar.gz
mem-estrogen-zone-663e797954721016625142c6a38d2915c0a6a698.tar.bz2
mem-estrogen-zone-663e797954721016625142c6a38d2915c0a6a698.tar.lz
mem-estrogen-zone-663e797954721016625142c6a38d2915c0a6a698.zip

feat: an RSS

Diffstat (limited to 'src/lib/vendor')
-rw-r--r--src/lib/vendor/rss/rss.example.md110
-rw-r--r--src/lib/vendor/rss/rss.ts231
-rw-r--r--src/lib/vendor/rss/xml.example.md12
-rw-r--r--src/lib/vendor/rss/xml.ts9
4 files changed, 353 insertions, 9 deletions
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;