From 5fe0472b7d6d948b9e9d5ba846e96a2e2eca52df Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Sat, 13 Jun 2026 12:15:09 +0300 Subject: [PATCH] Add JSON Feed output --- docs/commands.md | 2 +- docs/configuration.md | 2 +- docs/content.md | 4 +- docs/quickstart.md | 1 + psalm-baseline.xml | 8 -- roadmap.md | 2 +- src/Build/FeedGenerator.php | 103 ++++++++++++++++++++++++ src/Console/BuildCommand.php | 10 ++- tests/Unit/Build/FeedGeneratorTest.php | 48 +++++++++++ tests/Unit/Console/BuildCommandTest.php | 8 ++ themes/minimal/partials/head.php | 1 + 11 files changed, 175 insertions(+), 14 deletions(-) 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 @@ +