diff --git a/docs/commands.md b/docs/commands.md index ed8768d..5f237c7 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -51,7 +51,7 @@ The command: 4. Renders collection entries — converts markdown to HTML via MD4C, applies the entry template, writes each entry as `index.html` at its resolved permalink path. Drafts and future-dated entries are excluded by default. 5. Renders standalone pages — markdown files in the content root directory (e.g., `contact.md` → `/contact/`). 6. Copies content assets (images, SVGs, etc.) to the output directory. -7. Generates Atom (`feed.xml`) and RSS 2.0 (`rss.xml`) feeds for each collection with `feed: true`, capped by collection `feed_limit` (`20` by default, `0` for unlimited). +7. Generates site-wide Atom (`/feed.xml`) and RSS 2.0 (`/rss.xml`) feeds, plus per-collection feeds for each collection with `feed: true`, capped by collection `feed_limit` (`20` by default, `0` for unlimited). 8. Generates paginated collection listing pages (e.g., `/blog/`, `/blog/page/2/`) for collections with `listing: true`. 9. Generates `sitemap.xml` containing all entry URLs, standalone page URLs, collection listing URLs, and the home page. 10. Generates taxonomy pages for each taxonomy defined in `config.yaml` (e.g., `/tags/`, `/tags/php/`, `/categories/`). diff --git a/docs/configuration.md b/docs/configuration.md index bdce7f4..d0a4e9a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -326,7 +326,7 @@ Collection `_collection.yaml` fields override content config defaults: - Collection `entries_per_page` overrides `config.yaml` `entries_per_page` - Collection `permalink` overrides `config.yaml` `permalink` -- Collection `feed_limit` controls how many entries are rendered into that collection's RSS/Atom feeds (`20` by default, `0` for unlimited) +- Collection `feed_limit` controls how many entries are rendered into that collection's RSS/Atom feeds (`20` by default, `0` for unlimited). Site-wide `/feed.xml` and `/rss.xml` include entries from all feed-enabled collections and use the default limit. - Entry `permalink` overrides collection permalink Resolution order: entry → collection → content config → engine defaults. diff --git a/docs/content.md b/docs/content.md index 89320ae..b995956 100644 --- a/docs/content.md +++ b/docs/content.md @@ -58,8 +58,8 @@ feed_limit: 20 - **sort_by** — field to sort entries by: `date` (default), `weight`, `title` - **sort_order** — `desc` (default) or `asc` - **entries_per_page** — number of entries per page, `0` for no pagination -- **feed** — `true` to generate RSS/Atom feed for this collection -- **feed_limit** — maximum entries rendered into each RSS/Atom feed (default: `20`, `0` for unlimited) +- **feed** — `true` to generate RSS/Atom feeds for this collection and include its entries in site-wide `/feed.xml` and `/rss.xml` +- **feed_limit** — maximum entries rendered into each collection RSS/Atom feed (default: `20`, `0` for unlimited); site-wide feeds use the default limit - **listing** — `true` to generate a collection index page (default: `true`) - **navigation_pager** — `true` to render previous/next page links from the configured sidebar navigation (default: `false`) diff --git a/roadmap.md b/roadmap.md index f36c641..6ddd690 100644 --- a/roadmap.md +++ b/roadmap.md @@ -13,7 +13,7 @@ ## Priority 1: Essential blog features -- [x] RSS/Atom feed generation. Content should be generated using same plugins as for HTML content +- [x] Collection and site-wide RSS/Atom feed generation. Content should be generated using same plugins as for HTML content - [x] Sitemap generation - [x] Tags and categories (taxonomies) - [x] Draft support (front matter `draft: true` flag, exclude from build by default) diff --git a/src/Build/FeedGenerator.php b/src/Build/FeedGenerator.php index 96dab8b..65ed1fa 100644 --- a/src/Build/FeedGenerator.php +++ b/src/Build/FeedGenerator.php @@ -53,6 +53,38 @@ public function generateRss( return $xml->outputMemory(); } + /** + * @param array $collections + * @param list $entries + */ + public function generateSiteAtom( + SiteConfig $siteConfig, + array $collections, + array $entries, + ): string { + $xml = $this->createInMemoryWriter(); + $collection = $this->siteFeedCollection($siteConfig); + $this->writeAtomDocument($xml, $siteConfig, $collection, $this->limitEntries($collection, $entries), $collections); + + return $xml->outputMemory(); + } + + /** + * @param array $collections + * @param list $entries + */ + public function generateSiteRss( + SiteConfig $siteConfig, + array $collections, + array $entries, + ): string { + $xml = $this->createInMemoryWriter(); + $collection = $this->siteFeedCollection($siteConfig); + $this->writeRssDocument($xml, $siteConfig, $collection, $this->limitEntries($collection, $entries), $collections); + + return $xml->outputMemory(); + } + /** * @param list $entries */ @@ -82,20 +114,54 @@ public function writeRssFile( } /** + * @param array $collections * @param list $entries */ + public function writeSiteAtomFile( + string $path, + SiteConfig $siteConfig, + array $collections, + array $entries, + ): void { + $xml = $this->createFileWriter($path); + $collection = $this->siteFeedCollection($siteConfig); + $this->writeAtomDocument($xml, $siteConfig, $collection, $this->limitEntries($collection, $entries), $collections); + $xml->flush(); + } + + /** + * @param array $collections + * @param list $entries + */ + public function writeSiteRssFile( + string $path, + SiteConfig $siteConfig, + array $collections, + array $entries, + ): void { + $xml = $this->createFileWriter($path); + $collection = $this->siteFeedCollection($siteConfig); + $this->writeRssDocument($xml, $siteConfig, $collection, $this->limitEntries($collection, $entries), $collections); + $xml->flush(); + } + + /** + * @param list $entries + * @param array|null $entryCollections + */ private function writeAtomDocument( XMLWriter $xml, SiteConfig $siteConfig, Collection $collection, array $entries, + ?array $entryCollections = null, ): void { $xml->startDocument('1.0', 'UTF-8'); $xml->startElement('feed'); $xml->writeAttribute('xmlns', 'http://www.w3.org/2005/Atom'); - $feedUrl = rtrim($siteConfig->baseUrl, '/') . '/' . $collection->name . '/feed.xml'; - $collectionUrl = rtrim($siteConfig->baseUrl, '/') . '/' . $collection->name . '/'; + $feedUrl = rtrim($siteConfig->baseUrl, '/') . $this->feedPath($collection, 'feed.xml'); + $collectionUrl = rtrim($siteConfig->baseUrl, '/') . $this->collectionPath($collection); $xml->writeElement('title', $collection->title); if ($collection->description !== '') { @@ -127,7 +193,7 @@ private function writeAtomDocument( } foreach ($entries as $entry) { - $this->writeAtomEntry($xml, $siteConfig, $collection, $entry); + $this->writeAtomEntry($xml, $siteConfig, $entryCollections[$entry->collection] ?? $collection, $entry); } $xml->endElement(); @@ -136,12 +202,14 @@ private function writeAtomDocument( /** * @param list $entries + * @param array|null $entryCollections */ private function writeRssDocument( XMLWriter $xml, SiteConfig $siteConfig, Collection $collection, array $entries, + ?array $entryCollections = null, ): void { $xml->startDocument('1.0', 'UTF-8'); $xml->startElement('rss'); @@ -149,8 +217,8 @@ private function writeRssDocument( $xml->writeAttribute('xmlns:atom', 'http://www.w3.org/2005/Atom'); $xml->writeAttribute('xmlns:content', 'http://purl.org/rss/1.0/modules/content/'); - $feedUrl = rtrim($siteConfig->baseUrl, '/') . '/' . $collection->name . '/rss.xml'; - $collectionUrl = rtrim($siteConfig->baseUrl, '/') . '/' . $collection->name . '/'; + $feedUrl = rtrim($siteConfig->baseUrl, '/') . $this->feedPath($collection, 'rss.xml'); + $collectionUrl = rtrim($siteConfig->baseUrl, '/') . $this->collectionPath($collection); $xml->startElement('channel'); $xml->writeElement('title', $collection->title); @@ -173,7 +241,7 @@ private function writeRssDocument( } foreach ($entries as $entry) { - $this->writeRssItem($xml, $siteConfig, $collection, $entry); + $this->writeRssItem($xml, $siteConfig, $entryCollections[$entry->collection] ?? $collection, $entry); } $xml->endElement(); @@ -261,6 +329,31 @@ private function resolveEntryUrl(SiteConfig $siteConfig, Collection $collection, return rtrim($siteConfig->baseUrl, '/') . PermalinkResolver::resolve($entry, $collection, $siteConfig->i18n); } + private function collectionPath(Collection $collection): string + { + return $collection->name === '' ? '/' : '/' . $collection->name . '/'; + } + + private function feedPath(Collection $collection, string $feedFile): string + { + return $collection->name === '' ? '/' . $feedFile : '/' . $collection->name . '/' . $feedFile; + } + + private function siteFeedCollection(SiteConfig $siteConfig): Collection + { + return new Collection( + name: '', + title: $siteConfig->title, + description: $siteConfig->description, + permalink: $siteConfig->permalink, + sortBy: 'date', + sortOrder: 'desc', + entriesPerPage: $siteConfig->entriesPerPage, + feed: true, + listing: false, + ); + } + /** * @param list $entries * @return list diff --git a/src/Console/BuildCommand.php b/src/Console/BuildCommand.php index 4e1e959..4bf2b62 100644 --- a/src/Console/BuildCommand.php +++ b/src/Console/BuildCommand.php @@ -95,6 +95,7 @@ use function substr; use function strlen; use function trim; +use function usort; use function yaml_parse; #[AsCommand( @@ -711,6 +712,30 @@ function (array $feedTask) use ($siteConfig, $outputDir, $authors, $noWrite): in minTasksPerWorker: 1, ); + if ($feedTasks !== []) { + $siteFeedEntries = []; + foreach ($feedTasks as $feedTask) { + foreach ($feedTask['entries'] as $entry) { + $siteFeedEntries[] = $entry; + } + } + usort( + $siteFeedEntries, + static fn (Entry $a, Entry $b): int => ($b->date?->getTimestamp() ?? -PHP_INT_MAX) + <=> ($a->date?->getTimestamp() ?? -PHP_INT_MAX), + ); + + $feedGenerator = new FeedGenerator($this->feedPipeline, $authors); + if ($noWrite) { + $feedGenerator->generateSiteAtom($siteConfig, $collections, $siteFeedEntries); + $feedGenerator->generateSiteRss($siteConfig, $collections, $siteFeedEntries); + } else { + $feedGenerator->writeSiteAtomFile($outputDir . '/feed.xml', $siteConfig, $collections, $siteFeedEntries); + $feedGenerator->writeSiteRssFile($outputDir . '/rss.xml', $siteConfig, $collections, $siteFeedEntries); + } + $feedCount++; + } + if ($feedCount > 0) { $output->writeln(" Feeds generated: $feedCount (Atom + RSS)"); } @@ -1061,6 +1086,7 @@ private function dryRun( $output->writeln('Dry run — files that would be generated:'); $now = new DateTimeImmutable(); $files = []; + $hasFeeds = false; foreach ($collections as $collectionName => $collection) { $entries = []; @@ -1082,6 +1108,7 @@ private function dryRun( } if ($collection->feed) { + $hasFeeds = true; $files[] = $outputDir . '/' . $collectionName . '/feed.xml'; $files[] = $outputDir . '/' . $collectionName . '/rss.xml'; } @@ -1118,6 +1145,10 @@ private function dryRun( } } } + if ($hasFeeds) { + $files[] = $outputDir . '/feed.xml'; + $files[] = $outputDir . '/rss.xml'; + } foreach ($parser->parseStandalonePages($contentDir) as $page) { if ($page->title === '') { diff --git a/tests/Unit/Build/FeedGeneratorTest.php b/tests/Unit/Build/FeedGeneratorTest.php index 009e463..ce27399 100644 --- a/tests/Unit/Build/FeedGeneratorTest.php +++ b/tests/Unit/Build/FeedGeneratorTest.php @@ -274,6 +274,61 @@ public function process(string $content, Entry $entry): string $this->assertSame(1, $processor->calls); } + public function testSiteFeedsUseSiteMetadataAndEntryCollections(): void + { + $news = new Collection( + name: 'news', + title: 'News', + description: 'News posts', + permalink: '/news/:slug/', + sortBy: 'date', + sortOrder: 'desc', + entriesPerPage: 10, + feed: true, + listing: true, + ); + $entries = $this->createEntries(); + $bodyLength = (int) filesize($this->tempFile); + $entries[] = new Entry( + filePath: $this->tempFile, + collection: 'news', + slug: 'release', + title: 'Release', + date: new DateTimeImmutable('2024-04-01'), + draft: false, + tags: [], + categories: [], + authors: [], + summary: 'Release summary.', + permalink: '', + layout: '', + theme: '', + weight: 0, + language: 'en', + redirectTo: '', + extra: [], + bodyOffset: 0, + bodyLength: $bodyLength, + ); + + $generator = new FeedGenerator(new ContentProcessorPipeline(new MarkdownProcessor(new MarkdownRenderer()))); + $collections = ['blog' => $this->collection, 'news' => $news]; + + $atom = $generator->generateSiteAtom($this->siteConfig, $collections, $entries); + $rss = $generator->generateSiteRss($this->siteConfig, $collections, $entries); + + assertStringContainsString('Test Site', $atom); + assertStringContainsString('', $atom); + assertStringContainsString('', $atom); + assertStringContainsString('', $atom); + assertStringContainsString('', $atom); + + assertStringContainsString('Test Site', $rss); + assertStringContainsString('https://test.example.com/', $rss); + assertStringContainsString('', $rss); + assertStringContainsString('https://test.example.com/news/release/', $rss); + } + public function testInlineTagLinksUseAbsolutePublicRootInFeedContent(): void { $siteConfig = new SiteConfig( diff --git a/tests/Unit/Console/BuildCommandTest.php b/tests/Unit/Console/BuildCommandTest.php index 6670f79..b47d423 100644 --- a/tests/Unit/Console/BuildCommandTest.php +++ b/tests/Unit/Console/BuildCommandTest.php @@ -343,6 +343,20 @@ public function testBuildGeneratesFeedsForCollectionsWithFeedEnabled(): void assertStringContainsString('Test Post', $rss); assertStringContainsString('', $rss); + + $siteAtomFile = $this->outputDir . '/feed.xml'; + assertFileExists($siteAtomFile); + $siteAtom = file_get_contents($siteAtomFile); + assertStringContainsString('Test Site', $siteAtom); + assertStringContainsString('', $siteAtom); + assertStringContainsString('Test Post', $siteAtom); + + $siteRssFile = $this->outputDir . '/rss.xml'; + assertFileExists($siteRssFile); + $siteRss = file_get_contents($siteRssFile); + assertStringContainsString('Test Site', $siteRss); + assertStringContainsString('', $siteRss); + assertStringContainsString('Test Post', $siteRss); } public function testFeedEntriesAreSortedChronologically(): void @@ -851,6 +865,8 @@ public function testDryRunListsFilesWithoutWriting(): void assertSame(0, $exitCode, "Dry run failed: $outputText"); assertStringContainsString('Dry run', $outputText); assertStringContainsString('index.html', $outputText); + assertStringContainsString('/feed.xml', $outputText); + assertStringContainsString('/rss.xml', $outputText); assertStringContainsString('sitemap.xml', $outputText); assertStringContainsString('Total:', $outputText); assertFalse(is_dir($this->outputDir));