Add feed/reader mode with full-article capture at ingest

Reader mode presents the stories on a page one at a time in a focused
overlay, marking each read as it's shown. Left/right arrows (or the header
book button / `f`) page through them; read stories dim on the grid. Read
state is device-local in localStorage.

Backing this required actually capturing article bodies, which Pete wasn't
doing — it kept only the RSS <description> lede and discarded content:encoded:

- stories.content column (idempotent migration; old rows fall back to lede)
- parser keeps content:encoded as paragraph-preserving text
- article fetch already done for paywall detection now also returns its body,
  so ingest stores the richer of feed-content vs scraped body with no extra
  request (prefers the archive snapshot body for paywalled stories)
- GET /api/article?id= serves the stored text; card queries now select id and
  expose it as data-id for the reader

Tests cover content extraction, the storage round-trip, and the article
endpoint + card rendering end to end.
This commit is contained in:
prosolis
2026-07-06 22:46:14 -07:00
parent 410f8dda0a
commit 55aa167151
17 changed files with 803 additions and 13 deletions

View File

@@ -65,6 +65,7 @@ const googlebotUA = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.
// ArticleMeta is what we can learn from fetching an article page directly. // ArticleMeta is what we can learn from fetching an article page directly.
type ArticleMeta struct { type ArticleMeta struct {
ImageURL string // og:image or twitter:image, absolute URL ImageURL string // og:image or twitter:image, absolute URL
BodyText string // extracted visible article body text (capped at MaxBodyChars)
BodyChars int // length of extracted visible body text BodyChars int // length of extracted visible body text
Fetched bool // true if we got an HTTP 200 with HTML Fetched bool // true if we got an HTTP 200 with HTML
FetchError bool // true if the fetch failed at the network/HTTP layer FetchError bool // true if the fetch failed at the network/HTTP layer
@@ -192,7 +193,10 @@ func fetchArticleMetaOnce(articleURL, ua, referer string) ArticleMeta {
meta.Fetched = true meta.Fetched = true
meta.ImageURL = extractOGImage(doc, articleURL) meta.ImageURL = extractOGImage(doc, articleURL)
meta.BodyChars = extractBodyChars(doc) // One body extraction serves both purposes: the text feeds reader mode and
// its length is the paywall/thin-body signal.
meta.BodyText = extractBodyText(doc)
meta.BodyChars = len(meta.BodyText)
meta.Paywalled = detectPaywall(doc) meta.Paywalled = detectPaywall(doc)
meta.SubscriberOnly = detectSubscriberOnly(articleURL, doc) meta.SubscriberOnly = detectSubscriberOnly(articleURL, doc)
return meta return meta

View File

@@ -31,6 +31,7 @@ type FeedItem struct {
GUID string GUID string
Headline string Headline string
Lede string Lede string
Content string // full article text from content:encoded, block structure preserved; "" when the feed ships only a snippet
ImageURL string ImageURL string
ArticleURL string ArticleURL string
Source string Source string
@@ -43,8 +44,19 @@ var (
htmlTagRe = regexp.MustCompile(`<[^>]*>`) htmlTagRe = regexp.MustCompile(`<[^>]*>`)
wsRe = regexp.MustCompile(`\s+`) wsRe = regexp.MustCompile(`\s+`)
imgSrcRe = regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']+)["']`) imgSrcRe = regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']+)["']`)
// Used by extractContentText to preserve paragraph structure when turning
// content:encoded HTML into plain text.
blockCloseRe = regexp.MustCompile(`(?i)</(p|div|li|h[1-6]|blockquote|article|section|figure|figcaption|ul|ol|table|tr|pre)>`)
brRe = regexp.MustCompile(`(?i)<br\s*/?>`)
hspaceRe = regexp.MustCompile(`[ \t\f\r]+`)
manyNewlineRe = regexp.MustCompile(`\n{3,}`)
) )
// maxContentChars bounds the stored article text. Generous enough for any real
// article while keeping row size and the reader payload sane.
const maxContentChars = 20000
// FetchFeed fetches and parses an RSS/Atom feed, returning items. ua overrides // FetchFeed fetches and parses an RSS/Atom feed, returning items. ua overrides
// the User-Agent header; pass "" to use Pete's honest default bot UA. // the User-Agent header; pass "" to use Pete's honest default bot UA.
func FetchFeed(feedURL, ua string) ([]FeedItem, error) { func FetchFeed(feedURL, ua string) ([]FeedItem, error) {
@@ -79,6 +91,7 @@ func FetchFeed(feedURL, ua string) ([]FeedItem, error) {
GUID: itemGUID(item), GUID: itemGUID(item),
Headline: strings.TrimSpace(item.Title), Headline: strings.TrimSpace(item.Title),
Lede: extractLede(item.Description), Lede: extractLede(item.Description),
Content: extractContentText(item.Content),
ImageURL: NormalizeImageURL(extractImageURL(item)), ImageURL: NormalizeImageURL(extractImageURL(item)),
ArticleURL: strings.TrimSpace(item.Link), ArticleURL: strings.TrimSpace(item.Link),
Language: itemLanguage(item), Language: itemLanguage(item),
@@ -142,6 +155,31 @@ func extractLede(desc string) string {
return strings.TrimSpace(text) return strings.TrimSpace(text)
} }
// extractContentText converts a feed's content:encoded HTML into plain text
// with paragraph breaks preserved, for reader mode. Block-level boundaries
// (</p>, </div>, <br>, list items, headings…) become newlines before the
// remaining tags are stripped, so the reader can re-wrap the text into
// paragraphs. Returns "" when the feed carried no content:encoded.
func extractContentText(raw string) string {
if strings.TrimSpace(raw) == "" {
return ""
}
s := brRe.ReplaceAllString(raw, "\n")
s = blockCloseRe.ReplaceAllString(s, "\n\n")
s = htmlTagRe.ReplaceAllString(s, "")
s = html.UnescapeString(s)
lines := strings.Split(s, "\n")
for i, ln := range lines {
lines[i] = strings.TrimSpace(hspaceRe.ReplaceAllString(ln, " "))
}
s = manyNewlineRe.ReplaceAllString(strings.Join(lines, "\n"), "\n\n")
s = strings.TrimSpace(s)
if len(s) > maxContentChars {
s = s[:maxContentChars]
}
return s
}
// extractImageURL tries to find an image URL from feed item metadata. // extractImageURL tries to find an image URL from feed item metadata.
// Order: media:content/thumbnail, enclosures, item.Image, then <img> tags // Order: media:content/thumbnail, enclosures, item.Image, then <img> tags
// scraped from content:encoded / description (catches feeds like Ars that // scraped from content:encoded / description (catches feeds like Ars that

View File

@@ -1,6 +1,9 @@
package ingestion package ingestion
import "testing" import (
"strings"
"testing"
)
func TestExtractLede(t *testing.T) { func TestExtractLede(t *testing.T) {
tests := []struct { tests := []struct {
@@ -69,3 +72,26 @@ func TestExtractLede(t *testing.T) {
}) })
} }
} }
func TestExtractContentText(t *testing.T) {
if got := extractContentText(""); got != "" {
t.Errorf("empty input = %q, want empty", got)
}
if got := extractContentText(" \n "); got != "" {
t.Errorf("whitespace-only input = %q, want empty", got)
}
in := `<p>First paragraph with <a href="/x">a link</a> &amp; entity.</p>` +
`<p>Second line one.<br>line two.</p><ul><li>item</li></ul>`
got := extractContentText(in)
want := "First paragraph with a link & entity.\n\nSecond line one.\nline two.\n\nitem"
if got != want {
t.Errorf("extractContentText:\n got %q\n want %q", got, want)
}
// Output is capped so a runaway body can't bloat the row.
long := "<p>" + strings.Repeat("x", maxContentChars+500) + "</p>"
if n := len(extractContentText(long)); n > maxContentChars {
t.Errorf("capped length = %d, want <= %d", n, maxContentChars)
}
}

View File

@@ -160,6 +160,10 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
"guid", items[i].GUID, "url", originalURL, "source", src.Name) "guid", items[i].GUID, "url", originalURL, "source", src.Name)
continue continue
} }
// bodyText is the article text we may store for reader mode. It starts
// as the body scraped during the fetch above and is upgraded to the
// snapshot's body if we end up swapping in an archive snapshot.
bodyText := meta.BodyText
gated := meta.Gated() || (meta.Fetched && meta.BodyChars < PaywallBodyThreshold) gated := meta.Gated() || (meta.Fetched && meta.BodyChars < PaywallBodyThreshold)
// paywalled tracks whether the link the user will click is still // paywalled tracks whether the link the user will click is still
// gated — i.e. gating was detected AND no archive workaround worked. // gated — i.e. gating was detected AND no archive workaround worked.
@@ -175,6 +179,9 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
if items[i].ImageURL == "" && snapMeta.ImageURL != "" { if items[i].ImageURL == "" && snapMeta.ImageURL != "" {
items[i].ImageURL = snapMeta.ImageURL items[i].ImageURL = snapMeta.ImageURL
} }
if snapMeta.BodyText != "" {
bodyText = snapMeta.BodyText
}
workedAround = true workedAround = true
slog.Info("paywall detected, using archive snapshot", slog.Info("paywall detected, using archive snapshot",
"guid", items[i].GUID, "original", originalURL, "guid", items[i].GUID, "original", originalURL,
@@ -209,10 +216,18 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
if publishedAt > seenAt { if publishedAt > seenAt {
publishedAt = seenAt publishedAt = seenAt
} }
// Reader-mode body: prefer whichever source gave us more complete text.
// Feeds that ship full content:encoded usually win; feeds that only
// syndicate a snippet fall back to the scraped article body.
content := items[i].Content
if len(bodyText) > len(content) {
content = bodyText
}
if err := storage.InsertStory(&storage.Story{ if err := storage.InsertStory(&storage.Story{
GUID: items[i].GUID, GUID: items[i].GUID,
Headline: items[i].Headline, Headline: items[i].Headline,
Lede: items[i].Lede, Lede: items[i].Lede,
Content: content,
ImageURL: items[i].ImageURL, ImageURL: items[i].ImageURL,
ArticleURL: items[i].ArticleURL, ArticleURL: items[i].ArticleURL,
URLCanonical: canonical, URLCanonical: canonical,

View File

@@ -81,6 +81,10 @@ func runMigrations(d *sql.DB) error {
addColumnIfMissing(d, "stories", "url_canonical", "TEXT") addColumnIfMissing(d, "stories", "url_canonical", "TEXT")
addColumnIfMissing(d, "stories", "headline_norm", "TEXT") addColumnIfMissing(d, "stories", "headline_norm", "TEXT")
addColumnIfMissing(d, "stories", "paywalled", "INTEGER NOT NULL DEFAULT 0") addColumnIfMissing(d, "stories", "paywalled", "INTEGER NOT NULL DEFAULT 0")
// content holds the full article text (feed content:encoded when present,
// else the body scraped during paywall detection) for reader mode. Stories
// ingested before this column existed simply have NULL and fall back to lede.
addColumnIfMissing(d, "stories", "content", "TEXT")
addColumnIfMissing(d, "stories", "published_at", "INTEGER") addColumnIfMissing(d, "stories", "published_at", "INTEGER")
addColumnIfMissing(d, "post_log", "url_canonical", "TEXT") addColumnIfMissing(d, "post_log", "url_canonical", "TEXT")
addColumnIfMissing(d, "post_log", "forced", "INTEGER NOT NULL DEFAULT 0") addColumnIfMissing(d, "post_log", "forced", "INTEGER NOT NULL DEFAULT 0")

View File

@@ -38,13 +38,28 @@ func InsertStory(s *Story) error {
publishedAt = s.PublishedAt publishedAt = s.PublishedAt
} }
_, err := Get().Exec( _, err := Get().Exec(
`INSERT INTO stories (guid, headline, lede, image_url, article_url, url_canonical, headline_norm, source, platforms, channel, classified, paywalled, seen_at, published_at) `INSERT INTO stories (guid, headline, lede, content, image_url, article_url, url_canonical, headline_norm, source, platforms, channel, classified, paywalled, seen_at, published_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
s.GUID, s.Headline, s.Lede, s.ImageURL, s.ArticleURL, nullIfEmpty(s.URLCanonical), nullIfEmpty(s.HeadlineNorm), s.Source, s.Platforms, s.Channel, classified, paywalled, s.SeenAt, publishedAt, s.GUID, s.Headline, s.Lede, nullIfEmpty(s.Content), s.ImageURL, s.ArticleURL, nullIfEmpty(s.URLCanonical), nullIfEmpty(s.HeadlineNorm), s.Source, s.Platforms, s.Channel, classified, paywalled, s.SeenAt, publishedAt,
) )
return err return err
} }
// GetStoryReaderText returns the stored full article text and lede for a single
// story, for reader mode. found is false when no story has that id.
func GetStoryReaderText(id int64) (content, lede string, found bool, err error) {
var c sql.NullString
var l sql.NullString
row := Get().QueryRow(`SELECT content, lede FROM stories WHERE id = ?`, id)
switch err = row.Scan(&c, &l); {
case errors.Is(err, sql.ErrNoRows):
return "", "", false, nil
case err != nil:
return "", "", false, err
}
return c.String, l.String, true, nil
}
// nullIfEmpty returns a SQL NULL for empty strings so partial unique indexes // nullIfEmpty returns a SQL NULL for empty strings so partial unique indexes
// (WHERE col IS NOT NULL) can hold multiple "unknown" rows without conflict. // (WHERE col IS NOT NULL) can hold multiple "unknown" rows without conflict.
func nullIfEmpty(s string) any { func nullIfEmpty(s string) any {
@@ -236,7 +251,7 @@ func GetNewestPostableStoryByChannel(channel string) (*Story, error) {
// channels (_discarded, _duplicate) are excluded. // channels (_discarded, _duplicate) are excluded.
func ListClassifiedByChannel(channel string, limit, offset int) ([]Story, error) { func ListClassifiedByChannel(channel string, limit, offset int) ([]Story, error) {
rows, err := Get().Query( rows, err := Get().Query(
`SELECT s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at, `SELECT s.id, s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at,
EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted
FROM stories s FROM stories s
WHERE s.classified = 1 WHERE s.classified = 1
@@ -250,7 +265,7 @@ func ListClassifiedByChannel(channel string, limit, offset int) ([]Story, error)
var out []Story var out []Story
for rows.Next() { for rows.Next() {
var s Story var s Story
if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil { if err := rows.Scan(&s.ID, &s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil {
return nil, err return nil, err
} }
out = append(out, s) out = append(out, s)
@@ -262,7 +277,7 @@ func ListClassifiedByChannel(channel string, limit, offset int) ([]Story, error)
// channels, newest first. Sentinel channels are excluded. // channels, newest first. Sentinel channels are excluded.
func ListAllClassified(limit, offset int) ([]Story, error) { func ListAllClassified(limit, offset int) ([]Story, error) {
rows, err := Get().Query( rows, err := Get().Query(
`SELECT s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at, `SELECT s.id, s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at,
EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted
FROM stories s FROM stories s
WHERE s.classified = 1 WHERE s.classified = 1
@@ -277,7 +292,7 @@ func ListAllClassified(limit, offset int) ([]Story, error) {
var out []Story var out []Story
for rows.Next() { for rows.Next() {
var s Story var s Story
if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil { if err := rows.Scan(&s.ID, &s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil {
return nil, err return nil, err
} }
out = append(out, s) out = append(out, s)
@@ -289,7 +304,7 @@ func ListAllClassified(limit, offset int) ([]Story, error) {
// Matrix, ordered by post time (newest first). Posted is always true. // Matrix, ordered by post time (newest first). Posted is always true.
func ListRecentlyPosted(limit int) ([]Story, error) { func ListRecentlyPosted(limit int) ([]Story, error) {
rows, err := Get().Query( rows, err := Get().Query(
`SELECT s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at `SELECT s.id, s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at
FROM stories s FROM stories s
JOIN post_log p ON p.guid = s.guid JOIN post_log p ON p.guid = s.guid
GROUP BY s.guid GROUP BY s.guid
@@ -302,7 +317,7 @@ func ListRecentlyPosted(limit int) ([]Story, error) {
var out []Story var out []Story
for rows.Next() { for rows.Next() {
var s Story var s Story
if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt); err != nil { if err := rows.Scan(&s.ID, &s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt); err != nil {
return nil, err return nil, err
} }
s.Posted = true s.Posted = true

View File

@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS stories (
guid TEXT UNIQUE NOT NULL, guid TEXT UNIQUE NOT NULL,
headline TEXT NOT NULL, headline TEXT NOT NULL,
lede TEXT, lede TEXT,
content TEXT,
image_url TEXT, image_url TEXT,
article_url TEXT NOT NULL, article_url TEXT NOT NULL,
url_canonical TEXT, url_canonical TEXT,

View File

@@ -61,6 +61,67 @@ func TestInsertStoryAndGUIDSeen(t *testing.T) {
} }
} }
func TestGetStoryReaderText(t *testing.T) {
setupTestDB(t)
s := &Story{
GUID: "reader-guid",
Headline: "Reader Headline",
Lede: "Short lede.",
Content: "First paragraph.\n\nSecond paragraph.",
ArticleURL: "https://example.com/reader",
Source: "Src",
SeenAt: 1700000000,
}
if err := InsertStory(s); err != nil {
t.Fatal(err)
}
var id int64
if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, s.GUID).Scan(&id); err != nil {
t.Fatal(err)
}
content, lede, found, err := GetStoryReaderText(id)
if err != nil {
t.Fatal(err)
}
if !found {
t.Fatal("expected story to be found")
}
if content != s.Content {
t.Errorf("content = %q, want %q", content, s.Content)
}
if lede != s.Lede {
t.Errorf("lede = %q, want %q", lede, s.Lede)
}
// A story ingested before content capture (no content) is still found, with
// an empty content string so the reader falls back to the lede.
bare := &Story{GUID: "bare-guid", Headline: "H", Lede: "Only a lede.", ArticleURL: "https://a.com", Source: "S", SeenAt: 1}
if err := InsertStory(bare); err != nil {
t.Fatal(err)
}
var bareID int64
if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, bare.GUID).Scan(&bareID); err != nil {
t.Fatal(err)
}
content, lede, found, err = GetStoryReaderText(bareID)
if err != nil || !found {
t.Fatalf("bare story: found=%v err=%v", found, err)
}
if content != "" {
t.Errorf("bare content = %q, want empty", content)
}
if lede != "Only a lede." {
t.Errorf("bare lede = %q", lede)
}
// Unknown id reports not-found rather than erroring.
if _, _, found, err = GetStoryReaderText(999999); err != nil || found {
t.Errorf("unknown id: found=%v err=%v, want found=false err=nil", found, err)
}
}
func TestInsertStoryDuplicateGUID(t *testing.T) { func TestInsertStoryDuplicateGUID(t *testing.T) {
setupTestDB(t) setupTestDB(t)

View File

@@ -6,6 +6,7 @@ type Story struct {
GUID string GUID string
Headline string Headline string
Lede string Lede string
Content string // full article text for reader mode (feed content:encoded or scraped body); "" when unavailable
ImageURL string ImageURL string
ArticleURL string ArticleURL string
URLCanonical string URLCanonical string

View File

@@ -17,6 +17,7 @@ const pageSize = 24
// StoryView is the trimmed-down record used in templates. // StoryView is the trimmed-down record used in templates.
type StoryView struct { type StoryView struct {
ID int64
Headline string Headline string
Lede string Lede string
ImageURL string ImageURL string
@@ -30,6 +31,7 @@ type StoryView struct {
func toView(s storage.Story) StoryView { func toView(s storage.Story) StoryView {
return StoryView{ return StoryView{
ID: s.ID,
Headline: s.Headline, Headline: s.Headline,
Lede: s.Lede, Lede: s.Lede,
ImageURL: s.ImageURL, ImageURL: s.ImageURL,
@@ -334,6 +336,31 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"results": results}) _ = json.NewEncoder(w).Encode(map[string]any{"results": results})
} }
// handleArticle serves the stored full text of a single story for reader mode.
// The client already has headline/image/source/time from the card's data
// attributes, so this returns just the body text (and the lede as a fallback
// for stories ingested before content was captured).
func (s *Server) handleArticle(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
id, err := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("id")), 10, 64)
if err != nil || id <= 0 {
http.Error(w, `{"error":"bad id"}`, http.StatusBadRequest)
return
}
content, lede, found, err := storage.GetStoryReaderText(id)
if err != nil {
slog.Error("web: article read failed", "id", id, "err", err)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
if !found {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"content": content, "lede": lede})
}
func shortTimeAgo(t time.Time) string { func shortTimeAgo(t time.Time) string {
d := time.Since(t) d := time.Since(t)
switch { switch {

View File

@@ -0,0 +1,95 @@
package web
import (
"encoding/json"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"pete/internal/config"
"pete/internal/storage"
)
// TestReaderCardDataAndArticleAPI exercises the reader-mode path end to end: a
// classified story renders a card carrying its id + headline data attributes,
// and /api/article returns the stored full text for that id.
func TestReaderCardDataAndArticleAPI(t *testing.T) {
storage.Close()
if err := storage.Init(filepath.Join(t.TempDir(), "reader.db")); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { storage.Close() })
story := &storage.Story{
GUID: "reader-e2e",
Headline: "A Distinctive Reader Headline",
Lede: "The lede.",
Content: "Opening paragraph of the piece.\n\nA second paragraph with more detail.",
ArticleURL: "https://example.com/story",
Source: "Example Wire",
Channel: "tech",
Classified: true,
SeenAt: time.Now().Unix(),
}
if err := storage.InsertStory(story); err != nil {
t.Fatal(err)
}
var id int64
if err := storage.Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, story.GUID).Scan(&id); err != nil {
t.Fatal(err)
}
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil)
if err != nil {
t.Fatal(err)
}
// Index page renders the card with the reader data attributes.
rw := httptest.NewRecorder()
s.handleIndex(rw, httptest.NewRequest("GET", "/", nil))
body := rw.Body.String()
if rw.Code != 200 {
t.Fatalf("index status = %d", rw.Code)
}
for _, want := range []string{
`data-id="` + strconv.FormatInt(id, 10) + `"`,
`data-headline="A Distinctive Reader Headline"`,
`data-story-card`,
} {
if !strings.Contains(body, want) {
t.Errorf("index HTML missing %q", want)
}
}
// The article endpoint returns the stored content for that id.
rw2 := httptest.NewRecorder()
s.handleArticle(rw2, httptest.NewRequest("GET", "/api/article?id="+strconv.FormatInt(id, 10), nil))
if rw2.Code != 200 {
t.Fatalf("article status = %d body=%s", rw2.Code, rw2.Body.String())
}
var got struct {
Content string `json:"content"`
Lede string `json:"lede"`
}
if err := json.Unmarshal(rw2.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.Content != story.Content {
t.Errorf("content = %q, want %q", got.Content, story.Content)
}
// A bad id is a 400, an unknown id is a 404.
for _, tc := range []struct {
q string
code int
}{{"id=0", 400}, {"id=abc", 400}, {"id=999999", 404}} {
w := httptest.NewRecorder()
s.handleArticle(w, httptest.NewRequest("GET", "/api/article?"+tc.q, nil))
if w.Code != tc.code {
t.Errorf("article?%s status = %d, want %d", tc.q, w.Code, tc.code)
}
}
}

View File

@@ -118,6 +118,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig) (*Server, error) {
mux.HandleFunc("GET /{$}", s.handleIndex) mux.HandleFunc("GET /{$}", s.handleIndex)
mux.HandleFunc("GET /weather", s.handleWeatherDemo) mux.HandleFunc("GET /weather", s.handleWeatherDemo)
mux.HandleFunc("GET /api/search", s.handleSearch) mux.HandleFunc("GET /api/search", s.handleSearch)
mux.HandleFunc("GET /api/article", s.handleArticle)
for _, ch := range channels { for _, ch := range channels {
ch := ch ch := ch
mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) {

View File

@@ -169,3 +169,185 @@ html[data-phase="night"] {
filter: contrast(1.05); filter: contrast(1.05);
} }
} }
/* ----------------------------------------------------------------------------
Feed / reader mode. A focused, one-story-at-a-time overlay driven by
reader.js. Left/right arrows page through the stories currently on screen,
marking each read as it's shown. Styling leans on the phase palette vars so
it recolours with day/night like everything else.
---------------------------------------------------------------------------- */
@layer components {
.pete-reader-backdrop {
position: absolute;
inset: 0;
background: rgba(20, 14, 6, 0.55);
backdrop-filter: blur(6px);
}
html[data-phase="night"] .pete-reader-backdrop { background: rgba(6, 8, 20, 0.65); }
.pete-reader-shell {
position: relative;
height: 100%;
width: 100%;
max-width: 44rem;
margin: 0 auto;
display: flex;
flex-direction: column;
padding: max(env(safe-area-inset-top), 0.75rem) 1rem 0.75rem;
gap: 0.75rem;
}
.pete-reader-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
color: #fff;
}
.pete-reader-progress {
font-family: "Fredoka", "Nunito", system-ui, sans-serif;
font-weight: 600;
font-size: 0.85rem;
letter-spacing: 0.02em;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
}
.pete-reader-nav { display: flex; align-items: center; gap: 0.4rem; }
.pete-reader-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.25rem;
height: 2.25rem;
padding: 0 0.7rem;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.14);
color: #fff;
font-weight: 700;
font-size: 0.95rem;
border: 2px solid rgba(255, 255, 255, 0.18);
cursor: pointer;
transition: background 0.15s ease, transform 0.15s ease, opacity 0.15s ease;
}
.pete-reader-btn:hover { background: rgba(255, 255, 255, 0.28); transform: translateY(-1px); }
.pete-reader-btn:disabled { opacity: 0.35; cursor: default; transform: none; }
.pete-reader-btn-open { background: var(--accent); border-color: transparent; color: #1c1305; text-decoration: none; }
.pete-reader-btn-open:hover { background: var(--accent); filter: brightness(1.08); }
.pete-reader-scroll {
flex: 1 1 auto;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
border-radius: 1.75rem;
}
.pete-reader-article {
background: var(--card);
color: var(--ink);
border-radius: 1.75rem;
border: 2px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 6px 0 rgba(60, 40, 20, 0.12), 0 16px 32px rgba(60, 40, 20, 0.18);
padding: clamp(1.25rem, 4vw, 2.5rem);
}
html[data-phase="night"] .pete-reader-article { border-color: rgba(255, 255, 255, 0.08); }
.pete-reader-eyebrow {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.75rem;
margin-bottom: 0.85rem;
}
.pete-reader-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border-radius: 9999px;
padding: 0.15rem 0.65rem;
color: #fff;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.pete-reader-source { font-weight: 700; opacity: 0.75; }
.pete-reader-time { opacity: 0.55; }
.pete-reader-title {
font-family: "Fredoka", "Nunito", system-ui, sans-serif;
font-weight: 700;
line-height: 1.15;
letter-spacing: -0.01em;
font-size: clamp(1.6rem, 4.5vw, 2.4rem);
margin-bottom: 1rem;
}
.pete-reader-hero {
width: 100%;
border-radius: 1.1rem;
margin-bottom: 1.25rem;
background: rgba(0, 0, 0, 0.05);
}
.pete-reader-body { font-size: 1.08rem; line-height: 1.75; }
.pete-reader-body p { margin-bottom: 1.05em; }
.pete-reader-body p:last-child { margin-bottom: 0; }
.pete-reader-note {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid rgba(0, 0, 0, 0.1);
font-size: 0.85rem;
opacity: 0.7;
}
html[data-phase="night"] .pete-reader-note { border-color: rgba(255, 255, 255, 0.12); }
.pete-reader-note a { color: var(--accent); font-weight: 700; }
.pete-reader-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 0.75rem;
padding: clamp(2rem, 8vw, 4rem) 1rem;
color: var(--ink);
}
.pete-reader-empty-emoji { font-size: 2.5rem; }
.pete-reader-hint {
text-align: center;
color: rgba(255, 255, 255, 0.85);
font-size: 0.75rem;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
}
.pete-reader-hint kbd {
font-family: ui-monospace, monospace;
background: rgba(255, 255, 255, 0.16);
border-radius: 0.3rem;
padding: 0.05rem 0.3rem;
}
@media (max-width: 640px) {
.pete-reader-hint { display: none; }
}
/* Grid treatment for stories already read in feed mode: dimmed, with a small
corner check. Hovering restores full opacity so nothing feels lost. */
[data-story-card][data-read="1"] { opacity: 0.5; transition: opacity 0.2s ease; }
[data-story-card][data-read="1"]:hover { opacity: 1; }
[data-story-card][data-read="1"]::after {
content: "✓";
position: absolute;
top: 0.6rem;
right: 0.6rem;
z-index: 6;
display: grid;
place-items: center;
height: 1.5rem;
width: 1.5rem;
border-radius: 9999px;
background: rgba(20, 14, 6, 0.72);
color: #fff;
font-size: 0.8rem;
font-weight: 700;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,279 @@
// Feed / reader mode. Presents the stories currently on the page one at a
// time in a focused overlay, marking each read as it's shown. Left/right arrow
// keys (or the on-screen buttons) page through them; the full article text is
// whatever Pete captured at ingest (content:encoded or the scraped body),
// fetched on demand from /api/article and cached for the session.
//
// Read state lives in localStorage under pete.read.v1. It's deliberately
// device-local — unlike the small prefs blob, the read set grows unbounded, so
// it isn't mirrored through PetePrefs to the account.
(function () {
var overlay = document.getElementById("pete-reader");
if (!overlay) return;
var READ_KEY = "pete.read.v1";
var articleEl = overlay.querySelector("[data-reader-article]");
var scrollEl = overlay.querySelector("[data-reader-scroll]");
var progressEl = overlay.querySelector("[data-reader-progress]");
var linkEl = overlay.querySelector("[data-reader-link]");
var prevBtn = overlay.querySelector("[data-reader-prev]");
var nextBtn = overlay.querySelector("[data-reader-next]");
var closeBtn = overlay.querySelector("[data-reader-close]");
var backdrop = overlay.querySelector("[data-reader-backdrop]");
var items = []; // {id, url, headline, lede, image, time, source, chTitle, chEmoji, chTheme, posted, paywalled}
var index = 0;
var open = false;
var contentCache = {}; // id -> {content, lede}
var inflight = null;
// ---- read-state store -----------------------------------------------------
function loadRead() {
try {
var raw = localStorage.getItem(READ_KEY);
var obj = raw ? JSON.parse(raw) : {};
return obj && typeof obj === "object" ? obj : {};
} catch (e) { return {}; }
}
function saveRead(set) {
try { localStorage.setItem(READ_KEY, JSON.stringify(set)); } catch (e) {}
}
var readSet = loadRead();
function isRead(id) { return !!readSet[id]; }
function setRead(id, on) {
if (on) readSet[id] = 1; else delete readSet[id];
saveRead(readSet);
paintCard(id, on);
}
// Reflect read state onto every matching card on the page (a story can appear
// in more than one section on the home page).
function paintCard(id, on) {
document.querySelectorAll('[data-story-card][data-id="' + cssEsc(id) + '"]').forEach(function (c) {
if (on) c.setAttribute("data-read", "1");
else c.removeAttribute("data-read");
});
}
function cssEsc(s) {
return String(s).replace(/["\\]/g, "\\$&");
}
// ---- collecting the page's stories ----------------------------------------
function isVisible(el) {
return window.getComputedStyle(el).display !== "none";
}
function collect() {
var seen = Object.create(null);
var out = [];
document.querySelectorAll("[data-story-card]").forEach(function (c) {
var id = c.getAttribute("data-id");
if (!id || seen[id] || !isVisible(c)) return;
var url = c.getAttribute("data-url") || c.getAttribute("href") || "";
if (!/^https?:\/\//i.test(url)) url = "";
seen[id] = true;
out.push({
id: id,
url: url,
headline: c.getAttribute("data-headline") || "",
lede: c.getAttribute("data-lede") || "",
image: c.getAttribute("data-image") || "",
time: c.getAttribute("data-time") || "",
source: c.getAttribute("data-source") || "",
chTitle: c.getAttribute("data-ch-title") || "",
chEmoji: c.getAttribute("data-ch-emoji") || "",
chTheme: c.getAttribute("data-ch-theme") || "",
paywalled: c.getAttribute("data-paywalled") === "1"
});
});
return out;
}
// ---- rendering ------------------------------------------------------------
function escapeHTML(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function safeURL(s) {
var raw = String(s == null ? "" : s).trim();
return /^https?:\/\//i.test(raw) ? raw : "";
}
// Turn stored plain text (paragraphs separated by blank lines) into escaped
// <p> blocks. Single newlines inside a paragraph become spaces.
function paragraphs(text) {
var blocks = String(text || "").split(/\n{2,}/);
var html = "";
for (var i = 0; i < blocks.length; i++) {
var t = blocks[i].replace(/\s*\n\s*/g, " ").trim();
if (t) html += "<p>" + escapeHTML(t) + "</p>";
}
return html;
}
function eyebrow(it) {
var chip = it.chTheme
? '<span class="pete-reader-chip bg-theme-' + escapeHTML(it.chTheme) + '">' +
(it.chEmoji ? '<span aria-hidden="true">' + escapeHTML(it.chEmoji) + "</span>" : "") +
escapeHTML(it.chTitle) + "</span>"
: "";
var src = it.source ? '<span class="pete-reader-source">' + escapeHTML(it.source) + "</span>" : "";
var time = it.time ? '<span class="pete-reader-time">' + escapeHTML(it.time) + "</span>" : "";
return '<div class="pete-reader-eyebrow">' + chip + src + time + "</div>";
}
function renderBody(it, bodyHTML) {
var hero = it.image
? '<img class="pete-reader-hero" src="' + escapeHTML(it.image) + '" alt="" loading="lazy" decoding="async">'
: "";
var href = safeURL(it.url);
var note = '<p class="pete-reader-note">' +
(it.paywalled ? "This source is paywalled — the text above may be partial. " : "") +
(href ? 'Read it at the source: <a href="' + escapeHTML(href) + '" target="_blank" rel="noopener noreferrer">' +
escapeHTML(it.source || "original article") + " ↗</a>." : "") +
"</p>";
articleEl.innerHTML =
eyebrow(it) +
'<h1 class="pete-reader-title">' + escapeHTML(it.headline) + "</h1>" +
hero +
'<div class="pete-reader-body">' + bodyHTML + "</div>" +
note;
}
function loadingBody(it) {
renderBody(it, '<p style="opacity:.55">Loading the full story…</p>');
}
function fillContent(it, data) {
var text = (data && data.content) ? data.content : ((data && data.lede) || it.lede);
var html = paragraphs(text);
if (!html) {
html = '<p style="opacity:.7">No article text was captured for this story. Open the original to read it.</p>';
}
renderBody(it, html);
}
function fetchContent(it) {
if (contentCache[it.id]) { fillContent(it, contentCache[it.id]); return; }
loadingBody(it);
if (inflight) inflight.abort();
var ctrl = new AbortController();
inflight = ctrl;
var reqId = it.id;
fetch("/api/article?id=" + encodeURIComponent(it.id), { signal: ctrl.signal, credentials: "same-origin" })
.then(function (r) { if (!r.ok) throw new Error("status " + r.status); return r.json(); })
.then(function (data) {
contentCache[reqId] = data;
if (ctrl !== inflight) return; // superseded by a newer navigation
if (items[index] && items[index].id === reqId) fillContent(it, data);
})
.catch(function (err) {
if (err.name === "AbortError") return;
if (items[index] && items[index].id === reqId) fillContent(it, null); // fall back to lede
})
.finally(function () { if (ctrl === inflight) inflight = null; });
}
// ---- navigation -----------------------------------------------------------
function show(i) {
index = i;
if (index >= items.length) { renderDone(); return; }
var it = items[index];
progressEl.textContent = (index + 1) + " / " + items.length;
var href = safeURL(it.url);
if (href) { linkEl.href = href; linkEl.style.display = ""; }
else linkEl.style.display = "none";
prevBtn.disabled = index === 0;
nextBtn.disabled = false;
nextBtn.textContent = index === items.length - 1 ? "done ✓" : "→";
if (scrollEl) scrollEl.scrollTop = 0;
fetchContent(it);
setRead(it.id, true); // presenting a story marks it read
}
function renderDone() {
progressEl.textContent = items.length + " / " + items.length;
linkEl.style.display = "none";
prevBtn.disabled = items.length === 0;
nextBtn.disabled = true;
nextBtn.textContent = "→";
if (scrollEl) scrollEl.scrollTop = 0;
articleEl.innerHTML =
'<div class="pete-reader-empty">' +
'<span class="pete-reader-empty-emoji" aria-hidden="true">🎉</span>' +
'<h1 class="pete-reader-title" style="margin:0">You\'re all caught up</h1>' +
'<p style="opacity:.7;max-width:28rem">That\'s every story on this page. ' +
"Head back to browse more, or press ← to revisit the last one.</p>" +
"</div>";
}
function next() { if (index < items.length) show(index + 1); }
function prev() { if (index > 0) show(index - 1); }
function firstUnread() {
for (var i = 0; i < items.length; i++) if (!isRead(items[i].id)) return i;
return 0;
}
// ---- open / close ---------------------------------------------------------
function openReader() {
items = collect();
if (items.length === 0) return;
open = true;
overlay.classList.remove("hidden");
document.body.classList.add("overflow-hidden");
show(firstUnread());
}
function closeReader() {
open = false;
if (inflight) { inflight.abort(); inflight = null; }
overlay.classList.add("hidden");
document.body.classList.remove("overflow-hidden");
}
// ---- wiring ---------------------------------------------------------------
document.addEventListener("DOMContentLoaded", function () {
// Dim stories already read on this device.
document.querySelectorAll("[data-story-card]").forEach(function (c) {
var id = c.getAttribute("data-id");
if (id && isRead(id)) c.setAttribute("data-read", "1");
});
document.querySelectorAll("[data-reader-open]").forEach(function (b) {
b.addEventListener("click", function (e) { e.preventDefault(); openReader(); });
});
if (prevBtn) prevBtn.addEventListener("click", prev);
if (nextBtn) nextBtn.addEventListener("click", next);
if (closeBtn) closeBtn.addEventListener("click", closeReader);
if (backdrop) backdrop.addEventListener("click", closeReader);
});
document.addEventListener("keydown", function (e) {
// Enter feed mode with `f` when nothing is focused and no other overlay is up.
if (!open && (e.key === "f" || e.key === "F") && !e.metaKey && !e.ctrlKey && !e.altKey) {
var tag = (document.activeElement && document.activeElement.tagName) || "";
if (tag === "INPUT" || tag === "TEXTAREA") return;
var searchOpen = document.getElementById("pete-search");
if (searchOpen && !searchOpen.classList.contains("hidden")) return;
e.preventDefault();
openReader();
return;
}
if (!open) return;
switch (e.key) {
case "ArrowRight": case "l": case " ": e.preventDefault(); next(); break;
case "ArrowLeft": case "h": e.preventDefault(); prev(); break;
case "Escape": e.preventDefault(); closeReader(); break;
case "o": case "Enter": {
var it = items[index];
var href = it && safeURL(it.url);
if (href) { e.preventDefault(); window.open(href, "_blank", "noopener"); }
break;
}
case "u": {
var cur = items[index];
if (cur) setRead(cur.id, false); // let the user undo an accidental read
break;
}
}
});
})();

View File

@@ -1,6 +1,15 @@
{{define "card"}} {{define "card"}}{{$ch := channel .Story.Channel}}
<a href="{{.Story.ArticleURL}}" target="_blank" rel="noopener noreferrer" <a href="{{.Story.ArticleURL}}" target="_blank" rel="noopener noreferrer"
data-story-card data-source="{{.Story.Source}}" data-channel="{{.Story.Channel}}" data-story-card data-source="{{.Story.Source}}" data-channel="{{.Story.Channel}}"
data-id="{{.Story.ID}}"
data-headline="{{.Story.Headline}}"
data-lede="{{.Story.Lede}}"
data-url="{{.Story.ArticleURL}}"
data-image="{{if .Story.ImageURL}}{{thumb .Story.ImageURL}}{{end}}"
data-time="{{timeAgo .Story.SeenAt}}"
data-ch-title="{{$ch.Title}}" data-ch-emoji="{{$ch.Emoji}}" data-ch-theme="{{$ch.Theme}}"
data-posted="{{if .Story.Posted}}1{{end}}"
data-paywalled="{{if .Story.Paywalled}}1{{end}}"
class="group relative block rounded-3xl bg-[color:var(--card)] border-2 {{if .Story.Posted}}border-theme-{{.Theme}} glow-theme-{{.Theme}}{{else}}border-[color:var(--ink)]/10{{end}} shadow-pete overflow-hidden hover:-translate-y-1 hover:shadow-pete-lg transition"> class="group relative block rounded-3xl bg-[color:var(--card)] border-2 {{if .Story.Posted}}border-theme-{{.Theme}} glow-theme-{{.Theme}}{{else}}border-[color:var(--ink)]/10{{end}} shadow-pete overflow-hidden hover:-translate-y-1 hover:shadow-pete-lg transition">
{{if .Story.ImageURL}} {{if .Story.ImageURL}}
<div class="aspect-[16/10] w-full overflow-hidden bg-[color:var(--ink)]/5"> <div class="aspect-[16/10] w-full overflow-hidden bg-[color:var(--ink)]/5">

View File

@@ -49,6 +49,16 @@
</svg> </svg>
<span class="sr-only">Feed settings</span> <span class="sr-only">Feed settings</span>
</button> </button>
<button type="button" data-reader-open
title="Feed mode — read one story at a time (press f)"
class="inline-flex shrink-0 items-center justify-center rounded-full bg-[color:var(--card)] p-2 shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
<path d="M2 4h6a3 3 0 0 1 3 3v13a2.5 2.5 0 0 0-2.5-2.5H2z"></path>
<path d="M22 4h-6a3 3 0 0 0-3 3v13a2.5 2.5 0 0 1 2.5-2.5H22z"></path>
</svg>
<span class="sr-only">Feed mode</span>
</button>
{{if .Weather.Variant}} {{if .Weather.Variant}}
<button type="button" data-weather-toggle aria-pressed="true" <button type="button" data-weather-toggle aria-pressed="true"
title="Toggle weather animation" title="Toggle weather animation"
@@ -145,6 +155,27 @@
</div> </div>
</div> </div>
<div id="pete-reader" class="hidden fixed inset-0 z-50" aria-modal="true" role="dialog" aria-label="Feed mode reader">
<div class="pete-reader-backdrop" data-reader-backdrop></div>
<div class="pete-reader-shell">
<div class="pete-reader-bar">
<span class="pete-reader-progress tabular-nums" data-reader-progress></span>
<div class="pete-reader-nav">
<button type="button" data-reader-prev class="pete-reader-btn" title="Previous (←)" aria-label="Previous article"></button>
<button type="button" data-reader-next class="pete-reader-btn" title="Next (→)" aria-label="Next article"></button>
<a data-reader-link target="_blank" rel="noopener noreferrer" class="pete-reader-btn pete-reader-btn-open" title="Open original (o)">Open&nbsp;</a>
<button type="button" data-reader-close class="pete-reader-btn" title="Close (esc)" aria-label="Close reader">esc</button>
</div>
</div>
<div class="pete-reader-scroll" data-reader-scroll>
<article class="pete-reader-article" data-reader-article></article>
</div>
<div class="pete-reader-hint">
<span><kbd></kbd> <kbd></kbd> navigate · <kbd>o</kbd> open · <kbd>u</kbd> mark unread · <kbd>esc</kbd> close</span>
</div>
</div>
</div>
<main class="mx-auto max-w-6xl px-4 pb-24"> <main class="mx-auto max-w-6xl px-4 pb-24">
{{block "main" .}}{{end}} {{block "main" .}}{{end}}
</main> </main>
@@ -182,5 +213,6 @@
<script src="/static/js/weather-forecast.js" defer></script> <script src="/static/js/weather-forecast.js" defer></script>
<script src="/static/js/search.js" defer></script> <script src="/static/js/search.js" defer></script>
<script src="/static/js/settings.js" defer></script> <script src="/static/js/settings.js" defer></script>
<script src="/static/js/reader.js" defer></script>
</body> </body>
</html>{{end}} </html>{{end}}