nnw-theme 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,9 +33,9 @@ version, name it: `npx nnw-theme@2.0.0 check`.
33
33
  | `preview` | Serve the gallery on localhost, open it, and rebuild and reload it on save |
34
34
  | `render [fixtures…]` | Write the gallery to `build/preview/` once |
35
35
  | `check` | Validate and package, then render and test every case in WebKit |
36
- | `screenshot` | Check one case (any fixture and platform) and save its image; `--card` crops to the marketplace card, `--promote` makes it the card |
36
+ | `screenshot` | Check one case (any fixture and platform) and save its image; `--delivery` renders it as FreshRSS or Miniflux delivers it, `--card` crops to the marketplace card, `--promote` makes it the card |
37
37
  | `package` | Validate and build `dist/<Name>.nnwtheme.zip` |
38
- | `fixture list` / `fixture add <name>…` | Add ready-made fixtures modeled on WordPress, Ghost, Substack, and Medium markup, and on podcast and Media RSS enclosures |
38
+ | `fixture list` / `fixture add <name>…` | Add ready-made fixtures modeled on WordPress, Ghost, Substack, Medium, and Mastodon markup, and on podcast and Media RSS enclosures |
39
39
  | `capture` | Explain how to capture a real article from NetNewsWire as a fixture |
40
40
  | `bump` | Increase the `Info.plist` `Version` before a release |
41
41
  | `marketplace enable` | Add the marketplace discovery topic on GitHub |
@@ -1,8 +1,8 @@
1
1
  # Third-party notices
2
2
 
3
3
  The published package includes rendering files from
4
- [NetNewsWire](https://github.com/Ranchero-Software/NetNewsWire), `main` branch,
5
- commit `70c5dce656487bdab9e0226c318acc1a15071cf4`, under `assets/netnewswire/`.
4
+ [NetNewsWire](https://github.com/Ranchero-Software/NetNewsWire) release `mac-7.3b6`,
5
+ commit `9a07d406177e896826e33952fb16c06e21c19000`, under `assets/netnewswire/`.
6
6
  `netnewswire.json` in the source repository records the SHA-256 of every file, and the
7
7
  build verifies each one, including NetNewsWire’s MIT license, which ships beside them as
8
8
  `assets/netnewswire/LICENSE`. These files are used only to preview themes; they are
@@ -0,0 +1,50 @@
1
+ # nnw-theme catalog fixture: mastodon (catalog version 1)
2
+ # Markup: a Mastodon post from an account's RSS feed (https://example.org/@NAME.rss), as
3
+ # NetNewsWire renders it, captured 2026-11. Mastodon items have no title, and
4
+ # the feed is named for the account. The body is Mastodon's status HTML with its RSS
5
+ # content-warning preroll; the post's images arrive only as Media RSS
6
+ # <media:content> items, which NetNewsWire keeps without their alt text or size.
7
+ # Text and links are synthetic; the structure, classes, and attributes are real.
8
+ # Exercises: an untitled article (articleDatelineTitle), the content-warning paragraph
9
+ # and rule, h-card mentions and hashtags, a long link split into Mastodon's
10
+ # .invisible and .ellipsis spans (NetNewsWire shows all of them), 16x16 custom
11
+ # emoji inline in the text, a quote post's RE: fallback, and three unsized image
12
+ # enclosures with no caption.
13
+ # Enclosures need NetNewsWire 7.3b3 for Mac or 7.3 for iOS, or later.
14
+ # Refresh: npx nnw-theme@2 fixture add mastodon --force
15
+
16
+ date_long = 'November 3, 2026'
17
+ date_medium = 'Nov 3, 2026'
18
+ date_short = '11/3/26'
19
+ dateline_style = 'articleDatelineTitle'
20
+ datetime_long = 'November 3, 2026 at 8:12:47 PM'
21
+ datetime_medium = 'Nov 3, 2026 at 8:12 PM'
22
+ datetime_short = '11/3/26, 8:12 PM'
23
+ feed_link = 'https://example.org/@ines'
24
+ feed_link_title = 'Ines Calloway 📷 (@ines@example.org)'
25
+ preferred_link = 'https://example.org/@ines/115501234567890123'
26
+ time_long = '8:12:47 PM PST'
27
+ time_medium = '8:12:47 PM'
28
+ time_short = '8:12 PM'
29
+ title = ''
30
+ body = '''
31
+ <p><strong>Content warning:</strong> darkroom chemistry, one (1) paper cut</p><hr /><p class="quote-inline">RE: <a href="https://example.org/@kai/115498765432109876" target="_blank" rel="nofollow noopener" translate="no"><span class="invisible">https://</span><span class="ellipsis">example.org/@kai/1154987654321</span><span class="invisible">09876</span></a></p><p>First roll through the new tank and it came out… fine? Better than fine. <span class="h-card" translate="no"><a href="https://example.org/@kai" class="u-url mention">@<span>kai</span></a></span> was right about the agitation schedule, and <span class="h-card" translate="no"><a href="https://example.org/@marguerite" class="u-url mention">@<span>marguerite</span></a></span> was right that I would cut myself opening the canister <img rel="emoji" draggable="false" width="16" height="16" class="emojione custom-emoji" alt=":blobcat_sweat:" title=":blobcat_sweat:" src="https://example.org/system/custom_emojis/images/000/012/345/original/blobcat_sweat.png" /></p><p>Developer at 20°C, 9 minutes, inversions every 30 seconds for the first minute and then every minute after.<br />Stop bath, fixer, a long rinse, and a lot of staring at the negatives in front of the window like they would tell me something.</p><p>Notes and the full chart are up here: <a href="https://example.org/2026/11/notes-from-the-darkroom-first-roll-through-the-new-tank" target="_blank" rel="nofollow noopener" translate="no"><span class="invisible">https://</span><span class="ellipsis">example.org/2026/11/notes-from</span><span class="invisible">-the-darkroom-first-roll-through-the-new-tank</span></a> <img rel="emoji" draggable="false" width="16" height="16" class="emojione custom-emoji" alt=":film:" title=":film:" src="https://example.org/system/custom_emojis/images/000/006/789/original/film.png" /></p><p><a href="https://example.org/tags/FilmPhotography" class="mention hashtag" rel="tag">#<span>FilmPhotography</span></a> <a href="https://example.org/tags/Darkroom" class="mention hashtag" rel="tag">#<span>Darkroom</span></a> <a href="https://example.org/tags/BelieveInFilm" class="mention hashtag" rel="tag">#<span>BelieveInFilm</span></a></p>
32
+ '''
33
+
34
+ [[attachments]]
35
+ url = 'https://example.org/system/media_attachments/files/115/501/234/567/890/123/original/9f2c4e1a7b3d5f60.jpg'
36
+ mime_type = 'image/jpeg'
37
+ size_in_bytes = 482113
38
+ medium = 'image'
39
+
40
+ [[attachments]]
41
+ url = 'https://example.org/system/media_attachments/files/115/501/234/567/890/124/original/0b7d1e93c4a6f528.jpg'
42
+ mime_type = 'image/jpeg'
43
+ size_in_bytes = 397208
44
+ medium = 'image'
45
+
46
+ [[attachments]]
47
+ url = 'https://example.org/system/media_attachments/files/115/501/234/567/890/125/original/c81a5f02e9d74b36.png'
48
+ mime_type = 'image/png'
49
+ size_in_bytes = 1204977
50
+ medium = 'image'
@@ -2,8 +2,10 @@
2
2
  // list of failure messages. NetNewsWire writes each enclosure as
3
3
  // div.nnw-enclosure > (img | video | audio | iframe), with an optional
4
4
  // div.nnw-enclosure-caption, inside one div.nnw-enclosures: at [[enclosures]] in the
5
- // template, or after the body when the template has no such macro. A theme script
6
- // may move an enclosure elsewhere, so each is found wherever it ended up.
5
+ // template, or after the body when the template has no such macro. [[primary_media]]
6
+ // reuses those players, and adds div.nnw-primary-image > img with an optional
7
+ // div.nnw-primary-image-caption. A theme script may move any of them elsewhere, so
8
+ // each is found wherever it ended up.
7
9
  () => {
8
10
  const failures = [];
9
11
  const fail = (message) => failures.push(message);
@@ -14,7 +16,7 @@
14
16
  return Number.isFinite(number) && number > 0 ? number : 0;
15
17
  };
16
18
 
17
- const enclosures = [...document.querySelectorAll(".nnw-enclosure")];
19
+ const enclosures = [...document.querySelectorAll(".nnw-enclosure, .nnw-primary-image")];
18
20
  enclosures.forEach((enclosure, index) => {
19
21
  const media = enclosure.querySelector(":scope > :is(img, video, audio, iframe)");
20
22
  if (!media) return;
@@ -89,7 +91,7 @@
89
91
  return base.slice(0, 3);
90
92
  };
91
93
  const distance = (a, b) => [0, 1, 2].reduce((sum, i) => sum + Math.abs(a[i] - b[i]), 0);
92
- const captions = [...document.querySelectorAll(".nnw-enclosure-caption")];
94
+ const captions = [...document.querySelectorAll(".nnw-enclosure-caption, .nnw-primary-image-caption")];
93
95
  for (const caption of captions) {
94
96
  if (!caption.getClientRects().length || !caption.innerText.trim()) continue;
95
97
  const name = JSON.stringify(caption.innerText.trim().slice(0, 40));
@@ -1,5 +1,5 @@
1
- // Footnote checks, evaluated in each rendered page after its screenshot. Returns a
2
- // list of failure messages. NetNewsWire's newsfoot.js opens a popover for any
1
+ // Footnote checks, evaluated in each rendered page after its screenshot. Returns
2
+ // {failures, warnings}, each a list of messages. NetNewsWire's newsfoot.js opens a popover for any
3
3
  // a.footnote whose hash names an element; main.js adds that class to local
4
4
  // sup > a[href*='#fn'] links and Substack's a[id^='footnote-anchor-'] links, and a
5
5
  // theme script may add it to other formats.
@@ -7,11 +7,13 @@
7
7
  // expect is the fixture's [expect.footnotes] table: notes maps each marker's text to
8
8
  // its note text, plain_links lists selectors for links that are not footnotes, and
9
9
  // keep_with_word requires a marker written against its word to stay on its line.
10
- async ({notes = null, plain_links = [], keep_with_word = false} = {}) => {
10
+ // all_converted makes an unconverted marker a failure rather than a warning.
11
+ async ({notes = null, plain_links = [], keep_with_word = false, all_converted = false} = {}) => {
11
12
  const failures = [];
13
+ const warnings = [];
12
14
  const fail = message => failures.push(message);
13
15
  const article = document.querySelector(".articleBody");
14
- if (!article) return failures;
16
+ if (!article) return {failures, warnings};
15
17
  const text = element => element.textContent.replace(/\s+/g, " ").trim();
16
18
  const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
17
19
 
@@ -19,11 +21,42 @@ async ({notes = null, plain_links = [], keep_with_word = false} = {}) => {
19
21
  const markers = [...article.querySelectorAll("a.footnote")]
20
22
  .filter(marker => marker.getClientRects().length);
21
23
  const label = marker => text(marker) || marker.getAttribute("href");
24
+ const fragment = link => {
25
+ try {
26
+ return link.hash ? decodeURIComponent(link.hash.slice(1)) : "";
27
+ } catch {
28
+ return "";
29
+ }
30
+ };
31
+
32
+ // A reference in a shape main.js or a theme converts, linking to an element in the
33
+ // article, that never became a marker: NetNewsWire opens it as a link. It happens
34
+ // when the page's base URL doesn't match the link, as for an article synced through
35
+ // the Reader API, or when a sync service rewrote the markup a theme script expects.
36
+ const plain = new Set();
37
+ for (const selector of plain_links) {
38
+ try {
39
+ for (const link of document.querySelectorAll(selector)) plain.add(link);
40
+ } catch {
41
+ // Reported below.
42
+ }
43
+ }
44
+ const references = article.querySelectorAll(
45
+ "sup > a[href], sup > div > a[href], a[id^='footnote-anchor-'][href]");
46
+ for (const link of references) {
47
+ if (link.matches(".footnote") || plain.has(link) || !link.getClientRects().length) continue;
48
+ const id = fragment(link);
49
+ const target = id && document.getElementById(id);
50
+ if (!target || !article.contains(target) || target.contains(link)) continue;
51
+ const message = `footnote ${label(link)}: ${link.getAttribute("href")} links to a note ` +
52
+ "but never became an a.footnote marker, so NetNewsWire opens it as a link";
53
+ (all_converted ? failures : warnings).push(message);
54
+ }
22
55
 
23
56
  // Every marker resolves, as newsfoot does, to one non-empty note.
24
57
  const noteFor = new Map();
25
58
  for (const marker of markers) {
26
- const id = marker.hash ? decodeURIComponent(marker.hash.slice(1)) : "";
59
+ const id = fragment(marker);
27
60
  const target = id && document.getElementById(id);
28
61
  if (!target) {
29
62
  fail(`footnote ${label(marker)}: ${marker.getAttribute("href")} names no note`);
@@ -179,5 +212,5 @@ async ({notes = null, plain_links = [], keep_with_word = false} = {}) => {
179
212
  document.body.dispatchEvent(new MouseEvent("click", {bubbles: true, cancelable: true, view: window}));
180
213
  await pause(30);
181
214
  }
182
- return failures;
215
+ return {failures, warnings};
183
216
  }
@@ -15,7 +15,12 @@ prescribed aesthetic.
15
15
  These exercise the real mechanisms: a macOS size class and an iOS Dynamic Type size.
16
16
  - Check JavaScript-off macOS and iPhone renders for readable content and usable links.
17
17
  - If the theme styles enclosures, check the `podcast` and `media-rss` fixtures: players,
18
- captions, and the article image leading a podcast episode, in light and dark.
18
+ captions, and the article image leading a podcast episode, in light and dark. A
19
+ theme with `primary_media` checks them above the body, and its older-NetNewsWire render.
20
+ - If the theme script handles footnotes or relies on classes or ids, give a fixture
21
+ `deliveries = ["freshrss", "miniflux"]` and check the cases synced readers see.
22
+ - Check an untitled article, such as the `mastodon` fixture: the dateline stands in for
23
+ the headline, and inline 16x16 custom emoji must stay inline beside the text.
19
24
  - Inspect a real WebKit screenshot, not only the live gallery or Chromium fallback.
20
25
  - Promote `screenshots/theme-preview.png` only when the 16:10 macOS article image is
21
26
  an intentional representation of the release.
@@ -24,7 +24,7 @@ npx nnw-theme@2 fixture add wordpress --force # replace a copy with the curre
24
24
 
25
25
  Each is copied into `fixtures/` and is from then on an ordinary fixture of the
26
26
  repository, checked on macOS and iPhone in light and dark: four more renders per
27
- fixture in every `check`, twenty-four for all six. Their structure, classes, and attributes
27
+ fixture in every `check`, twenty-eight for all seven. Their structure, classes, and attributes
28
28
  come from real feeds; their text is synthetic and their links point at `example.org`.
29
29
  The copy never changes on its own when `nnw-theme` updates. A header comment records
30
30
  its catalog version, and `fixture list` says when a newer one is available; take it
@@ -36,11 +36,13 @@ footnotes stay plain links until a theme script turns them into `a.footnote` mar
36
36
  ## Enclosures
37
37
 
38
38
  An article's enclosures are data, not markup: NetNewsWire renders them into the
39
- `enclosures` macro, or after the body, from the article's attachments. A fixture lists
40
- them after its other keys, in feed order, and may give the article's image:
39
+ `enclosures` and `primary_media` macros, or after the body, from the article's
40
+ attachments. A fixture lists them after its other keys, in feed order, and may give
41
+ the article's image and thumbnail, which NetNewsWire treats differently:
41
42
 
42
43
  ```toml
43
- image_url = "https://example.org/episode-art.jpg" # else thumbnail_url
44
+ image_url = "https://example.org/episode-art.jpg"
45
+ thumbnail_url = "https://example.org/thumb.jpg" # a primary_media image only
44
46
 
45
47
  [[attachments]]
46
48
  url = "https://example.org/episode-311.mp3" # required
@@ -61,13 +63,38 @@ poster at its size, and the YouTube player becomes a placeholder frame. A `media
61
63
  URL plays or shows the file from `fixtures/media/`, including audio and video for a
62
64
  screenshot; keep such files tiny, since every page that renders the fixture embeds them.
63
65
 
66
+ ## Sync services
67
+
68
+ Readers who sync through a service see an article as that service hands it to
69
+ NetNewsWire, not as the publisher wrote it. A fixture can be rendered that way too:
70
+
71
+ ```toml
72
+ deliveries = ["freshrss", "miniflux"] # also render as each service delivers it
73
+ base_url = "https://example.org/" # the page's base URL in every case
74
+ ```
75
+
76
+ Each delivery adds two cases, Mac light and iPhone dark, with the body rewritten by
77
+ that service's sanitizer and the feed's home page (`feed_link`, or `base_url` when
78
+ given) as the page's base URL. That base is what NetNewsWire uses for every article synced through the Reader
79
+ API, since those carry only an external link. FreshRSS renames every `id` and `class` to
80
+ `data-sanitized-id` and `data-sanitized-class` and makes every link absolute,
81
+ fragments included; NetNewsWire 7.3 restores the names for FreshRSS accounts, but
82
+ earlier releases do not, and the delivery shows the content as they do. Miniflux strips
83
+ `class`, keeps `id` only on links, headings, list items, and `sup`, unwraps wrappers
84
+ such as `div.footnotes`, and marks external links `target="_blank"`. `base_url`
85
+ replaces the base in every case, delivered or not: give it the site URL the service
86
+ reports when that differs from the post URLs, or use it alone to reproduce a page whose
87
+ base differs from its links without rewriting the body.
88
+
64
89
  ## Your own articles
65
90
 
66
91
  Prefer real articles for content a theme must handle. `npx nnw-theme@2 capture`
67
92
  prints the steps for saving the selected article from a NetNewsWire debug build with
68
93
  the `nnwdump` lldb command; it needs Xcode and a NetNewsWire clone, so the person
69
94
  usually runs it. It records the article's attachments and image as the keys above,
70
- not NetNewsWire's rendered enclosures, so any template previews them. Rendering swaps every image that would load over the network for a
95
+ not NetNewsWire's rendered enclosures, so any template previews them, and records
96
+ `base_url` when the page's base differs from the article's link, as it does for an
97
+ article synced through the Reader API. Rendering swaps every image that would load over the network for a
71
98
  same-size placeholder and a network feed icon for a generated one; do not inline or
72
99
  fetch the originals.
73
100
 
@@ -79,15 +106,21 @@ each is copied into every page that renders the fixture.
79
106
 
80
107
  Footnote markers are the links NetNewsWire's `newsfoot.js` handles: `a.footnote`.
81
108
  Its `main.js` adds that class to local `sup > a[href*='#fn']` links and to Substack's
82
- `a[id^='footnote-anchor-']` links; a theme script may add it to other formats. `check` requires every visible marker to resolve to a
109
+ `a[id^='footnote-anchor-']` links, where local means the link starts with the page's
110
+ base URL; a theme script may add it to other formats. `check` requires every visible marker to resolve to a
83
111
  non-empty note with a unique id, stay legible on its background, match the other
84
112
  markers' height, font size, and baseline offset, and open a popover with its note
85
- without resizing. A fixture may declare exact expectations after its other keys:
113
+ without resizing. A `sup > a` or Substack anchor whose fragment names an element in
114
+ the article but that never became a marker is opened as a link instead: `check` warns
115
+ about one, and fails it in a fixture with `base_url` or `deliveries`, or with
116
+ `all_converted = true`. List a link that really is ordinary in `plain_links`.
117
+ A fixture may declare exact expectations after its other keys:
86
118
 
87
119
  ```toml
88
120
  [expect.footnotes]
89
121
  plain_links = ["#ordinary-sup-link"] # CSS selectors that must not become markers
90
122
  keep_with_word = true # markers must not wrap apart from their word
123
+ all_converted = true # fail, not warn, on footnote links left plain
91
124
 
92
125
  [expect.footnotes.notes]
93
126
  "1" = "First note." # marker text as rendered = note text
@@ -20,8 +20,8 @@ specific reading needs. Prefer changing the CSS variables and rules in
20
20
  needs it. For format constraints and macro behavior, read the `theme-format` topic.
21
21
  To preview content the built-in articles lack, such as a real feed's markup or
22
22
  footnotes, add a fixture as the `fixtures` topic describes; `npx nnw-theme@2 fixture
23
- list` offers ready-made ones modeled on WordPress, Ghost, Substack, and Medium, plus
24
- podcast and Media RSS enclosures.
23
+ list` offers ready-made ones modeled on WordPress, Ghost, Substack, Medium, and
24
+ Mastodon, plus podcast and Media RSS enclosures.
25
25
 
26
26
  Iterate with this loop:
27
27
 
@@ -64,11 +64,14 @@ The markup is fixed and carries no inline styles:
64
64
  Images and video carry the feed's `width` and `height` when it gives both; a caption
65
65
  appears only when there is text for it. The audio caption joins the title (else the
66
66
  file name), duration, and size with ` · `. A YouTube channel enclosure becomes the
67
- iframe player. Enclosures whose URL already appears in the body are skipped, and
68
- documents are never shown. When nothing visual remains, neither an enclosure nor an
69
- image or video in the body, the article's image (or thumbnail) leads the list as an
70
- image enclosure: a podcast episode's artwork above its player. A video uses that image
71
- as its poster.
67
+ iframe player. Enclosures already in the body are skipped, including a resized
68
+ copy at another URL or the still of a YouTube video the body embeds, and documents are
69
+ never shown. A video uses the article's image, else its thumbnail, as its poster.
70
+
71
+ A template without `primary_media`, and a short untitled post, gets the article's image
72
+ as the first image enclosure unless the body already shows it, even beside other
73
+ enclosures or a video that uses it as its poster. The thumbnail is not shown there:
74
+ only `primary_media` uses it as an image.
72
75
 
73
76
  NetNewsWire releases before enclosure support leave the macro as literal text in
74
77
  every article, so a theme that places it should hide its container when it holds no
@@ -79,10 +82,42 @@ fixture, `check` also fails an enclosure image, player, or video that is hidden,
79
82
  leaves the window, or is drawn out of its shape, an audio player too narrow to seek,
80
83
  and an illegible caption.
81
84
 
85
+ ## Primary media
86
+
87
+ The `primary_media` macro places the article's main media, usually above the body: a
88
+ video with the article image as its poster, else audio with the article image above
89
+ it, else the article image. The image is the article's own, else the first image
90
+ enclosure, else the thumbnail. Media already in the body is left out, and whatever
91
+ appears here is left out of the enclosures. A short untitled post, such as a Mastodon
92
+ post, keeps its images in the enclosures, after the text. With nothing to show the
93
+ macro is empty.
94
+
95
+ ```html
96
+ <div class="nnw-primary-media">
97
+ <div class="nnw-primary-image"><img src="…" alt="…" title="…" width="…" height="…">
98
+ <div class="nnw-primary-image-caption">The image's title</div></div>
99
+ <div class="nnw-enclosure nnw-enclosure-audio">…</div>
100
+ </div>
101
+ ```
102
+
103
+ The video and audio blocks are the enclosures' markup. The caption appears only when
104
+ the image has a title that differs from the article's. `core.css` adds margins and
105
+ `height: auto`. `check` applies the enclosure rules here too.
106
+
107
+ NetNewsWire before 7.3b6 leaves the macro as text, as with `enclosures`. Either hide its
108
+ container, `.my-media:not(:has(.nnw-primary-media)) { display: none; }`, or put the
109
+ macro only in `template7.3.html`: NetNewsWire 7.3 reads that file instead of
110
+ `template.html` when a theme ships one, and older releases ignore it, so `template.html`
111
+ can stay free of new macros at the cost of keeping two templates in step. `check`
112
+ renders `template7.3.html` when it exists, and its older-NetNewsWire cases render
113
+ `template.html` with both macros left as text.
114
+
82
115
  ## Pinned NetNewsWire
83
116
 
84
- The renderer pins NetNewsWire `main` commit
85
- `70c5dce656487bdab9e0226c318acc1a15071cf4`, the first with enclosure support. It uses upstream `core.css`, macOS and
117
+ The renderer pins NetNewsWire 7.3b6 for Mac (`mac-7.3b6`, commit
118
+ `9a07d406177e896826e33952fb16c06e21c19000`), whose rendering files iOS 7.3 shares.
119
+ Enclosure support starts with 7.3b3, 7.3b4 keeps the footnote popover inside the
120
+ article column, and 7.3b6 adds `primary_media` and `template7.3.html`. It uses upstream `core.css`, macOS and
86
121
  iOS page skeletons, and `main.js`, `main_mac.js`, `main_ios.js`, and `newsfoot.js`.
87
122
  These files ship inside the `nnw-theme` package, verified by SHA-256, so previews work
88
123
  offline. iPhone and iPad share the iOS skeleton. WebKit native
@@ -30,6 +30,11 @@ function selectedHTML() {
30
30
  container.querySelectorAll("a[href]").forEach(function(anchor) {
31
31
  anchor.setAttribute("href", anchor.href);
32
32
  });
33
+ // Images served from the timeline’s copy would break outside this page.
34
+ container.querySelectorAll("img[data-nnw-src]").forEach(function(image) {
35
+ image.setAttribute("src", image.getAttribute("data-nnw-src"));
36
+ image.removeAttribute("data-nnw-src");
37
+ });
33
38
  // A single-paragraph selection doesn't need its <p> wrapper.
34
39
  if (container.childNodes.length === 1 && container.firstChild.nodeName === "P") {
35
40
  return container.firstChild.innerHTML;
@@ -63,6 +63,25 @@ img.twemoji, img.emoji {
63
63
  width: auto;
64
64
  }
65
65
 
66
+ /* Primary media: the article’s main video, audio, or image, from the feed */
67
+
68
+ .nnw-primary-media {
69
+ margin: 1em 0;
70
+ }
71
+
72
+ .nnw-primary-image {
73
+ margin: 1em 0;
74
+ }
75
+
76
+ .nnw-primary-image img {
77
+ height: auto;
78
+ }
79
+
80
+ .nnw-primary-image-caption {
81
+ margin: 0.5em 0 0;
82
+ font-size: 0.9em;
83
+ }
84
+
66
85
  /* Enclosures: audio and video players and images after the article body */
67
86
 
68
87
  .nnw-enclosures {
@@ -152,25 +171,19 @@ img[src*="/img/social/"] {
152
171
  position: absolute;
153
172
  display: block;
154
173
  padding: 0em 1em;
155
- margin: 1em auto;
174
+ margin: 1em 0;
156
175
  top: 0.75em;
157
176
  max-width: 44em;
158
177
  border-radius: 0.3em;
159
178
  box-sizing: border-box;
160
179
  }
161
180
 
162
- .newsfoot-footnote-popover {
163
- left: calc(-1 * (50vw - 1em));
164
- right: calc(-1 * (50vw - 1em));
165
- }
166
-
167
181
  .newsfoot-footnote-popover-arrow {
168
182
  content: '';
169
183
  display: block;
170
184
  width: 1em;
171
185
  position: absolute;
172
186
  top: -0.5em;
173
- left: calc(50% - 0.5em);
174
187
  height: 1em !important;
175
188
  transform: rotate(45deg);
176
189
  z-index:0;
@@ -15,6 +15,18 @@ function wrapFrames() {
15
15
  });
16
16
  }
17
17
 
18
+ // Optimole lazy-loading leaves src as about:blank, with the real URL in data-opt-src.
19
+ // <https://github.com/Ranchero-Software/NetNewsWire/issues/5545>
20
+ function loadLazyIframes() {
21
+ document.querySelectorAll("iframe[data-opt-src]").forEach(element => {
22
+ const src = element.getAttribute("src");
23
+ const lazySrc = element.getAttribute("data-opt-src");
24
+ if ((!src || src === "about:blank") && /^https?:\/\//i.test(lazySrc)) {
25
+ element.src = lazySrc;
26
+ }
27
+ });
28
+ }
29
+
18
30
  // The stylesheet clamps iframes to the article width with max-width: 100%,
19
31
  // which leaves an attribute-sized iframe with its full height — distorted,
20
32
  // and a huge blank box when the embed fails to load. Let the height follow
@@ -69,12 +81,23 @@ function constrainBodyRelativeIframes() {
69
81
  }
70
82
  }
71
83
 
84
+ // An image served from the timeline’s copy falls back to its original URL when the copy can’t be loaded.
85
+ // Registered at document start, and capturing, since error events don’t bubble.
86
+ document.addEventListener("error", event => {
87
+ const element = event.target;
88
+ if (element instanceof HTMLImageElement && element.hasAttribute("data-nnw-src")) {
89
+ const originalSrc = element.getAttribute("data-nnw-src");
90
+ element.removeAttribute("data-nnw-src");
91
+ element.src = originalSrc;
92
+ }
93
+ }, true);
94
+
72
95
  // Convert all Feedbin proxy images to be used as src, otherwise change image locations to be absolute if not already
73
96
  function convertImgSrc() {
74
97
  document.querySelectorAll("img").forEach(element => {
75
98
  if (element.hasAttribute("data-canonical-src")) {
76
99
  element.src = element.getAttribute("data-canonical-src")
77
- } else if (!/^[a-z]+\:\/\//i.test(element.src)) {
100
+ } else if (!element.hasAttribute("data-nnw-src") && !/^[a-z]+\:\/\//i.test(element.src)) {
78
101
  element.src = new URL(element.src, document.baseURI).href;
79
102
  }
80
103
  });
@@ -169,6 +192,67 @@ function stopMediaPlayback() {
169
192
  });
170
193
  }
171
194
 
195
+ // The element at the top of the viewport, how far through it the viewport top is,
196
+ // and any remaining distance. A scroll offset alone means nothing once the article
197
+ // reflows at a different width.
198
+ let scrollAnchorElement = null;
199
+ let scrollAnchorFraction = 0;
200
+ let scrollAnchorOffset = 0;
201
+
202
+ const scrollAnchorSampleSpacing = 16;
203
+
204
+ // False for a container hit in the gap between blocks: it has element children
205
+ // and only whitespace text of its own.
206
+ function isScrollAnchorCandidate(element) {
207
+ if (element.children.length === 0) {
208
+ return true;
209
+ }
210
+ for (const node of element.childNodes) {
211
+ if (node.nodeType === Node.TEXT_NODE && node.textContent.trim() !== "") {
212
+ return true;
213
+ }
214
+ }
215
+ return false;
216
+ }
217
+
218
+ function findScrollAnchorElement() {
219
+ const x = window.innerWidth / 2;
220
+ const maximumSampleDepth = window.innerHeight / 4;
221
+ let firstHit = null;
222
+ for (let y = 1; y <= maximumSampleDepth; y += scrollAnchorSampleSpacing) {
223
+ const element = document.elementFromPoint(x, y);
224
+ if (!element) {
225
+ continue;
226
+ }
227
+ if (isScrollAnchorCandidate(element)) {
228
+ return element;
229
+ }
230
+ if (!firstHit) {
231
+ firstHit = element;
232
+ }
233
+ }
234
+ return firstHit;
235
+ }
236
+
237
+ function recordScrollAnchor() {
238
+ scrollAnchorElement = findScrollAnchorElement();
239
+ if (!scrollAnchorElement) {
240
+ return;
241
+ }
242
+ const rect = scrollAnchorElement.getBoundingClientRect();
243
+ scrollAnchorFraction = rect.height > 0 ? Math.min(Math.max(-rect.top / rect.height, 0), 1) : 0;
244
+ scrollAnchorOffset = -(rect.top + scrollAnchorFraction * rect.height);
245
+ }
246
+
247
+ // Scrolls back to the same place in the recorded element after a reflow.
248
+ function restoreScrollAnchor() {
249
+ if (!scrollAnchorElement || !scrollAnchorElement.isConnected) {
250
+ return;
251
+ }
252
+ const rect = scrollAnchorElement.getBoundingClientRect();
253
+ window.scrollTo(0, window.scrollY + rect.top + scrollAnchorFraction * rect.height + scrollAnchorOffset);
254
+ }
255
+
172
256
  function error() {
173
257
  document.body.innerHTML = "error";
174
258
  }
@@ -220,6 +304,7 @@ function removeInstapaperIgnoreElements() {
220
304
  function processPage() {
221
305
  // stripStyles must run first — wrapFrames sets aspect-ratio on fixed-size iframes, which stripStyles would remove.
222
306
  stripStyles();
307
+ loadLazyIframes();
223
308
  wrapFrames();
224
309
  wrapTables();
225
310
  inlineVideos();
@@ -15,17 +15,6 @@
15
15
  return el;
16
16
  }
17
17
 
18
- /** @type {<T extends any[]>(fn: (...args: T) => void, t: number) => ((...args: T) => void)} */
19
- function debounce(f, ms) {
20
- let t = Date.now();
21
- return (...args) => {
22
- const now = Date.now();
23
- if (now - t < ms) return;
24
- t = now;
25
- f(...args);
26
- };
27
- }
28
-
29
18
  const clsPrefix = "newsfoot-footnote-";
30
19
  const CONTAINER_CLS = `${clsPrefix}container`;
31
20
  const POPOVER_CLS = `${clsPrefix}popover`;
@@ -76,7 +65,7 @@
76
65
  }
77
66
  document.addEventListener("click", this.clickoutHandler, {capture: true});
78
67
 
79
- this.resizeHandler = debounce(() => this.reposition(), 20);
68
+ this.resizeHandler = () => this.reposition();
80
69
  window.addEventListener("resize", this.resizeHandler);
81
70
  }
82
71
 
@@ -92,23 +81,31 @@
92
81
 
93
82
  reposition() {
94
83
  const refRect = this.fnref.getBoundingClientRect();
95
- const center = refRect.left + (refRect.width / 2);
96
- const popoverHalfWidth = this.popover.clientWidth / 2;
97
- const marginLeft = stripPx(this.style.marginLeft);
98
- const marginRight = stripPx(this.style.marginRight);
84
+ const containerRect = this.fnref.closest(`.${CONTAINER_CLS}`).getBoundingClientRect();
85
+ const articleBody = this.fnref.closest(".articleBody");
86
+ const visibleRect = articleBody ? articleBody.getBoundingClientRect() : document.documentElement.getBoundingClientRect();
87
+ const maximumWidth = stripPx(this.style.maxWidth);
88
+ const popoverWidth = Math.min(visibleRect.width, maximumWidth);
89
+ const refCenter = refRect.left + (refRect.width / 2);
90
+ const minimumLeft = visibleRect.left;
91
+ const maximumLeft = visibleRect.right - popoverWidth;
92
+ const popoverLeft = Math.min(
93
+ Math.max(refCenter - (popoverWidth / 2), minimumLeft),
94
+ maximumLeft
95
+ );
99
96
 
100
- const rightOverhang = center + popoverHalfWidth + marginRight > window.innerWidth;
101
- const leftOverhang = center - (popoverHalfWidth + marginLeft) < 0;
97
+ // Keep the popover inside the visible article column while centering it on the
98
+ // footnote reference whenever there is enough room.
99
+ this.popover.style.left = `${popoverLeft - containerRect.left}px`;
100
+ this.popover.style.width = `${popoverWidth}px`;
102
101
 
103
- let offset = 0;
104
- if (leftOverhang || rightOverhang) {
105
- // Popover can't be centered on the ref without overflowing the viewport.
106
- // Center it horizontally in the viewport so left/right margins are balanced.
107
- // The arrow gets the inverse transform below and stays anchored to the ref.
108
- offset = (window.innerWidth / 2) - center;
109
- }
110
- this.popover.style.transform = `translate(${offset}px)`;
111
- this.arrow.style.transform = `translate(${-offset}px) rotate(45deg)`;
102
+ const arrowWidth = this.arrow.clientWidth;
103
+ const arrowCenter = refCenter - popoverLeft;
104
+ const insetArrowCenter = Math.min(
105
+ Math.max(arrowCenter, arrowWidth),
106
+ popoverWidth - arrowWidth
107
+ );
108
+ this.arrow.style.left = `${insetArrowCenter - (arrowWidth / 2)}px`;
112
109
  }
113
110
  }
114
111
 
@@ -37,7 +37,7 @@ class ImageViewer {
37
37
  const rect = this.img.getBoundingClientRect();
38
38
 
39
39
  // Instead of trying to convert to canvas (which fails with CORS),
40
- // send the original image src URL
40
+ // send the image's src URL. Swift checks a routed timeline image's signature.
41
41
  const message = {
42
42
  x: rect.x,
43
43
  y: rect.y,