4 Commits

Author SHA1 Message Date
nkanaev
a18ed04193 ui: tweaks 2026-06-22 14:55:19 +01:00
nkanaev
31f2ca57df parser: fix parsing namespaced RSS feeds 2026-06-22 09:33:56 +01:00
nkanaev
d0f8e70095 parser: more tests for edge cases 2026-06-21 22:51:14 +01:00
nkanaev
af7a38fccd parser: fix test 2026-06-21 20:30:43 +01:00
7 changed files with 138 additions and 21 deletions

View File

@@ -4,6 +4,7 @@
- (fix) crash on empty article list with article is selected (thanks to @rksvc) - (fix) crash on empty article list with article is selected (thanks to @rksvc)
- (fix) invalid article title in RSS feeds with media containing titles (thanks to @bwwu-git for the report) - (fix) invalid article title in RSS feeds with media containing titles (thanks to @bwwu-git for the report)
- (fix) missing image enclosures in certain RSS feeds (thanks to @palinek for the report) - (fix) missing image enclosures in certain RSS feeds (thanks to @palinek for the report)
- (fix) parsing namespaced legacy RSS feeds (thanks to @f100024)
# v2.6 (2025-11-24) # v2.6 (2025-11-24)

View File

@@ -67,6 +67,7 @@
<div class="row text-center m-0"> <div class="row text-center m-0">
<button class="btn btn-link col-4 px-0 rounded-0" <button class="btn btn-link col-4 px-0 rounded-0"
:class="'theme-'+t" :class="'theme-'+t"
:title="t"
:aria-label="t" :aria-label="t"
:aria-pressed="theme.name == t" :aria-pressed="theme.name == t"
@click.stop="theme.name = t" @click.stop="theme.name = t"
@@ -126,13 +127,17 @@
</button> </button>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<header class="dropdown-header" role="heading" aria-level="2">ᚨ / 𐎠 / 𑖀</header> <header class="dropdown-header" role="heading" aria-level="2">ᚨ / 𐎠 / 𑖀</header>
<button <div class="d-flex">
v-for="lang in languages" <button
class="dropdown-item" v-for="lang in languages"
:class="{active: language==lang.code}" class="dropdown-item text-center"
@click.stop="changeLanguage(lang.code)"> :aria-label="lang.name"
{{ lang.name }} :title="lang.name"
</button> :class="{active: language==lang.code}"
@click.stop="changeLanguage(lang.code)">
{{ lang.code }}
</button>
</div>
<div class="dropdown-divider" v-if="authenticated"></div> <div class="dropdown-divider" v-if="authenticated"></div>
<button class="dropdown-item" v-if="authenticated" @click="logout()"> <button class="dropdown-item" v-if="authenticated" @click="logout()">
<span class="icon mr-1">{% inline "log-out.svg" %}</span> <span class="icon mr-1">{% inline "log-out.svg" %}</span>

View File

@@ -4,9 +4,6 @@
html { html {
font-size: 15px !important; font-size: 15px !important;
}
body {
overscroll-behavior: none; overscroll-behavior: none;
} }

View File

@@ -80,7 +80,7 @@ func TestAtomHTMLTitle(t *testing.T) {
feed, _ := Parse(strings.NewReader(` feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"> <feed xmlns="http://www.w3.org/2005/Atom">
<entry><title type="html">say &lt;code&gt;what&lt;/code&gt;?</entry> <entry><title type="html">say &lt;code&gt;what&lt;/code&gt;?</title></entry>
</feed> </feed>
`)) `))
have := feed.Items[0].Title have := feed.Items[0].Title
@@ -96,12 +96,13 @@ func TestAtomXHTMLTitle(t *testing.T) {
feed, _ := Parse(strings.NewReader(` feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"> <feed xmlns="http://www.w3.org/2005/Atom">
<entry><title type="xhtml">say &lt;code&gt;what&lt;/code&gt;?</entry> <entry><title type="xhtml">say &lt;code&gt;what&lt;/code&gt;?</title></entry>
</feed> </feed>
`)) `))
have := feed.Items[0].Title have := feed.Items[0].Title
want := "say what?" want := "say what?"
if !reflect.DeepEqual(want, have) { if !reflect.DeepEqual(want, have) {
t.Log(feed)
t.Logf("want: %#v", want) t.Logf("want: %#v", want)
t.Logf("have: %#v", have) t.Logf("have: %#v", have)
t.FailNow() t.FailNow()

View File

@@ -48,12 +48,6 @@ type rssLink struct {
Rel string `xml:"rel,attr"` Rel string `xml:"rel,attr"`
} }
type rssTitle struct {
XMLName xml.Name
Data string `xml:",chardata"`
Inner string `xml:",innerxml"`
}
type rssEnclosure struct { type rssEnclosure struct {
URL string `xml:"url,attr"` URL string `xml:"url,attr"`
Type string `xml:"type,attr"` Type string `xml:"type,attr"`
@@ -63,9 +57,10 @@ type rssEnclosure struct {
func ParseRSS(r io.Reader) (*Feed, error) { func ParseRSS(r io.Reader) (*Feed, error) {
srcfeed := rssFeed{} srcfeed := rssFeed{}
decoder := xmlDecoder(r) rawDecoder := xmlDecoder(r)
decoder.DefaultSpace = "rss" rawDecoder.DefaultSpace = "rss"
if err := decoder.Decode(&srcfeed); err != nil { rssDecoder := xml.NewTokenDecoder(&rssTokenReader{Decoder: rawDecoder})
if err := rssDecoder.Decode(&srcfeed); err != nil {
return nil, err return nil, err
} }

View File

@@ -327,3 +327,68 @@ func TestRSSMultipleMedia(t *testing.T) {
t.Fatal("invalid rss") t.Fatal("invalid rss")
} }
} }
// When both RSS <link> and Atom <atom:link> elements are present in an item,
// the RSS link must not be lost. The <link> tag is namespace-qualified as
// `rss link` to disambiguate — see commit ee2a825, found in:
// https://rss.nytimes.com/services/xml/rss/nyt/Arts.xml
func TestRSSItemLinkWithAtomLinkPresent(t *testing.T) {
have, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Example</title>
<item>
<title>Article</title>
<link>http://example.com/article/1</link>
<atom:link href="http://example.com/article/1/atom" rel="alternate" type="text/html"/>
</item>
</channel>
</rss>
`))
want := &Feed{
Title: "Example",
Items: []Item{
{
GUID: "http://example.com/article/1",
URL: "http://example.com/article/1",
Title: "Article",
},
},
}
if !reflect.DeepEqual(want, have) {
t.Fatalf("RSS link lost when atom:link is present\nwant: %#v\nhave: %#v", want, have)
}
}
// Feeds that declare a default namespace on the root <rss> element (e.g. the
// legacy Userland namespace) must still parse — see sud.ua/rss/rss_news_uk.xml.
func TestRSSDefaultNamespace(t *testing.T) {
have, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="utf-8"?>
<rss xmlns="http://backend.userland.com/rss2" version="2.0">
<channel>
<title>Feed</title>
<item>
<title>Title 1</title>
<link>https://example.com/news/1</link>
</item>
</channel>
</rss>
`))
want := &Feed{
Title: "Feed",
Items: []Item{
{
GUID: "https://example.com/news/1",
URL: "https://example.com/news/1",
Title: "Title 1",
},
},
}
if !reflect.DeepEqual(want, have) {
t.Fatalf("default-namespaced rss not parsed \nwant: %#v\nhave: %#v", want, have)
// t.Logf("have: %#v", have)
// t.Fatal("default-namespaced rss not parsed")
}
}

View File

@@ -42,6 +42,59 @@ func xmlDecoder(r io.Reader) *xml.Decoder {
return decoder return decoder
} }
// XML token reader that strips the default namespace.
// It's primary purpose is to support namespaced legacy UserLand RSS feeds.
// NOTE: token readers cannot populate ",innerxml"-tagged struct fields,
// see https://github.com/golang/go/issues/39645
type rssTokenReader struct {
Decoder *xml.Decoder
defaultNS string
}
func (r *rssTokenReader) Token() (xml.Token, error) {
tok, err := r.Decoder.Token()
if err != nil {
return nil, err
}
switch t := tok.(type) {
case xml.StartElement:
// extract default namespace: <rss xmlns="<defaultNS>">
if t.Name.Local == "rss" {
for _, attr := range t.Attr {
if attr.Name.Space == "" && attr.Name.Local == "xmlns" && attr.Value != "" {
r.defaultNS = attr.Value
break
}
}
}
if r.defaultNS != "" {
// Rewrite element namespace
if t.Name.Space == r.defaultNS {
t.Name.Space = r.Decoder.DefaultSpace
}
// Rewrite attribute namespaces
attrs := t.Attr[:0]
for _, a := range t.Attr {
if a.Name.Space == r.defaultNS {
a.Name.Space = r.Decoder.DefaultSpace
}
attrs = append(attrs, a)
}
t.Attr = attrs
}
return t, nil
case xml.EndElement:
if r.defaultNS != "" && t.Name.Space == r.defaultNS {
t.Name.Space = r.Decoder.DefaultSpace
}
return t, nil
default:
return tok, nil
}
}
type safexmlreader struct { type safexmlreader struct {
reader *bufio.Reader reader *bufio.Reader
buffer *bytes.Buffer buffer *bytes.Buffer