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 +2 -2
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/assets/catalog/mastodon.toml +50 -0
- package/assets/enclosures.js +6 -4
- package/assets/footnotes.js +39 -6
- package/assets/guide/design-checklist.md +6 -1
- package/assets/guide/fixtures.md +40 -7
- package/assets/guide/skill.md +2 -2
- package/assets/guide/theme-format.md +42 -7
- package/assets/netnewswire/Mac/main_mac.js +5 -0
- package/assets/netnewswire/Shared/core.css +20 -7
- package/assets/netnewswire/Shared/main.js +86 -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/body-images.js +209 -0
- package/dist/browser.js +29 -12
- package/dist/commands/check.js +25 -8
- 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/enclosures.js +176 -42
- package/dist/project.js +13 -2
- package/dist/render.js +145 -31
- package/dist/validate.js +33 -30
- package/lldb/nnwdump.py +16 -2
- 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.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'
|
package/assets/enclosures.js
CHANGED
|
@@ -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.
|
|
6
|
-
//
|
|
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));
|
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
|
}
|
|
@@ -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.
|
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
|
|
@@ -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`
|
|
40
|
-
them after its other keys, in feed order, and may give
|
|
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"
|
|
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
|
|
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
|
|
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
|
|
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
|
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
|
|
|
@@ -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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 `
|
|
85
|
-
`
|
|
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
|
|
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 =
|
|
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,
|