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 +2 -2
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/assets/catalog/mastodon.toml +50 -0
- package/assets/footnotes.js +39 -6
- package/assets/guide/design-checklist.md +4 -0
- package/assets/guide/fixtures.md +35 -4
- package/assets/guide/skill.md +2 -2
- package/assets/guide/theme-format.md +4 -2
- package/assets/netnewswire/Mac/main_mac.js +5 -0
- package/assets/netnewswire/Shared/core.css +1 -7
- package/assets/netnewswire/Shared/main.js +73 -1
- package/assets/netnewswire/Shared/newsfoot.js +24 -27
- package/assets/netnewswire/iOS/main_ios.js +1 -1
- package/assets/netnewswire/netnewswire.json +7 -7
- package/dist/browser.js +12 -2
- package/dist/commands/check.js +24 -7
- package/dist/commands/preview.js +2 -2
- package/dist/commands/render.js +2 -2
- package/dist/commands/screenshot.js +7 -2
- package/dist/commands.js +10 -2
- package/dist/delivery.js +516 -0
- package/dist/project.js +11 -2
- package/dist/render.js +109 -15
- package/lldb/nnwdump.py +13 -1
- package/package.json +1 -1
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
|
|
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 |
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -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)
|
|
5
|
-
commit `
|
|
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'
|
package/assets/footnotes.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Footnote checks, evaluated in each rendered page after its screenshot. Returns
|
|
2
|
-
// list of
|
|
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
|
-
|
|
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 =
|
|
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.
|
package/assets/guide/fixtures.md
CHANGED
|
@@ -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-
|
|
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
|
|
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
|
|
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
|
|
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
|
package/assets/guide/skill.md
CHANGED
|
@@ -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,
|
|
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 `
|
|
85
|
-
`
|
|
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
|
|
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 =
|
|
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
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
const
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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": "
|
|
3
|
-
"commit": "
|
|
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": "
|
|
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": "
|
|
23
|
+
"sha256": "1cc7d654559b3cd6db1202391c6fcfb8041eaae6fc81850df1be72ebb6921090"
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
"destination": "Shared/main.js",
|
|
27
27
|
"source": "Shared/Article Rendering/main.js",
|
|
28
|
-
"sha256": "
|
|
28
|
+
"sha256": "1c3c1084e295f83668a427b8ae89813a1df621cfb053e8192bbad3f044be3581"
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
"destination": "Shared/newsfoot.js",
|
|
32
32
|
"source": "Shared/Article Rendering/newsfoot.js",
|
|
33
|
-
"sha256": "
|
|
33
|
+
"sha256": "8e0a5fd14b2b5e3b57e6d420388afa2999a8df8af56f957c16303834ad53ed61"
|
|
34
34
|
},
|
|
35
35
|
{
|
|
36
36
|
"destination": "iOS/main_ios.js",
|
|
37
37
|
"source": "iOS/Resources/main_ios.js",
|
|
38
|
-
"sha256": "
|
|
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 {
|
|
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();
|
package/dist/commands/check.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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);
|