|
| 1 | +// The site a Moshpit name gets when its owner has a feed but no server. |
| 2 | +// |
| 3 | +// Two layouts, because a feed is one of two things to a reader. A blog is read |
| 4 | +// — the entry is a headline, a date and enough of the opening to decide, and |
| 5 | +// the destination is somebody else's page. A podcast is played — the entry is |
| 6 | +// an episode with artwork and a running time, and the destination is right |
| 7 | +// here, in an audio element, because making someone leave to press play is the |
| 8 | +// one thing a podcast page must not do. |
| 9 | +// |
| 10 | +// Everything drawn here came out of a document written by whoever owns the |
| 11 | +// feed, which is not necessarily whoever owns the name and is certainly not us. |
| 12 | +// So every string goes through esc() and every URL through the parser's |
| 13 | +// safeUrl() before it reaches an href, a src or a player. There is no path in |
| 14 | +// this file that interpolates feed content unescaped. |
| 15 | + |
| 16 | +import { esc } from "./html.mjs"; |
| 17 | + |
| 18 | +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; |
| 19 | + |
| 20 | +/** |
| 21 | + * A date a reader can scan, in UTC. |
| 22 | + * |
| 23 | + * Fixed rather than localised: this is rendered on the server for a visitor |
| 24 | + * whose locale we do not know, so a format that changes with the request's |
| 25 | + * headers would only make the same page differ between caches. |
| 26 | + */ |
| 27 | +export function feedDate(ms) { |
| 28 | + if (!Number.isFinite(ms)) return ""; |
| 29 | + const date = new Date(ms); |
| 30 | + return `${date.getUTCDate()} ${MONTHS[date.getUTCMonth()]} ${date.getUTCFullYear()}`; |
| 31 | +} |
| 32 | + |
| 33 | +/** An ISO stamp for `<time datetime>`, so a machine reading the page gets the real value. */ |
| 34 | +function isoDate(ms) { |
| 35 | + return Number.isFinite(ms) ? new Date(ms).toISOString() : ""; |
| 36 | +} |
| 37 | + |
| 38 | +/** A file size in the units a reader thinks in. Enclosures are megabytes. */ |
| 39 | +function fileSize(bytes) { |
| 40 | + if (!Number.isFinite(bytes) || bytes <= 0) return ""; |
| 41 | + const mb = bytes / (1024 * 1024); |
| 42 | + return mb >= 1 ? `${mb.toFixed(mb >= 10 ? 0 : 1)} MB` : `${Math.round(bytes / 1024)} KB`; |
| 43 | +} |
| 44 | + |
| 45 | +export const FEED_CSS = ` |
| 46 | +.feed-wrap{max-width:760px;margin:0 auto;padding:56px 24px 80px} |
| 47 | +.feed-head{display:flex;gap:22px;align-items:flex-start;margin-bottom:34px} |
| 48 | +.feed-cover{width:132px;height:132px;flex:none;border-radius:var(--r);border:1px solid var(--line-2); |
| 49 | + object-fit:cover;background:var(--surface)} |
| 50 | +.feed-head-text{min-width:0} |
| 51 | +.feed-name{font-family:var(--mono);font-size:.68rem;letter-spacing:.2em;text-transform:uppercase; |
| 52 | + color:var(--acid);margin:0 0 8px} |
| 53 | +.feed-title{font-size:1.9rem;line-height:1.12;margin:0 0 10px;text-transform:none;letter-spacing:-.02em} |
| 54 | +.feed-desc{color:var(--dim);margin:0;font-size:.95rem;max-width:60ch} |
| 55 | +.feed-meta{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:14px} |
| 56 | +.feed-note{border:1px solid color-mix(in srgb,var(--warn) 40%,var(--line));color:var(--warn); |
| 57 | + border-radius:9px;padding:9px 13px;font-family:var(--mono);font-size:.74rem;margin:0 0 24px} |
| 58 | +.feed-list{list-style:none;margin:0;padding:0;display:grid;gap:2px} |
| 59 | +.feed-item{border-top:1px solid var(--line);padding:22px 0} |
| 60 | +.feed-item:last-child{border-bottom:1px solid var(--line)} |
| 61 | +.feed-when{font-family:var(--mono);font-size:.68rem;letter-spacing:.14em;text-transform:uppercase; |
| 62 | + color:var(--faint);display:flex;gap:10px;flex-wrap:wrap;align-items:center} |
| 63 | +.feed-item h2{font-size:1.12rem;line-height:1.25;margin:8px 0 0;text-transform:none;letter-spacing:-.01em} |
| 64 | +.feed-item h2 a:hover{color:var(--acid)} |
| 65 | +.feed-sum{color:var(--dim);font-size:.9rem;margin:8px 0 0;max-width:64ch} |
| 66 | +.feed-more{font-family:var(--mono);font-size:.74rem;color:var(--acid);display:inline-block;margin-top:10px} |
| 67 | +.feed-more:hover{text-decoration:underline} |
| 68 | +.ep{display:flex;gap:16px;align-items:flex-start} |
| 69 | +.ep-art{width:74px;height:74px;flex:none;border-radius:8px;border:1px solid var(--line);object-fit:cover; |
| 70 | + background:var(--surface)} |
| 71 | +.ep-body{min-width:0;flex:1} |
| 72 | +.ep audio{width:100%;margin-top:12px;height:38px;border-radius:8px} |
| 73 | +.feed-foot{margin-top:44px;padding-top:22px;border-top:1px solid var(--line); |
| 74 | + display:flex;gap:14px;flex-wrap:wrap;align-items:center;justify-content:space-between; |
| 75 | + font-family:var(--mono);font-size:.72rem;color:var(--faint)} |
| 76 | +.feed-foot a{color:var(--dim)} |
| 77 | +.feed-foot a:hover{color:var(--acid)} |
| 78 | +@media (max-width:620px){ |
| 79 | + .feed-head{flex-direction:column;gap:16px} |
| 80 | + .feed-cover{width:104px;height:104px} |
| 81 | + .feed-title{font-size:1.5rem} |
| 82 | + .ep{gap:12px} |
| 83 | + .ep-art{width:56px;height:56px} |
| 84 | +} |
| 85 | +`; |
| 86 | + |
| 87 | +/** The chips under the title: what it is, who makes it, and how to subscribe. */ |
| 88 | +function headMeta({ feed, kind, feedUrl }) { |
| 89 | + const chips = [`<span class="pill on">${kind === "podcast" ? "podcast" : "blog"}</span>`]; |
| 90 | + if (feed.author) chips.push(`<span class="pill">${esc(feed.author)}</span>`); |
| 91 | + if (feed.site) chips.push(`<a class="pill" href="${esc(feed.site)}" rel="noopener nofollow ugc">website ↗</a>`); |
| 92 | + chips.push(`<a class="pill" href="${esc(feedUrl)}" rel="noopener nofollow ugc">subscribe ↗</a>`); |
| 93 | + return `<div class="feed-meta">${chips.join("")}</div>`; |
| 94 | +} |
| 95 | + |
| 96 | +/** |
| 97 | + * One post. |
| 98 | + * |
| 99 | + * The whole row is not a link even though the title is: an entry with no link |
| 100 | + * is a legitimate feed entry (a note with no permalink), and wrapping the row |
| 101 | + * would leave a clickable card that goes nowhere. |
| 102 | + */ |
| 103 | +function post(item) { |
| 104 | + const when = item.date |
| 105 | + ? `<time datetime="${esc(isoDate(item.date))}">${esc(feedDate(item.date))}</time>` |
| 106 | + : ""; |
| 107 | + const by = item.author ? `<span>${esc(item.author)}</span>` : ""; |
| 108 | + const title = item.link |
| 109 | + ? `<a href="${esc(item.link)}" rel="noopener nofollow ugc">${esc(item.title)}</a>` |
| 110 | + : esc(item.title); |
| 111 | + |
| 112 | + return `<li class="feed-item"> |
| 113 | + ${when || by ? `<div class="feed-when">${when}${by}</div>` : ""} |
| 114 | + <h2>${title}</h2> |
| 115 | + ${item.summary ? `<p class="feed-sum">${esc(item.summary)}</p>` : ""} |
| 116 | + ${item.link ? `<a class="feed-more" href="${esc(item.link)}" rel="noopener nofollow ugc">read →</a>` : ""} |
| 117 | +</li>`; |
| 118 | +} |
| 119 | + |
| 120 | +/** |
| 121 | + * One episode. |
| 122 | + * |
| 123 | + * `preload="none"` on every player: a page of twenty episodes that each start |
| 124 | + * buffering on load is tens of megabytes pulled from the show's host for a |
| 125 | + * visitor who has pressed nothing. |
| 126 | + */ |
| 127 | +function episode(item, feed) { |
| 128 | + const art = item.image || feed.image; |
| 129 | + const when = item.date |
| 130 | + ? `<time datetime="${esc(isoDate(item.date))}">${esc(feedDate(item.date))}</time>` |
| 131 | + : ""; |
| 132 | + const bits = [when]; |
| 133 | + if (item.duration) bits.push(`<span>${esc(item.duration)}</span>`); |
| 134 | + if (item.audio?.bytes) bits.push(`<span>${esc(fileSize(item.audio.bytes))}</span>`); |
| 135 | + const title = item.link |
| 136 | + ? `<a href="${esc(item.link)}" rel="noopener nofollow ugc">${esc(item.title)}</a>` |
| 137 | + : esc(item.title); |
| 138 | + |
| 139 | + // A video enclosure in a podcast feed is still an episode; it just needs the |
| 140 | + // element that can play it. |
| 141 | + const player = item.audio |
| 142 | + ? `<${item.audio.video ? "video" : "audio"} controls preload="none" src="${esc(item.audio.url)}"></${item.audio.video ? "video" : "audio"}>` |
| 143 | + : ""; |
| 144 | + |
| 145 | + return `<li class="feed-item"><div class="ep"> |
| 146 | + ${art ? `<img class="ep-art" src="${esc(art)}" alt="" loading="lazy" referrerpolicy="no-referrer">` : ""} |
| 147 | + <div class="ep-body"> |
| 148 | + ${bits.filter(Boolean).length ? `<div class="feed-when">${bits.filter(Boolean).join("")}</div>` : ""} |
| 149 | + <h2>${title}</h2> |
| 150 | + ${item.summary ? `<p class="feed-sum">${esc(item.summary)}</p>` : ""} |
| 151 | + ${player} |
| 152 | + </div> |
| 153 | +</div></li>`; |
| 154 | +} |
| 155 | + |
| 156 | +/** |
| 157 | + * The page a name with a feed shows. |
| 158 | + * |
| 159 | + * @param {object} input |
| 160 | + * @param {string} input.name the Moshpit name being visited |
| 161 | + * @param {object} input.feed what parseFeed returned |
| 162 | + * @param {string} input.feedUrl where it came from |
| 163 | + * @param {string|null} input.kind the owner's choice of layout, if they made one |
| 164 | + * @param {boolean} input.stale the origin failed and this is the last good copy |
| 165 | + */ |
| 166 | +export function feedPage({ name, feed, feedUrl, kind = null, stale = false }) { |
| 167 | + const layout = kind || feed.kind || "blog"; |
| 168 | + const items = layout === "podcast" |
| 169 | + ? feed.items.map((item) => episode(item, feed)).join("") |
| 170 | + : feed.items.map(post).join(""); |
| 171 | + |
| 172 | + return `<main class="feed-wrap"> |
| 173 | + <header class="feed-head"> |
| 174 | + ${layout === "podcast" && feed.image |
| 175 | + ? `<img class="feed-cover" src="${esc(feed.image)}" alt="" loading="lazy" referrerpolicy="no-referrer">` |
| 176 | + : ""} |
| 177 | + <div class="feed-head-text"> |
| 178 | + <p class="feed-name">${esc(name)}</p> |
| 179 | + <h1 class="feed-title">${esc(feed.title || name)}</h1> |
| 180 | + ${feed.description ? `<p class="feed-desc">${esc(feed.description)}</p>` : ""} |
| 181 | + ${headMeta({ feed, kind: layout, feedUrl })} |
| 182 | + </div> |
| 183 | + </header> |
| 184 | +
|
| 185 | + ${stale ? `<p class="feed-note">The feed has not answered recently — showing the last copy that came through.</p>` : ""} |
| 186 | +
|
| 187 | + <ul class="feed-list">${items}</ul> |
| 188 | +
|
| 189 | + <div class="feed-foot"> |
| 190 | + <span>${esc(name)} · served from its feed by the pit</span> |
| 191 | + <span><a href="/n/${encodeURIComponent(name)}?view=directory">what else is on this ending →</a> · <a href="/pit">the pit →</a></span> |
| 192 | + </div> |
| 193 | +</main>`; |
| 194 | +} |
| 195 | + |
| 196 | +/** |
| 197 | + * What a name shows when its feed cannot be read. |
| 198 | + * |
| 199 | + * Not a 502 page: the name is claimed and pointed at something, so the visitor |
| 200 | + * is looking at a site whose contents are temporarily missing, not at a broken |
| 201 | + * name. The reason is named because exactly one person can act on it, and |
| 202 | + * "the feed answered 404" tells them which thing to go and fix. |
| 203 | + */ |
| 204 | +export function feedUnavailable({ name, feedUrl, error }) { |
| 205 | + return `<main class="feed-wrap"> |
| 206 | + <header class="feed-head"><div class="feed-head-text"> |
| 207 | + <p class="feed-name">${esc(name)}</p> |
| 208 | + <h1 class="feed-title">Nothing came back from the feed.</h1> |
| 209 | + <p class="feed-desc">This name publishes |
| 210 | + <a class="acid" href="${esc(feedUrl)}" rel="noopener nofollow ugc">its feed</a>, |
| 211 | + but ${esc(error || "it could not be read")}.</p> |
| 212 | + </div></header> |
| 213 | + <div class="feed-foot"> |
| 214 | + <span>${esc(name)}</span> |
| 215 | + <span><a href="/n/${encodeURIComponent(name)}?view=directory">the rest of this ending →</a> · <a href="/pit">the pit →</a></span> |
| 216 | + </div> |
| 217 | +</main>`; |
| 218 | +} |
0 commit comments