nnw-theme 2.2.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/catalog/media-rss.toml +79 -0
- package/assets/catalog/podcast.toml +52 -0
- package/assets/enclosures.js +113 -0
- package/assets/footnotes.js +39 -6
- package/assets/guide/design-checklist.md +6 -0
- package/assets/guide/fixtures.md +64 -4
- package/assets/guide/skill.md +2 -1
- package/assets/guide/theme-format.md +48 -2
- package/assets/netnewswire/Mac/main_mac.js +5 -0
- package/assets/netnewswire/Shared/core.css +25 -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 +30 -6
- package/dist/commands/check.js +26 -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 +12 -2
- package/dist/delivery.js +516 -0
- package/dist/enclosures.js +299 -0
- package/dist/project.js +11 -2
- package/dist/render.js +220 -21
- package/lldb/nnwdump.py +87 -5
- 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'
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# nnw-theme catalog fixture: media-rss (catalog version 1)
|
|
2
|
+
# Markup: a photo-and-video post whose feed lists its media as RSS <enclosure> and
|
|
3
|
+
# Media RSS <media:content> items, as NetNewsWire renders them, captured 2026-10.
|
|
4
|
+
# Text and links are synthetic; the structure and the enclosures' fields are real. The
|
|
5
|
+
# YouTube link is the channel-feed form NetNewsWire turns into its player; previews
|
|
6
|
+
# swap in a same-size placeholder and never load it.
|
|
7
|
+
# Exercises: [[enclosures]] images with captions, a landscape and a portrait image,
|
|
8
|
+
# a video file with its poster, the YouTube player, an image the body already shows
|
|
9
|
+
# (skipped), and a PDF (not shown).
|
|
10
|
+
# Enclosures need NetNewsWire with enclosure support (upstream main, October 2026).
|
|
11
|
+
# Refresh: npx nnw-theme@2 fixture add media-rss --force
|
|
12
|
+
|
|
13
|
+
byline = 'Ines Calloway'
|
|
14
|
+
date_long = 'October 19, 2026'
|
|
15
|
+
date_medium = 'Oct 19, 2026'
|
|
16
|
+
date_short = '10/19/26'
|
|
17
|
+
dateline_style = 'articleDateline'
|
|
18
|
+
datetime_long = 'October 19, 2026 at 7:40:00 AM'
|
|
19
|
+
datetime_medium = 'Oct 19, 2026 at 7:40 AM'
|
|
20
|
+
datetime_short = '10/19/26, 7:40 AM'
|
|
21
|
+
feed_link = 'https://example.org/'
|
|
22
|
+
feed_link_title = 'Field & Frame'
|
|
23
|
+
preferred_link = 'https://example.org/2026/10/ridge-at-first-light/'
|
|
24
|
+
time_long = '7:40:00 AM PDT'
|
|
25
|
+
time_medium = '7:40:00 AM'
|
|
26
|
+
time_short = '7:40 AM'
|
|
27
|
+
title = 'The ridge at first light'
|
|
28
|
+
image_url = 'https://example.org/media/2026/10/ridge-poster.jpg'
|
|
29
|
+
body = '''
|
|
30
|
+
<p>We left the trailhead at four, which is the only way to reach the ridge before the light does. The forecast promised clear skies and delivered a lid of low cloud that burned off, all at once, about ten minutes after sunrise.</p>
|
|
31
|
+
<p><img src="https://example.org/media/2026/10/ridge-wide.jpg" width="2000" height="1000" alt="The ridge from the saddle, under cloud"></p>
|
|
32
|
+
<p>The feed lists everything we shot as enclosures. The wide view above is in the post itself, so the reader shows it once. The rest follow the post: two photographs, the timelapse, and the video walk-through from the channel.</p>
|
|
33
|
+
<p>Getting there: the saddle is four miles and 2,600 feet from the upper trailhead. The last half mile is loose scree, slow going in the dark, and worth a helmet if anyone is above you. Bring more water than seems reasonable; there is none after the first creek crossing.</p>
|
|
34
|
+
<p>Next month: the same ridge under the first snow, if the road stays open long enough to reach it.</p>
|
|
35
|
+
<p>The PDF trail map rides along as an enclosure too, though there is nothing to show for it in the article.</p>
|
|
36
|
+
'''
|
|
37
|
+
|
|
38
|
+
[[attachments]]
|
|
39
|
+
url = 'https://example.org/media/2026/10/ridge-wide.jpg'
|
|
40
|
+
mime_type = 'image/jpeg'
|
|
41
|
+
width = 2000
|
|
42
|
+
height = 1000
|
|
43
|
+
medium = 'image'
|
|
44
|
+
|
|
45
|
+
[[attachments]]
|
|
46
|
+
url = 'https://example.org/media/2026/10/cairn.jpg'
|
|
47
|
+
mime_type = 'image/jpeg'
|
|
48
|
+
title = 'The cairn at the false summit, with the real one behind it.'
|
|
49
|
+
width = 1600
|
|
50
|
+
height = 1067
|
|
51
|
+
medium = 'image'
|
|
52
|
+
|
|
53
|
+
[[attachments]]
|
|
54
|
+
url = 'https://example.org/media/2026/10/larch.jpg'
|
|
55
|
+
title = 'Larches turning on the north slope.'
|
|
56
|
+
width = 1000
|
|
57
|
+
height = 1500
|
|
58
|
+
|
|
59
|
+
[[attachments]]
|
|
60
|
+
url = 'https://example.org/media/2026/10/ridge-timelapse.mp4'
|
|
61
|
+
mime_type = 'video/mp4'
|
|
62
|
+
title = 'Timelapse: the cloud lid lifting, 40 minutes in 30 seconds.'
|
|
63
|
+
size_in_bytes = 48210944
|
|
64
|
+
duration_in_seconds = 30
|
|
65
|
+
width = 1920
|
|
66
|
+
height = 1080
|
|
67
|
+
medium = 'video'
|
|
68
|
+
|
|
69
|
+
[[attachments]]
|
|
70
|
+
url = 'https://www.youtube.com/v/Xr2kTq9mZ4c?version=3'
|
|
71
|
+
mime_type = 'application/x-shockwave-flash'
|
|
72
|
+
width = 640
|
|
73
|
+
height = 390
|
|
74
|
+
|
|
75
|
+
[[attachments]]
|
|
76
|
+
url = 'https://example.org/media/2026/10/ridge-trail-map.pdf'
|
|
77
|
+
mime_type = 'application/pdf'
|
|
78
|
+
size_in_bytes = 2310144
|
|
79
|
+
medium = 'document'
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# nnw-theme catalog fixture: podcast (catalog version 1)
|
|
2
|
+
# Markup: a podcast episode as NetNewsWire renders it from RSS, captured 2026-10: show
|
|
3
|
+
# notes in the body, an <enclosure> for the episode's audio, and the episode's
|
|
4
|
+
# itunes:image as the article image. Modeled on ATP 311 in NetNewsWire's own tests.
|
|
5
|
+
# Text and links are synthetic; the structure and the enclosure's fields are real.
|
|
6
|
+
# Exercises: the [[enclosures]] audio player and its caption (title · duration · size),
|
|
7
|
+
# and the article image NetNewsWire shows as an enclosure when nothing else is visual.
|
|
8
|
+
# Enclosures need NetNewsWire with enclosure support (upstream main, October 2026).
|
|
9
|
+
# Refresh: npx nnw-theme@2 fixture add podcast --force
|
|
10
|
+
|
|
11
|
+
byline = 'Morgan Hale, Priya Nandakumar, Theo Brandt'
|
|
12
|
+
date_long = 'October 22, 2026'
|
|
13
|
+
date_medium = 'Oct 22, 2026'
|
|
14
|
+
date_short = '10/22/26'
|
|
15
|
+
dateline_style = 'articleDateline'
|
|
16
|
+
datetime_long = 'October 22, 2026 at 6:15:00 PM'
|
|
17
|
+
datetime_medium = 'Oct 22, 2026 at 6:15 PM'
|
|
18
|
+
datetime_short = '10/22/26, 6:15 PM'
|
|
19
|
+
feed_link = 'https://example.org/podcast/'
|
|
20
|
+
feed_link_title = 'Three Desks Over'
|
|
21
|
+
preferred_link = 'https://example.org/podcast/311'
|
|
22
|
+
time_long = '6:15:00 PM PDT'
|
|
23
|
+
time_medium = '6:15:00 PM'
|
|
24
|
+
time_short = '6:15 PM'
|
|
25
|
+
title = '311: The Long One'
|
|
26
|
+
image_url = 'https://example.org/podcast/artwork/311-artwork.jpg'
|
|
27
|
+
body = '''
|
|
28
|
+
<p>The follow-up runs long, the main topic runs longer, and an After Show question finally gets the answer it deserves.</p>
|
|
29
|
+
<ul>
|
|
30
|
+
<li>Follow-up:<ul>
|
|
31
|
+
<li><a href="https://example.org/notes/keyboard-repair">The keyboard repair program</a>, again</li>
|
|
32
|
+
<li>Listener mail about <a href="https://example.org/notes/backups">offsite backups</a></li>
|
|
33
|
+
</ul></li>
|
|
34
|
+
<li><a href="https://example.org/notes/video-calls">Video calls that join themselves</a></li>
|
|
35
|
+
<li><a href="https://example.org/notes/app-review">What app review is for</a></li>
|
|
36
|
+
<li>Post-show: <a href="https://example.org/notes/coffee">the coffee grinder that won’t quit</a></li>
|
|
37
|
+
</ul>
|
|
38
|
+
<p>Sponsored by:</p>
|
|
39
|
+
<ul>
|
|
40
|
+
<li><a href="https://example.org/sponsors/notebooks">Field Notebooks</a>: Paper that keeps up. Use code <strong>DESKS</strong> for 20% off.</li>
|
|
41
|
+
<li><a href="https://example.org/sponsors/hosting">Quiet Hosting</a>: Your site, faster, with nothing to configure.</li>
|
|
42
|
+
</ul>
|
|
43
|
+
<p>Become a member for ad-free episodes and our <a href="https://example.org/podcast/bootleg">bootleg feed</a>, with every show’s raw recording.</p>
|
|
44
|
+
'''
|
|
45
|
+
|
|
46
|
+
[[attachments]]
|
|
47
|
+
url = 'https://example.org/podcast/audio/tdo311.mp3'
|
|
48
|
+
mime_type = 'audio/mpeg'
|
|
49
|
+
title = '311: The Long One'
|
|
50
|
+
size_in_bytes = 54919661
|
|
51
|
+
duration_in_seconds = 6849
|
|
52
|
+
medium = 'audio'
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Enclosure checks, evaluated in each rendered page after its screenshot. Returns a
|
|
2
|
+
// list of failure messages. NetNewsWire writes each enclosure as
|
|
3
|
+
// div.nnw-enclosure > (img | video | audio | iframe), with an optional
|
|
4
|
+
// div.nnw-enclosure-caption, inside one div.nnw-enclosures: at [[enclosures]] in the
|
|
5
|
+
// template, or after the body when the template has no such macro. A theme script
|
|
6
|
+
// may move an enclosure elsewhere, so each is found wherever it ended up.
|
|
7
|
+
() => {
|
|
8
|
+
const failures = [];
|
|
9
|
+
const fail = (message) => failures.push(message);
|
|
10
|
+
const width = document.documentElement.clientWidth;
|
|
11
|
+
const px = (value) => Math.round(value);
|
|
12
|
+
const size = (value) => {
|
|
13
|
+
const number = Number(value);
|
|
14
|
+
return Number.isFinite(number) && number > 0 ? number : 0;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const enclosures = [...document.querySelectorAll(".nnw-enclosure")];
|
|
18
|
+
enclosures.forEach((enclosure, index) => {
|
|
19
|
+
const media = enclosure.querySelector(":scope > :is(img, video, audio, iframe)");
|
|
20
|
+
if (!media) return;
|
|
21
|
+
const kind =
|
|
22
|
+
media.localName === "iframe"
|
|
23
|
+
? "video player"
|
|
24
|
+
: {
|
|
25
|
+
img: "image",
|
|
26
|
+
video: "video",
|
|
27
|
+
audio: "audio player",
|
|
28
|
+
}[media.localName];
|
|
29
|
+
const label = `enclosure ${index + 1} (${kind})`;
|
|
30
|
+
const box = media.getBoundingClientRect();
|
|
31
|
+
if (
|
|
32
|
+
!media.getClientRects().length ||
|
|
33
|
+
box.width < 1 ||
|
|
34
|
+
box.height < 1 ||
|
|
35
|
+
getComputedStyle(media).visibility !== "visible"
|
|
36
|
+
) {
|
|
37
|
+
fail(`${label}: not shown`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (box.left < -1 || box.right > width + 1) {
|
|
41
|
+
fail(`${label}: spans ${px(box.left)}–${px(box.right)}px, outside the ${width}px window`);
|
|
42
|
+
}
|
|
43
|
+
if (media.localName === "audio") {
|
|
44
|
+
// Narrower than this, WebKit drops the scrubber and the player can't seek.
|
|
45
|
+
const least = Math.min(240, width * 0.6);
|
|
46
|
+
if (box.width < least) fail(`${label}: only ${px(box.width)}px wide`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (media.localName === "iframe") {
|
|
50
|
+
// The player letterboxes its video in any box, so only a squashed or
|
|
51
|
+
// towering frame is wrong; its width and height are the old Flash player's.
|
|
52
|
+
const ratio = box.height / box.width;
|
|
53
|
+
if (ratio < 0.45 || ratio > 1.05) {
|
|
54
|
+
fail(`${label}: drawn ${px(box.width)}×${px(box.height)}, not a video's shape`);
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const [w, h] =
|
|
59
|
+
media.localName === "img" && media.naturalWidth
|
|
60
|
+
? [media.naturalWidth, media.naturalHeight]
|
|
61
|
+
: [size(media.getAttribute("width")), size(media.getAttribute("height"))];
|
|
62
|
+
if (!w || !h) return;
|
|
63
|
+
const expected = (box.width * h) / w;
|
|
64
|
+
if (Math.abs(box.height - expected) > Math.max(2, expected * 0.03)) {
|
|
65
|
+
fail(
|
|
66
|
+
`${label}: drawn ${px(box.width)}×${px(box.height)}, distorting its ${w}×${h} shape`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// A caption must stand out from what it sits on, as footnote numerals must.
|
|
72
|
+
const rgba = (value) => {
|
|
73
|
+
const n = (value.match(/[\d.]+/g) || []).map(Number);
|
|
74
|
+
return n.length ? [n[0], n[1], n[2], n.length > 3 ? n[3] : 1] : [0, 0, 0, 0];
|
|
75
|
+
};
|
|
76
|
+
const backdrop = (element) => {
|
|
77
|
+
const layers = [];
|
|
78
|
+
for (let node = element; node; node = node.parentElement) {
|
|
79
|
+
const layer = rgba(getComputedStyle(node).backgroundColor);
|
|
80
|
+
if (!layer[3]) continue;
|
|
81
|
+
layers.push(layer);
|
|
82
|
+
if (layer[3] === 1) break;
|
|
83
|
+
}
|
|
84
|
+
let base = layers.pop() || [255, 255, 255, 1];
|
|
85
|
+
while (layers.length) {
|
|
86
|
+
const top = layers.pop();
|
|
87
|
+
base = [0, 1, 2].map((i) => top[i] * top[3] + base[i] * (1 - top[3]));
|
|
88
|
+
}
|
|
89
|
+
return base.slice(0, 3);
|
|
90
|
+
};
|
|
91
|
+
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")];
|
|
93
|
+
for (const caption of captions) {
|
|
94
|
+
if (!caption.getClientRects().length || !caption.innerText.trim()) continue;
|
|
95
|
+
const name = JSON.stringify(caption.innerText.trim().slice(0, 40));
|
|
96
|
+
const glyphs = [caption, ...caption.querySelectorAll("*")].filter((element) =>
|
|
97
|
+
[...element.childNodes].some(
|
|
98
|
+
(node) => node.nodeType === Node.TEXT_NODE && node.data.trim(),
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
for (const glyph of glyphs) {
|
|
102
|
+
const color = rgba(getComputedStyle(glyph).color);
|
|
103
|
+
const ground = backdrop(glyph);
|
|
104
|
+
if (color[3] < 0.5 || distance(color, ground) <= 60) {
|
|
105
|
+
fail(`enclosure caption ${name}: text ${color.slice(0, 3)} is illegible on ${ground}`);
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const fontSize = Number.parseFloat(getComputedStyle(caption).fontSize);
|
|
110
|
+
if (fontSize < 10) fail(`enclosure caption ${name}: ${fontSize}px text is too small`);
|
|
111
|
+
}
|
|
112
|
+
return failures;
|
|
113
|
+
}
|
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
|
}
|
|
@@ -14,6 +14,12 @@ prescribed aesthetic.
|
|
|
14
14
|
- Check large-text macOS and iPhone renders for clipping, overlap, and lost hierarchy.
|
|
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
|
+
- 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.
|
|
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.
|
|
17
23
|
- Inspect a real WebKit screenshot, not only the live gallery or Chromium fallback.
|
|
18
24
|
- Promote `screenshots/theme-preview.png` only when the 16:10 macOS article image is
|
|
19
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`,
|
|
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
|
|
@@ -33,12 +33,66 @@ with `fixture add NAME --force`, which replaces any edits to the file.
|
|
|
33
33
|
NetNewsWire turns Substack footnotes into popovers on its own. Most WordPress
|
|
34
34
|
footnotes stay plain links until a theme script turns them into `a.footnote` markers.
|
|
35
35
|
|
|
36
|
+
## Enclosures
|
|
37
|
+
|
|
38
|
+
An article's enclosures are data, not markup: NetNewsWire renders them into the
|
|
39
|
+
`enclosures` macro, or after the body, from the article's attachments. A fixture lists
|
|
40
|
+
them after its other keys, in feed order, and may give the article's image:
|
|
41
|
+
|
|
42
|
+
```toml
|
|
43
|
+
image_url = "https://example.org/episode-art.jpg" # else thumbnail_url
|
|
44
|
+
|
|
45
|
+
[[attachments]]
|
|
46
|
+
url = "https://example.org/episode-311.mp3" # required
|
|
47
|
+
mime_type = "audio/mpeg"
|
|
48
|
+
title = "311: The Long One"
|
|
49
|
+
size_in_bytes = 54919661
|
|
50
|
+
duration_in_seconds = 6849
|
|
51
|
+
width = 1920 # images and video
|
|
52
|
+
height = 1080
|
|
53
|
+
medium = "audio" # image, audio, video, document, or executable
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`nnw-theme` renders them as the pinned NetNewsWire does; the `theme-format` guide
|
|
57
|
+
describes the markup. `fixture add podcast media-rss` adds fixtures that exercise
|
|
58
|
+
every kind. Previews stay offline: images and posters become same-size placeholders,
|
|
59
|
+
players lose their network source, a video with nothing to show gets a placeholder
|
|
60
|
+
poster at its size, and the YouTube player becomes a placeholder frame. A `media/NAME`
|
|
61
|
+
URL plays or shows the file from `fixtures/media/`, including audio and video for a
|
|
62
|
+
screenshot; keep such files tiny, since every page that renders the fixture embeds them.
|
|
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
|
+
|
|
36
87
|
## Your own articles
|
|
37
88
|
|
|
38
89
|
Prefer real articles for content a theme must handle. `npx nnw-theme@2 capture`
|
|
39
90
|
prints the steps for saving the selected article from a NetNewsWire debug build with
|
|
40
91
|
the `nnwdump` lldb command; it needs Xcode and a NetNewsWire clone, so the person
|
|
41
|
-
usually runs it.
|
|
92
|
+
usually runs it. It records the article's attachments and image as the keys above,
|
|
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
|
|
42
96
|
same-size placeholder and a network feed icon for a generated one; do not inline or
|
|
43
97
|
fetch the originals.
|
|
44
98
|
|
|
@@ -50,15 +104,21 @@ each is copied into every page that renders the fixture.
|
|
|
50
104
|
|
|
51
105
|
Footnote markers are the links NetNewsWire's `newsfoot.js` handles: `a.footnote`.
|
|
52
106
|
Its `main.js` adds that class to local `sup > a[href*='#fn']` links and to Substack's
|
|
53
|
-
`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
|
|
54
109
|
non-empty note with a unique id, stay legible on its background, match the other
|
|
55
110
|
markers' height, font size, and baseline offset, and open a popover with its note
|
|
56
|
-
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:
|
|
57
116
|
|
|
58
117
|
```toml
|
|
59
118
|
[expect.footnotes]
|
|
60
119
|
plain_links = ["#ordinary-sup-link"] # CSS selectors that must not become markers
|
|
61
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
|
|
62
122
|
|
|
63
123
|
[expect.footnotes.notes]
|
|
64
124
|
"1" = "First note." # marker text as rendered = note text
|
package/assets/guide/skill.md
CHANGED
|
@@ -20,7 +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
|
|
23
|
+
list` offers ready-made ones modeled on WordPress, Ghost, Substack, Medium, and
|
|
24
|
+
Mastodon, plus podcast and Media RSS enclosures.
|
|
24
25
|
|
|
25
26
|
Iterate with this loop:
|
|
26
27
|
|
|
@@ -39,13 +39,59 @@ comment early and shows the rest of it as text. `check` fails on them. NetNewsWi
|
|
|
39
39
|
`:root`; a theme with its own typography should not, because that shorthand resets
|
|
40
40
|
`font-family` and `line-height`.
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
## Enclosures
|
|
43
|
+
|
|
44
|
+
NetNewsWire shows an article's enclosures (RSS `<enclosure>` and Media RSS items) as
|
|
45
|
+
players and images. A template places them with the `enclosures` macro; one without it
|
|
46
|
+
gets them appended after the body, inside the body container, and its body is then
|
|
47
|
+
wrapped in an extra `<div>` (so the enclosures escape any tag the body leaves open).
|
|
48
|
+
That wrapper changes the body's children: a theme whose selectors rely on
|
|
49
|
+
`.articleBody > …` should place the macro itself. NetNewsWire tests for the literal
|
|
50
|
+
macro text anywhere in `template.html`, comments included.
|
|
51
|
+
|
|
52
|
+
The markup is fixed and carries no inline styles:
|
|
53
|
+
|
|
54
|
+
```html
|
|
55
|
+
<div class="nnw-enclosures">
|
|
56
|
+
<div class="nnw-enclosure nnw-enclosure-image"><img src="…" alt="" width="…" height="…"></div>
|
|
57
|
+
<div class="nnw-enclosure nnw-enclosure-audio"><audio controls preload="none" src="…"></audio>
|
|
58
|
+
<div class="nnw-enclosure-caption">Title · 1:54:09 · 54.9 MB</div></div>
|
|
59
|
+
<div class="nnw-enclosure nnw-enclosure-video"><video controls preload="metadata" playsinline …></video></div>
|
|
60
|
+
<div class="nnw-enclosure nnw-enclosure-video"><iframe src="https://www.youtube-nocookie.com/embed/…" …></iframe></div>
|
|
61
|
+
</div>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Images and video carry the feed's `width` and `height` when it gives both; a caption
|
|
65
|
+
appears only when there is text for it. The audio caption joins the title (else the
|
|
66
|
+
file name), duration, and size with ` · `. A YouTube channel enclosure becomes the
|
|
67
|
+
iframe player. Enclosures whose URL already appears in the body are skipped, and
|
|
68
|
+
documents are never shown. When nothing visual remains, neither an enclosure nor an
|
|
69
|
+
image or video in the body, the article's image (or thumbnail) leads the list as an
|
|
70
|
+
image enclosure: a podcast episode's artwork above its player. A video uses that image
|
|
71
|
+
as its poster.
|
|
72
|
+
|
|
73
|
+
NetNewsWire releases before enclosure support leave the macro as literal text in
|
|
74
|
+
every article, so a theme that places it should hide its container when it holds no
|
|
75
|
+
enclosures, for example `<div class="my-enclosures">[[enclosures]]</div>` with
|
|
76
|
+
`.my-enclosures:not(:has(.nnw-enclosures)) { display: none; }`. `check` renders the
|
|
77
|
+
article fixture that way too and fails if the text shows. With enclosures in a
|
|
78
|
+
fixture, `check` also fails an enclosure image, player, or video that is hidden,
|
|
79
|
+
leaves the window, or is drawn out of its shape, an audio player too narrow to seek,
|
|
80
|
+
and an illegible caption.
|
|
81
|
+
|
|
82
|
+
## Pinned NetNewsWire
|
|
83
|
+
|
|
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
|
|
44
88
|
iOS page skeletons, and `main.js`, `main_mac.js`, `main_ios.js`, and `newsfoot.js`.
|
|
45
89
|
These files ship inside the `nnw-theme` package, verified by SHA-256, so previews work
|
|
46
90
|
offline. iPhone and iPad share the iOS skeleton. WebKit native
|
|
47
91
|
color-scheme emulation controls light/dark captures; CSS is not rewritten.
|
|
48
92
|
|
|
93
|
+
## Scripts and resources
|
|
94
|
+
|
|
49
95
|
Inline scripts in `template.html` are supported. External scripts and stylesheets are
|
|
50
96
|
rejected. Remote theme-owned fonts, images, audio, or video require an explicit local
|
|
51
97
|
warning override and are still blocked during checks. The article must remain useful
|
|
@@ -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;
|