nnw-theme 2.1.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 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 and save its image; `--promote` makes it the marketplace card |
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 |
@@ -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), release `mac-7.2b4`,
5
- commit `b7f4d8c7d273e4247c5d033ee47014805072297b`, under `assets/netnewswire/`.
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.
@@ -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`, sixteen for all four. Their structure, classes, and attributes
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,15 +33,50 @@ 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. Rendering swaps every image that would load over the network for a
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
 
74
+ To show a real image, as a marketplace screenshot needs, commit it under
75
+ `fixtures/media/` and reference it by that relative path:
76
+ `<img src="media/sunset.jpg" width="1600" height="900">`. Rendering inlines it, so
77
+ previews stay offline; a missing file shows as a placeholder. Use only images the theme may redistribute, and keep them small:
78
+ each is copied into every page that renders the fixture.
79
+
45
80
  Footnote markers are the links NetNewsWire's `newsfoot.js` handles: `a.footnote`.
46
81
  Its `main.js` adds that class to local `sup > a[href*='#fn']` links and to Substack's
47
82
  `a[id^='footnote-anchor-']` links; a theme script may add it to other formats. `check` requires every visible marker to resolve to a
@@ -24,6 +24,12 @@ stable GitHub release; and the valid ZIP asset. A useful repository description
24
24
  metadata warns but does not block publication. `npx nnw-theme@2 marketplace enable`
25
25
  adds the recommended topic through the author’s authenticated `gh` session.
26
26
 
27
+ `npx nnw-theme@2 screenshot --fixture NAME --card --promote` makes the card image from
28
+ any fixture: an 800×552 viewport at 2x, the shape of the card, which crops from the top. A
29
+ fixture written for the card, with images in `fixtures/media/`, shows the theme best;
30
+ `--platform ipad` or `iphone` renders it on another platform. The **Refresh marketplace
31
+ screenshot** workflow takes the same fixture name and card choice.
32
+
27
33
  The screenshot workflow pushes a branch and opens a pull request for review, so the
28
34
  repository needs **Settings → Actions → General → Workflow permissions → Allow GitHub
29
35
  Actions to create and approve pull requests**. Without it the run pushes the branch and
@@ -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
- The renderer pins NetNewsWire `mac-7.2b4` commit
43
- `b7f4d8c7d273e4247c5d033ee47014805072297b`. It uses upstream `core.css`, macOS and
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": "mac-7.2b4",
3
- "commit": "b7f4d8c7d273e4247c5d033ee47014805072297b",
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": "d67552259cb45261e1aacf0c1af6bc39b560b084c7b9e3943191907e0977cd4d"
23
+ "sha256": "3cdc76a22a6bbcd1aeed6eec4611598bb64a457cade3e1553e7b88f03bc78ef9"
24
24
  },
25
25
  {
26
26
  "destination": "Shared/main.js",
@@ -5,15 +5,18 @@ on:
5
5
  workflow_dispatch:
6
6
  inputs:
7
7
  fixture:
8
- description: Fixture to capture
9
- type: choice
10
- options: [article, kitchen-sink]
8
+ description: Fixture to capture (article, kitchen-sink, or a fixtures/*.toml name)
9
+ type: string
11
10
  default: article
12
11
  appearance:
13
12
  description: Appearance to capture
14
13
  type: choice
15
14
  options: [light, dark]
16
15
  default: light
16
+ card:
17
+ description: Capture the marketplace card's shape instead of the full page
18
+ type: boolean
19
+ default: true
17
20
 
18
21
  permissions:
19
22
  contents: write
@@ -25,3 +28,4 @@ jobs:
25
28
  with:
26
29
  fixture: ${{ inputs.fixture }}
27
30
  appearance: ${{ inputs.appearance }}
31
+ card: ${{ inputs.card }}
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
- function pageState() {
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(document.body.innerHTML),
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)
@@ -255,6 +265,7 @@ export async function checkPages(site, targets, progress, expectations = {}) {
255
265
  async function checkPage(browser, origin, url, target, screenshot, footnotes) {
256
266
  const context = await browser.newContext({
257
267
  viewport: { width: target.width, height: target.height },
268
+ deviceScaleFactor: target.card ? 2 : 1,
258
269
  colorScheme: target.appearance,
259
270
  serviceWorkers: "block",
260
271
  });
@@ -282,11 +293,15 @@ async function checkPage(browser, origin, url, target, screenshot, footnotes) {
282
293
  page.on("pageerror", (error) => pageErrors.push(String(error)));
283
294
  await page.goto(url, { waitUntil: "networkidle" });
284
295
  await page.evaluate(loadImages, 10_000);
285
- await page.screenshot({ path: screenshot, fullPage: true });
286
- const state = await page.evaluate(pageState);
296
+ await page.screenshot({ path: screenshot, fullPage: !target.card });
297
+ const state = await page.evaluate(pageState, {
298
+ olderNetNewsWire: target.olderNetNewsWire,
299
+ macro: ENCLOSURES_MACRO,
300
+ });
301
+ const enclosures = (await page.evaluate(`(${ENCLOSURE_CHECK})()`));
287
302
  // Last: it clicks footnotes open, so it must not disturb the screenshot.
288
303
  const found = (await page.evaluate(`(${FOOTNOTE_CHECK})(${JSON.stringify(footnotes)})`));
289
- return { ...state, footnotes: found, blocked, pageErrors };
304
+ return { ...state, enclosures, footnotes: found, blocked, pageErrors };
290
305
  }
291
306
  finally {
292
307
  await context.close();
@@ -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…");
@@ -2,18 +2,17 @@ import { copyFileSync, mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { checkPages } from "../browser.js";
4
4
  import { findRoot, findTheme, ThemeError } from "../project.js";
5
- import { extraFixtures, normalTargets, renderSite } from "../render.js";
5
+ import { extraFixtures, FIXTURE_NAMES, RenderTarget, renderSite, } from "../render.js";
6
6
  import { expectations } from "./check.js";
7
+ // Any fixture on any platform: a screenshot is a deliberate one-off, not the check matrix.
7
8
  function selectTarget(root, values) {
8
- const targets = normalTargets(extraFixtures(root));
9
- const { fixture, platform, appearance } = values;
10
- if (!targets.some((target) => target.fixture === fixture)) {
9
+ const { fixture, platform, appearance, card } = values;
10
+ if (![...Object.keys(FIXTURE_NAMES), ...extraFixtures(root)].includes(fixture)) {
11
11
  throw new ThemeError(`unknown fixture: ${fixture}`);
12
12
  }
13
- const target = targets.find((item) => item.fixture === fixture && item.platform === platform && item.appearance === appearance);
14
- if (!target)
15
- throw new ThemeError(`${fixture} is checked on Mac and iPhone only`);
16
- return target;
13
+ return new RenderTarget(fixture, platform, appearance, {
14
+ card: !!card,
15
+ });
17
16
  }
18
17
  export default async function screenshot({ values }) {
19
18
  const root = findRoot();