diff --git a/TODO.md b/TODO.md
index 19f84dd..8f1320a 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,20 +1,5 @@
# TODO
-## High Prio
-
- - Create tests for everything testable.
-
- - Create similarly address GD2/Imagick wrappers.
-
- - Figure out the SQL Query builder.
- - Might make sense to just have a predicate builder and selector builder.
+ - Create similarly addressable GD2/Imagick wrappers.
- Create a URL formatter.
-
- - Add RSS/Atom feed construction utilities.
-
-## Low Prio
-
- - Get guides working on phpdoc.
-
- - Create phpdoc template.
diff --git a/VERSION b/VERSION
index 199aff6..c5fd99e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.2410.21941
+0.2410.32039
diff --git a/src/Syndication/FeedBuilder.php b/src/Syndication/FeedBuilder.php
new file mode 100644
index 0000000..ff1d425
--- /dev/null
+++ b/src/Syndication/FeedBuilder.php
@@ -0,0 +1,147 @@
+title = $title;
+ }
+
+ /**
+ * Sets the description of the feed, may contain HTML.
+ *
+ * @param string $description Feed description.
+ */
+ public function setDescription(string $description): void {
+ $this->description = $description;
+ }
+
+ /**
+ * Sets the last updated time for this feed.
+ *
+ * @param DateTimeInterface|int $updatedAt Timestamp at which the feed was last updated.
+ */
+ public function setUpdatedAt(DateTimeInterface|int $updatedAt): void {
+ $this->updatedAt = XDateTime::toRfc822String($updatedAt);
+ }
+
+ /**
+ * Sets the URL to the content this feed represents.
+ *
+ * @param string $url Content URL.
+ */
+ public function setContentUrl(string $url): void {
+ $this->contentUrl = $url;
+ }
+
+ /**
+ * Sets the URL to this feed's XML file.
+ *
+ * @param string $url Feed XML URL.
+ */
+ public function setFeedUrl(string $url): void {
+ $this->feedUrl = $url;
+ }
+
+ /**
+ * Creates a feed item using a builder.
+ *
+ * @param callable(FeedItemBuilder): void $handler Item builder closure.
+ */
+ public function createEntry(callable $handler): void {
+ $builder = new FeedItemBuilder;
+ $handler($builder);
+ $this->items[] = $builder;
+ }
+
+ /**
+ * Creates a DOMDocument from this builder.
+ *
+ * @return DOMDocument XML document representing this feed.
+ */
+ public function toXmlDocument(): DOMDocument {
+ $document = new DOMDocument('1.0', 'UTF-8');
+ $document->preserveWhiteSpace = false;
+ $document->formatOutput = true;
+
+ $rss = $document->createElement('rss');
+ $rss->setAttribute('version', '2.0');
+ $rss->setAttribute('xmlns:atom', 'http://www.w3.org/2005/Atom');
+ $document->appendChild($rss);
+
+ $channel = $document->createElement('channel');
+
+ $elements = [
+ 'title' => $this->title,
+ 'pubDate' => $this->updatedAt,
+ 'link' => $this->contentUrl,
+ ];
+ foreach($elements as $elemName => $elemValue)
+ if(!XString::nullOrWhitespace($elemValue))
+ $channel->appendChild($document->createElement($elemName, $elemValue));
+
+ if(!XString::nullOrWhitespace($this->description)) {
+ $description = $document->createElement('description');
+ $description->appendChild($document->createCDATASection($this->description));
+ $channel->appendChild($description);
+ }
+
+ if(!XString::nullOrWhitespace($this->feedUrl)) {
+ $element = $document->createElement('atom:link');
+ $element->setAttribute('href', $this->feedUrl);
+ $element->setAttribute('ref', 'self');
+ $element->setAttribute('type', 'application/rss+xml');
+ $channel->appendChild($element);
+ }
+
+ foreach($this->items as $item)
+ $channel->appendChild($item->createElement($document));
+
+ $rss->appendChild($channel);
+
+ return $document;
+ }
+
+ /**
+ * Converts the XML Document to a printable string.
+ *
+ * @return string Printable XML document.
+ */
+ public function toXmlString(): string {
+ $string = $this->toXmlDocument()->saveXML();
+ if($string === false)
+ $string = '';
+
+ return $string;
+ }
+
+ public function __toString(): string {
+ return $this->toXmlString();
+ }
+}
diff --git a/src/Syndication/FeedItemBuilder.php b/src/Syndication/FeedItemBuilder.php
new file mode 100644
index 0000000..b77db6d
--- /dev/null
+++ b/src/Syndication/FeedItemBuilder.php
@@ -0,0 +1,137 @@
+title = $title;
+ }
+
+ /**
+ * Sets the description of the feed item, may contain HTML.
+ *
+ * @param string $description Item description.
+ */
+ public function setDescription(string $description): void {
+ $this->description = $description;
+ }
+
+ /**
+ * Sets the creation time for this feed item.
+ *
+ * @param DateTimeInterface|int $createdAt Timestamp at which the feed item was created.
+ */
+ public function setCreatedAt(DateTimeInterface|int $createdAt): void {
+ $this->createdAt = XDateTime::toRfc822String($createdAt);
+ }
+
+ /**
+ * Sets the last updated time for this feed item.
+ *
+ * @param DateTimeInterface|int $updatedAt Timestamp at which the feed item was last updated.
+ */
+ public function setUpdatedAt(DateTimeInterface|int $updatedAt): void {
+ $this->updatedAt = XDateTime::toIso8601String($updatedAt);
+ }
+
+ /**
+ * Sets the URL to the content this feed item represents.
+ *
+ * @param string $url Content URL.
+ */
+ public function setContentUrl(string $url): void {
+ $this->contentUrl = $url;
+ }
+
+ /**
+ * Sets the URL to where comments can be made on the content this feed item represents.
+ *
+ * @param string $url Comments URL.
+ */
+ public function setCommentsUrl(string $url): void {
+ $this->commentsUrl = $url;
+ }
+
+ /**
+ * Sets the name of the author of this feed item.
+ *
+ * @param string $name Name of the author.
+ */
+ public function setAuthorName(string $name): void {
+ $this->authorName = $name;
+ }
+
+ /**
+ * Sets the URL for the author of this feed item. Has no effect if no author name is provided.
+ *
+ * @param string $url URL for the author.
+ */
+ public function setAuthorUrl(string $url): void {
+ $this->authorUrl = $url;
+ }
+
+ /**
+ * Creates an XML document based on this item builder.
+ *
+ * @param DOMDocument $document Document to which this element is to be appended.
+ * @return DOMElement Element created from this builder.
+ */
+ public function createElement(DOMDocument $document): DOMElement {
+ $item = $document->createElement('item');
+
+ $elements = [
+ 'title' => $this->title,
+ 'pubDate' => $this->createdAt,
+ 'atom:updated' => $this->updatedAt,
+ 'link' => $this->contentUrl,
+ 'guid' => $this->contentUrl,
+ 'comments' => $this->commentsUrl,
+ ];
+ foreach($elements as $elemName => $elemValue)
+ if(!XString::nullOrWhitespace($elemValue))
+ $item->appendChild($document->createElement($elemName, $elemValue));
+
+ if(!XString::nullOrWhitespace($this->description)) {
+ $description = $document->createElement('description');
+ $description->appendChild($document->createCDATASection($this->description));
+ $item->appendChild($description);
+ }
+
+ if(!XString::nullOrWhitespace($this->authorName)) {
+ $author = $document->createElement('atom:author');
+ $author->appendChild($document->createElement('atom:name', $this->authorName));
+
+ if(!XString::nullOrWhitespace($this->authorUrl))
+ $author->appendChild($document->createElement('atom:uri', $this->authorUrl));
+
+ $item->appendChild($author);
+ }
+
+ return $item;
+ }
+}
diff --git a/tests/SyndicationTest.php b/tests/SyndicationTest.php
new file mode 100644
index 0000000..056a76a
--- /dev/null
+++ b/tests/SyndicationTest.php
@@ -0,0 +1,132 @@
+
+
It has HTML and multiple lines and all that awesome stuff!!!!
+Technology is so cool...
]]> +It has HTML and multiple lines and all that awesome stuff!!!!
+Technology is so cool...
+ HTML); + $item->setCreatedAt(1727485985); + $item->setContentUrl('https://example.railgun.sh/articles/1'); + $item->setCommentsUrl('https://example.railgun.sh/articles/1#comments'); + $item->setAuthorName('flashwave'); + $item->setAuthorUrl('https://example.railgun.sh/author/1'); + }); + $feed->createEntry(function($item) { + $item->setTitle('Article 2'); + $item->setDescription(<<hidden author spooky + HTML); + $item->setCreatedAt(1727585985); + $item->setContentUrl('https://example.railgun.sh/articles/2'); + $item->setCommentsUrl('https://example.railgun.sh/articles/2#comments'); + }); + $feed->createEntry(function($item) { + $item->setTitle('Article 3 without a description'); + $item->setCreatedAt(1727685985); + $item->setContentUrl('https://example.railgun.sh/articles/3'); + $item->setCommentsUrl('https://example.railgun.sh/articles/3#comments'); + $item->setAuthorName('Anonymous'); + }); + + $this->assertEquals(self::ITEMS_FEED, $feed->toXmlString()); + } +}