diff --git a/docs/commands.md b/docs/commands.md
index ed8768d..c793e9b 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 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/`).
diff --git a/docs/configuration.md b/docs/configuration.md
index bdce7f4..d903166 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, and JSON Feed files (`20` by default, `0` for unlimited)
- 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..86649f4 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, 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`)
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 53cfd6c..9e7f0eb 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -128,6 +128,7 @@ This generates static HTML in the `output/` directory:
output/
├── blog/
│ ├── feed.xml
+│ ├── feed.json
│ ├── hello-world/
│ │ └── index.html
│ ├── rss.xml
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index aecee89..08d264a 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -67,14 +67,6 @@
-
-
-
-
-
- authors[$authorSlug]->title]]>
-
-
diff --git a/roadmap.md b/roadmap.md
index f36c641..b16305a 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] 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)
diff --git a/src/Build/FeedGenerator.php b/src/Build/FeedGenerator.php
index 96dab8b..2792a2e 100644
--- a/src/Build/FeedGenerator.php
+++ b/src/Build/FeedGenerator.php
@@ -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;
@@ -14,12 +15,17 @@
use XMLWriter;
use function array_slice;
+use function file_put_contents;
+use function json_encode;
final class FeedGenerator
{
/** @var array */
private array $renderedContentCache = [];
+ /**
+ * @param array $authors
+ */
public function __construct(
private readonly ContentProcessorPipeline $pipeline,
private readonly array $authors = [],
@@ -53,6 +59,20 @@ public function generateRss(
return $xml->outputMemory();
}
+ /**
+ * @param list $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 $entries
*/
@@ -81,6 +101,18 @@ public function writeRssFile(
$xml->flush();
}
+ /**
+ * @param list $entries
+ */
+ public function writeJsonFile(
+ string $path,
+ SiteConfig $siteConfig,
+ Collection $collection,
+ array $entries,
+ ): void {
+ file_put_contents($path, $this->generateJson($siteConfig, $collection, $entries));
+ }
+
/**
* @param list $entries
*/
@@ -256,6 +288,77 @@ private function writeRssItem(
$xml->endElement();
}
+ /**
+ * @param list $entries
+ * @return array
+ */
+ 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
+ */
+ 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);
diff --git a/src/Console/BuildCommand.php b/src/Console/BuildCommand.php
index 4e1e959..2e00bd0 100644
--- a/src/Console/BuildCommand.php
+++ b/src/Console/BuildCommand.php
@@ -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)) {
@@ -705,6 +706,12 @@ function (array $feedTask) use ($siteConfig, $outputDir, $authors, $noWrite): in
$collection,
$entries,
);
+ $feedGenerator->writeJsonFile(
+ $feedDir . '/feed.json',
+ $siteConfig,
+ $collection,
+ $entries,
+ );
}
return 1;
},
@@ -712,7 +719,7 @@ function (array $feedTask) use ($siteConfig, $outputDir, $authors, $noWrite): in
);
if ($feedCount > 0) {
- $output->writeln(" Feeds generated: $feedCount (Atom + RSS)");
+ $output->writeln(" Feeds generated: $feedCount (Atom + RSS + JSON)");
}
$profile->switchTo('write listings');
@@ -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) {
diff --git a/tests/Unit/Build/FeedGeneratorTest.php b/tests/Unit/Build/FeedGeneratorTest.php
index 009e463..67977a9 100644
--- a/tests/Unit/Build/FeedGeneratorTest.php
+++ b/tests/Unit/Build/FeedGeneratorTest.php
@@ -152,18 +152,45 @@ public function testRssFeedContainsItems(): void
assertStringContainsString('', $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('First post body.
', $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('', $atom);
assertStringNotContainsString('', $atom);
assertStringContainsString('', $rss);
assertStringNotContainsString('- ', $rss);
+
+ $this->assertSame([], $feed['items']);
}
public function testDefaultFeedLimitCapsItemsAtTwenty(): void
@@ -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, ''));
$this->assertSame(20, substr_count($rss, '
- '));
+ $this->assertSame(20, count($feed['items']));
assertStringContainsString('Entry 20', $atom);
assertStringNotContainsString('Entry 21', $atom);
+ $this->assertSame('Entry 20', $feed['items'][19]['title']);
}
public function testCustomFeedLimitCapsItems(): void
@@ -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, '
- '));
+ $this->assertSame(3, count($feed['items']));
assertStringContainsString('Entry 3', $rss);
assertStringNotContainsString('Entry 4', $rss);
+ $this->assertSame('Entry 3', $feed['items'][2]['title']);
}
public function testZeroFeedLimitKeepsAllItems(): void
@@ -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, ''));
$this->assertSame(25, substr_count($rss, '
- '));
+ $this->assertSame(25, count($feed['items']));
assertStringContainsString('Entry 25', $atom);
assertStringContainsString('Entry 25', $rss);
+ $this->assertSame('Entry 25', $feed['items'][24]['title']);
}
public function testFeedFilesCanBeWrittenDirectly(): void
@@ -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('', (string) file_get_contents($atomPath));
assertStringContainsString('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);
}
diff --git a/tests/Unit/Console/BuildCommandTest.php b/tests/Unit/Console/BuildCommandTest.php
index 6670f79..b812c14 100644
--- a/tests/Unit/Console/BuildCommandTest.php
+++ b/tests/Unit/Console/BuildCommandTest.php
@@ -343,6 +343,14 @@ public function testBuildGeneratesFeedsForCollectionsWithFeedEnabled(): void
assertStringContainsString('Test Post', $rss);
assertStringContainsString('', $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
diff --git a/themes/minimal/partials/head.php b/themes/minimal/partials/head.php
index d13b97e..491c752 100644
--- a/themes/minimal/partials/head.php
+++ b/themes/minimal/partials/head.php
@@ -182,6 +182,7 @@
+