Skip to content
Merged
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 .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Metrics/PerceivedComplexity:

# Class length for SimpleRSS (single-file library)
Metrics/ClassLength:
Max: 360
Max: 370

# Frozen string literal is optional for this gem
Style/FrozenStringLiteralComment:
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ Version 2.0 is a major update with powerful new capabilities:

- **Modern Ruby** - Full compatibility with Ruby 3.1 through 4.0, with RBS type annotations and Steep type checking.

- **Enumerable Support** - Iterate feeds naturally with `each`, `map`, `select`, and all Enumerable methods. Access items by index with `rss[0]` and get the latest items sorted by date with `latest(n)`.

## Installation

Add to your Gemfile:
Expand Down Expand Up @@ -136,6 +138,30 @@ item.pubDate.class # => Time
item.pubDate.year # => 2024
```

### Iterating with Enumerable

SimpleRSS includes `Enumerable`, so you can iterate feeds naturally:

```ruby
feed = SimpleRSS.parse(xml)

# Iterate over items
feed.each { |item| puts item.title }

# Use any Enumerable method
titles = feed.map { |item| item.title }
tech_posts = feed.select { |item| item.category == "tech" }
first_five = feed.first(5)
total = feed.count

# Access items by index
feed[0].title # first item
feed[-1].title # last item

# Get the n most recent items (sorted by pubDate or updated)
feed.latest(10)
```

### JSON Serialization

```ruby
Expand Down Expand Up @@ -244,6 +270,9 @@ Fetch and parse a feed from a URL.
|--------|-------------|
| `#channel` / `#feed` | Returns self (for RSS/Atom style access) |
| `#items` / `#entries` | Array of parsed items |
| `#each` | Iterate over items (includes `Enumerable`) |
| `#[](index)` | Access item by index |
| `#latest(n = 10)` | Get n most recent items by date |
| `#to_json` | JSON string representation |
| `#to_hash` / `#as_json` | Hash representation |
| `#to_xml(format:)` | XML string (`:rss2` or `:atom`) |
Expand Down
31 changes: 31 additions & 0 deletions lib/simple-rss.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
require "time"

class SimpleRSS
# @rbs skip
include Enumerable

# @rbs!
# include Enumerable[Hash[Symbol, untyped]]

VERSION = "2.0.0".freeze

# @rbs @items: Array[Hash[Symbol, untyped]]
Expand Down Expand Up @@ -64,6 +70,31 @@ def channel
end
alias feed channel

# Iterate over all items in the feed
#
# @rbs () { (Hash[Symbol, untyped]) -> void } -> self
# | () -> Enumerator[Hash[Symbol, untyped], self]
def each(&block)
return enum_for(:each) unless block

items.each(&block)
self
end

# Access an item by index
#
# @rbs (Integer) -> Hash[Symbol, untyped]?
def [](index)
items[index]
end

# Get the n most recent items, sorted by date
#
# @rbs (?Integer) -> Array[Hash[Symbol, untyped]]
def latest(count = 10)
items.sort_by { |item| item[:pubDate] || item[:updated] || Time.at(0) }.reverse.first(count)
end

# @rbs (?Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
def as_json(_options = {})
hash = {} #: Hash[Symbol, untyped]
Expand Down
101 changes: 101 additions & 0 deletions test/base/enumerable_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
require "test_helper"

class EnumerableTest < Test::Unit::TestCase
def setup
@rss20 = SimpleRSS.parse open(File.dirname(__FILE__) + "/../data/rss20.xml")
@atom = SimpleRSS.parse open(File.dirname(__FILE__) + "/../data/atom.xml")
end

def test_includes_enumerable
assert_includes SimpleRSS.included_modules, Enumerable
end

def test_each_iterates_over_items
titles = @rss20.map { |item| item[:title] }
assert_equal @rss20.items.map { |i| i[:title] }, titles
end

def test_each_returns_enumerator_without_block
enumerator = @rss20.each
assert_kind_of Enumerator, enumerator
assert_equal @rss20.items.size, enumerator.count
end

def test_each_returns_self_with_block
count = 0
result = @rss20.each { |_item| count += 1 }
assert_equal @rss20, result
assert_equal @rss20.items.size, count
end

def test_enumerable_map
titles = @rss20.map { |item| item[:title] }
assert_equal @rss20.items.map { |i| i[:title] }, titles
end

def test_enumerable_select
items_with_link = @rss20.select { |item| item[:link] }
assert_equal @rss20.items.select { |i| i[:link] }, items_with_link
end

def test_enumerable_first
assert_equal @rss20.items.first, @rss20.first
assert_equal @rss20.items.first(3), @rss20.first(3)
end

def test_enumerable_count
assert_equal @rss20.items.size, @rss20.count
end

def test_index_accessor
assert_equal @rss20.items[0], @rss20[0]
assert_equal @rss20.items[5], @rss20[5]
assert_equal @rss20.items[-1], @rss20[-1]
end

def test_index_accessor_out_of_bounds
assert_nil @rss20[100]
end

def test_latest_returns_sorted_items
latest = @rss20.latest(3)
assert_equal 3, latest.size

dates = latest.map { |item| item[:pubDate] }
assert_equal dates, dates.sort.reverse
end

def test_latest_default_count
latest = @rss20.latest
assert latest.size <= 10
end

def test_latest_with_atom_uses_updated
latest = @atom.latest(1)
assert_equal 1, latest.size
end

def test_latest_handles_missing_dates
rss_with_missing_dates = SimpleRSS.parse <<~RSS
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Test Feed</title>
<link>http://example.com</link>
<item>
<title>No Date</title>
</item>
<item>
<title>Has Date</title>
<pubDate>Wed, 24 Aug 2005 13:33:34 GMT</pubDate>
</item>
</channel>
</rss>
RSS

latest = rss_with_missing_dates.latest(2)
assert_equal 2, latest.size
assert_equal "Has Date", latest.first[:title]
assert_equal "No Date", latest.last[:title]
end
end