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
87 changes: 87 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Repository Guidelines

This file is the shared source of project instructions. Keep `CLAUDE.md` as an
`@AGENTS.md` import so both tools use the same guidance.

## Commands

```bash
bundle exec rake test # Run all tests
bundle exec ruby -Ilib:test test/base/base_test.rb # Run single test file
bundle exec rubocop # Lint
bundle exec rubocop -A # Auto-fix lint issues
bundle exec rbs-inline --output sig lib/ # Generate RBS from annotations
bundle exec rbs -I sig validate # Validate generated types
bundle exec steep check # Type check
bundle exec rake console # Interactive console
```

## Architecture

Single-file library (`lib/simple-rss.rb`) using regex-based XML parsing for flexibility with malformed feeds.

**Tag Syntax** (extend via `SimpleRSS.item_tags <<`):
- `tag` - simple element extraction
- `tag#attr` - attribute value (e.g., `media:content#url` → `media_content_url`)
- `tag+rel` - rel attribute matching (e.g., `link+alternate` → `link_alternate`)

**Dynamic Accessors**: Feed tags become `attr_reader` methods at parse time. Item tags are hash keys with `method_missing` for dot notation.

## Type Annotations

Uses RBS inline syntax:
```ruby
# @rbs (String, Integer) -> Bool # method signature
attr_reader :name #: String # attribute type
@items = [] #: Array[untyped] # inline variable
```

## Changelog

Update `CHANGELOG.md` in the same pull request as any user-visible feature,
behavior change, or bug fix. Record unreleased work under `## Unreleased` at the
top. Keep released versions newest first with headings formatted as
`## X.Y.Z - YYYY-MM-DD`.

Write concise bullets that explain what changed for callers and why it matters.
Mention affected APIs, compatibility changes, and migration steps when relevant.
For bug fixes, describe the failing behavior and the corrected result. Link the
issue or pull request when useful. Include performance numbers only when they
were measured, and distinguish evidence from intended behavior.

Do not copy commit logs into the changelog, claim planned work is implemented,
or add a release date before publishing. Correct inaccurate history when found;
do not silently move a change into a version that did not contain it. Routine
test, tooling, and prose-only edits do not need their own release-note bullet.

When backfilling history, verify tag and commit boundaries, cite the source, and
distinguish source-history dates from package publication dates. Identify
intermediate source versions and the earliest repository import explicitly.

## Release

Before tagging a release, bump the version in both files:
- `simple-rss.gemspec` (`s.version`)
- `lib/simple-rss.rb` (`VERSION`)

Both values must match. If they do not, CI may build and attempt to push the wrong gem version.

Move the completed `Unreleased` notes into a section for that exact version and
the release date, then leave an empty `Unreleased` section for future work.
Check that the changelog, both version declarations, and the tag agree. Run the
test suite, RuboCop, RBS generation and validation, and Steep before publishing.
`Gemfile.lock` is ignored in this library; do not add it as part of a release.

Then tag with `v*` to trigger automated RubyGems release:
```bash
git tag -a vX.Y.Z -m "Version X.Y.Z"
git push origin vX.Y.Z
```

Quick preflight checks:
```bash
rg -n "s.version" simple-rss.gemspec
rg -n "VERSION" lib/simple-rss.rb
rg '^## ' CHANGELOG.md
gem build simple-rss.gemspec
```
123 changes: 123 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Changelog

Entries through 2.2.0 were backfilled from Git history. Their dates identify
release tags or version-changing commits; older package publication dates can
differ. Linked source boundaries identify the evidence for each entry. The
repository begins with a 1.1 import, so earlier releases are not reconstructed.

## Unreleased

- Use the first valid `pubDate`, `updated`, or `published` timestamp in
`latest`, `items_since`, and merge ordering. Malformed dates no longer make
`latest` raise or hide a valid fallback. Preserve the original field values,
retain source order for equal dates, and place undated entries after dated
entries, including those before 1970. Preserve merge identity rules and the
placement of unidentified entries. ([#62](https://github.com/cardmagic/simple-rss/pull/62))
- Make documented relation accessors such as `item.link_alternate` and
`item[:link_alternate]` return the link's `href`, including when attributes
precede `rel` or the link contains nested media. Keep relation matching within
direct child links of the current entry. Preserve legacy keys such as
`item[:"link+alternate"]` and the existing `item.link` behavior; hash and JSON
serialization now include both relation keys.
([#57](https://github.com/cardmagic/simple-rss/issues/57))

## 2.2.0 - 2026-03-29

- Add `feed_type`, feed validation through instance and class `valid?` methods,
and filtering with `items_since`, `items_by_category`, and `search`.
- Add instance and class `merge` methods to combine feeds, deduplicate identified
entries, and order them by date. Add `diff` to report added and removed entries
between feed snapshots.
- Add item `has_media?` and `media_url` helpers, feed-level `enclosures` and
`images` collections, and parsing for `media:description`, `itunes:duration`,
and `itunes:image#href`. Treat blank media URLs as absent when choosing a
usable URL. ([Source](https://github.com/cardmagic/simple-rss/compare/v2.1.0...v2.2.0))

## 2.1.0 - 2025-12-29

- Add `SimpleRSS.fetch` with timeouts, custom headers, redirects, and conditional
GET support. Expose ETag and Last-Modified values and return `nil` for a
`304 Not Modified` response.
- Add `as_json`, `to_json`, and `to_hash`, serializing time values as ISO 8601.
Add `to_xml` to generate RSS 2.0 or Atom output from a parsed feed.
- Include `Enumerable` so feeds support iteration, mapping, and filtering.
Add index access with `feed[index]` and date ordering with `latest(count)`.
([Source](https://github.com/cardmagic/simple-rss/compare/v2.0.0...v2.1.0))

## 2.0.0 - 2025-12-28

- Add the `array_tags` option to collect multiple values for selected item tags.
- Support extracting attributes from channel/feed and item/entry elements with
the `tag#attribute` syntax.
- Normalize parsed strings to UTF-8 and skip empty or whitespace-only tag
definitions that could make parsing hang.
- Add inline RBS type annotations, Steep validation, and GitHub Actions checks
for modern Ruby versions.
([Source](https://github.com/cardmagic/simple-rss/compare/f5e879b...v2.0.0))

## 1.3.3 - 2018-04-23

- Parse `media:content#duration`, making video duration available through
`media_content_duration`. Synchronize the gemspec and library version at
1.3.3. ([Source](https://github.com/cardmagic/simple-rss/compare/51f0eb6...f5e879b))

## 1.3.2 - 2015-08-17

- Stop forcing parsed content to binary encoding during unescaping. Apply CDATA
removal and whitespace trimming after either unescaping branch, and add UTF-8
fixture coverage. This date follows the version change in source;
[RubyGems](https://rubygems.org/gems/simple-rss/versions/1.3.2) records package
publication in April 2018.
([Source](https://github.com/cardmagic/simple-rss/compare/f3768bd...76a56c6))

## 1.3.1 - 2013-12-16

- Restore Ruby 1.8 compatibility by checking for `force_encoding` before calling
it while unescaping content.
([Source](https://github.com/cardmagic/simple-rss/commit/f3768bd))

## 1.3.0 - 2013-12-16

- Make dynamically generated feed accessors usable on Ruby 1.9 without changing
the visibility of private parser methods.
- Adjust regular expressions and encoding handling for Ruby 1.9, including
suppression of UTF-8 regexp warnings.
([Source](https://github.com/cardmagic/simple-rss/compare/aaf86ad...0e2ce64))

## 1.2.3 - 2010-07-06

- Add `tag#attribute` mapping for item child elements. Parse Media RSS content
URLs, types, dimensions, thumbnails, titles, credits, and categories through
the corresponding attribute and element accessors.
([Source](https://github.com/cardmagic/simple-rss/compare/0915f34...aaf86ad))

## 1.2.2 - 2009-06-22

- Replace evaluation of feed-field assignments with `instance_variable_set`
and generate feed readers directly. Copy the options hash when constructing
the parser. ([Source](https://github.com/cardmagic/simple-rss/commit/0915f34))

## 1.2.1 - 2009-06-22

- Introduce an options hash for `parse` and initialization, including a
`marshalable` option that skips feed-reader generation. This intermediate
version is recorded in Git; a package publication was not verified.
([Source](https://github.com/cardmagic/simple-rss/commit/c9757d2))

## 1.2 - 2009-02-25

- Add `tag+rel` matching and built-in Atom link relations for alternate, self,
edit, and replies links. Add `feedburner:origLink` support.
- Remove CDATA delimiters and trim whitespace even when percent-unescaping is
unnecessary. ([Source](https://github.com/cardmagic/simple-rss/compare/40f3ca5...fa54025))

## 1.1 - 2006-02-02

- Earliest repository import: parse RSS and Atom from strings or IO objects,
expose feed and item fields through method access, provide RSS/Atom aliases,
and allow callers to extend the recognized feed and item tags.
([Source](https://github.com/cardmagic/simple-rss/commit/437b6a6))
- Subsequent commits retaining version 1.1 make percent-unescaping conditional
and add gem packaging metadata. These changes postdate the initial import.
([Unescaping](https://github.com/cardmagic/simple-rss/commit/ac95fb4),
[packaging](https://github.com/cardmagic/simple-rss/commit/379f639))
56 changes: 1 addition & 55 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,55 +1 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
bundle exec rake test # Run all tests
bundle exec ruby -Ilib:test test/base/base_test.rb # Run single test file
bundle exec rubocop # Lint
bundle exec rubocop -A # Auto-fix lint issues
bundle exec rbs-inline --output sig lib/ # Generate RBS from annotations
bundle exec steep check # Type check
bundle exec rake console # Interactive console
```

## Architecture

Single-file library (`lib/simple-rss.rb`) using regex-based XML parsing for flexibility with malformed feeds.

**Tag Syntax** (extend via `SimpleRSS.item_tags <<`):
- `tag` - simple element extraction
- `tag#attr` - attribute value (e.g., `media:content#url` → `media_content_url`)
- `tag+rel` - rel attribute matching (e.g., `link+alternate` → `link_alternate`)

**Dynamic Accessors**: Feed tags become `attr_reader` methods at parse time. Item tags are hash keys with `method_missing` for dot notation.

## Type Annotations

Uses RBS inline syntax:
```ruby
# @rbs (String, Integer) -> Bool # method signature
attr_reader :name #: String # attribute type
@items = [] #: Array[untyped] # inline variable
```

## Release

Before tagging a release, bump the version in both files:
- `simple-rss.gemspec` (`s.version`)
- `lib/simple-rss.rb` (`VERSION`)

Both values must match. If they do not, CI may build and attempt to push the wrong gem version.

Then tag with `v*` to trigger automated RubyGems release:
```bash
git tag -a v1.3.4 -m "v1.3.4" && git push origin v1.3.4
```

Quick preflight checks:
```bash
grep -n "s.version" simple-rss.gemspec
grep -n "VERSION" lib/simple-rss.rb
gem build simple-rss.gemspec
```
@AGENTS.md
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ A simple, flexible, extensible, and liberal RSS and Atom reader for Ruby. Design
- Extensible tag definitions
- Zero runtime dependencies

## What's New in 2.0
## What's New in 2.x

Version 2.0 is a major update with powerful new capabilities:
See the [changelog](CHANGELOG.md) for release history and unreleased changes.

The 2.x releases add:

- **URL Fetching** - One-liner feed fetching with `SimpleRSS.fetch(url)`. Supports timeouts, custom headers, and automatic redirect following.

Expand Down Expand Up @@ -237,6 +239,19 @@ SimpleRSS.item_tags << :"entry#xml:lang"
| `tag#attr` | `:"media:content#url"` | `.media_content_url` | Attribute value |
| `tag+rel` | `:"link+alternate"` | `.link_alternate` | Element with specific `rel` attribute |

Relation tags provide both underscore and legacy `+` hash keys. For example,
`item.link_alternate`, `item[:link_alternate]`, and `item[:"link+alternate"]`
return the same parsed value. Both keys appear in `to_hash`, `as_json`, and
`to_json`; they are ordinary hash entries, so reassigning one does not update the
other. The built-in link relations are `alternate`, `self`, `edit`, and `replies`.

Link relation accessors read `href` from the first direct child link with the
requested explicit `rel` attribute. Attribute order and child markup do not
affect the URL. Links inside source metadata, content, or other nested elements
are excluded. A missing relation or `href` returns `nil`. Relative href values
remain relative. `item.link` retains its existing extraction behavior; it does
not select a canonical article URL across relations or feed formats.

### Collecting Multiple Values

By default, SimpleRSS returns only the first occurrence of each tag. To collect all values:
Expand Down
61 changes: 56 additions & 5 deletions lib/simple-rss.rb
Original file line number Diff line number Diff line change
Expand Up @@ -445,15 +445,66 @@ def parse_item_tag(item, tag, content, item_attrs = nil)

# @rbs (Hash[Symbol, untyped], String, String) -> void
def parse_rel_tag(item, tag_str, content)
tag, rel = tag_str.split("+")
tag, rel = tag_str.split("+", 2)
Comment thread
cardmagic marked this conversation as resolved.
return unless tag && rel

content =~ %r{<(rss:|atom:)?#{tag}(.*?)rel=['"]#{rel}['"](.*?)>(.*?)</(rss:|atom:)?#{tag}>}mi ||
content =~ %r{<(rss:|atom:)?#{tag}(.*?)rel=['"]#{rel}['"](.*?)/\s*>}mi
value = if tag == "link"
Comment thread
cardmagic marked this conversation as resolved.
link_relation_href(content, rel)
else
content =~ %r{<(rss:|atom:)?#{tag}(.*?)rel=['"]#{rel}['"](.*?)>(.*?)</(rss:|atom:)?#{tag}>}mi ||
Comment thread
cardmagic marked this conversation as resolved.
content =~ %r{<(rss:|atom:)?#{tag}(.*?)rel=['"]#{rel}['"](.*?)/\s*>}mi
Comment thread
cardmagic marked this conversation as resolved.

return unless Regexp.last_match(3) || Regexp.last_match(4)
return unless Regexp.last_match(3) || Regexp.last_match(4)

item[clean_tag("#{tag}+#{rel}")] = clean_content(tag.to_sym, Regexp.last_match(3), Regexp.last_match(4))
clean_content(tag.to_sym, Regexp.last_match(3), Regexp.last_match(4))
Comment thread
cardmagic marked this conversation as resolved.
end
return if value.nil?

item[clean_tag("#{tag}+#{rel}")] = value
item[clean_tag("#{tag}_#{rel}")] = value
end

# @rbs (String, String) -> String?
def link_relation_href(content, relation)
attributes = entry_link_attributes(content).find { |link| link["rel"]&.casecmp?(relation) }
Comment thread
cardmagic marked this conversation as resolved.
return unless attributes

attributes["href"]
Comment thread
cardmagic marked this conversation as resolved.
end

# @rbs (String) -> Array[Hash[String, String]]
def entry_link_attributes(content)
Comment thread
cardmagic marked this conversation as resolved.
Comment thread
cardmagic marked this conversation as resolved.
links = [] #: Array[Hash[String, String]]
depth = 0
tokens = %r{<!--.*?-->|<!\[CDATA\[.*?\]\]>|<\?.*?\?>|<(/?)([\w:.-]+)((?:[^<>"']|"[^"]*"|'[^']*')*)>}m
Comment thread
cardmagic marked this conversation as resolved.

content.scan(tokens) do
closing = Regexp.last_match(1)
tag = Regexp.last_match(2)&.downcase
attributes = Regexp.last_match(3)
next unless attributes

if closing == "/"
Comment thread
cardmagic marked this conversation as resolved.
depth = [depth - 1, 0].max
next
end

links << xml_attributes(attributes) if depth.zero? && %w[link atom:link rss:link].include?(tag)
Comment thread
cardmagic marked this conversation as resolved.
depth += 1 unless attributes.rstrip.end_with?("/")
Comment thread
cardmagic marked this conversation as resolved.
Comment thread
cardmagic marked this conversation as resolved.
end

links
end

# @rbs (String) -> Hash[String, String]
def xml_attributes(attributes)
values = {} #: Hash[String, String]
attributes.scan(/([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/m) do
name = Regexp.last_match(1)
value = Regexp.last_match(2) || Regexp.last_match(3)
values[name.downcase] = value if name && value
end
values
end

# @rbs (String, String?) -> void
Expand Down
3 changes: 2 additions & 1 deletion simple-rss.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ Gem::Specification.new do |s|
s.summary = "A simple, flexible, extensible, and liberal RSS and Atom reader for Ruby. It is designed to be backwards compatible with the standard RSS parser, but will never do RSS generation."
s.email = "lucas@rufy.com"
s.homepage = "https://github.com/cardmagic/simple-rss"
s.metadata["changelog_uri"] = "https://github.com/cardmagic/simple-rss/blob/master/CHANGELOG.md"
s.description = "A simple, flexible, extensible, and liberal RSS and Atom reader for Ruby. It is designed to be backwards compatible with the standard RSS parser, but will never do RSS generation."
s.authors = ["Lucas Carlson"]
s.files = Dir["lib/**/*", "test/**/*", "LICENSE", "README.md", "Rakefile", "simple-rss.gemspec"]
s.files = Dir["lib/**/*", "test/**/*", "LICENSE", "README.md", "CHANGELOG.md", "Rakefile", "simple-rss.gemspec"]
s.required_ruby_version = ">= 3.1"
s.add_development_dependency "rake"
s.add_development_dependency "rdoc"
Expand Down
Loading
Loading