nnw-theme 2.2.0 → 2.3.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 +1 -1
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/assets/catalog/media-rss.toml +79 -0
- package/assets/catalog/podcast.toml +52 -0
- package/assets/enclosures.js +113 -0
- package/assets/guide/design-checklist.md +2 -0
- package/assets/guide/fixtures.md +31 -2
- package/assets/guide/skill.md +2 -1
- package/assets/guide/theme-format.md +46 -2
- package/assets/netnewswire/Shared/core.css +24 -0
- package/assets/netnewswire/netnewswire.json +3 -3
- package/dist/browser.js +19 -5
- package/dist/commands/check.js +4 -2
- package/dist/commands.js +2 -0
- package/dist/enclosures.js +299 -0
- package/dist/render.js +114 -9
- package/lldb/nnwdump.py +75 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ version, name it: `npx nnw-theme@2.0.0 check`.
|
|
|
35
35
|
| `check` | Validate and package, then render and test every case in WebKit |
|
|
36
36
|
| `screenshot` | Check one case (any fixture and platform) and save its image; `--card` crops to the marketplace card, `--promote` makes it the card |
|
|
37
37
|
| `package` | Validate and build `dist/<Name>.nnwtheme.zip` |
|
|
38
|
-
| `fixture list` / `fixture add <name>…` | Add ready-made fixtures modeled on WordPress, Ghost, Substack, and Medium markup |
|
|
38
|
+
| `fixture list` / `fixture add <name>…` | Add ready-made fixtures modeled on WordPress, Ghost, Substack, and Medium markup, and on podcast and Media RSS enclosures |
|
|
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), `main` branch,
|
|
5
|
+
commit `70c5dce656487bdab9e0226c318acc1a15071cf4`, 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,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
|
+
}
|
|
@@ -14,6 +14,8 @@ 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.
|
|
17
19
|
- Inspect a real WebKit screenshot, not only the live gallery or Chromium fallback.
|
|
18
20
|
- Promote `screenshots/theme-preview.png` only when the 16:10 macOS article image is
|
|
19
21
|
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-four for all six. 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,41 @@ 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
|
+
|
|
36
64
|
## Your own articles
|
|
37
65
|
|
|
38
66
|
Prefer real articles for content a theme must handle. `npx nnw-theme@2 capture`
|
|
39
67
|
prints the steps for saving the selected article from a NetNewsWire debug build with
|
|
40
68
|
the `nnwdump` lldb command; it needs Xcode and a NetNewsWire clone, so the person
|
|
41
|
-
usually runs it.
|
|
69
|
+
usually runs it. It records the article's attachments and image as the keys above,
|
|
70
|
+
not NetNewsWire's rendered enclosures, so any template previews them. Rendering swaps every image that would load over the network for a
|
|
42
71
|
same-size placeholder and a network feed icon for a generated one; do not inline or
|
|
43
72
|
fetch the originals.
|
|
44
73
|
|
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 Medium
|
|
23
|
+
list` offers ready-made ones modeled on WordPress, Ghost, Substack, and Medium, plus
|
|
24
|
+
podcast and Media RSS enclosures.
|
|
24
25
|
|
|
25
26
|
Iterate with this loop:
|
|
26
27
|
|
|
@@ -39,13 +39,57 @@ 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 `main` commit
|
|
85
|
+
`70c5dce656487bdab9e0226c318acc1a15071cf4`, the first with enclosure support. It uses upstream `core.css`, macOS and
|
|
44
86
|
iOS page skeletons, and `main.js`, `main_mac.js`, `main_ios.js`, and `newsfoot.js`.
|
|
45
87
|
These files ship inside the `nnw-theme` package, verified by SHA-256, so previews work
|
|
46
88
|
offline. iPhone and iPad share the iOS skeleton. WebKit native
|
|
47
89
|
color-scheme emulation controls light/dark captures; CSS is not rewritten.
|
|
48
90
|
|
|
91
|
+
## Scripts and resources
|
|
92
|
+
|
|
49
93
|
Inline scripts in `template.html` are supported. External scripts and stylesheets are
|
|
50
94
|
rejected. Remote theme-owned fonts, images, audio, or video require an explicit local
|
|
51
95
|
warning override and are still blocked during checks. The article must remain useful
|
|
@@ -63,6 +63,30 @@ img.twemoji, img.emoji {
|
|
|
63
63
|
width: auto;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/* Enclosures: audio and video players and images after the article body */
|
|
67
|
+
|
|
68
|
+
.nnw-enclosures {
|
|
69
|
+
margin-top: 1.5em;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.nnw-enclosure {
|
|
73
|
+
margin: 1em 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.nnw-enclosure audio {
|
|
77
|
+
width: 100%;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.nnw-enclosure video,
|
|
81
|
+
.nnw-enclosure img {
|
|
82
|
+
height: auto;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.nnw-enclosure-caption {
|
|
86
|
+
margin: 0.5em 0 0;
|
|
87
|
+
font-size: 0.9em;
|
|
88
|
+
}
|
|
89
|
+
|
|
66
90
|
/* Hide the external link at the bottom of Daring Fireball posts */
|
|
67
91
|
|
|
68
92
|
.x-netnewswire-hide {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release": "
|
|
3
|
-
"commit": "
|
|
2
|
+
"release": "main-70c5dce",
|
|
3
|
+
"commit": "70c5dce656487bdab9e0226c318acc1a15071cf4",
|
|
4
4
|
"files": [
|
|
5
5
|
{
|
|
6
6
|
"destination": "LICENSE",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
{
|
|
21
21
|
"destination": "Shared/core.css",
|
|
22
22
|
"source": "Shared/Article Rendering/core.css",
|
|
23
|
-
"sha256": "
|
|
23
|
+
"sha256": "3cdc76a22a6bbcd1aeed6eec4611598bb64a457cade3e1553e7b88f03bc78ef9"
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
"destination": "Shared/main.js",
|
package/dist/browser.js
CHANGED
|
@@ -10,6 +10,8 @@ import { extname, join, normalize, sep } from "node:path";
|
|
|
10
10
|
import { packagePath, ThemeError } from "./project.js";
|
|
11
11
|
import { pyReprList } from "./pyformat.js";
|
|
12
12
|
const FOOTNOTE_CHECK = readFileSync(packagePath("assets", "footnotes.js"), "utf8");
|
|
13
|
+
const ENCLOSURE_CHECK = readFileSync(packagePath("assets", "enclosures.js"), "utf8");
|
|
14
|
+
const ENCLOSURES_MACRO = "[[enclosures]]";
|
|
13
15
|
const LIVE_RELOAD = readFileSync(packagePath("assets", "live-reload.js"), "utf8");
|
|
14
16
|
const NOT_INSTALLED = "WebKit is not installed; run `npx nnw-theme@2 setup`";
|
|
15
17
|
const CONTENT_TYPES = {
|
|
@@ -148,6 +150,10 @@ export function failures(state) {
|
|
|
148
150
|
found.push("article content is missing or unreadable");
|
|
149
151
|
if (state.unresolved)
|
|
150
152
|
found.push("unresolved theme macro");
|
|
153
|
+
if (state.literalEnclosures) {
|
|
154
|
+
found.push("[[enclosures]] shows as text in NetNewsWire releases without enclosure support; " +
|
|
155
|
+
"hide its element when it holds no .nnw-enclosures");
|
|
156
|
+
}
|
|
151
157
|
if (state.overflow)
|
|
152
158
|
found.push("horizontal document overflow");
|
|
153
159
|
if (state.brokenImages.length)
|
|
@@ -156,6 +162,7 @@ export function failures(state) {
|
|
|
156
162
|
found.push(`images drawn at zero size: ${pyReprList(state.collapsedImages)}`);
|
|
157
163
|
if (state.unloadedImages.length)
|
|
158
164
|
found.push(`images still loading: ${pyReprList(state.unloadedImages)}`);
|
|
165
|
+
found.push(...(state.enclosures ?? []));
|
|
159
166
|
found.push(...(state.footnotes ?? []));
|
|
160
167
|
if (state.blocked.length)
|
|
161
168
|
found.push(`external requests: ${pyReprList(state.blocked)}`);
|
|
@@ -178,16 +185,19 @@ async function loadImages(timeout) {
|
|
|
178
185
|
new Promise((resolve) => setTimeout(resolve, timeout)),
|
|
179
186
|
]);
|
|
180
187
|
}
|
|
181
|
-
// Runs in the page after its screenshot.
|
|
182
|
-
|
|
188
|
+
// Runs in the page after its screenshot. With olderNetNewsWire, the template's
|
|
189
|
+
// [[enclosures]] is expected in the markup and fails only if it shows as text.
|
|
190
|
+
function pageState({ olderNetNewsWire, macro }) {
|
|
183
191
|
const article = document.querySelector(".articleBody");
|
|
184
192
|
const root = document.documentElement;
|
|
193
|
+
const html = document.body.innerHTML;
|
|
185
194
|
return {
|
|
186
195
|
article: Boolean(article),
|
|
187
196
|
textLength: (article?.innerText || "").trim().length,
|
|
188
197
|
// Body only: macOS CSS legitimately keeps the font-size macro literal. Match
|
|
189
198
|
// whole macro names, so a theme script's regex such as /[[(]/ is no macro.
|
|
190
|
-
unresolved: /\[\[[A-Za-z0-9_-]+\]\]/.test(
|
|
199
|
+
unresolved: /\[\[[A-Za-z0-9_-]+\]\]/.test(olderNetNewsWire ? html.replaceAll(macro, "") : html),
|
|
200
|
+
literalEnclosures: olderNetNewsWire && document.body.innerText.includes(macro),
|
|
191
201
|
overflow: root.scrollWidth > root.clientWidth + 1,
|
|
192
202
|
brokenImages: [...document.images]
|
|
193
203
|
.filter((image) => image.complete && image.naturalWidth === 0)
|
|
@@ -284,10 +294,14 @@ async function checkPage(browser, origin, url, target, screenshot, footnotes) {
|
|
|
284
294
|
await page.goto(url, { waitUntil: "networkidle" });
|
|
285
295
|
await page.evaluate(loadImages, 10_000);
|
|
286
296
|
await page.screenshot({ path: screenshot, fullPage: !target.card });
|
|
287
|
-
const state = await page.evaluate(pageState
|
|
297
|
+
const state = await page.evaluate(pageState, {
|
|
298
|
+
olderNetNewsWire: target.olderNetNewsWire,
|
|
299
|
+
macro: ENCLOSURES_MACRO,
|
|
300
|
+
});
|
|
301
|
+
const enclosures = (await page.evaluate(`(${ENCLOSURE_CHECK})()`));
|
|
288
302
|
// Last: it clicks footnotes open, so it must not disturb the screenshot.
|
|
289
303
|
const found = (await page.evaluate(`(${FOOTNOTE_CHECK})(${JSON.stringify(footnotes)})`));
|
|
290
|
-
return { ...state, footnotes: found, blocked, pageErrors };
|
|
304
|
+
return { ...state, enclosures, footnotes: found, blocked, pageErrors };
|
|
291
305
|
}
|
|
292
306
|
finally {
|
|
293
307
|
await context.close();
|
package/dist/commands/check.js
CHANGED
|
@@ -6,7 +6,7 @@ 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, renderSite, writeGallery, } from "../render.js";
|
|
9
|
+
import { checkTargets, extraFixtures, placesEnclosures, 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";
|
|
@@ -34,7 +34,9 @@ export default async function check({ values }) {
|
|
|
34
34
|
const theme = findTheme(root);
|
|
35
35
|
console.log(`nnw-theme ${version()}`);
|
|
36
36
|
printWarnings(staleStubs(root));
|
|
37
|
-
const targets = checkTargets(extraFixtures(root)
|
|
37
|
+
const targets = checkTargets(extraFixtures(root), {
|
|
38
|
+
enclosuresMacro: placesEnclosures(theme),
|
|
39
|
+
});
|
|
38
40
|
const expected = expectations(root, targets);
|
|
39
41
|
const progress = new CheckProgress(targets.length);
|
|
40
42
|
progress.status("Validating and packaging the theme…");
|
package/dist/commands.js
CHANGED
|
@@ -19,6 +19,8 @@ export const BUILT_IN_FIXTURE_NAMES = ["article", "kitchen-sink"];
|
|
|
19
19
|
export const CATALOG_FIXTURES = {
|
|
20
20
|
ghost: "Ghost cards, galleries, and lazy images",
|
|
21
21
|
medium: "Medium figures, unsized images, and footer",
|
|
22
|
+
"media-rss": "Image, video, and YouTube enclosures with captions",
|
|
23
|
+
podcast: "Podcast audio enclosure and episode artwork",
|
|
22
24
|
substack: "Substack images, widgets, and footnotes",
|
|
23
25
|
wordpress: "WordPress block and classic editor markup",
|
|
24
26
|
};
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// A port of NetNewsWire's enclosure rendering: Attachment.displayMedium
|
|
2
|
+
// (Modules/Articles/Sources/Articles/Attachment.swift) and the [[enclosures]] HTML
|
|
3
|
+
// (Shared/Article Rendering/ArticleRenderer+Enclosures.swift), at the pinned commit.
|
|
4
|
+
// Keep it line for line with the Swift, so a diff of the two stays easy to review.
|
|
5
|
+
import { ThemeError } from "./project.js";
|
|
6
|
+
export const MEDIA = ["image", "audio", "video", "document", "executable"];
|
|
7
|
+
const STRING_KEYS = ["url", "mime_type", "title"];
|
|
8
|
+
const INTEGER_KEYS = ["size_in_bytes", "duration_in_seconds", "width", "height"];
|
|
9
|
+
const ATTACHMENT_KEYS = [...STRING_KEYS, ...INTEGER_KEYS, "medium"];
|
|
10
|
+
function isTable(value) {
|
|
11
|
+
return (!!value && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A fixture's [[attachments]], validated. Like Attachment(url:…), an empty url makes
|
|
15
|
+
* no attachment, so NetNewsWire never sees it.
|
|
16
|
+
*/
|
|
17
|
+
export function fixtureAttachments(fixture, name) {
|
|
18
|
+
const value = fixture.attachments;
|
|
19
|
+
if (value === undefined)
|
|
20
|
+
return [];
|
|
21
|
+
if (!Array.isArray(value) || !value.every(isTable)) {
|
|
22
|
+
throw new ThemeError(`${name}: attachments must be [[attachments]] tables`);
|
|
23
|
+
}
|
|
24
|
+
return value.flatMap((table, index) => {
|
|
25
|
+
const where = `${name}: attachments[${index}]`;
|
|
26
|
+
const unknown = Object.keys(table)
|
|
27
|
+
.filter((key) => !ATTACHMENT_KEYS.includes(key))
|
|
28
|
+
.sort();
|
|
29
|
+
if (unknown.length)
|
|
30
|
+
throw new ThemeError(`${where}: unknown key(s): ${unknown.join(", ")}`);
|
|
31
|
+
for (const key of STRING_KEYS) {
|
|
32
|
+
if (key in table && typeof table[key] !== "string") {
|
|
33
|
+
throw new ThemeError(`${where}: ${key} must be a string`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (const key of INTEGER_KEYS) {
|
|
37
|
+
if (key in table && !Number.isSafeInteger(table[key])) {
|
|
38
|
+
throw new ThemeError(`${where}: ${key} must be an integer`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if ("medium" in table && !MEDIA.includes(table.medium)) {
|
|
42
|
+
throw new ThemeError(`${where}: medium must be one of ${MEDIA.join(", ")}`);
|
|
43
|
+
}
|
|
44
|
+
if (typeof table.url !== "string")
|
|
45
|
+
throw new ThemeError(`${where}: url is required`);
|
|
46
|
+
return table.url ? [table] : [];
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** A fixture's image_url, else its thumbnail_url: Article.imageLink ?? thumbnailLink. */
|
|
50
|
+
export function fixtureImageURL(fixture, name) {
|
|
51
|
+
for (const key of ["image_url", "thumbnail_url"]) {
|
|
52
|
+
const value = fixture[key];
|
|
53
|
+
if (value === undefined)
|
|
54
|
+
continue;
|
|
55
|
+
if (typeof value !== "string")
|
|
56
|
+
throw new ThemeError(`${name}: ${key} must be a string`);
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const AUDIO_EXTENSIONS = new Set(["mp3", "m4a", "aac", "wav", "ogg", "oga", "opus", "flac"]);
|
|
62
|
+
const VIDEO_EXTENSIONS = new Set(["mp4", "m4v", "mov", "webm"]);
|
|
63
|
+
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "webp", "heic", "avif"]);
|
|
64
|
+
const VISUAL_ELEMENT_TAGS = ["<img", "<picture", "<video", "<iframe"];
|
|
65
|
+
const YOUTUBE_FLASH_PLAYER_PATH_PREFIX = "/v/";
|
|
66
|
+
const YOUTUBE_EMBED_URL_PREFIX = "https://www.youtube-nocookie.com/embed/";
|
|
67
|
+
/** Foundation's URL(string:), enough for host and path: relative URLs have no host. */
|
|
68
|
+
function parseURL(value) {
|
|
69
|
+
try {
|
|
70
|
+
const url = new URL(value);
|
|
71
|
+
return { host: url.hostname, path: url.pathname };
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
if (/[\s<>"]/.test(value))
|
|
75
|
+
return undefined;
|
|
76
|
+
return { host: "", path: value.replace(/[?#].*$/s, "") };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function lastPathComponent(path) {
|
|
80
|
+
const parts = path.split("/").filter(Boolean);
|
|
81
|
+
return parts.at(-1) ?? (path.startsWith("/") ? "/" : "");
|
|
82
|
+
}
|
|
83
|
+
function pathExtension(path) {
|
|
84
|
+
const last = lastPathComponent(path);
|
|
85
|
+
const dot = last.lastIndexOf(".");
|
|
86
|
+
return dot > 0 ? last.slice(dot + 1) : "";
|
|
87
|
+
}
|
|
88
|
+
/** How the article view shows an attachment, or undefined for anything else. */
|
|
89
|
+
export function displayMedium(attachment) {
|
|
90
|
+
const mimeType = attachment.mime_type?.toLowerCase();
|
|
91
|
+
if (mimeType !== undefined) {
|
|
92
|
+
if (mimeType.startsWith("audio/"))
|
|
93
|
+
return "audio";
|
|
94
|
+
if (mimeType.startsWith("video/"))
|
|
95
|
+
return "video";
|
|
96
|
+
if (mimeType.startsWith("image/"))
|
|
97
|
+
return "image";
|
|
98
|
+
}
|
|
99
|
+
const { medium } = attachment;
|
|
100
|
+
if (medium === "audio" || medium === "video" || medium === "image")
|
|
101
|
+
return medium;
|
|
102
|
+
const path = parseURL(attachment.url)?.path;
|
|
103
|
+
const extension = path === undefined ? "" : pathExtension(path).toLowerCase();
|
|
104
|
+
if (!extension)
|
|
105
|
+
return undefined;
|
|
106
|
+
if (AUDIO_EXTENSIONS.has(extension))
|
|
107
|
+
return "audio";
|
|
108
|
+
if (VIDEO_EXTENSIONS.has(extension))
|
|
109
|
+
return "video";
|
|
110
|
+
if (IMAGE_EXTENSIONS.has(extension))
|
|
111
|
+
return "image";
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
/** RSCore's escapingSpecialXMLCharacters: drops characters XML cannot hold. */
|
|
115
|
+
export function escapeXML(value) {
|
|
116
|
+
let escaped = "";
|
|
117
|
+
for (const character of value) {
|
|
118
|
+
const code = character.codePointAt(0) ?? 0;
|
|
119
|
+
if (character === "&")
|
|
120
|
+
escaped += "&";
|
|
121
|
+
else if (character === "<")
|
|
122
|
+
escaped += "<";
|
|
123
|
+
else if (character === ">")
|
|
124
|
+
escaped += ">";
|
|
125
|
+
else if (character === '"')
|
|
126
|
+
escaped += """;
|
|
127
|
+
else if (character === "'")
|
|
128
|
+
escaped += "'";
|
|
129
|
+
else if (code === 0x9 ||
|
|
130
|
+
code === 0xa ||
|
|
131
|
+
code === 0xd ||
|
|
132
|
+
(code >= 0x20 && code <= 0xd7ff) ||
|
|
133
|
+
(code >= 0xe000 && code <= 0xfffd) ||
|
|
134
|
+
code >= 0x10000) {
|
|
135
|
+
escaped += character;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return escaped;
|
|
139
|
+
}
|
|
140
|
+
/** ArticleRenderingSpecialCases.youTubeEmbedURL(forAttachmentURL:). */
|
|
141
|
+
export function youTubeEmbedURL(value) {
|
|
142
|
+
const url = parseURL(value);
|
|
143
|
+
const host = url?.host.toLowerCase().replace(/^www\./, "");
|
|
144
|
+
if (!url || !host || !(host === "youtube.com" || host.endsWith(".youtube.com"))) {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
if (!url.path.startsWith(YOUTUBE_FLASH_PLAYER_PATH_PREFIX))
|
|
148
|
+
return undefined;
|
|
149
|
+
const videoID = url.path.slice(YOUTUBE_FLASH_PLAYER_PATH_PREFIX.length);
|
|
150
|
+
if (!videoID || !/^[A-Za-z0-9_-]+$/.test(videoID))
|
|
151
|
+
return undefined;
|
|
152
|
+
return YOUTUBE_EMBED_URL_PREFIX + videoID;
|
|
153
|
+
}
|
|
154
|
+
/** Foundation's ByteCountFormatter.string(fromByteCount:countStyle: .file), en_US. */
|
|
155
|
+
export function byteCount(bytes) {
|
|
156
|
+
if (bytes === 0)
|
|
157
|
+
return "Zero KB";
|
|
158
|
+
if (Math.abs(bytes) < 1000)
|
|
159
|
+
return `${bytes} ${Math.abs(bytes) === 1 ? "byte" : "bytes"}`;
|
|
160
|
+
const units = ["KB", "MB", "GB", "TB", "PB", "EB"];
|
|
161
|
+
let value = bytes / 1000;
|
|
162
|
+
let unit = 0;
|
|
163
|
+
while (Math.abs(value) >= 1000 && unit < units.length - 1) {
|
|
164
|
+
value /= 1000;
|
|
165
|
+
unit += 1;
|
|
166
|
+
}
|
|
167
|
+
const digits = Math.min(unit, 2);
|
|
168
|
+
const text = value.toLocaleString("en-US", { maximumFractionDigits: digits });
|
|
169
|
+
return `${text} ${units[unit]}`;
|
|
170
|
+
}
|
|
171
|
+
function durationString(durationInSeconds) {
|
|
172
|
+
const hours = Math.trunc(durationInSeconds / 3600);
|
|
173
|
+
const minutes = Math.trunc((durationInSeconds % 3600) / 60);
|
|
174
|
+
const seconds = durationInSeconds % 60;
|
|
175
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
176
|
+
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
|
|
177
|
+
}
|
|
178
|
+
function fileName(value) {
|
|
179
|
+
const path = parseURL(value)?.path;
|
|
180
|
+
if (path === undefined)
|
|
181
|
+
return undefined;
|
|
182
|
+
const last = lastPathComponent(path);
|
|
183
|
+
if (!last || last === "/")
|
|
184
|
+
return undefined;
|
|
185
|
+
try {
|
|
186
|
+
return decodeURIComponent(last);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return last;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** Title (or file name), duration, and size, such as “Episode 311 · 1:54:09 · 54.9 MB”. */
|
|
193
|
+
function audioCaption(attachment) {
|
|
194
|
+
const components = [];
|
|
195
|
+
const name = attachment.title || fileName(attachment.url);
|
|
196
|
+
if (name)
|
|
197
|
+
components.push(name);
|
|
198
|
+
if (attachment.duration_in_seconds !== undefined) {
|
|
199
|
+
components.push(durationString(attachment.duration_in_seconds));
|
|
200
|
+
}
|
|
201
|
+
if (attachment.size_in_bytes !== undefined)
|
|
202
|
+
components.push(byteCount(attachment.size_in_bytes));
|
|
203
|
+
return components.join(" · ");
|
|
204
|
+
}
|
|
205
|
+
function dimensionAttributes(attachment) {
|
|
206
|
+
if (attachment.width === undefined || attachment.height === undefined)
|
|
207
|
+
return "";
|
|
208
|
+
return ` width="${attachment.width}" height="${attachment.height}"`;
|
|
209
|
+
}
|
|
210
|
+
function isVisual(attachment) {
|
|
211
|
+
if (youTubeEmbedURL(attachment.url) !== undefined)
|
|
212
|
+
return true;
|
|
213
|
+
const medium = displayMedium(attachment);
|
|
214
|
+
return medium === "image" || medium === "video";
|
|
215
|
+
}
|
|
216
|
+
function bodyContainsVisualElement(body) {
|
|
217
|
+
const lower = body.toLowerCase();
|
|
218
|
+
return VISUAL_ELEMENT_TAGS.some((tag) => lower.includes(tag));
|
|
219
|
+
}
|
|
220
|
+
/** Body HTML escapes `&` in attributes, so check both forms of the URL. */
|
|
221
|
+
function bodyContainsURL(body, url) {
|
|
222
|
+
return body.includes(url) || body.includes(escapeXML(url));
|
|
223
|
+
}
|
|
224
|
+
function enclosureHTML(attachment, posterURL) {
|
|
225
|
+
const youTube = youTubeEmbedURL(attachment.url);
|
|
226
|
+
if (youTube !== undefined) {
|
|
227
|
+
const iframe = `<iframe src="${youTube}"${dimensionAttributes(attachment)} allowfullscreen></iframe>`;
|
|
228
|
+
return `<div class="nnw-enclosure nnw-enclosure-video">${iframe}</div>`;
|
|
229
|
+
}
|
|
230
|
+
const medium = displayMedium(attachment);
|
|
231
|
+
if (medium === undefined)
|
|
232
|
+
return undefined;
|
|
233
|
+
const src = escapeXML(attachment.url);
|
|
234
|
+
let mediaElement;
|
|
235
|
+
let caption;
|
|
236
|
+
switch (medium) {
|
|
237
|
+
case "audio":
|
|
238
|
+
mediaElement = `<audio controls preload="none" src="${src}"></audio>`;
|
|
239
|
+
caption = audioCaption(attachment);
|
|
240
|
+
break;
|
|
241
|
+
case "video": {
|
|
242
|
+
let attributes = `controls preload="metadata" playsinline src="${src}"${dimensionAttributes(attachment)}`;
|
|
243
|
+
if (posterURL)
|
|
244
|
+
attributes += ` poster="${escapeXML(posterURL)}"`;
|
|
245
|
+
mediaElement = `<video ${attributes}></video>`;
|
|
246
|
+
caption = attachment.title;
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
case "image":
|
|
250
|
+
mediaElement = `<img src="${src}" alt=""${dimensionAttributes(attachment)}>`;
|
|
251
|
+
caption = attachment.title;
|
|
252
|
+
break;
|
|
253
|
+
default:
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
let html = `<div class="nnw-enclosure nnw-enclosure-${medium}">${mediaElement}`;
|
|
257
|
+
if (caption)
|
|
258
|
+
html += `<div class="nnw-enclosure-caption">${escapeXML(caption)}</div>`;
|
|
259
|
+
return `${html}</div>`;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* HTML for the [[enclosures]] substitution, empty when there's nothing to show.
|
|
263
|
+
* Skips attachments whose URLs already appear in the body. imageURL is shown only
|
|
264
|
+
* when nothing else visual is: no image or video enclosure, nor one in the body.
|
|
265
|
+
*/
|
|
266
|
+
export function enclosuresHTML(attachments, body, imageURL) {
|
|
267
|
+
const enclosures = [];
|
|
268
|
+
let hasVisualEnclosure = false;
|
|
269
|
+
for (const attachment of attachments) {
|
|
270
|
+
if (bodyContainsURL(body, attachment.url))
|
|
271
|
+
continue;
|
|
272
|
+
const enclosure = enclosureHTML(attachment, imageURL);
|
|
273
|
+
if (enclosure === undefined)
|
|
274
|
+
continue;
|
|
275
|
+
enclosures.push(enclosure);
|
|
276
|
+
hasVisualEnclosure ||= isVisual(attachment);
|
|
277
|
+
}
|
|
278
|
+
if (!hasVisualEnclosure &&
|
|
279
|
+
imageURL &&
|
|
280
|
+
!bodyContainsVisualElement(body) &&
|
|
281
|
+
!bodyContainsURL(body, imageURL)) {
|
|
282
|
+
enclosures.unshift(`<div class="nnw-enclosure nnw-enclosure-image"><img src="${escapeXML(imageURL)}" alt=""></div>`);
|
|
283
|
+
}
|
|
284
|
+
if (!enclosures.length)
|
|
285
|
+
return "";
|
|
286
|
+
return `<div class="nnw-enclosures">\n${enclosures.join("\n")}\n</div>`;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* The body and [[enclosures]] values, as ArticleRenderer.articleSubstitutions() sets
|
|
290
|
+
* them. A template without the macro gets its enclosures after the body, which a
|
|
291
|
+
* wrapper <div> closes so an unclosed tag in it can't swallow them.
|
|
292
|
+
*/
|
|
293
|
+
export function placeEnclosures(template, body, enclosures) {
|
|
294
|
+
if (template.includes("[[enclosures]]"))
|
|
295
|
+
return { body, enclosures };
|
|
296
|
+
if (!enclosures)
|
|
297
|
+
return { body };
|
|
298
|
+
return { body: `<div>${body}</div>${enclosures}` };
|
|
299
|
+
}
|
package/dist/render.js
CHANGED
|
@@ -3,6 +3,7 @@ import { mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync,
|
|
|
3
3
|
import { extname, isAbsolute, join, relative } from "node:path";
|
|
4
4
|
import { decodeHTML } from "entities";
|
|
5
5
|
import { Parser } from "htmlparser2";
|
|
6
|
+
import { enclosuresHTML, fixtureAttachments, fixtureImageURL, placeEnclosures, } from "./enclosures.js";
|
|
6
7
|
import { renderingInputs } from "./netnewswire.js";
|
|
7
8
|
import { fixturePath, readFixture, repositoryFixtures, ThemeError, themeStem, } from "./project.js";
|
|
8
9
|
import { formatG, pyStr } from "./pyformat.js";
|
|
@@ -64,7 +65,7 @@ const VIEWPORTS = {
|
|
|
64
65
|
// The marketplace card shows its image at 347x240, cropped from the top.
|
|
65
66
|
const CARD_VIEWPORT = [800, 552];
|
|
66
67
|
// Fixture images a fixture may reference as media/NAME, from fixtures/media/NAME.
|
|
67
|
-
const
|
|
68
|
+
const IMAGE_TYPES = {
|
|
68
69
|
".avif": "image/avif",
|
|
69
70
|
".gif": "image/gif",
|
|
70
71
|
".jpeg": "image/jpeg",
|
|
@@ -73,6 +74,23 @@ const MEDIA_TYPES = {
|
|
|
73
74
|
".svg": "image/svg+xml",
|
|
74
75
|
".webp": "image/webp",
|
|
75
76
|
};
|
|
77
|
+
// Enclosure players may also play a fixture's own audio or video file.
|
|
78
|
+
const PLAYABLE_TYPES = {
|
|
79
|
+
".aac": "audio/aac",
|
|
80
|
+
".flac": "audio/flac",
|
|
81
|
+
".m4a": "audio/mp4",
|
|
82
|
+
".m4v": "video/mp4",
|
|
83
|
+
".mov": "video/quicktime",
|
|
84
|
+
".mp3": "audio/mpeg",
|
|
85
|
+
".mp4": "video/mp4",
|
|
86
|
+
".oga": "audio/ogg",
|
|
87
|
+
".ogg": "audio/ogg",
|
|
88
|
+
".opus": "audio/ogg",
|
|
89
|
+
".wav": "audio/wav",
|
|
90
|
+
".webm": "video/webm",
|
|
91
|
+
};
|
|
92
|
+
// An enclosure's own tag, as enclosuresHTML writes it.
|
|
93
|
+
const ENCLOSURE_TAG_RE = /<(video|audio|iframe)\b([^>]*)>/g;
|
|
76
94
|
// The template's own fixtures get the full matrix and the stress cases. Any other
|
|
77
95
|
// fixture a theme adds is an extra: checked on Mac and iPhone in both appearances.
|
|
78
96
|
export const FIXTURE_NAMES = {
|
|
@@ -102,6 +120,12 @@ const SCENARIOS = [
|
|
|
102
120
|
"The kitchen-sink fixture at a large reading size: the xxLargeText size class on " +
|
|
103
121
|
"Mac and 23 pt Dynamic Type on iPhone.",
|
|
104
122
|
],
|
|
123
|
+
[
|
|
124
|
+
"older-netnewswire",
|
|
125
|
+
"Older NetNewsWire",
|
|
126
|
+
"The article fixture as NetNewsWire releases before enclosure support render it: " +
|
|
127
|
+
"they leave the template's [[enclosures]] macro as literal text, which must not show.",
|
|
128
|
+
],
|
|
105
129
|
[
|
|
106
130
|
"article-javascript-off",
|
|
107
131
|
"Article JavaScript off",
|
|
@@ -117,6 +141,8 @@ const CHECKS = [
|
|
|
117
141
|
"no broken images",
|
|
118
142
|
"no external requests",
|
|
119
143
|
"no JavaScript errors",
|
|
144
|
+
"enclosure players and images show, fit the window, and keep their shape",
|
|
145
|
+
"enclosure captions are legible",
|
|
120
146
|
];
|
|
121
147
|
export class RenderTarget {
|
|
122
148
|
fixture;
|
|
@@ -126,15 +152,18 @@ export class RenderTarget {
|
|
|
126
152
|
height;
|
|
127
153
|
largeText;
|
|
128
154
|
themeScripts;
|
|
155
|
+
/** Rendered as NetNewsWire before [[enclosures]]: the macro stays literal. */
|
|
156
|
+
olderNetNewsWire;
|
|
129
157
|
/** A 2x capture of the card viewport alone, for the marketplace card. */
|
|
130
158
|
card;
|
|
131
|
-
constructor(fixture, platform, appearance, { largeText = false, themeScripts = true, card = false } = {}) {
|
|
159
|
+
constructor(fixture, platform, appearance, { largeText = false, themeScripts = true, olderNetNewsWire = false, card = false } = {}) {
|
|
132
160
|
this.fixture = fixture;
|
|
133
161
|
this.platform = platform;
|
|
134
162
|
this.appearance = appearance;
|
|
135
163
|
[this.width, this.height] = card ? CARD_VIEWPORT : VIEWPORTS[platform];
|
|
136
164
|
this.largeText = largeText;
|
|
137
165
|
this.themeScripts = themeScripts;
|
|
166
|
+
this.olderNetNewsWire = olderNetNewsWire;
|
|
138
167
|
this.card = card;
|
|
139
168
|
}
|
|
140
169
|
get ios() {
|
|
@@ -150,6 +179,8 @@ export class RenderTarget {
|
|
|
150
179
|
parts.push("large text");
|
|
151
180
|
if (!this.themeScripts)
|
|
152
181
|
parts.push("Article JavaScript off");
|
|
182
|
+
if (this.olderNetNewsWire)
|
|
183
|
+
parts.push("older NetNewsWire");
|
|
153
184
|
if (this.card)
|
|
154
185
|
parts.push("card");
|
|
155
186
|
return parts.join(" · ");
|
|
@@ -159,6 +190,8 @@ export class RenderTarget {
|
|
|
159
190
|
return "large-text";
|
|
160
191
|
if (!this.themeScripts)
|
|
161
192
|
return "article-javascript-off";
|
|
193
|
+
if (this.olderNetNewsWire)
|
|
194
|
+
return "older-netnewswire";
|
|
162
195
|
return this.fixture;
|
|
163
196
|
}
|
|
164
197
|
get slug() {
|
|
@@ -167,6 +200,8 @@ export class RenderTarget {
|
|
|
167
200
|
parts.push("large-text");
|
|
168
201
|
if (!this.themeScripts)
|
|
169
202
|
parts.push("no-article-js");
|
|
203
|
+
if (this.olderNetNewsWire)
|
|
204
|
+
parts.push("older-netnewswire");
|
|
170
205
|
if (this.card)
|
|
171
206
|
parts.push("card");
|
|
172
207
|
return parts.join("-");
|
|
@@ -199,14 +234,27 @@ export function normalTargets(extras = []) {
|
|
|
199
234
|
}
|
|
200
235
|
return targets;
|
|
201
236
|
}
|
|
202
|
-
|
|
203
|
-
|
|
237
|
+
/**
|
|
238
|
+
* Everything check renders. A template that places [[enclosures]] itself also gets
|
|
239
|
+
* the older-NetNewsWire cases, since releases before enclosure support leave that
|
|
240
|
+
* macro as text in every article.
|
|
241
|
+
*/
|
|
242
|
+
export function checkTargets(extras = [], { enclosuresMacro = false } = {}) {
|
|
243
|
+
const targets = [
|
|
204
244
|
...normalTargets(extras),
|
|
205
245
|
new RenderTarget("kitchen-sink", "mac", "light", { largeText: true }),
|
|
206
246
|
new RenderTarget("kitchen-sink", "iphone", "light", { largeText: true }),
|
|
207
247
|
new RenderTarget("article", "mac", "light", { themeScripts: false }),
|
|
208
248
|
new RenderTarget("article", "iphone", "light", { themeScripts: false }),
|
|
209
249
|
];
|
|
250
|
+
if (enclosuresMacro) {
|
|
251
|
+
targets.push(new RenderTarget("article", "mac", "light", { olderNetNewsWire: true }), new RenderTarget("article", "iphone", "dark", { olderNetNewsWire: true }));
|
|
252
|
+
}
|
|
253
|
+
return targets;
|
|
254
|
+
}
|
|
255
|
+
/** Whether a theme places enclosures itself, as NetNewsWire decides it. */
|
|
256
|
+
export function placesEnclosures(theme) {
|
|
257
|
+
return read(join(theme, "template.html")).includes("[[enclosures]]");
|
|
210
258
|
}
|
|
211
259
|
/** Perform NetNewsWire's single-pass, non-recursive macro substitution. */
|
|
212
260
|
export function substitute(template, mapping) {
|
|
@@ -258,8 +306,8 @@ function placeholder(width, height) {
|
|
|
258
306
|
* A fixture's own image, media/NAME, as a data URI read from fixtures/media/NAME.
|
|
259
307
|
* Undefined, so a placeholder as before, unless it is an image inside that directory.
|
|
260
308
|
*/
|
|
261
|
-
function mediaDataURI(media, src) {
|
|
262
|
-
const type =
|
|
309
|
+
function mediaDataURI(media, src, types = IMAGE_TYPES) {
|
|
310
|
+
const type = types[extname(src).toLowerCase()];
|
|
263
311
|
if (!src.startsWith("media/") || !type)
|
|
264
312
|
return undefined;
|
|
265
313
|
try {
|
|
@@ -306,14 +354,71 @@ export function offlineMedia(body, media) {
|
|
|
306
354
|
return `<${tagName}${kept} src="${local ?? placeholder(values.get("width"), values.get("height"))}">`;
|
|
307
355
|
});
|
|
308
356
|
}
|
|
309
|
-
|
|
357
|
+
/** A tag's attributes, in order, with their raw (still quoted) values. */
|
|
358
|
+
function parseAttributes(rest) {
|
|
359
|
+
return [...rest.matchAll(ATTRIBUTE_RE)].map((match) => [
|
|
360
|
+
match[1] ?? "",
|
|
361
|
+
match[2] ?? "",
|
|
362
|
+
]);
|
|
363
|
+
}
|
|
364
|
+
function attributeValue(attributes, name) {
|
|
365
|
+
const found = attributes.find(([key]) => key.toLowerCase() === name);
|
|
366
|
+
return found && decodeHTML(found[1].replace(/^["']+|["']+$/g, ""));
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Take enclosures offline, as offlineMedia does a body. Images and a video's poster
|
|
370
|
+
* become placeholders (media/NAME shows the fixture's file); a player that would
|
|
371
|
+
* fetch loses its source, as if the file had not loaded, unless it plays a media/
|
|
372
|
+
* file; a video with nothing to show gets a placeholder poster at its own size, for
|
|
373
|
+
* the frame it would load; and the YouTube player becomes a same-size placeholder.
|
|
374
|
+
*/
|
|
375
|
+
export function offlineEnclosures(html, media) {
|
|
376
|
+
const local = (src, types) => media === undefined ? undefined : mediaDataURI(media, src, types);
|
|
377
|
+
return offlineMedia(html, media).replace(ENCLOSURE_TAG_RE, (_whole, tagName, rest) => {
|
|
378
|
+
const attributes = parseAttributes(rest);
|
|
379
|
+
const width = attributeValue(attributes, "width");
|
|
380
|
+
const height = attributeValue(attributes, "height");
|
|
381
|
+
const src = attributeValue(attributes, "src") ?? "";
|
|
382
|
+
if (tagName === "iframe") {
|
|
383
|
+
const label = `${width && height ? `${width} × ${height} ` : ""}YouTube player`;
|
|
384
|
+
const page = "<body style='margin:0;display:grid;place-items:center;height:100vh;" +
|
|
385
|
+
`background:#8a8f98;color:#fff;font:600 16px system-ui'>${label}`;
|
|
386
|
+
const kept = attributes.filter(([name]) => name.toLowerCase() !== "src");
|
|
387
|
+
return `<iframe${kept.map(([n, v]) => (v ? ` ${n}=${v}` : ` ${n}`)).join("")} srcdoc="${escapeHTML(page)}">`;
|
|
388
|
+
}
|
|
389
|
+
const playable = fetches(src) ? local(src, PLAYABLE_TYPES) : src;
|
|
390
|
+
const kept = attributes.filter(([name]) => !["src", "poster"].includes(name.toLowerCase()));
|
|
391
|
+
let tag = `<${tagName}${kept.map(([n, v]) => (v ? ` ${n}=${v}` : ` ${n}`)).join("")}`;
|
|
392
|
+
if (playable)
|
|
393
|
+
tag += ` src="${escapeHTML(playable)}"`;
|
|
394
|
+
if (tagName === "video") {
|
|
395
|
+
const poster = attributeValue(attributes, "poster");
|
|
396
|
+
let shown;
|
|
397
|
+
if (poster)
|
|
398
|
+
shown = fetches(poster) ? (local(poster) ?? placeholder(width, height)) : poster;
|
|
399
|
+
else if (!playable)
|
|
400
|
+
shown = placeholder(width, height);
|
|
401
|
+
if (shown)
|
|
402
|
+
tag += ` poster="${escapeHTML(shown)}"`;
|
|
403
|
+
}
|
|
404
|
+
return `${tag}>`;
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
function fixtureMapping(fixture, target, template, media) {
|
|
310
408
|
const mapping = {};
|
|
311
409
|
for (const key of TEMPLATE_KEYS)
|
|
312
410
|
mapping[key] = pyStr(fixture[key] ?? "");
|
|
313
411
|
if (!mapping.dateline_style) {
|
|
314
412
|
mapping.dateline_style = mapping.title ? "articleDateline" : "articleDatelineTitle";
|
|
315
413
|
}
|
|
316
|
-
|
|
414
|
+
const body = mapping.body ?? "";
|
|
415
|
+
mapping.body = offlineMedia(body, media);
|
|
416
|
+
// NetNewsWire before enclosure support ignores attachments and leaves the macro.
|
|
417
|
+
if (!target.olderNetNewsWire) {
|
|
418
|
+
const name = `fixtures/${target.fixture}.toml`;
|
|
419
|
+
const enclosures = enclosuresHTML(fixtureAttachments(fixture, name), body, fixtureImageURL(fixture, name));
|
|
420
|
+
Object.assign(mapping, placeEnclosures(template, mapping.body, offlineEnclosures(enclosures, media)));
|
|
421
|
+
}
|
|
317
422
|
if (!mapping.avatar_src || fetches(mapping.avatar_src)) {
|
|
318
423
|
mapping.avatar_src = avatarDataURI(mapping.feed_link_title ?? "");
|
|
319
424
|
}
|
|
@@ -401,7 +506,7 @@ export function renderPage(theme, fixture, target, snapshot = renderingInputs().
|
|
|
401
506
|
}
|
|
402
507
|
const style = substitute(`${read(join(snapshot, "Shared", "core.css"))}\n${stylesheet}`, styleSubstitutions);
|
|
403
508
|
const template = read(join(theme, "template.html"));
|
|
404
|
-
let body = substitute(template, fixtureMapping(fixture, target, media));
|
|
509
|
+
let body = substitute(template, fixtureMapping(fixture, target, template, media));
|
|
405
510
|
if (!target.themeScripts)
|
|
406
511
|
body = removeScripts(body);
|
|
407
512
|
let page = substitute(skeleton, {
|
package/lldb/nnwdump.py
CHANGED
|
@@ -4,6 +4,12 @@ Stopped at the final `return d` of ArticleRenderer.articleSubstitutions(), this
|
|
|
4
4
|
reads the substitution dictionary NetNewsWire built for the selected article and
|
|
5
5
|
writes it as a TOML fixture. `npx nnw-theme@2 capture` prints the setup steps.
|
|
6
6
|
|
|
7
|
+
NetNewsWire with enclosure support renders an article's enclosures into that
|
|
8
|
+
dictionary: as `enclosures`, or appended to a wrapped `body` when the template has no
|
|
9
|
+
[[enclosures]] macro. nnwdump keeps neither. It records the article's attachments
|
|
10
|
+
and image instead, as [[attachments]] tables and image_url/thumbnail_url, so nnw-theme
|
|
11
|
+
renders them for whichever template previews the fixture.
|
|
12
|
+
|
|
7
13
|
Values cross the debugger boundary base64-encoded, so nothing is truncated or
|
|
8
14
|
mis-escaped. HTML values are written as '''literal''' strings, so fixtures stay
|
|
9
15
|
readable and diff cleanly.
|
|
@@ -16,6 +22,7 @@ helpers can be tested without a debugger.
|
|
|
16
22
|
from __future__ import annotations
|
|
17
23
|
|
|
18
24
|
import base64
|
|
25
|
+
import json
|
|
19
26
|
import optparse
|
|
20
27
|
import shlex
|
|
21
28
|
from pathlib import Path
|
|
@@ -24,6 +31,20 @@ USAGE = "nnwdump [--var NAME] [--article NAME] [--no-icon] [OUTPUT.toml]"
|
|
|
24
31
|
DEFAULT_OUTPUT = "fixtures/nnw-capture.toml"
|
|
25
32
|
# Sorted last so the short metadata stays at the top of the file.
|
|
26
33
|
_HTML_KEYS = {"body"}
|
|
34
|
+
_ENCLOSURES_KEY = "enclosures"
|
|
35
|
+
# ArticleRenderer's body for a template without [[enclosures]]: <div>BODY</div>ENCLOSURES.
|
|
36
|
+
_WRAPPED_BODY_TAIL = '</div><div class="nnw-enclosures">'
|
|
37
|
+
# Attachment's Codable keys, in fixture order, with their fixture names.
|
|
38
|
+
_ATTACHMENT_KEYS = (
|
|
39
|
+
("url", "url"),
|
|
40
|
+
("mimeType", "mime_type"),
|
|
41
|
+
("title", "title"),
|
|
42
|
+
("sizeInBytes", "size_in_bytes"),
|
|
43
|
+
("durationInSeconds", "duration_in_seconds"),
|
|
44
|
+
("width", "width"),
|
|
45
|
+
("height", "height"),
|
|
46
|
+
("medium", "medium"),
|
|
47
|
+
)
|
|
27
48
|
|
|
28
49
|
|
|
29
50
|
def resolve_output(argument: str) -> Path:
|
|
@@ -56,13 +77,49 @@ def toml_line(key: str, value: str) -> str:
|
|
|
56
77
|
return f'{key} = "{_toml_basic(value)}"'
|
|
57
78
|
|
|
58
79
|
|
|
59
|
-
def
|
|
80
|
+
def without_enclosures(pairs: list[tuple[str, str]]) -> list[tuple[str, str]]:
|
|
81
|
+
"""The substitutions as they were before NetNewsWire rendered the enclosures."""
|
|
82
|
+
values = dict(pairs)
|
|
83
|
+
if _ENCLOSURES_KEY not in values:
|
|
84
|
+
body = values.get("body", "")
|
|
85
|
+
at = body.rfind(_WRAPPED_BODY_TAIL)
|
|
86
|
+
if body.startswith("<div>") and at != -1:
|
|
87
|
+
values["body"] = body[len("<div>") : at]
|
|
88
|
+
values.pop(_ENCLOSURES_KEY, None)
|
|
89
|
+
return list(values.items())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def attachments_from_json(text: str) -> list[dict]:
|
|
93
|
+
"""Attachment JSON, as [Attachment].json() writes it, as fixture tables."""
|
|
94
|
+
tables = []
|
|
95
|
+
for attachment in json.loads(text or "[]") or []:
|
|
96
|
+
table = {
|
|
97
|
+
name: attachment[key]
|
|
98
|
+
for key, name in _ATTACHMENT_KEYS
|
|
99
|
+
if attachment.get(key) is not None
|
|
100
|
+
}
|
|
101
|
+
if table.get("url"):
|
|
102
|
+
tables.append(table)
|
|
103
|
+
return tables
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _toml_value_line(key: str, value) -> str:
|
|
107
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
108
|
+
return toml_line(key, str(value))
|
|
109
|
+
return f"{key} = {value}"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def fixture_text(pairs: list[tuple[str, str]], name: str, attachments: list[dict] = ()) -> str:
|
|
60
113
|
ordered = sorted(pairs, key=lambda pair: (pair[0] in _HTML_KEYS, pair[0]))
|
|
61
114
|
header = (
|
|
62
115
|
"# NetNewsWire article captured with nnwdump.\n"
|
|
63
116
|
f"# Preview with: npx nnw-theme@2 render {name}\n\n"
|
|
64
117
|
)
|
|
65
|
-
|
|
118
|
+
text = header + "\n".join(toml_line(key, value) for key, value in ordered) + "\n"
|
|
119
|
+
for table in attachments:
|
|
120
|
+
lines = [_toml_value_line(key, value) for key, value in table.items()]
|
|
121
|
+
text += "\n[[attachments]]\n" + "\n".join(lines) + "\n"
|
|
122
|
+
return text
|
|
66
123
|
|
|
67
124
|
|
|
68
125
|
def _evaluate(interpreter, expression: str):
|
|
@@ -127,19 +184,32 @@ def nnwdump(debugger, command, result, internal_dict):
|
|
|
127
184
|
result.SetError(f"`{options.var}` is empty; is this the substitution dictionary?")
|
|
128
185
|
return
|
|
129
186
|
|
|
187
|
+
# Older NetNewsWire has no attachments to read; capture the article without them.
|
|
188
|
+
pairs = without_enclosures(pairs)
|
|
189
|
+
attachments = []
|
|
190
|
+
raw, _ = _evaluate(interpreter, f'({options.article}.attachments ?? []).json() ?? "[]"')
|
|
191
|
+
if raw:
|
|
192
|
+
attachments = attachments_from_json(raw)
|
|
193
|
+
for key, link in (("image_url", "imageLink"), ("thumbnail_url", "thumbnailLink")):
|
|
194
|
+
value, _ = _evaluate(interpreter, f'{options.article}.{link} ?? ""')
|
|
195
|
+
if value:
|
|
196
|
+
pairs.append((key, value))
|
|
197
|
+
|
|
130
198
|
note = ""
|
|
199
|
+
if attachments:
|
|
200
|
+
note = f" and {len(attachments)} attachment{'s' if len(attachments) > 1 else ''}"
|
|
131
201
|
if not options.no_icon:
|
|
132
202
|
icon = _icon_data_url(interpreter, options.article)
|
|
133
203
|
if icon:
|
|
134
204
|
pairs = [(key, value) for key, value in pairs if key != "avatar_src"]
|
|
135
205
|
pairs.append(("avatar_src", icon))
|
|
136
|
-
note
|
|
206
|
+
note += " with the feed's icon"
|
|
137
207
|
else:
|
|
138
|
-
note
|
|
208
|
+
note += "; the feed has no icon, so previews generate one"
|
|
139
209
|
|
|
140
210
|
try:
|
|
141
211
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
-
output.write_text(fixture_text(pairs, output.stem), encoding="utf-8")
|
|
212
|
+
output.write_text(fixture_text(pairs, output.stem, attachments), encoding="utf-8")
|
|
143
213
|
except OSError as error:
|
|
144
214
|
result.SetError(f"could not write {output}: {error}")
|
|
145
215
|
return
|