diff --git a/.gitea/workflows/prerequisites.yaml b/.gitea/workflows/prerequisites.yaml
index 45ae5b0..371eb9f 100644
--- a/.gitea/workflows/prerequisites.yaml
+++ b/.gitea/workflows/prerequisites.yaml
@@ -1,5 +1,5 @@
name: Prerequisites Testing
-on: [push, pull_request]
+on: [pull_request]
jobs:
test-php-version-requirements:
diff --git a/src/Controller/FeedController/FeedController.php b/src/Controller/FeedController/FeedController.php
index 5a9c998..a1e415b 100644
--- a/src/Controller/FeedController/FeedController.php
+++ b/src/Controller/FeedController/FeedController.php
@@ -1,34 +1,27 @@
config = ConfigModel::load();
$tickModel = new TickModel();
- $ticks = iterator_to_array($tickModel->stream($config->itemsPerPage));
+ $this->ticks = iterator_to_array($tickModel->stream($this->config->itemsPerPage));
- $this->vars = [
- 'config' => $config,
- 'ticks' => $ticks,
- ];
+ Log::debug("Loaded " . count($this->ticks) . " ticks for feeds");
}
public function rss(){
- $this->render("feed/rss.php", $this->vars);
+ $generator = new RssGenerator($this->config, $this->ticks);
+
+ header('Content-Type: ' . $generator->getContentType());
+ echo $generator->generate();
}
public function atom(){
- $this->render("feed/atom.php", $this->vars);
+ $generator = new AtomGenerator($this->config, $this->ticks);
+
+ header('Content-Type: ' . $generator->getContentType());
+ echo $generator->generate();
}
}
diff --git a/src/Feed/AtomGenerator.php b/src/Feed/AtomGenerator.php
new file mode 100644
index 0000000..8411ad2
--- /dev/null
+++ b/src/Feed/AtomGenerator.php
@@ -0,0 +1,53 @@
+' . "\n";
+ $xml .= $this->buildFeed();
+ return $xml;
+ }
+
+ public function getContentType(): string {
+ return 'application/atom+xml; charset=utf-8';
+ }
+
+ private function buildFeed(): string {
+ $feedTitle = Util::escape_xml($this->config->siteTitle . " Atom Feed");
+ $siteUrl = Util::escape_xml(Util::buildUrl($this->config->baseUrl, $this->config->basePath));
+ $feedUrl = Util::escape_xml(Util::buildUrl($this->config->baseUrl, $this->config->basePath, 'feed/atom'));
+ $updated = date(DATE_ATOM, strtotime($this->ticks[0]['timestamp'] ?? 'now'));
+
+ ob_start();
+ ?>
+
+
+
+
+
+
+
+ = Util::escape_xml($this->config->siteTitle) ?>
+
+ticks as $tick):
+ // build the tick entry components
+ $tickPath = "tick/" . $tick['id'];
+ $tickUrl = Util::escape_xml($siteUrl . $tickPath);
+ $tickTime = date(DATE_ATOM, strtotime($tick['timestamp']));
+ $tickTitle = Util::escape_xml($tick['tick']);
+ $tickContent = Util::linkify($tickTitle);
+?>
+
+ = $tickTitle ?>
+
+ = $tickUrl ?>
+ = $tickTime ?>
+ = $tickContent ?>
+
+
+
+config = $config;
+ $this->ticks = $ticks;
+ }
+
+ abstract public function generate(): string;
+ abstract public function getContentType(): string;
+
+ protected function buildTickUrl(int $tickId): string {
+ return Util::buildUrl($this->config->baseUrl, $this->config->basePath, "tick/{$tickId}");
+ }
+
+ protected function getSiteUrl(): string {
+ return Util::buildUrl($this->config->baseUrl, $this->config->basePath);
+ }
+}
\ No newline at end of file
diff --git a/src/Feed/RssGenerator.php b/src/Feed/RssGenerator.php
new file mode 100644
index 0000000..3a7e677
--- /dev/null
+++ b/src/Feed/RssGenerator.php
@@ -0,0 +1,47 @@
+' . "\n";
+ $xml .= '' . "\n";
+ $xml .= $this->buildChannel();
+ $xml .= '' . "\n";
+ return $xml;
+ }
+
+ public function getContentType(): string {
+ return 'application/rss+xml; charset=utf-8';
+ }
+
+ private function buildChannel(): string {
+ ob_start();
+ ?>
+
+ config->siteTitle . ' RSS Feed') ?>
+ config->baseUrl, $this->config->basePath))?>
+
+ config->siteDescription) ?>
+ en-us
+
+ticks as $tick):
+ // build the tick entry components
+ $tickPath = "tick/" . $tick['id'];
+ $tickUrl = Util::escape_xml($this->buildTickUrl($tick['id']));
+ $tickDate = date(DATE_RSS, strtotime($tick['timestamp']));
+ $tickTitle = Util::escape_xml($tick['tick']);
+ $tickDescription = Util::linkify($tickTitle);
+?>
+ -
+
+
+
+
+
+
+
+
+s . ' second' . ($diff->s != 1 ? 's' : '') . ' ago';
}
+
+ public static function buildUrl(string $baseUrl, string $basePath, string $path = ''): string {
+ // Normalize baseUrl (remove trailing slash)
+ $baseUrl = rtrim($baseUrl, '/');
+
+ // Normalize basePath (ensure leading slash, remove trailing slash unless it's just '/')
+ if ($basePath === '' || $basePath === '/') {
+ $basePath = '/';
+ } else {
+ $basePath = '/' . trim($basePath, '/') . '/';
+ }
+
+ // Normalize path (remove leading slash if present)
+ $path = ltrim($path, '/');
+
+ return $baseUrl . $basePath . $path;
+ }
}
\ No newline at end of file
diff --git a/templates/feed/atom.php b/templates/feed/atom.php
deleted file mode 100644
index a00936b..0000000
--- a/templates/feed/atom.php
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-siteTitle Atom Feed");
-$siteUrl = Util::escape_xml($config->baseUrl . $config->basePath);
-$feedUrl = Util::escape_xml($config->baseUrl . $config->basePath . 'feed/atom');
-$updated = date(DATE_ATOM, strtotime($ticks[0]['timestamp'] ?? 'now'));
-
-header('Content-Type: application/atom+xml; charset=utf-8');
-echo '' . "\n";
-?>
-
-
-
-
-
-
-
- = Util::escape_xml($config->siteTitle) ?>
-
-
-
- = $tickTitle ?>
-
- = $tickUrl ?>
- = $tickTime ?>
- = $tickContent ?>
-
-
-
diff --git a/templates/feed/rss.php b/templates/feed/rss.php
deleted file mode 100644
index 59c037e..0000000
--- a/templates/feed/rss.php
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-' . "\n";
-?>
-
-
- siteTitle . 'RSS Feed') ?>
- baseUrl . $config->basePath)?>
-
- siteDescription) ?>
- en-us
-
-baseUrl . $config->basePath . $tickPath);
- $tickDate = date(DATE_RSS, strtotime($tick['timestamp']));
- $tickTitle = Util::escape_xml($tick['tick']);
- $tickDescription = Util::linkify($tickTitle);
-?>
- -
-
-
-
-
-
-
-
-
-
diff --git a/tests/Feed/AtomGeneratorTest.php b/tests/Feed/AtomGeneratorTest.php
new file mode 100644
index 0000000..fe74773
--- /dev/null
+++ b/tests/Feed/AtomGeneratorTest.php
@@ -0,0 +1,139 @@
+siteTitle = 'Test Site';
+ $config->siteDescription = 'Test Description';
+ $config->baseUrl = 'https://example.com';
+ $config->basePath = '/tkr/';
+ return $config;
+ }
+
+ private function createSampleTicks() {
+ return [
+ ['id' => 1, 'timestamp' => '2025-01-15 12:00:00', 'tick' => 'First test tick'],
+ ['id' => 2, 'timestamp' => '2025-01-15 13:00:00', 'tick' => 'Second test tick']
+ ];
+ }
+
+ public function testCanGenerateValidAtom() {
+ $config = $this->createMockConfig();
+ $ticks = $this->createSampleTicks();
+
+ $generator = new AtomGenerator($config, $ticks);
+ $xml = $generator->generate();
+
+ // Test XML structure
+ $this->assertStringStartsWith('assertStringContainsString('', $xml);
+ $this->assertStringContainsString('Test Site Atom Feed', $xml);
+ $this->assertStringContainsString('', $xml);
+ $this->assertStringContainsString('assertStringContainsString('href="https://example.com/tkr/feed/atom"', $xml);
+ $this->assertStringContainsString('https://example.com/tkr/', $xml);
+ $this->assertStringContainsString('', $xml);
+ $this->assertStringContainsString('Test Site', $xml);
+ $this->assertStringContainsString('', $xml);
+ $this->assertStringContainsString('', $xml);
+ $this->assertStringEndsWith('' . "\n", $xml);
+
+ // Test tick content
+ $this->assertStringContainsString('First test tick', $xml);
+ $this->assertStringContainsString('Second test tick', $xml);
+
+ // Ensure the XML is still valid
+ $doc = new DOMDocument();
+ $this->assertTrue($doc->loadXML($xml), 'Valid Atom should load into an XML document');
+ }
+
+ public function testReturnsCorrectContentType() {
+ $generator = new AtomGenerator($this->createMockConfig(), []);
+ $this->assertEquals('application/atom+xml; charset=utf-8', $generator->getContentType());
+ }
+
+ public function testCanHandleEmptyTickList() {
+ $config = $this->createMockConfig();
+ $generator = new AtomGenerator($config, []);
+ $xml = $generator->generate();
+
+ // Should still be valid Atom with no entries
+ // Test XML structure
+ $this->assertStringStartsWith('assertStringContainsString('', $xml);
+ $this->assertStringContainsString('Test Site Atom Feed', $xml);
+ $this->assertStringContainsString('', $xml);
+ $this->assertStringContainsString('assertStringContainsString('href="https://example.com/tkr/feed/atom"', $xml);
+ $this->assertStringContainsString('https://example.com/tkr/', $xml);
+ $this->assertStringContainsString('', $xml);
+ $this->assertStringContainsString('Test Site', $xml);
+ $this->assertStringEndsWith('' . "\n", $xml);
+
+ // Test tick content
+ $this->assertStringNotContainsString('', $xml);
+ $this->assertStringNotContainsString('', $xml);
+
+ // Ensure the XML is still valid
+ $doc = new DOMDocument();
+ $this->assertTrue($doc->loadXML($xml), 'XML with no entries should still be valid');
+ }
+
+ public function testCanHandleSpecialCharactersAndUnicode() {
+ $config = $this->createMockConfig();
+
+ // Test various challenging characters
+ $ticks = [
+ [
+ 'id' => 1,
+ 'timestamp' => '2025-01-15 12:00:00',
+ 'tick' => 'Testing emojis 🎉🔥💯 and unicode characters'
+ ],
+ [
+ 'id' => 2,
+ 'timestamp' => '2025-01-15 13:00:00',
+ 'tick' => 'XML entities: & "quotes" & \'apostrophes\''
+ ],
+ [
+ 'id' => 3,
+ 'timestamp' => '2025-01-15 14:00:00',
+ 'tick' => 'International: café naïve résumé 北京 москва'
+ ],
+ [
+ 'id' => 4,
+ 'timestamp' => '2025-01-15 15:00:00',
+ 'tick' => 'Math symbols: ∑ ∆ π ∞ ≠ ≤ ≥'
+ ]
+ ];
+
+ $generator = new AtomGenerator($config, $ticks);
+ $xml = $generator->generate();
+
+ // Test that emojis are preserved
+ $this->assertStringContainsString('🎉🔥💯', $xml);
+
+ // Test that XML entities are properly escaped
+ $this->assertStringContainsString('<tag>', $xml);
+ $this->assertStringContainsString('&', $xml);
+ $this->assertStringContainsString('"quotes"', $xml);
+ $this->assertStringContainsString(''apostrophes'', $xml);
+
+ // Test that international characters are preserved
+ $this->assertStringContainsString('café naïve résumé', $xml);
+ $this->assertStringContainsString('北京', $xml);
+ $this->assertStringContainsString('москва', $xml);
+
+ // Test that math symbols are preserved
+ $this->assertStringContainsString('∑ ∆ π ∞', $xml);
+
+ // Ensure no raw < > & characters (security)
+ $this->assertStringNotContainsString('', $xml);
+ $this->assertStringNotContainsString(' & "', $xml);
+
+ // Ensure the XML is still valid
+ $doc = new DOMDocument();
+ $this->assertTrue($doc->loadXML($xml), 'XML with Unicode should still be valid');
+ }
+}
\ No newline at end of file
diff --git a/tests/Feed/FeedGeneratorTest.php b/tests/Feed/FeedGeneratorTest.php
new file mode 100644
index 0000000..677dfa0
--- /dev/null
+++ b/tests/Feed/FeedGeneratorTest.php
@@ -0,0 +1,114 @@
+siteTitle = 'Test Site';
+ $config->siteDescription = 'Test Description';
+ $config->baseUrl = 'https://example.com';
+ $config->basePath = '/tkr/';
+ return $config;
+ }
+
+ private function createSampleTicks() {
+ return [
+ ['id' => 1, 'timestamp' => '2025-01-15 12:00:00', 'tick' => 'First test tick'],
+ ['id' => 2, 'timestamp' => '2025-01-15 13:00:00', 'tick' => 'Second test tick']
+ ];
+ }
+
+ private function createTestGenerator($config = null, $ticks = null) {
+ $config = $config ?? $this->createMockConfig();
+ $ticks = $ticks ?? $this->createSampleTicks();
+
+ return new class($config, $ticks) extends FeedGenerator {
+ public function generate(): string {
+ return 'content';
+ }
+
+ public function getContentType(): string {
+ return 'application/test+xml';
+ }
+
+ // Expose protected methods for testing
+ public function testBuildTickUrl(int $tickId): string {
+ return $this->buildTickUrl($tickId);
+ }
+
+ public function testGetSiteUrl(): string {
+ return $this->getSiteUrl();
+ }
+ };
+ }
+
+ public function testConstructorStoresConfigAndTicks() {
+ $generator = $this->createTestGenerator();
+
+ $this->assertEquals('content', $generator->generate());
+ $this->assertEquals('application/test+xml', $generator->getContentType());
+ }
+
+ public function testBuildTickUrlGeneratesCorrectUrl() {
+ $generator = $this->createTestGenerator();
+
+ $tickUrl = $generator->testBuildTickUrl(123);
+ $this->assertEquals('https://example.com/tkr/tick/123', $tickUrl);
+ }
+
+ public function testGetSiteUrlGeneratesCorrectUrl() {
+ $generator = $this->createTestGenerator();
+
+ $siteUrl = $generator->testGetSiteUrl();
+ $this->assertEquals('https://example.com/tkr/', $siteUrl);
+ }
+
+ public function testUrlMethodsHandleSubdomainConfiguration() {
+ $config = new ConfigModel();
+ $config->siteTitle = 'Test Site';
+ $config->baseUrl = 'https://tkr.example.com';
+ $config->basePath = '/';
+
+ $generator = $this->createTestGenerator($config, []);
+
+ $this->assertEquals('https://tkr.example.com/', $generator->testGetSiteUrl());
+ $this->assertEquals('https://tkr.example.com/tick/456', $generator->testBuildTickUrl(456));
+ }
+
+ public function testUrlMethodsHandleEmptyBasePath() {
+ $config = new ConfigModel();
+ $config->siteTitle = 'Test Site';
+ $config->baseUrl = 'https://example.com';
+ $config->basePath = '';
+
+ $generator = $this->createTestGenerator($config, []);
+
+ $this->assertEquals('https://example.com/', $generator->testGetSiteUrl());
+ $this->assertEquals('https://example.com/tick/789', $generator->testBuildTickUrl(789));
+ }
+
+ public function testUrlMethodsHandleVariousBasePathFormats() {
+ $testCases = [
+ // [basePath, expectedSiteUrl, expectedTickUrl]
+ ['', 'https://example.com/', 'https://example.com/tick/123'],
+ ['/', 'https://example.com/', 'https://example.com/tick/123'],
+ ['tkr', 'https://example.com/tkr/', 'https://example.com/tkr/tick/123'],
+ ['/tkr', 'https://example.com/tkr/', 'https://example.com/tkr/tick/123'],
+ ['tkr/', 'https://example.com/tkr/', 'https://example.com/tkr/tick/123'],
+ ['/tkr/', 'https://example.com/tkr/', 'https://example.com/tkr/tick/123'],
+ ];
+
+ foreach ($testCases as [$basePath, $expectedSiteUrl, $expectedTickUrl]) {
+ $config = new ConfigModel();
+ $config->siteTitle = 'Test Site';
+ $config->baseUrl = 'https://example.com';
+ $config->basePath = $basePath;
+
+ $generator = $this->createTestGenerator($config, []);
+
+ $this->assertEquals($expectedSiteUrl, $generator->testGetSiteUrl(), "Failed for basePath: '$basePath'");
+ $this->assertEquals($expectedTickUrl, $generator->testBuildTickUrl(123), "Failed for basePath: '$basePath'");
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/Feed/RssGeneratorTest.php b/tests/Feed/RssGeneratorTest.php
new file mode 100644
index 0000000..7405d45
--- /dev/null
+++ b/tests/Feed/RssGeneratorTest.php
@@ -0,0 +1,135 @@
+siteTitle = 'Test Site';
+ $config->siteDescription = 'Test Description';
+ $config->baseUrl = 'https://example.com';
+ $config->basePath = '/tkr/';
+ return $config;
+ }
+
+ private function createSampleTicks() {
+ return [
+ ['id' => 1, 'timestamp' => '2025-01-15 12:00:00', 'tick' => 'First test tick'],
+ ['id' => 2, 'timestamp' => '2025-01-15 13:00:00', 'tick' => 'Second test tick']
+ ];
+ }
+
+ public function testCanGenerateValidRss() {
+ $config = $this->createMockConfig();
+ $ticks = $this->createSampleTicks();
+
+ $generator = new RssGenerator($config, $ticks);
+ $xml = $generator->generate();
+
+ // Test XML structure
+ $this->assertStringStartsWith('assertStringContainsString('assertStringContainsString('Test Site RSS Feed', $xml);
+ $this->assertStringContainsString('https://example.com/tkr/', $xml);
+ $this->assertStringContainsString('assertStringContainsString('', $xml);
+ $this->assertStringContainsString('- ', $xml);
+ $this->assertStringContainsString('
', $xml);
+ $this->assertStringContainsString('', $xml);
+ $this->assertStringEndsWith('' . "\n", $xml);
+
+ // Test tick content
+ $this->assertStringContainsString('First test tick', $xml);
+ $this->assertStringContainsString('Second test tick', $xml);
+
+ // Ensure the XML is still valid
+ $doc = new DOMDocument();
+ $this->assertTrue($doc->loadXML($xml), 'Valid RSS should load into an XML document');
+ }
+
+ public function testReturnsCorrectContentType() {
+ $generator = new RssGenerator($this->createMockConfig(), []);
+ $this->assertEquals('application/rss+xml; charset=utf-8', $generator->getContentType());
+ }
+
+ public function testCanHandleEmptyTickList() {
+ $config = $this->createMockConfig();
+ $generator = new RssGenerator($config, []);
+ $xml = $generator->generate();
+
+ // Should still be valid RSS with no items
+ // Test XML structure
+ $this->assertStringStartsWith('assertStringContainsString('assertStringContainsString('Test Site RSS Feed', $xml);
+ $this->assertStringContainsString('https://example.com/tkr/', $xml);
+ $this->assertStringContainsString('assertStringContainsString('', $xml);
+ $this->assertStringContainsString('', $xml);
+ $this->assertStringEndsWith('' . "\n", $xml);
+
+ // Test tick content
+ $this->assertStringNotContainsString('- ', $xml);
+ $this->assertStringNotContainsString('
', $xml);
+
+ // Ensure the XML is still valid
+ $doc = new DOMDocument();
+ $this->assertTrue($doc->loadXML($xml), 'XML with no items should still be valid');
+ }
+
+ public function testCanHandleSpecialCharactersAndUnicode() {
+ $config = $this->createMockConfig();
+
+ // Test various challenging characters
+ $ticks = [
+ [
+ 'id' => 1,
+ 'timestamp' => '2025-01-15 12:00:00',
+ 'tick' => 'Testing emojis 🎉🔥💯 and unicode characters'
+ ],
+ [
+ 'id' => 2,
+ 'timestamp' => '2025-01-15 13:00:00',
+ 'tick' => 'XML entities: & "quotes" & \'apostrophes\''
+ ],
+ [
+ 'id' => 3,
+ 'timestamp' => '2025-01-15 14:00:00',
+ 'tick' => 'International: café naïve résumé 北京 москва'
+ ],
+ [
+ 'id' => 4,
+ 'timestamp' => '2025-01-15 15:00:00',
+ 'tick' => 'Math symbols: ∑ ∆ π ∞ ≠ ≤ ≥'
+ ]
+ ];
+
+ $generator = new RssGenerator($config, $ticks);
+ $xml = $generator->generate();
+
+ // Test that emojis are preserved
+ $this->assertStringContainsString('🎉🔥💯', $xml);
+
+ // Test that XML entities are properly escaped
+ $this->assertStringContainsString('<tag>', $xml);
+ $this->assertStringContainsString('&', $xml);
+ $this->assertStringContainsString('"quotes"', $xml);
+ $this->assertStringContainsString(''apostrophes'', $xml);
+
+ // Test that international characters are preserved
+ $this->assertStringContainsString('café naïve résumé', $xml);
+ $this->assertStringContainsString('北京', $xml);
+ $this->assertStringContainsString('москва', $xml);
+
+ // Test that math symbols are preserved
+ $this->assertStringContainsString('∑ ∆ π ∞', $xml);
+
+ // Ensure no raw < > & characters (security)
+ $this->assertStringNotContainsString('', $xml);
+ $this->assertStringNotContainsString(' & "', $xml);
+
+ // Ensure the XML is still valid
+ $doc = new DOMDocument();
+ $this->assertTrue($doc->loadXML($xml), 'XML with Unicode should still be valid');
+ }
+}