nnw-theme 2.3.0 → 2.4.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.3b4`,
5
+ commit `e530be1ab19f68fe322850bf5fb95bca4b36fb91`, 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'
@@ -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
  }
@@ -16,6 +16,10 @@ prescribed aesthetic.
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
18
  captions, and the article image leading a podcast episode, in light and dark.
19
+ - If the theme script handles footnotes or relies on classes or ids, give a fixture
20
+ `deliveries = ["freshrss", "miniflux"]` and check the cases synced readers see.
21
+ - Check an untitled article, such as the `mastodon` fixture: the dateline stands in for
22
+ the headline, and inline 16x16 custom emoji must stay inline beside the text.
19
23
  - Inspect a real WebKit screenshot, not only the live gallery or Chromium fallback.
20
24
  - Promote `screenshots/theme-preview.png` only when the 16:10 macOS article image is
21
25
  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
@@ -61,13 +61,38 @@ poster at its size, and the YouTube player becomes a placeholder frame. A `media
61
61
  URL plays or shows the file from `fixtures/media/`, including audio and video for a
62
62
  screenshot; keep such files tiny, since every page that renders the fixture embeds them.
63
63
 
64
+ ## Sync services
65
+
66
+ Readers who sync through a service see an article as that service hands it to
67
+ NetNewsWire, not as the publisher wrote it. A fixture can be rendered that way too:
68
+
69
+ ```toml
70
+ deliveries = ["freshrss", "miniflux"] # also render as each service delivers it
71
+ base_url = "https://example.org/" # the page's base URL in every case
72
+ ```
73
+
74
+ Each delivery adds two cases, Mac light and iPhone dark, with the body rewritten by
75
+ that service's sanitizer and the feed's home page (`feed_link`, or `base_url` when
76
+ given) as the page's base URL. That base is what NetNewsWire uses for every article synced through the Reader
77
+ API, since those carry only an external link. FreshRSS renames every `id` and `class` to
78
+ `data-sanitized-id` and `data-sanitized-class` and makes every link absolute,
79
+ fragments included; NetNewsWire 7.3 restores the names for FreshRSS accounts, but
80
+ earlier releases do not, and the delivery shows the content as they do. Miniflux strips
81
+ `class`, keeps `id` only on links, headings, list items, and `sup`, unwraps wrappers
82
+ such as `div.footnotes`, and marks external links `target="_blank"`. `base_url`
83
+ replaces the base in every case, delivered or not: give it the site URL the service
84
+ reports when that differs from the post URLs, or use it alone to reproduce a page whose
85
+ base differs from its links without rewriting the body.
86
+
64
87
  ## Your own articles
65
88
 
66
89
  Prefer real articles for content a theme must handle. `npx nnw-theme@2 capture`
67
90
  prints the steps for saving the selected article from a NetNewsWire debug build with
68
91
  the `nnwdump` lldb command; it needs Xcode and a NetNewsWire clone, so the person
69
92
  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
93
+ not NetNewsWire's rendered enclosures, so any template previews them, and records
94
+ `base_url` when the page's base differs from the article's link, as it does for an
95
+ article synced through the Reader API. Rendering swaps every image that would load over the network for a
71
96
  same-size placeholder and a network feed icon for a generated one; do not inline or
72
97
  fetch the originals.
73
98
 
@@ -79,15 +104,21 @@ each is copied into every page that renders the fixture.
79
104
 
80
105
  Footnote markers are the links NetNewsWire's `newsfoot.js` handles: `a.footnote`.
81
106
  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
107
+ `a[id^='footnote-anchor-']` links, where local means the link starts with the page's
108
+ base URL; a theme script may add it to other formats. `check` requires every visible marker to resolve to a
83
109
  non-empty note with a unique id, stay legible on its background, match the other
84
110
  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:
111
+ without resizing. A `sup > a` or Substack anchor whose fragment names an element in
112
+ the article but that never became a marker is opened as a link instead: `check` warns
113
+ about one, and fails it in a fixture with `base_url` or `deliveries`, or with
114
+ `all_converted = true`. List a link that really is ordinary in `plain_links`.
115
+ A fixture may declare exact expectations after its other keys:
86
116
 
87
117
  ```toml
88
118
  [expect.footnotes]
89
119
  plain_links = ["#ordinary-sup-link"] # CSS selectors that must not become markers
90
120
  keep_with_word = true # markers must not wrap apart from their word
121
+ all_converted = true # fail, not warn, on footnote links left plain
91
122
 
92
123
  [expect.footnotes.notes]
93
124
  "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
 
@@ -81,8 +81,10 @@ and an illegible caption.
81
81
 
82
82
  ## Pinned NetNewsWire
83
83
 
84
- The renderer pins NetNewsWire `main` commit
85
- `70c5dce656487bdab9e0226c318acc1a15071cf4`, the first with enclosure support. It uses upstream `core.css`, macOS and
84
+ The renderer pins NetNewsWire 7.3b4 for Mac (`mac-7.3b4`, commit
85
+ `e530be1ab19f68fe322850bf5fb95bca4b36fb91`), whose rendering files iOS 7.3 shares.
86
+ Enclosure support starts with 7.3b3, and 7.3b4 keeps the footnote popover inside the
87
+ article column. It uses upstream `core.css`, macOS and
86
88
  iOS page skeletons, and `main.js`, `main_mac.js`, `main_ios.js`, and `newsfoot.js`.
87
89
  These files ship inside the `nnw-theme` package, verified by SHA-256, so previews work
88
90
  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;
@@ -152,25 +152,19 @@ img[src*="/img/social/"] {
152
152
  position: absolute;
153
153
  display: block;
154
154
  padding: 0em 1em;
155
- margin: 1em auto;
155
+ margin: 1em 0;
156
156
  top: 0.75em;
157
157
  max-width: 44em;
158
158
  border-radius: 0.3em;
159
159
  box-sizing: border-box;
160
160
  }
161
161
 
162
- .newsfoot-footnote-popover {
163
- left: calc(-1 * (50vw - 1em));
164
- right: calc(-1 * (50vw - 1em));
165
- }
166
-
167
162
  .newsfoot-footnote-popover-arrow {
168
163
  content: '';
169
164
  display: block;
170
165
  width: 1em;
171
166
  position: absolute;
172
167
  top: -0.5em;
173
- left: calc(50% - 0.5em);
174
168
  height: 1em !important;
175
169
  transform: rotate(45deg);
176
170
  z-index:0;
@@ -69,12 +69,23 @@ function constrainBodyRelativeIframes() {
69
69
  }
70
70
  }
71
71
 
72
+ // An image served from the timeline’s copy falls back to its original URL when the copy can’t be loaded.
73
+ // Registered at document start, and capturing, since error events don’t bubble.
74
+ document.addEventListener("error", event => {
75
+ const element = event.target;
76
+ if (element instanceof HTMLImageElement && element.hasAttribute("data-nnw-src")) {
77
+ const originalSrc = element.getAttribute("data-nnw-src");
78
+ element.removeAttribute("data-nnw-src");
79
+ element.src = originalSrc;
80
+ }
81
+ }, true);
82
+
72
83
  // Convert all Feedbin proxy images to be used as src, otherwise change image locations to be absolute if not already
73
84
  function convertImgSrc() {
74
85
  document.querySelectorAll("img").forEach(element => {
75
86
  if (element.hasAttribute("data-canonical-src")) {
76
87
  element.src = element.getAttribute("data-canonical-src")
77
- } else if (!/^[a-z]+\:\/\//i.test(element.src)) {
88
+ } else if (!element.hasAttribute("data-nnw-src") && !/^[a-z]+\:\/\//i.test(element.src)) {
78
89
  element.src = new URL(element.src, document.baseURI).href;
79
90
  }
80
91
  });
@@ -169,6 +180,67 @@ function stopMediaPlayback() {
169
180
  });
170
181
  }
171
182
 
183
+ // The element at the top of the viewport, how far through it the viewport top is,
184
+ // and any remaining distance. A scroll offset alone means nothing once the article
185
+ // reflows at a different width.
186
+ let scrollAnchorElement = null;
187
+ let scrollAnchorFraction = 0;
188
+ let scrollAnchorOffset = 0;
189
+
190
+ const scrollAnchorSampleSpacing = 16;
191
+
192
+ // False for a container hit in the gap between blocks: it has element children
193
+ // and only whitespace text of its own.
194
+ function isScrollAnchorCandidate(element) {
195
+ if (element.children.length === 0) {
196
+ return true;
197
+ }
198
+ for (const node of element.childNodes) {
199
+ if (node.nodeType === Node.TEXT_NODE && node.textContent.trim() !== "") {
200
+ return true;
201
+ }
202
+ }
203
+ return false;
204
+ }
205
+
206
+ function findScrollAnchorElement() {
207
+ const x = window.innerWidth / 2;
208
+ const maximumSampleDepth = window.innerHeight / 4;
209
+ let firstHit = null;
210
+ for (let y = 1; y <= maximumSampleDepth; y += scrollAnchorSampleSpacing) {
211
+ const element = document.elementFromPoint(x, y);
212
+ if (!element) {
213
+ continue;
214
+ }
215
+ if (isScrollAnchorCandidate(element)) {
216
+ return element;
217
+ }
218
+ if (!firstHit) {
219
+ firstHit = element;
220
+ }
221
+ }
222
+ return firstHit;
223
+ }
224
+
225
+ function recordScrollAnchor() {
226
+ scrollAnchorElement = findScrollAnchorElement();
227
+ if (!scrollAnchorElement) {
228
+ return;
229
+ }
230
+ const rect = scrollAnchorElement.getBoundingClientRect();
231
+ scrollAnchorFraction = rect.height > 0 ? Math.min(Math.max(-rect.top / rect.height, 0), 1) : 0;
232
+ scrollAnchorOffset = -(rect.top + scrollAnchorFraction * rect.height);
233
+ }
234
+
235
+ // Scrolls back to the same place in the recorded element after a reflow.
236
+ function restoreScrollAnchor() {
237
+ if (!scrollAnchorElement || !scrollAnchorElement.isConnected) {
238
+ return;
239
+ }
240
+ const rect = scrollAnchorElement.getBoundingClientRect();
241
+ window.scrollTo(0, window.scrollY + rect.top + scrollAnchorFraction * rect.height + scrollAnchorOffset);
242
+ }
243
+
172
244
  function error() {
173
245
  document.body.innerHTML = "error";
174
246
  }
@@ -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,
@@ -1,6 +1,6 @@
1
1
  {
2
- "release": "main-70c5dce",
3
- "commit": "70c5dce656487bdab9e0226c318acc1a15071cf4",
2
+ "release": "mac-7.3b4",
3
+ "commit": "e530be1ab19f68fe322850bf5fb95bca4b36fb91",
4
4
  "files": [
5
5
  {
6
6
  "destination": "LICENSE",
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "destination": "Mac/main_mac.js",
12
12
  "source": "Mac/MainWindow/Detail/main_mac.js",
13
- "sha256": "3ac1ef46523f6c4d8103fe7dbe6f45486d57a94c3d54d0fcb6211a35215bb676"
13
+ "sha256": "51c8812b9063d70aeb9a163a224bc83954f127dfb78736b2016d6572dd9ab63f"
14
14
  },
15
15
  {
16
16
  "destination": "Mac/page.html",
@@ -20,22 +20,22 @@
20
20
  {
21
21
  "destination": "Shared/core.css",
22
22
  "source": "Shared/Article Rendering/core.css",
23
- "sha256": "3cdc76a22a6bbcd1aeed6eec4611598bb64a457cade3e1553e7b88f03bc78ef9"
23
+ "sha256": "1cc7d654559b3cd6db1202391c6fcfb8041eaae6fc81850df1be72ebb6921090"
24
24
  },
25
25
  {
26
26
  "destination": "Shared/main.js",
27
27
  "source": "Shared/Article Rendering/main.js",
28
- "sha256": "847dc1f06ec0e7f96aee1004fd6186771db907445347cf0371abc67158aadb5a"
28
+ "sha256": "1c3c1084e295f83668a427b8ae89813a1df621cfb053e8192bbad3f044be3581"
29
29
  },
30
30
  {
31
31
  "destination": "Shared/newsfoot.js",
32
32
  "source": "Shared/Article Rendering/newsfoot.js",
33
- "sha256": "828e6889bd976ebce417d808ac726dd9252a801946d77e32de207ba4a14e21fa"
33
+ "sha256": "8e0a5fd14b2b5e3b57e6d420388afa2999a8df8af56f957c16303834ad53ed61"
34
34
  },
35
35
  {
36
36
  "destination": "iOS/main_ios.js",
37
37
  "source": "iOS/Resources/main_ios.js",
38
- "sha256": "e764183ea8e9ce75df13d268834741b176afdb440eb492efeaba8ccabd7dfb8b"
38
+ "sha256": "ab60f228e06b11589aefaac579a5ea42d1ff5ecf43aab44e2eba7679a18d6ef2"
39
39
  },
40
40
  {
41
41
  "destination": "iOS/page.html",
package/dist/browser.js CHANGED
@@ -232,8 +232,9 @@ function pageState({ olderNetNewsWire, macro }) {
232
232
  *
233
233
  * expectations maps a fixture name to its [expect.footnotes] table. They describe
234
234
  * the theme's own footnote handling, so they are skipped with theme scripts off.
235
+ * warnings, when given, receives each target's warnings by slug.
235
236
  */
236
- export async function checkPages(site, targets, progress, expectations = {}) {
237
+ export async function checkPages(site, targets, progress, expectations = {}, warnings) {
237
238
  const results = {};
238
239
  const screenshots = join(site, "screenshots");
239
240
  mkdirSync(screenshots, { recursive: true });
@@ -250,6 +251,8 @@ export async function checkPages(site, targets, progress, expectations = {}) {
250
251
  const state = await checkPage(browser, server.url, url, target, join(screenshots, `${target.slug}.png`), footnotes);
251
252
  const found = failures(state);
252
253
  results[target.slug] = found;
254
+ if (warnings)
255
+ warnings[target.slug] = state.warnings ?? [];
253
256
  progress?.finish(index, target, found);
254
257
  }
255
258
  }
@@ -301,7 +304,14 @@ async function checkPage(browser, origin, url, target, screenshot, footnotes) {
301
304
  const enclosures = (await page.evaluate(`(${ENCLOSURE_CHECK})()`));
302
305
  // Last: it clicks footnotes open, so it must not disturb the screenshot.
303
306
  const found = (await page.evaluate(`(${FOOTNOTE_CHECK})(${JSON.stringify(footnotes)})`));
304
- return { ...state, enclosures, footnotes: found, blocked, pageErrors };
307
+ return {
308
+ ...state,
309
+ enclosures,
310
+ footnotes: found.failures,
311
+ warnings: found.warnings,
312
+ blocked,
313
+ pageErrors,
314
+ };
305
315
  }
306
316
  finally {
307
317
  await context.close();
@@ -6,14 +6,23 @@ import { interactive, openInBrowser, promptConfirm } from "../interactive.js";
6
6
  import { version } from "../main.js";
7
7
  import { buildArchive } from "../package.js";
8
8
  import { findRoot, findTheme, fixturePath, footnoteExpectations, readFixture, repositoryFixtures, ThemeError, themeStem, } from "../project.js";
9
- import { checkTargets, extraFixtures, placesEnclosures, renderSite, writeGallery, } from "../render.js";
9
+ import { allFixtures, checkTargets, extraFixtures, placesEnclosures, readDeliveries, renderSite, writeGallery, } from "../render.js";
10
10
  import { staleStubs } from "../stubs.js";
11
11
  import { printWarnings } from "./package.js";
12
12
  import { CheckProgress } from "./progress.js";
13
+ /**
14
+ * Each fixture's [expect.footnotes]. A fixture that models a sync service, with
15
+ * base_url or deliveries, fails a footnote link that never became a marker unless it
16
+ * sets all_converted = false; any other fixture only warns about one, unless it sets
17
+ * all_converted = true.
18
+ */
13
19
  export function expectations(root, targets) {
14
20
  const result = {};
15
21
  for (const name of new Set(targets.map((target) => target.fixture))) {
16
- result[name] = footnoteExpectations(readFixture(fixturePath(root, name)), `fixtures/${name}.toml`);
22
+ const fixture = readFixture(fixturePath(root, name));
23
+ const expected = footnoteExpectations(fixture, `fixtures/${name}.toml`);
24
+ expected.all_converted ??= "base_url" in fixture || "deliveries" in fixture;
25
+ result[name] = expected;
17
26
  }
18
27
  return result;
19
28
  }
@@ -22,7 +31,7 @@ export function fixtureHint(root) {
22
31
  if (repositoryFixtures(root).length)
23
32
  return undefined;
24
33
  return ("Hint: this theme has no fixtures of its own. `npx nnw-theme@2 fixture list` " +
25
- "shows ready-made ones modeled on WordPress, Ghost, Substack, and Medium markup.");
34
+ "shows ready-made ones modeled on WordPress, Ghost, Substack, Medium, and Mastodon markup.");
26
35
  }
27
36
  async function offerToOpen(index, choice) {
28
37
  const open = choice ?? (interactive() && (await promptConfirm("Open the preview?", true)));
@@ -34,8 +43,10 @@ export default async function check({ values }) {
34
43
  const theme = findTheme(root);
35
44
  console.log(`nnw-theme ${version()}`);
36
45
  printWarnings(staleStubs(root));
37
- const targets = checkTargets(extraFixtures(root), {
46
+ const extras = extraFixtures(root);
47
+ const targets = checkTargets(extras, {
38
48
  enclosuresMacro: placesEnclosures(theme),
49
+ deliveries: readDeliveries(root, allFixtures(extras)),
39
50
  });
40
51
  const expected = expectations(root, targets);
41
52
  const progress = new CheckProgress(targets.length);
@@ -48,23 +59,29 @@ export default async function check({ values }) {
48
59
  progress.status(`Rendering ${targets.length} pages…`);
49
60
  const site = renderSite(root, theme, targets);
50
61
  let results;
62
+ const warned = {};
51
63
  try {
52
- results = await checkPages(site, targets, progress, expected);
64
+ results = await checkPages(site, targets, progress, expected, warned);
53
65
  }
54
66
  finally {
55
67
  progress.done();
56
68
  }
57
69
  writeGallery(site, themeStem(theme), targets, results);
58
70
  const failures = targets.flatMap((target) => (results[target.slug] ?? []).map((message) => `${target.label}: ${message}`));
71
+ const warnings = targets.flatMap((target) => (warned[target.slug] ?? []).map((message) => `Warning: ${target.label}: ${message}`));
72
+ for (const warning of warnings)
73
+ console.log(warning);
74
+ const warningLines = warnings.map((warning) => `${warning}\n`).join("");
59
75
  const passed = targets.filter((target) => !results[target.slug]?.length).length;
60
76
  const packageName = basename(archive.path);
61
77
  const footer = `nnw-theme ${version()}\n`;
62
78
  const report = join(root, "build", "check-report.txt");
63
79
  if (failures.length) {
64
- writeFileSync(report, `FAIL\n${failures.join("\n")}\n${footer}`, "utf8");
80
+ writeFileSync(report, `FAIL\n${failures.join("\n")}\n${warningLines}${footer}`, "utf8");
65
81
  }
66
82
  else {
67
- writeFileSync(report, `PASS\n${targets.length} WebKit renders checked\nPackage: ${packageName}\n${footer}`, "utf8");
83
+ writeFileSync(report, `PASS\n${targets.length} WebKit renders checked\nPackage: ${packageName}\n` +
84
+ `${warningLines}${footer}`, "utf8");
68
85
  console.log(`PASS: ${targets.length} WebKit renders and ${packageName}`);
69
86
  }
70
87
  const hint = fixtureHint(root);