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 Atom (`feed.xml`), RSS 2.0 (`rss.xml`), and JSON Feed (`feed.json`) 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, and JSON Feed files (`20` by default, `0` for unlimited)
- 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, and JSON Feed files for this collection
- **feed_limit** — maximum entries rendered into each feed (default: `20`, `0` for unlimited)
- **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
1 change: 1 addition & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ This generates static HTML in the `output/` directory:
output/
├── blog/
│ ├── feed.xml
│ ├── feed.json
│ ├── hello-world/
│ │ └── index.html
│ ├── rss.xml
Expand Down
8 changes: 0 additions & 8 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,6 @@
<code><![CDATA[require $templatePath]]></code>
</UnresolvableInclude>
</file>
<file src="src/Build/FeedGenerator.php">
<MixedArgument>
<code><![CDATA[$authorName]]></code>
</MixedArgument>
<MixedPropertyFetch>
<code><![CDATA[$this->authors[$authorSlug]->title]]></code>
</MixedPropertyFetch>
</file>
<file src="src/Build/NavigationPager.php">
<InvalidArrayOffset>
<code><![CDATA[$items[$index - 1]]]></code>
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] RSS/Atom/JSON 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
103 changes: 103 additions & 0 deletions src/Build/FeedGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace YiiPress\Build;

use YiiPress\Content\Model\Author;
use YiiPress\Content\Model\Collection;
use YiiPress\Content\Model\Entry;
use YiiPress\Content\Model\SiteConfig;
Expand All @@ -14,12 +15,17 @@
use XMLWriter;

use function array_slice;
use function file_put_contents;
use function json_encode;

final class FeedGenerator
{
/** @var array<string, string> */
private array $renderedContentCache = [];

/**
* @param array<string, Author> $authors
*/
public function __construct(
private readonly ContentProcessorPipeline $pipeline,
private readonly array $authors = [],
Expand Down Expand Up @@ -53,6 +59,20 @@ public function generateRss(
return $xml->outputMemory();
}

/**
* @param list<Entry> $entries
*/
public function generateJson(
SiteConfig $siteConfig,
Collection $collection,
array $entries,
): string {
return json_encode(
$this->jsonDocument($siteConfig, $collection, $this->limitEntries($collection, $entries)),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
);
}

/**
* @param list<Entry> $entries
*/
Expand Down Expand Up @@ -81,6 +101,18 @@ public function writeRssFile(
$xml->flush();
}

/**
* @param list<Entry> $entries
*/
public function writeJsonFile(
string $path,
SiteConfig $siteConfig,
Collection $collection,
array $entries,
): void {
file_put_contents($path, $this->generateJson($siteConfig, $collection, $entries));
}

/**
* @param list<Entry> $entries
*/
Expand Down Expand Up @@ -256,6 +288,77 @@ private function writeRssItem(
$xml->endElement();
}

/**
* @param list<Entry> $entries
* @return array<string, mixed>
*/
private function jsonDocument(SiteConfig $siteConfig, Collection $collection, array $entries): array
{
$collectionUrl = rtrim($siteConfig->baseUrl, '/') . '/' . $collection->name . '/';
$document = [
'version' => 'https://jsonfeed.org/version/1.1',
'title' => $collection->title,
'home_page_url' => $collectionUrl,
'feed_url' => rtrim($siteConfig->baseUrl, '/') . '/' . $collection->name . '/feed.json',
'items' => array_map(
fn (Entry $entry): array => $this->jsonItem($siteConfig, $collection, $entry),
$entries,
),
];

$description = $collection->description !== '' ? $collection->description : $siteConfig->description;
if ($description !== '') {
$document['description'] = $description;
}

if ($siteConfig->defaultLanguage !== '') {
$document['language'] = $siteConfig->defaultLanguage;
}

return $document;
}

/**
* @return array<string, mixed>
*/
private function jsonItem(SiteConfig $siteConfig, Collection $collection, Entry $entry): array
{
$entryUrl = $this->resolveEntryUrl($siteConfig, $collection, $entry);
$item = [
'id' => $entryUrl,
'url' => $entryUrl,
'title' => $entry->title,
];

$summary = $entry->summary();
if ($summary !== '') {
$item['summary'] = $summary;
}

$html = $this->renderedContent($siteConfig, $entry);
if ($html !== '') {
$item['content_html'] = $html;
}

if ($entry->date !== null) {
$item['date_published'] = $entry->date->format(DateTimeInterface::ATOM);
$item['date_modified'] = $entry->date->format(DateTimeInterface::ATOM);
}

if ($entry->authors !== []) {
$item['authors'] = array_map(
fn (string $authorSlug): array => ['name' => $this->authors[$authorSlug]->title ?? $authorSlug],
$entry->authors,
);
}

if ($entry->tags !== []) {
$item['tags'] = $entry->tags;
}

return $item;
}

private function resolveEntryUrl(SiteConfig $siteConfig, Collection $collection, Entry $entry): string
{
return rtrim($siteConfig->baseUrl, '/') . PermalinkResolver::resolve($entry, $collection, $siteConfig->i18n);
Expand Down
10 changes: 9 additions & 1 deletion src/Console/BuildCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,7 @@ function (array $feedTask) use ($siteConfig, $outputDir, $authors, $noWrite): in
if ($noWrite) {
$feedGenerator->generateAtom($siteConfig, $collection, $entries);
$feedGenerator->generateRss($siteConfig, $collection, $entries);
$feedGenerator->generateJson($siteConfig, $collection, $entries);
} else {
$feedDir = $outputDir . '/' . $collectionName;
if (!is_dir($feedDir) && !mkdir($feedDir, 0o755, true) && !is_dir($feedDir)) {
Expand All @@ -705,14 +706,20 @@ function (array $feedTask) use ($siteConfig, $outputDir, $authors, $noWrite): in
$collection,
$entries,
);
$feedGenerator->writeJsonFile(
$feedDir . '/feed.json',
$siteConfig,
$collection,
$entries,
);
}
return 1;
},
minTasksPerWorker: 1,
);

if ($feedCount > 0) {
$output->writeln(" Feeds generated: <comment>$feedCount</comment> (Atom + RSS)");
$output->writeln(" Feeds generated: <comment>$feedCount</comment> (Atom + RSS + JSON)");
}

$profile->switchTo('write listings');
Expand Down Expand Up @@ -1084,6 +1091,7 @@ private function dryRun(
if ($collection->feed) {
$files[] = $outputDir . '/' . $collectionName . '/feed.xml';
$files[] = $outputDir . '/' . $collectionName . '/rss.xml';
$files[] = $outputDir . '/' . $collectionName . '/feed.json';
}

if ($collection->listing) {
Expand Down
48 changes: 48 additions & 0 deletions tests/Unit/Build/FeedGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -152,18 +152,45 @@ public function testRssFeedContainsItems(): void
assertStringContainsString('<content:encoded>', $rss);
}

public function testJsonFeedContainsValidStructureAndItems(): void
{
$generator = new FeedGenerator(new ContentProcessorPipeline(new MarkdownProcessor(new MarkdownRenderer())));
$entries = $this->createEntries();

$json = $generator->generateJson($this->siteConfig, $this->collection, $entries);
$feed = json_decode($json, true, 512, JSON_THROW_ON_ERROR);

$this->assertSame('https://jsonfeed.org/version/1.1', $feed['version']);
$this->assertSame('Blog', $feed['title']);
$this->assertSame('Latest posts', $feed['description']);
$this->assertSame('https://test.example.com/blog/', $feed['home_page_url']);
$this->assertSame('https://test.example.com/blog/feed.json', $feed['feed_url']);
$this->assertSame('en', $feed['language']);
$this->assertSame('First Post', $feed['items'][0]['title']);
$this->assertSame('https://test.example.com/blog/first-post/', $feed['items'][0]['url']);
$this->assertSame('First post summary.', $feed['items'][0]['summary']);
$this->assertSame('2024-03-15T00:00:00+00:00', $feed['items'][0]['date_published']);
$this->assertSame([['name' => 'john-doe']], $feed['items'][0]['authors']);
$this->assertSame(['php'], $feed['items'][0]['tags']);
assertStringContainsString('<p>First post body.</p>', $feed['items'][0]['content_html']);
}

public function testEmptyEntriesProducesValidFeed(): void
{
$generator = new FeedGenerator(new ContentProcessorPipeline(new MarkdownProcessor(new MarkdownRenderer())));

$atom = $generator->generateAtom($this->siteConfig, $this->collection, []);
$rss = $generator->generateRss($this->siteConfig, $this->collection, []);
$json = $generator->generateJson($this->siteConfig, $this->collection, []);
$feed = json_decode($json, true, 512, JSON_THROW_ON_ERROR);

assertStringContainsString('<feed xmlns="http://www.w3.org/2005/Atom">', $atom);
assertStringNotContainsString('<entry>', $atom);

assertStringContainsString('<channel>', $rss);
assertStringNotContainsString('<item>', $rss);

$this->assertSame([], $feed['items']);
}

public function testDefaultFeedLimitCapsItemsAtTwenty(): void
Expand All @@ -173,11 +200,15 @@ public function testDefaultFeedLimitCapsItemsAtTwenty(): void

$atom = $generator->generateAtom($this->siteConfig, $this->collection, $entries);
$rss = $generator->generateRss($this->siteConfig, $this->collection, $entries);
$json = $generator->generateJson($this->siteConfig, $this->collection, $entries);
$feed = json_decode($json, true, 512, JSON_THROW_ON_ERROR);

$this->assertSame(20, substr_count($atom, '<entry>'));
$this->assertSame(20, substr_count($rss, '<item>'));
$this->assertSame(20, count($feed['items']));
assertStringContainsString('<title>Entry 20</title>', $atom);
assertStringNotContainsString('<title>Entry 21</title>', $atom);
$this->assertSame('Entry 20', $feed['items'][19]['title']);
}

public function testCustomFeedLimitCapsItems(): void
Expand All @@ -186,10 +217,14 @@ public function testCustomFeedLimitCapsItems(): void
$collection = $this->collectionWithFeedLimit(3);

$rss = $generator->generateRss($this->siteConfig, $collection, $this->createFeedEntries(5));
$json = $generator->generateJson($this->siteConfig, $collection, $this->createFeedEntries(5));
$feed = json_decode($json, true, 512, JSON_THROW_ON_ERROR);

$this->assertSame(3, substr_count($rss, '<item>'));
$this->assertSame(3, count($feed['items']));
assertStringContainsString('<title>Entry 3</title>', $rss);
assertStringNotContainsString('<title>Entry 4</title>', $rss);
$this->assertSame('Entry 3', $feed['items'][2]['title']);
}

public function testZeroFeedLimitKeepsAllItems(): void
Expand All @@ -199,11 +234,15 @@ public function testZeroFeedLimitKeepsAllItems(): void

$atom = $generator->generateAtom($this->siteConfig, $collection, $this->createFeedEntries(25));
$rss = $generator->generateRss($this->siteConfig, $collection, $this->createFeedEntries(25));
$json = $generator->generateJson($this->siteConfig, $collection, $this->createFeedEntries(25));
$feed = json_decode($json, true, 512, JSON_THROW_ON_ERROR);

$this->assertSame(25, substr_count($atom, '<entry>'));
$this->assertSame(25, substr_count($rss, '<item>'));
$this->assertSame(25, count($feed['items']));
assertStringContainsString('<title>Entry 25</title>', $atom);
assertStringContainsString('<title>Entry 25</title>', $rss);
$this->assertSame('Entry 25', $feed['items'][24]['title']);
}

public function testFeedFilesCanBeWrittenDirectly(): void
Expand All @@ -212,15 +251,19 @@ public function testFeedFilesCanBeWrittenDirectly(): void
$entries = $this->createEntries();
$atomPath = sys_get_temp_dir() . '/yiipress-feed-atom-' . uniqid() . '.xml';
$rssPath = sys_get_temp_dir() . '/yiipress-feed-rss-' . uniqid() . '.xml';
$jsonPath = sys_get_temp_dir() . '/yiipress-feed-json-' . uniqid() . '.json';

try {
$generator->writeAtomFile($atomPath, $this->siteConfig, $this->collection, $entries);
$generator->writeRssFile($rssPath, $this->siteConfig, $this->collection, $entries);
$generator->writeJsonFile($jsonPath, $this->siteConfig, $this->collection, $entries);

assertFileExists($atomPath);
assertFileExists($rssPath);
assertFileExists($jsonPath);
assertStringContainsString('<feed xmlns="http://www.w3.org/2005/Atom">', (string) file_get_contents($atomPath));
assertStringContainsString('<rss version="2.0"', (string) file_get_contents($rssPath));
assertStringContainsString('"version":"https://jsonfeed.org/version/1.1"', (string) file_get_contents($jsonPath));
} finally {
if (is_file($atomPath)) {
unlink($atomPath);
Expand All @@ -229,6 +272,10 @@ public function testFeedFilesCanBeWrittenDirectly(): void
if (is_file($rssPath)) {
unlink($rssPath);
}

if (is_file($jsonPath)) {
unlink($jsonPath);
}
}
}

Expand Down Expand Up @@ -270,6 +317,7 @@ public function process(string $content, Entry $entry): string

$generator->generateAtom($this->siteConfig, $this->collection, $entries);
$generator->generateRss($this->siteConfig, $this->collection, $entries);
$generator->generateJson($this->siteConfig, $this->collection, $entries);

$this->assertSame(1, $processor->calls);
}
Expand Down
8 changes: 8 additions & 0 deletions tests/Unit/Console/BuildCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,14 @@ public function testBuildGeneratesFeedsForCollectionsWithFeedEnabled(): void
assertStringContainsString('<rss version="2.0"', $rss);
assertStringContainsString('<title>Test Post</title>', $rss);
assertStringContainsString('<content:encoded>', $rss);

$jsonFile = $this->outputDir . '/blog/feed.json';
assertFileExists($jsonFile);
$json = (string) file_get_contents($jsonFile);
$feed = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
assertSame('https://jsonfeed.org/version/1.1', $feed['version']);
assertStringContainsString('"title":"Test Post"', $json);
assertStringContainsString('This is the body of the test post.', $json);
}

public function testFeedEntriesAreSortedChronologically(): void
Expand Down
1 change: 1 addition & 0 deletions themes/minimal/partials/head.php
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@
<?php if ($collectionName !== null): ?>
<link rel="alternate" type="application/rss+xml" title="<?= $h($t('rss_feed')) ?>" data-ui-attr-title="rss_feed" href="<?= $h($url($collectionName . '/rss.xml')) ?>">
<link rel="alternate" type="application/atom+xml" title="<?= $h($t('atom_feed')) ?>" data-ui-attr-title="atom_feed" href="<?= $h($url($collectionName . '/feed.xml')) ?>">
<link rel="alternate" type="application/feed+json" title="JSON Feed" href="<?= $h($url($collectionName . '/feed.json')) ?>">
<?php endif; ?>
<script src="<?= $h(Asset::url('assets/theme/image-zoom.js', $rootPath, $assetManifest)) ?>" defer></script>
<script src="<?= $h(Asset::url('assets/theme/code-copy.js', $rootPath, $assetManifest)) ?>" defer></script>
Expand Down
Loading