diff options
Diffstat (limited to 'src/lib/vendor/rss')
| -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 |
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; |