Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`).
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions docs/content.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
2 changes: 1 addition & 1 deletion roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
105 changes: 99 additions & 6 deletions src/Build/FeedGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,38 @@ public function generateRss(
return $xml->outputMemory();
}

/**
* @param array<string, Collection> $collections
* @param list<Entry> $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<string, Collection> $collections
* @param list<Entry> $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<Entry> $entries
*/
Expand Down Expand Up @@ -82,20 +114,54 @@ public function writeRssFile(
}

/**
* @param array<string, Collection> $collections
* @param list<Entry> $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();
Comment on lines +120 to +129

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== FeedGenerator class definitions =="
rg -nP --type=php 'namespace\s+YiiPress\\Build;|class\s+FeedGenerator\b'

echo
echo "== FeedGenerator method declarations related to failing call =="
rg -nP --type=php -C2 'function\s+writeSiteAtomFile\s*\(|function\s+createFileWriter\s*\('

echo
echo "== Autoload/classmap references to FeedGenerator =="
rg -nP 'YiiPress\\\\Build\\\\FeedGenerator|FeedGenerator\.php'

Repository: yiipress/engine

Length of output: 3226


CI fatal: createFileWriter() undefined in writeSiteAtomFile()—check for stale/packaged FeedGenerator

  • src/Build/FeedGenerator.php defines a single final class YiiPress\Build\FeedGenerator.
  • That same class declares private function createFileWriter(string $path): XMLWriter (around line ~391) and writeSiteAtomFile() calls $this->createFileWriter($path) (around line ~122).
  • If CI still reports the method as undefined at the call site, the build is likely loading an older/stale FeedGenerator artifact (PHAR/vendor/autoload/classmap cache). Inspect the CI packaging/autoload inputs used during build.
🧰 Tools
🪛 GitHub Actions: Run Tests / 0_test.txt

[error] 122-122: Console command "build" failed: PHP Fatal error — Call to undefined method YiiPress\Build\FeedGenerator::createFileWriter().

🪛 GitHub Actions: Run Tests / test

[error] 122-122: Error: Call to undefined method YiiPress\Build\FeedGenerator::createFileWriter() while running console command "build". Triggered from writeSiteAtomFile() via /app/src/Console/BuildCommand.php(759).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Build/FeedGenerator.php` around lines 120 - 129, The call to
$this->createFileWriter() in YiiPress\Build\FeedGenerator::writeSiteAtomFile is
reported as undefined; ensure the private function createFileWriter(string
$path): XMLWriter is present with the exact name and signature in the same final
class YiiPress\Build\FeedGenerator (or change its visibility to match usage if
needed), then rebuild/refresh packaging and autoload so CI picks up the updated
class (run composer dump-autoload, clear any PHAR/classmap caches and OPcache,
and verify no duplicate/stale FeedGenerator class exists in vendor or packaged
artifacts); if you intentionally moved or renamed createFileWriter, update
writeSiteAtomFile to call the new method name instead.

Source: Pipeline failures

}

/**
* @param array<string, Collection> $collections
* @param list<Entry> $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<Entry> $entries
* @param array<string, Collection>|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 !== '') {
Expand Down Expand Up @@ -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();
Expand All @@ -136,21 +202,23 @@ private function writeAtomDocument(

/**
* @param list<Entry> $entries
* @param array<string, Collection>|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');
$xml->writeAttribute('version', '2.0');
$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);
Expand All @@ -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();
Expand Down Expand Up @@ -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<Entry> $entries
* @return list<Entry>
Expand Down
31 changes: 31 additions & 0 deletions src/Console/BuildCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
use function substr;
use function strlen;
use function trim;
use function usort;
use function yaml_parse;

#[AsCommand(
Expand Down Expand Up @@ -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: <comment>$feedCount</comment> (Atom + RSS)");
}
Expand Down Expand Up @@ -1061,6 +1086,7 @@ private function dryRun(
$output->writeln('<info>Dry run — files that would be generated:</info>');
$now = new DateTimeImmutable();
$files = [];
$hasFeeds = false;

foreach ($collections as $collectionName => $collection) {
$entries = [];
Expand All @@ -1082,6 +1108,7 @@ private function dryRun(
}

if ($collection->feed) {
$hasFeeds = true;
$files[] = $outputDir . '/' . $collectionName . '/feed.xml';
$files[] = $outputDir . '/' . $collectionName . '/rss.xml';
}
Expand Down Expand Up @@ -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 === '') {
Expand Down
55 changes: 55 additions & 0 deletions tests/Unit/Build/FeedGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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('<title>Test Site</title>', $atom);
assertStringContainsString('<link href="https://test.example.com/"/>', $atom);
assertStringContainsString('<link href="https://test.example.com/feed.xml" rel="self" type="application/atom+xml"/>', $atom);
assertStringContainsString('<link href="https://test.example.com/blog/first-post/"/>', $atom);
assertStringContainsString('<link href="https://test.example.com/news/release/"/>', $atom);

assertStringContainsString('<title>Test Site</title>', $rss);
assertStringContainsString('<link>https://test.example.com/</link>', $rss);
assertStringContainsString('<atom:link href="https://test.example.com/rss.xml" rel="self" type="application/rss+xml"/>', $rss);
assertStringContainsString('<link>https://test.example.com/news/release/</link>', $rss);
}

public function testInlineTagLinksUseAbsolutePublicRootInFeedContent(): void
{
$siteConfig = new SiteConfig(
Expand Down
16 changes: 16 additions & 0 deletions tests/Unit/Console/BuildCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,20 @@ public function testBuildGeneratesFeedsForCollectionsWithFeedEnabled(): void
assertStringContainsString('<rss version="2.0"', $rss);
assertStringContainsString('<title>Test Post</title>', $rss);
assertStringContainsString('<content:encoded>', $rss);

$siteAtomFile = $this->outputDir . '/feed.xml';
assertFileExists($siteAtomFile);
$siteAtom = file_get_contents($siteAtomFile);
assertStringContainsString('<title>Test Site</title>', $siteAtom);
assertStringContainsString('<link href="https://test.example.com/feed.xml" rel="self" type="application/atom+xml"/>', $siteAtom);
assertStringContainsString('<title>Test Post</title>', $siteAtom);

$siteRssFile = $this->outputDir . '/rss.xml';
assertFileExists($siteRssFile);
$siteRss = file_get_contents($siteRssFile);
assertStringContainsString('<title>Test Site</title>', $siteRss);
assertStringContainsString('<atom:link href="https://test.example.com/rss.xml" rel="self" type="application/rss+xml"/>', $siteRss);
assertStringContainsString('<title>Test Post</title>', $siteRss);
}

public function testFeedEntriesAreSortedChronologically(): void
Expand Down Expand Up @@ -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));
Expand Down
Loading