diff --git a/config/hyde.php b/config/hyde.php index fa863a6b..8c6bb5cc 100644 --- a/config/hyde.php +++ b/config/hyde.php @@ -44,6 +44,8 @@ | | Here are some configuration options for URL generation. | + | A site_url is required to use sitemaps and RSS feeds. + | | `site_url` is used to create canonical URLs and permalinks. | `prettyUrls` will when enabled create links that do not end in .html. | `generateSitemap` determines if a sitemap.xml file should be generated. diff --git a/src/Commands/HydeBuildStaticSiteCommand.php b/src/Commands/HydeBuildStaticSiteCommand.php index 8735a840..65c68519 100644 --- a/src/Commands/HydeBuildStaticSiteCommand.php +++ b/src/Commands/HydeBuildStaticSiteCommand.php @@ -13,6 +13,7 @@ use Hyde\Framework\Models\MarkdownPage; use Hyde\Framework\Models\MarkdownPost; use Hyde\Framework\Services\DiscoveryService; +use Hyde\Framework\Services\RssFeedService; use Hyde\Framework\Services\SitemapService; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\File; @@ -172,9 +173,17 @@ public function postBuildActions(): void } if (SitemapService::canGenerateSitemap()) { - $this->info('Generating sitemap.xml'); + $actionTime = microtime(true); + $this->comment('Generating sitemap...'); file_put_contents(Hyde::getSiteOutputPath('sitemap.xml'), SitemapService::generateSitemap()); - $this->newLine(); + $this->line(' > Created sitemap.xml in '.$this->getExecutionTimeInMs($actionTime)."ms\n"); + } + + if (RssFeedService::canGenerateFeed()) { + $actionTime = microtime(true); + $this->comment('Generating RSS feed...'); + file_put_contents(Hyde::getSiteOutputPath(RssFeedService::getDefaultOutputFilename()), RssFeedService::generateFeed()); + $this->line(' > Created '.RssFeedService::getDefaultOutputFilename().' in '.$this->getExecutionTimeInMs($actionTime)."ms\n"); } } @@ -198,4 +207,9 @@ private function runNodeCommand(string $command, string $message, ?string $actio $output ?? 'Could not '.($actionMessage ?? 'run script').'! Is NPM installed?' ); } + + protected function getExecutionTimeInMs(float $timeStart): float + { + return number_format(((microtime(true) - $timeStart) * 1000), 2); + } } diff --git a/src/Models/Author.php b/src/Models/Author.php index 9b126507..b27cc39b 100644 --- a/src/Models/Author.php +++ b/src/Models/Author.php @@ -47,4 +47,14 @@ public function __construct(string $username, ?array $data = []) $this->website = $data['website']; } } + + /** + * Get the author's name. + * + * @return string + */ + public function getName(): string + { + return $this->name ?? $this->username; + } } diff --git a/src/Models/MarkdownPost.php b/src/Models/MarkdownPost.php index 4a93c672..fa558a4d 100644 --- a/src/Models/MarkdownPost.php +++ b/src/Models/MarkdownPost.php @@ -6,6 +6,7 @@ use Hyde\Framework\Concerns\HasAuthor; use Hyde\Framework\Concerns\HasDateString; use Hyde\Framework\Concerns\HasFeaturedImage; +use Hyde\Framework\Hyde; use Hyde\Framework\Models\Parsers\MarkdownPostParser; class MarkdownPost extends MarkdownDocument @@ -36,4 +37,14 @@ public function getCurrentPagePath(): string { return 'posts/'.$this->slug; } + + public function getCanonicalLink(): string + { + return Hyde::uriPath(Hyde::pageLink($this->getCurrentPagePath().'.html')); + } + + public function getPostDescription(): string + { + return $this->matter['description'] ?? substr($this->body, 0, 125).'...'; + } } diff --git a/src/Services/RssFeedService.php b/src/Services/RssFeedService.php new file mode 100644 index 00000000..dc93228f --- /dev/null +++ b/src/Services/RssFeedService.php @@ -0,0 +1,137 @@ +feed = new SimpleXMLElement(' + '); + $this->feed->addChild('channel'); + + $this->addInitialChannelItems(); + } + + public function generate(): self + { + /** @var MarkdownPost $post */ + foreach (Hyde::getLatestPosts() as $post) { + $this->addItem($post); + } + + return $this; + } + + public function getXML(): string + { + return $this->feed->asXML(); + } + + protected function addItem(MarkdownPost $post): void + { + $item = $this->feed->channel->addChild('item'); + $item->addChild('title', $post->findTitleForDocument()); + $item->addChild('link', $post->getCanonicalLink()); + $item->addChild('guid', $post->getCanonicalLink()); + $item->addChild('description', $post->getPostDescription()); + + $this->addAdditionalItemData($item, $post); + } + + protected function addAdditionalItemData(SimpleXMLElement $item, MarkdownPost $post): void + { + if (isset($post->date)) { + $item->addChild('pubDate', $post->date->dateTimeObject->format(DATE_RSS)); + } + + if (isset($post->author)) { + $item->addChild('dc:creator', $post->author->getName(), 'http://purl.org/dc/elements/1.1/'); + } + + if (isset($post->category)) { + $item->addChild('category', $post->category); + } + + // Only support local images, as remote images would take extra time to make HTTP requests to get length + if (isset($post->image) && isset($post->image->path)) { + $image = $item->addChild('enclosure'); + $image->addAttribute('url', Hyde::uriPath(str_replace('_media', 'media', $post->image->path))); + $image->addAttribute('type', str_ends_with($post->image->path, '.png') ? 'image/png' : 'image/jpeg'); + $image->addAttribute('length', filesize(Hyde::path($post->image->path))); + } + } + + protected function addInitialChannelItems(): void + { + $this->feed->channel->addChild('title', $this->getTitle()); + $this->feed->channel->addChild('link', $this->getLink()); + $this->feed->channel->addChild('description', $this->getDescription()); + + $atomLink = $this->feed->channel->addChild('atom:link', namespace: 'http://www.w3.org/2005/Atom'); + $atomLink->addAttribute('href', $this->getLink().'/'.static::getDefaultOutputFilename()); + $atomLink->addAttribute('rel', 'self'); + $atomLink->addAttribute('type', 'application/rss+xml'); + + $this->addAdditionalChannelData(); + } + + protected function addAdditionalChannelData(): void + { + $this->feed->channel->addChild('language', config('hyde.language', 'en')); + $this->feed->channel->addChild('generator', 'HydePHP '.Hyde::version()); + $this->feed->channel->addChild('lastBuildDate', date(DATE_RSS)); + } + + protected function getTitle(): string + { + return $this->xmlEscape( + config('hyde.name', 'HydePHP') + ); + } + + protected function getLink(): string + { + return $this->xmlEscape( + config('hyde.site_url') ?? 'http://localhost' + ); + } + + protected function getDescription(): string + { + return $this->xmlEscape( + config('hyde.rssDescription', + $this->getTitle().' RSS Feed') + ); + } + + protected function xmlEscape(string $string): string + { + return htmlspecialchars($string, ENT_XML1 | ENT_COMPAT, 'UTF-8'); + } + + public static function getDefaultOutputFilename(): string + { + return config('hyde.rssFilename', 'feed.rss'); + } + + public static function generateFeed(): string + { + return (new static)->generate()->getXML(); + } + + public static function canGenerateFeed(): bool + { + return (Hyde::uriPath() !== false) && config('hyde.generateRssFeed', true); + } +} diff --git a/tests/Feature/Commands/BuildStaticSiteCommandTest.php b/tests/Feature/Commands/BuildStaticSiteCommandTest.php index 9cecfe37..28c662bf 100644 --- a/tests/Feature/Commands/BuildStaticSiteCommandTest.php +++ b/tests/Feature/Commands/BuildStaticSiteCommandTest.php @@ -112,10 +112,55 @@ public function test_sitemap_is_generated_when_conditions_are_met() unlinkIfExists(Hyde::path('_site/sitemap.xml')); $this->artisan('build') - ->expectsOutput('Generating sitemap.xml') + ->expectsOutput('Generating sitemap...') ->assertExitCode(0); $this->assertFileExists(Hyde::path('_site/sitemap.xml')); + unlink(Hyde::path('_site/sitemap.xml')); + } + + public function test_rss_feed_is_not_generated_when_conditions_are_not_met() + { + config(['hyde.site_url' => '']); + config(['hyde.generateRssFeed' => false]); + + unlinkIfExists(Hyde::path('_site/feed.rss')); + $this->artisan('build') + ->assertExitCode(0); + + $this->assertFileDoesNotExist(Hyde::path('_site/feed.rss')); + } + + public function test_rss_feed_is_generated_when_conditions_are_met() + { + config(['hyde.site_url' => 'https://example.com']); + config(['hyde.generateRssFeed' => true]); + + unlinkIfExists(Hyde::path('_site/feed.rss')); + $this->artisan('build') + ->expectsOutput('Generating RSS feed...') + ->assertExitCode(0); + + $this->assertFileExists(Hyde::path('_site/feed.rss')); + unlink(Hyde::path('_site/feed.rss')); + } + + public function test_rss_filename_can_be_changed() + { + config(['hyde.site_url' => 'https://example.com']); + config(['hyde.generateRssFeed' => true]); + config(['hyde.rssFilename' => 'blog.xml']); + + unlinkIfExists(Hyde::path('_site/feed.rss')); + unlinkIfExists(Hyde::path('_site/blog.xml')); + + $this->artisan('build') + ->expectsOutput('Generating RSS feed...') + ->assertExitCode(0); + + $this->assertFileDoesNotExist(Hyde::path('_site/feed.rss')); + $this->assertFileExists(Hyde::path('_site/blog.xml')); + unlink(Hyde::path('_site/blog.xml')); } /** diff --git a/tests/Feature/Services/RssFeedServiceTest.php b/tests/Feature/Services/RssFeedServiceTest.php new file mode 100644 index 00000000..5e8dca27 --- /dev/null +++ b/tests/Feature/Services/RssFeedServiceTest.php @@ -0,0 +1,156 @@ +assertInstanceOf('SimpleXMLElement', $service->feed); + } + + // Test XML root element is set to RSS 2.0 + public function test_xml_root_element_is_set_to_rss_2_0() + { + $service = new RssFeedService(); + $this->assertEquals('rss', $service->feed->getName()); + $this->assertEquals('2.0', $service->feed->attributes()->version); + } + + // Test XML element has channel element + public function test_xml_element_has_channel_element() + { + $service = new RssFeedService(); + $this->assertObjectHasAttribute('channel', $service->feed); + } + + // Test XML channel element has required elements + public function test_xml_channel_element_has_required_elements() + { + config(['hyde.name' => 'Test Blog']); + config(['hyde.site_url' => 'https://example.com']); + + $service = new RssFeedService(); + $this->assertObjectHasAttribute('title', $service->feed->channel); + $this->assertObjectHasAttribute('link', $service->feed->channel); + $this->assertObjectHasAttribute('description', $service->feed->channel); + + $this->assertEquals('Test Blog', $service->feed->channel->title); + $this->assertEquals('https://example.com', $service->feed->channel->link); + $this->assertEquals('Test Blog RSS Feed', $service->feed->channel->description); + } + + // Test XML channel element has additional elements + public function test_xml_channel_element_has_additional_elements() + { + config(['hyde.site_url' => 'https://example.com']); + + $service = new RssFeedService(); + $this->assertObjectHasAttribute('link', $service->feed->channel); + $this->assertEquals('https://example.com', $service->feed->channel->link); + $this->assertEquals('https://example.com/feed.rss', + $service->feed->channel->children('atom', true)->link->attributes()->href); + + $this->assertObjectHasAttribute('language', $service->feed->channel); + $this->assertObjectHasAttribute('generator', $service->feed->channel); + $this->assertObjectHasAttribute('lastBuildDate', $service->feed->channel); + } + + // Test XML channel data can be customized + public function test_xml_channel_data_can_be_customized() + { + config(['hyde.name' => 'Foo']); + config(['hyde.site_url' => 'https://blog.foo.com/bar']); + config(['hyde.rssDescription' => 'Foo is a web log about stuff']); + + $service = new RssFeedService(); + $this->assertEquals('Foo', $service->feed->channel->title); + $this->assertEquals('https://blog.foo.com/bar', $service->feed->channel->link); + $this->assertEquals('Foo is a web log about stuff', $service->feed->channel->description); + } + + // Test Markdown blog posts are added to RSS feed through autodiscovery + public function test_markdown_blog_posts_are_added_to_rss_feed_through_autodiscovery() + { + file_put_contents(Hyde::path('_posts/rss.md'), <<<'MD' + --- + title: RSS + author: Hyde + date: "2022-05-19 10:15:30" + description: RSS description + category: test + image: rss-test.jpg + --- + + # RSS Post + + Foo bar + MD + ); + + config(['hyde.site_url' => 'https://example.com']); + + file_put_contents(Hyde::path('rss-test.jpg'), 'statData'); // 8 bytes to test stat gets file length + + $service = new RssFeedService(); + $service->generate(); + $this->assertCount(1, $service->feed->channel->item); + + $item = $service->feed->channel->item[0]; + $this->assertEquals('RSS', $item->title); + $this->assertEquals('RSS description', $item->description); + $this->assertEquals('https://example.com/posts/rss.html', $item->link); + $this->assertEquals(date(DATE_RSS, strtotime('2022-05-19T10:15:30+00:00')), $item->pubDate); + $this->assertEquals('Hyde', $item->children('dc', true)->creator); + $this->assertEquals('test', $item->category); + + $this->assertObjectHasAttribute('enclosure', $item); + $this->assertEquals('https://example.com/rss-test.jpg', $item->enclosure->attributes()->url); + $this->assertEquals('image/jpeg', $item->enclosure->attributes()->type); + $this->assertEquals('8', $item->enclosure->attributes()->length); + + unlink(Hyde::path('_posts/rss.md')); + unlink(Hyde::path('rss-test.jpg')); + } + + // Test getXML method returns XML string + public function test_getXML_method_returns_XML_string() + { + $service = new RssFeedService(); + $this->assertStringStartsWith('', ($service->getXML())); + } + + // Test generateFeed helper returns XML string + public function test_generateFeed_helper_returns_XML_string() + { + $this->assertStringStartsWith('', (RssFeedService::generateFeed())); + } + + public function test_can_generate_sitemap_helper_returns_true_if_hyde_has_base_url() + { + config(['hyde.site_url' => 'foo']); + $this->assertTrue(RssFeedService::canGenerateFeed()); + } + + public function test_can_generate_sitemap_helper_returns_false_if_hyde_does_not_have_base_url() + { + config(['hyde.site_url' => '']); + $this->assertFalse(RssFeedService::canGenerateFeed()); + } + + public function test_can_generate_sitemap_helper_returns_false_if_sitemaps_are_disabled_in_config() + { + config(['hyde.site_url' => 'foo']); + config(['hyde.generateRssFeed' => false]); + $this->assertFalse(RssFeedService::canGenerateFeed()); + } +} diff --git a/tests/Feature/Services/SitemapServiceTest/SitemapServiceTest.php b/tests/Feature/Services/SitemapServiceTest.php similarity index 98% rename from tests/Feature/Services/SitemapServiceTest/SitemapServiceTest.php rename to tests/Feature/Services/SitemapServiceTest.php index 9e208101..98f700df 100644 --- a/tests/Feature/Services/SitemapServiceTest/SitemapServiceTest.php +++ b/tests/Feature/Services/SitemapServiceTest.php @@ -1,6 +1,6 @@