nnw-theme 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.3b4`,
5
- commit `e530be1ab19f68fe322850bf5fb95bca4b36fb91`, under `assets/netnewswire/`.
4
+ [NetNewsWire](https://github.com/Ranchero-Software/NetNewsWire) release `mac-7.3b6`,
5
+ commit `9a07d406177e896826e33952fb16c06e21c19000`, under `assets/netnewswire/`.
6
6
  `netnewswire.json` in the source repository records the SHA-256 of every file, and the
7
7
  build verifies each one, including NetNewsWire’s MIT license, which ships beside them as
8
8
  `assets/netnewswire/LICENSE`. These files are used only to preview themes; they are
@@ -2,8 +2,10 @@
2
2
  // list of failure messages. NetNewsWire writes each enclosure as
3
3
  // div.nnw-enclosure > (img | video | audio | iframe), with an optional
4
4
  // div.nnw-enclosure-caption, inside one div.nnw-enclosures: at [[enclosures]] in the
5
- // template, or after the body when the template has no such macro. A theme script
6
- // may move an enclosure elsewhere, so each is found wherever it ended up.
5
+ // template, or after the body when the template has no such macro. [[primary_media]]
6
+ // reuses those players, and adds div.nnw-primary-image > img with an optional
7
+ // div.nnw-primary-image-caption. A theme script may move any of them elsewhere, so
8
+ // each is found wherever it ended up.
7
9
  () => {
8
10
  const failures = [];
9
11
  const fail = (message) => failures.push(message);
@@ -14,7 +16,7 @@
14
16
  return Number.isFinite(number) && number > 0 ? number : 0;
15
17
  };
16
18
 
17
- const enclosures = [...document.querySelectorAll(".nnw-enclosure")];
19
+ const enclosures = [...document.querySelectorAll(".nnw-enclosure, .nnw-primary-image")];
18
20
  enclosures.forEach((enclosure, index) => {
19
21
  const media = enclosure.querySelector(":scope > :is(img, video, audio, iframe)");
20
22
  if (!media) return;
@@ -89,7 +91,7 @@
89
91
  return base.slice(0, 3);
90
92
  };
91
93
  const distance = (a, b) => [0, 1, 2].reduce((sum, i) => sum + Math.abs(a[i] - b[i]), 0);
92
- const captions = [...document.querySelectorAll(".nnw-enclosure-caption")];
94
+ const captions = [...document.querySelectorAll(".nnw-enclosure-caption, .nnw-primary-image-caption")];
93
95
  for (const caption of captions) {
94
96
  if (!caption.getClientRects().length || !caption.innerText.trim()) continue;
95
97
  const name = JSON.stringify(caption.innerText.trim().slice(0, 40));
@@ -15,7 +15,8 @@ prescribed aesthetic.
15
15
  These exercise the real mechanisms: a macOS size class and an iOS Dynamic Type size.
16
16
  - Check JavaScript-off macOS and iPhone renders for readable content and usable links.
17
17
  - If the theme styles enclosures, check the `podcast` and `media-rss` fixtures: players,
18
- captions, and the article image leading a podcast episode, in light and dark.
18
+ captions, and the article image leading a podcast episode, in light and dark. A
19
+ theme with `primary_media` checks them above the body, and its older-NetNewsWire render.
19
20
  - If the theme script handles footnotes or relies on classes or ids, give a fixture
20
21
  `deliveries = ["freshrss", "miniflux"]` and check the cases synced readers see.
21
22
  - Check an untitled article, such as the `mastodon` fixture: the dateline stands in for
@@ -36,11 +36,13 @@ footnotes stay plain links until a theme script turns them into `a.footnote` mar
36
36
  ## Enclosures
37
37
 
38
38
  An article's enclosures are data, not markup: NetNewsWire renders them into the
39
- `enclosures` 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:
39
+ `enclosures` and `primary_media` macros, or after the body, from the article's
40
+ attachments. A fixture lists them after its other keys, in feed order, and may give
41
+ the article's image and thumbnail, which NetNewsWire treats differently:
41
42
 
42
43
  ```toml
43
- image_url = "https://example.org/episode-art.jpg" # else thumbnail_url
44
+ image_url = "https://example.org/episode-art.jpg"
45
+ thumbnail_url = "https://example.org/thumb.jpg" # a primary_media image only
44
46
 
45
47
  [[attachments]]
46
48
  url = "https://example.org/episode-311.mp3" # required
@@ -64,11 +64,14 @@ The markup is fixed and carries no inline styles:
64
64
  Images and video carry the feed's `width` and `height` when it gives both; a caption
65
65
  appears only when there is text for it. The audio caption joins the title (else the
66
66
  file name), duration, and size with ` · `. A YouTube channel enclosure becomes the
67
- iframe player. Enclosures 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.
67
+ iframe player. Enclosures already in the body are skipped, including a resized
68
+ copy at another URL or the still of a YouTube video the body embeds, and documents are
69
+ never shown. A video uses the article's image, else its thumbnail, as its poster.
70
+
71
+ A template without `primary_media`, and a short untitled post, gets the article's image
72
+ as the first image enclosure unless the body already shows it, even beside other
73
+ enclosures or a video that uses it as its poster. The thumbnail is not shown there:
74
+ only `primary_media` uses it as an image.
72
75
 
73
76
  NetNewsWire releases before enclosure support leave the macro as literal text in
74
77
  every article, so a theme that places it should hide its container when it holds no
@@ -79,12 +82,42 @@ fixture, `check` also fails an enclosure image, player, or video that is hidden,
79
82
  leaves the window, or is drawn out of its shape, an audio player too narrow to seek,
80
83
  and an illegible caption.
81
84
 
85
+ ## Primary media
86
+
87
+ The `primary_media` macro places the article's main media, usually above the body: a
88
+ video with the article image as its poster, else audio with the article image above
89
+ it, else the article image. The image is the article's own, else the first image
90
+ enclosure, else the thumbnail. Media already in the body is left out, and whatever
91
+ appears here is left out of the enclosures. A short untitled post, such as a Mastodon
92
+ post, keeps its images in the enclosures, after the text. With nothing to show the
93
+ macro is empty.
94
+
95
+ ```html
96
+ <div class="nnw-primary-media">
97
+ <div class="nnw-primary-image"><img src="…" alt="…" title="…" width="…" height="…">
98
+ <div class="nnw-primary-image-caption">The image's title</div></div>
99
+ <div class="nnw-enclosure nnw-enclosure-audio">…</div>
100
+ </div>
101
+ ```
102
+
103
+ The video and audio blocks are the enclosures' markup. The caption appears only when
104
+ the image has a title that differs from the article's. `core.css` adds margins and
105
+ `height: auto`. `check` applies the enclosure rules here too.
106
+
107
+ NetNewsWire before 7.3b6 leaves the macro as text, as with `enclosures`. Either hide its
108
+ container, `.my-media:not(:has(.nnw-primary-media)) { display: none; }`, or put the
109
+ macro only in `template7.3.html`: NetNewsWire 7.3 reads that file instead of
110
+ `template.html` when a theme ships one, and older releases ignore it, so `template.html`
111
+ can stay free of new macros at the cost of keeping two templates in step. `check`
112
+ renders `template7.3.html` when it exists, and its older-NetNewsWire cases render
113
+ `template.html` with both macros left as text.
114
+
82
115
  ## Pinned NetNewsWire
83
116
 
84
- The renderer pins NetNewsWire 7.3b4 for Mac (`mac-7.3b4`, commit
85
- `e530be1ab19f68fe322850bf5fb95bca4b36fb91`), whose rendering files iOS 7.3 shares.
86
- Enclosure support starts with 7.3b3, and 7.3b4 keeps the footnote popover inside the
87
- article column. It uses upstream `core.css`, macOS and
117
+ The renderer pins NetNewsWire 7.3b6 for Mac (`mac-7.3b6`, commit
118
+ `9a07d406177e896826e33952fb16c06e21c19000`), whose rendering files iOS 7.3 shares.
119
+ Enclosure support starts with 7.3b3, 7.3b4 keeps the footnote popover inside the
120
+ article column, and 7.3b6 adds `primary_media` and `template7.3.html`. It uses upstream `core.css`, macOS and
88
121
  iOS page skeletons, and `main.js`, `main_mac.js`, `main_ios.js`, and `newsfoot.js`.
89
122
  These files ship inside the `nnw-theme` package, verified by SHA-256, so previews work
90
123
  offline. iPhone and iPad share the iOS skeleton. WebKit native
@@ -63,6 +63,25 @@ img.twemoji, img.emoji {
63
63
  width: auto;
64
64
  }
65
65
 
66
+ /* Primary media: the article’s main video, audio, or image, from the feed */
67
+
68
+ .nnw-primary-media {
69
+ margin: 1em 0;
70
+ }
71
+
72
+ .nnw-primary-image {
73
+ margin: 1em 0;
74
+ }
75
+
76
+ .nnw-primary-image img {
77
+ height: auto;
78
+ }
79
+
80
+ .nnw-primary-image-caption {
81
+ margin: 0.5em 0 0;
82
+ font-size: 0.9em;
83
+ }
84
+
66
85
  /* Enclosures: audio and video players and images after the article body */
67
86
 
68
87
  .nnw-enclosures {
@@ -15,6 +15,18 @@ function wrapFrames() {
15
15
  });
16
16
  }
17
17
 
18
+ // Optimole lazy-loading leaves src as about:blank, with the real URL in data-opt-src.
19
+ // <https://github.com/Ranchero-Software/NetNewsWire/issues/5545>
20
+ function loadLazyIframes() {
21
+ document.querySelectorAll("iframe[data-opt-src]").forEach(element => {
22
+ const src = element.getAttribute("src");
23
+ const lazySrc = element.getAttribute("data-opt-src");
24
+ if ((!src || src === "about:blank") && /^https?:\/\//i.test(lazySrc)) {
25
+ element.src = lazySrc;
26
+ }
27
+ });
28
+ }
29
+
18
30
  // The stylesheet clamps iframes to the article width with max-width: 100%,
19
31
  // which leaves an attribute-sized iframe with its full height — distorted,
20
32
  // and a huge blank box when the embed fails to load. Let the height follow
@@ -292,6 +304,7 @@ function removeInstapaperIgnoreElements() {
292
304
  function processPage() {
293
305
  // stripStyles must run first — wrapFrames sets aspect-ratio on fixed-size iframes, which stripStyles would remove.
294
306
  stripStyles();
307
+ loadLazyIframes();
295
308
  wrapFrames();
296
309
  wrapTables();
297
310
  inlineVideos();
@@ -1,6 +1,6 @@
1
1
  {
2
- "release": "mac-7.3b4",
3
- "commit": "e530be1ab19f68fe322850bf5fb95bca4b36fb91",
2
+ "release": "mac-7.3b6",
3
+ "commit": "9a07d406177e896826e33952fb16c06e21c19000",
4
4
  "files": [
5
5
  {
6
6
  "destination": "LICENSE",
@@ -20,12 +20,12 @@
20
20
  {
21
21
  "destination": "Shared/core.css",
22
22
  "source": "Shared/Article Rendering/core.css",
23
- "sha256": "1cc7d654559b3cd6db1202391c6fcfb8041eaae6fc81850df1be72ebb6921090"
23
+ "sha256": "3a1a56fe6e76e9e11b4180399e6d90b702075224fd3da4e678cf627e83939c5b"
24
24
  },
25
25
  {
26
26
  "destination": "Shared/main.js",
27
27
  "source": "Shared/Article Rendering/main.js",
28
- "sha256": "1c3c1084e295f83668a427b8ae89813a1df621cfb053e8192bbad3f044be3581"
28
+ "sha256": "6be65bcb16be38b686ddf8d96bca3b0d844eee2e138758842b247ab50503dcc4"
29
29
  },
30
30
  {
31
31
  "destination": "Shared/newsfoot.js",
@@ -0,0 +1,209 @@
1
+ // NetNewsWire's search for a body's real images: RSParser's HTMLImageParser and
2
+ // SignificantImageFilter, at the pinned commit. Enclosures and primary media use it to
3
+ // spot a resized copy of an image the body already shows.
4
+ import { Parser } from "htmlparser2";
5
+ const MINIMUM_DIMENSION = 64;
6
+ const MAXIMUM_ASPECT_RATIO = 6;
7
+ const PREFERRED_SRCSET_WIDTH = 1200;
8
+ const LAZY_SRCSET_ATTRIBUTES = ["data-lazy-srcset", "data-srcset"];
9
+ const LAZY_SOURCE_ATTRIBUTES = ["data-lazy-src", "data-src", "data-original"];
10
+ const DECORATIVE_CLASSES = new Set(["emoji", "wp-smiley"]);
11
+ const BLOCKED_FILE_NAMES = new Set(["1x1", "blank", "pixel", "spacer", "transparent"]);
12
+ const BLOCKED_PATH_COMPONENTS = new Set(["open", "track"]);
13
+ const PHOTO_EXTENSIONS = new Set(["avif", "heic", "jpeg", "jpg", "png", "webp"]);
14
+ const BLOCKED_IMAGES = [
15
+ ["addtoany.com", "/"],
16
+ ["feedburner.com", "/"],
17
+ ["feeds.wordpress.com", "/"],
18
+ ["pixel.wp.com", "/"],
19
+ ["s.w.org", "/images/core/emoji/"],
20
+ ["s0.wp.com", "/i/blank.jpg"],
21
+ ["stats.wordpress.com", "/"],
22
+ ];
23
+ const SHARING_LINKS = [
24
+ ["addtoany.com", "/"],
25
+ ["bsky.app", "/intent/"],
26
+ ["digg.com", "/submit"],
27
+ ["facebook.com", "/dialog/"],
28
+ ["facebook.com", "/share"],
29
+ ["flipboard.com", "/bookmarklet"],
30
+ ["getpocket.com", "/save"],
31
+ ["linkedin.com", "/cws/share"],
32
+ ["linkedin.com", "/sharearticle"],
33
+ ["linkedin.com", "/sharing/"],
34
+ ["news.ycombinator.com", "/submitlink"],
35
+ ["pinterest.com", "/pin/create"],
36
+ ["reddit.com", "/submit"],
37
+ ["shareaholic.com", "/"],
38
+ ["sharethis.com", "/"],
39
+ ["t.me", "/share"],
40
+ ["threads.net", "/intent/"],
41
+ ["tumblr.com", "/share"],
42
+ ["tumblr.com", "/widgets/share"],
43
+ ["twitter.com", "/intent/"],
44
+ ["twitter.com", "/share"],
45
+ ["whatsapp.com", "/send"],
46
+ ["x.com", "/intent/"],
47
+ ["x.com", "/share"],
48
+ ];
49
+ function matches(url, prefixes) {
50
+ const host = url.hostname.toLowerCase();
51
+ const path = url.pathname.toLowerCase();
52
+ return prefixes.some(([domain, prefix]) => (host === domain || host.endsWith(`.${domain}`)) && path.startsWith(prefix));
53
+ }
54
+ function parsed(value) {
55
+ try {
56
+ return new URL(value);
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ }
62
+ function hasSignificantDimensions(width, height) {
63
+ if (width !== undefined && width < MINIMUM_DIMENSION)
64
+ return false;
65
+ if (height !== undefined && height < MINIMUM_DIMENSION)
66
+ return false;
67
+ if (width !== undefined && height !== undefined) {
68
+ return Math.max(width, height) / Math.min(width, height) <= MAXIMUM_ASPECT_RATIO;
69
+ }
70
+ return true;
71
+ }
72
+ function isGravatar(url) {
73
+ const host = url.hostname.toLowerCase();
74
+ return ((host === "gravatar.com" || host.endsWith(".gravatar.com")) &&
75
+ url.pathname.startsWith("/avatar/"));
76
+ }
77
+ function isBlockedURL(value) {
78
+ const url = parsed(value);
79
+ if (!url || !["http:", "https:"].includes(url.protocol) || !url.hostname)
80
+ return true;
81
+ const components = url.pathname.split("/").filter(Boolean);
82
+ const name = components.at(-1) ?? "";
83
+ const dot = name.lastIndexOf(".");
84
+ const extension = dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
85
+ if (extension === "svg")
86
+ return true;
87
+ if (BLOCKED_FILE_NAMES.has((dot > 0 ? name.slice(0, dot) : name).toLowerCase()))
88
+ return true;
89
+ if (!PHOTO_EXTENSIONS.has(extension) &&
90
+ components.some((component) => BLOCKED_PATH_COMPONENTS.has(component.toLowerCase()))) {
91
+ return true;
92
+ }
93
+ return matches(url, BLOCKED_IMAGES) || isGravatar(url);
94
+ }
95
+ function isSharingLink(value) {
96
+ const url = value === undefined ? undefined : parsed(value);
97
+ if (!url)
98
+ return false;
99
+ return url.protocol === "mailto:" || (url.hostname !== "" && matches(url, SHARING_LINKS));
100
+ }
101
+ function hasDecorativeClass(htmlClass) {
102
+ return (htmlClass ?? "")
103
+ .split(/\s+/)
104
+ .some((name) => DECORATIVE_CLASSES.has(name.toLowerCase()));
105
+ }
106
+ /** Foundation's URL(string:relativeTo:).absoluteString; undefined for data: or empty. */
107
+ function resolved(value, baseURL) {
108
+ if (value === undefined || /^\s*data:/i.test(value))
109
+ return undefined;
110
+ const trimmed = value.trim();
111
+ if (!trimmed)
112
+ return undefined;
113
+ try {
114
+ return new URL(trimmed, baseURL || undefined).href;
115
+ }
116
+ catch {
117
+ return undefined;
118
+ }
119
+ }
120
+ /** The narrowest candidate at least 1200w wide, else the one with the largest descriptor. */
121
+ function preferredSrcsetCandidate(srcset) {
122
+ let narrowest;
123
+ let largest;
124
+ for (const match of srcset.matchAll(/[\s,]*([^\s,][^\s]*)([^,]*)/g)) {
125
+ let url = match[1] ?? "";
126
+ const trailingComma = url.endsWith(",");
127
+ url = url.replace(/,+$/, "");
128
+ const descriptor = trailingComma ? "" : ((match[2] ?? "").trim().split(/\s+/)[0] ?? "");
129
+ if (!url)
130
+ continue;
131
+ const size = Number.parseFloat(descriptor) || 1;
132
+ if (descriptor.endsWith("w") &&
133
+ size >= PREFERRED_SRCSET_WIDTH &&
134
+ size < (narrowest?.size ?? Number.POSITIVE_INFINITY)) {
135
+ narrowest = { url, size };
136
+ }
137
+ if (size > (largest?.size ?? Number.NEGATIVE_INFINITY))
138
+ largest = { url, size };
139
+ }
140
+ return (narrowest ?? largest)?.url;
141
+ }
142
+ function styleProperties(style) {
143
+ const properties = new Map();
144
+ for (const declaration of (style ?? "").split(";")) {
145
+ const colon = declaration.indexOf(":");
146
+ if (colon === -1)
147
+ continue;
148
+ properties.set(declaration.slice(0, colon).trim().toLowerCase(), declaration
149
+ .slice(colon + 1)
150
+ .trim()
151
+ .toLowerCase());
152
+ }
153
+ return properties;
154
+ }
155
+ /** "300" and "300px" are pixel dimensions; percentages and other units are not. */
156
+ function pixelDimension(value) {
157
+ const match = value
158
+ ?.trim()
159
+ .toLowerCase()
160
+ .match(/^([+-]?\d+)(?:px)?$/);
161
+ return match ? Number(match[1]) : undefined;
162
+ }
163
+ /** HTMLImageParser.significantImageURLStrings: absolute URLs, in document order. */
164
+ export function significantImageURLs(html, baseURL) {
165
+ const found = new Set();
166
+ let enclosingLink;
167
+ const parser = new Parser({
168
+ onopentag(name, attributes) {
169
+ if (name === "a") {
170
+ enclosingLink = resolved(attributes.href, baseURL);
171
+ return;
172
+ }
173
+ if (name !== "img")
174
+ return;
175
+ const style = styleProperties(attributes.style);
176
+ if (attributes.hidden !== undefined || style.get("display")?.startsWith("none"))
177
+ return;
178
+ const width = pixelDimension(style.get("width")) ?? pixelDimension(attributes.width);
179
+ const height = pixelDimension(style.get("height")) ?? pixelDimension(attributes.height);
180
+ if (!hasSignificantDimensions(width, height))
181
+ return;
182
+ if (hasDecorativeClass(attributes.class) || isSharingLink(enclosingLink))
183
+ return;
184
+ const sources = [
185
+ ...LAZY_SRCSET_ATTRIBUTES.map((key) => attributes[key])
186
+ .filter((value) => value !== undefined)
187
+ .map(preferredSrcsetCandidate),
188
+ ...LAZY_SOURCE_ATTRIBUTES.map((key) => attributes[key]),
189
+ attributes.srcset === undefined
190
+ ? undefined
191
+ : preferredSrcsetCandidate(attributes.srcset),
192
+ attributes.src,
193
+ ];
194
+ for (const source of sources) {
195
+ const url = resolved(source, baseURL);
196
+ if (url !== undefined && !isBlockedURL(url)) {
197
+ found.add(url);
198
+ return;
199
+ }
200
+ }
201
+ },
202
+ onclosetag(name) {
203
+ if (name === "a")
204
+ enclosingLink = undefined;
205
+ },
206
+ }, { decodeEntities: true, lowerCaseTags: true, lowerCaseAttributeNames: true });
207
+ parser.end(html);
208
+ return [...found];
209
+ }
package/dist/browser.js CHANGED
@@ -11,7 +11,11 @@ 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
13
  const ENCLOSURE_CHECK = readFileSync(packagePath("assets", "enclosures.js"), "utf8");
14
- const ENCLOSURES_MACRO = "[[enclosures]]";
14
+ /** Macros older NetNewsWire leaves as text, and what explains each when it shows. */
15
+ const NEWER_MACROS = {
16
+ "[[enclosures]]": "without enclosure support; hide its element when it holds no .nnw-enclosures",
17
+ "[[primary_media]]": "before 7.3b6; hide its element when it holds no .nnw-primary-media, or use it only in template7.3.html",
18
+ };
15
19
  const LIVE_RELOAD = readFileSync(packagePath("assets", "live-reload.js"), "utf8");
16
20
  const NOT_INSTALLED = "WebKit is not installed; run `npx nnw-theme@2 setup`";
17
21
  const CONTENT_TYPES = {
@@ -150,9 +154,8 @@ export function failures(state) {
150
154
  found.push("article content is missing or unreadable");
151
155
  if (state.unresolved)
152
156
  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");
157
+ for (const macro of state.literalMacros ?? []) {
158
+ found.push(`${macro} shows as text in NetNewsWire releases ${NEWER_MACROS[macro]}`);
156
159
  }
157
160
  if (state.overflow)
158
161
  found.push("horizontal document overflow");
@@ -185,9 +188,9 @@ async function loadImages(timeout) {
185
188
  new Promise((resolve) => setTimeout(resolve, timeout)),
186
189
  ]);
187
190
  }
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 }) {
191
+ // Runs in the page after its screenshot. With olderNetNewsWire, the macros in `macros`
192
+ // are expected in the markup and fail only if they show as text.
193
+ function pageState({ olderNetNewsWire, macros, }) {
191
194
  const article = document.querySelector(".articleBody");
192
195
  const root = document.documentElement;
193
196
  const html = document.body.innerHTML;
@@ -196,8 +199,12 @@ function pageState({ olderNetNewsWire, macro }) {
196
199
  textLength: (article?.innerText || "").trim().length,
197
200
  // Body only: macOS CSS legitimately keeps the font-size macro literal. Match
198
201
  // whole macro names, so a theme script's regex such as /[[(]/ is no macro.
199
- unresolved: /\[\[[A-Za-z0-9_-]+\]\]/.test(olderNetNewsWire ? html.replaceAll(macro, "") : html),
200
- literalEnclosures: olderNetNewsWire && document.body.innerText.includes(macro),
202
+ unresolved: /\[\[[A-Za-z0-9_-]+\]\]/.test(olderNetNewsWire
203
+ ? macros.reduce((text, macro) => text.replaceAll(macro, ""), html)
204
+ : html),
205
+ literalMacros: olderNetNewsWire
206
+ ? macros.filter((macro) => document.body.innerText.includes(macro))
207
+ : [],
201
208
  overflow: root.scrollWidth > root.clientWidth + 1,
202
209
  brokenImages: [...document.images]
203
210
  .filter((image) => image.complete && image.naturalWidth === 0)
@@ -299,7 +306,7 @@ async function checkPage(browser, origin, url, target, screenshot, footnotes) {
299
306
  await page.screenshot({ path: screenshot, fullPage: !target.card });
300
307
  const state = await page.evaluate(pageState, {
301
308
  olderNetNewsWire: target.olderNetNewsWire,
302
- macro: ENCLOSURES_MACRO,
309
+ macros: Object.keys(NEWER_MACROS),
303
310
  });
304
311
  const enclosures = (await page.evaluate(`(${ENCLOSURE_CHECK})()`));
305
312
  // Last: it clicks footnotes open, so it must not disturb the screenshot.
@@ -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 { allFixtures, checkTargets, extraFixtures, placesEnclosures, readDeliveries, renderSite, writeGallery, } from "../render.js";
9
+ import { allFixtures, checkTargets, extraFixtures, readDeliveries, renderSite, rendersDifferentlyWhenOlder, 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";
@@ -45,7 +45,7 @@ export default async function check({ values }) {
45
45
  printWarnings(staleStubs(root));
46
46
  const extras = extraFixtures(root);
47
47
  const targets = checkTargets(extras, {
48
- enclosuresMacro: placesEnclosures(theme),
48
+ olderNetNewsWire: rendersDifferentlyWhenOlder(theme),
49
49
  deliveries: readDeliveries(root, allFixtures(extras)),
50
50
  });
51
51
  const expected = expectations(root, targets);
@@ -1,7 +1,9 @@
1
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.
2
+ // (Modules/Articles/Sources/Articles/Attachment.swift) and the [[enclosures]] and
3
+ // [[primary_media]] HTML (Shared/Article Rendering/ArticleRenderer+Enclosures.swift and
4
+ // ArticleRenderer+PrimaryMedia.swift), at the pinned commit.
4
5
  // Keep it line for line with the Swift, so a diff of the two stays easy to review.
6
+ import { significantImageURLs } from "./body-images.js";
5
7
  import { ThemeError } from "./project.js";
6
8
  export const MEDIA = ["image", "audio", "video", "document", "executable"];
7
9
  const STRING_KEYS = ["url", "mime_type", "title"];
@@ -46,24 +48,26 @@ export function fixtureAttachments(fixture, name) {
46
48
  return table.url ? [table] : [];
47
49
  });
48
50
  }
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"]) {
51
+ /** A fixture's image_url and thumbnail_url: Article.imageLink and thumbnailLink. */
52
+ export function fixtureImages(fixture, name) {
53
+ const text = (key) => {
52
54
  const value = fixture[key];
53
- if (value === undefined)
54
- continue;
55
- if (typeof value !== "string")
55
+ if (value !== undefined && typeof value !== "string") {
56
56
  throw new ThemeError(`${name}: ${key} must be a string`);
57
+ }
57
58
  return value;
58
- }
59
- return undefined;
59
+ };
60
+ return { imageLink: text("image_url"), thumbnailLink: text("thumbnail_url") };
60
61
  }
61
62
  const AUDIO_EXTENSIONS = new Set(["mp3", "m4a", "aac", "wav", "ogg", "oga", "opus", "flac"]);
62
63
  const VIDEO_EXTENSIONS = new Set(["mp4", "m4v", "mov", "webm"]);
63
64
  const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "webp", "heic", "avif"]);
64
- const VISUAL_ELEMENT_TAGS = ["<img", "<picture", "<video", "<iframe"];
65
65
  const YOUTUBE_FLASH_PLAYER_PATH_PREFIX = "/v/";
66
66
  const YOUTUBE_EMBED_URL_PREFIX = "https://www.youtube-nocookie.com/embed/";
67
+ const YOUTUBE_EMBED_PATHS = ["youtube.com/embed/", "youtube-nocookie.com/embed/"];
68
+ const WORDPRESS_SIZE_SUFFIX = /-\d+x\d+(?=\.[A-Za-z0-9]+$)/;
69
+ const ENGADGET_LARGE_IMAGE_PREFIX = "l-";
70
+ const SHORT_POST_MAXIMUM_BODY_LENGTH = 2048;
67
71
  /** Foundation's URL(string:), enough for host and path: relative URLs have no host. */
68
72
  function parseURL(value) {
69
73
  try {
@@ -207,20 +211,64 @@ function dimensionAttributes(attachment) {
207
211
  return "";
208
212
  return ` width="${attachment.width}" height="${attachment.height}"`;
209
213
  }
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
214
  /** Body HTML escapes `&` in attributes, so check both forms of the URL. */
221
215
  function bodyContainsURL(body, url) {
222
216
  return body.includes(url) || body.includes(escapeXML(url));
223
217
  }
218
+ /** URL.pathComponents without the root, percent-decoded. */
219
+ function pathComponents(value) {
220
+ const path = parseURL(value)?.path;
221
+ if (path === undefined)
222
+ return undefined;
223
+ return path
224
+ .split("/")
225
+ .filter(Boolean)
226
+ .map((component) => {
227
+ try {
228
+ return decodeURIComponent(component);
229
+ }
230
+ catch {
231
+ return component;
232
+ }
233
+ });
234
+ }
235
+ /** ArticleRenderingSpecialCases.bodyEmbedsYouTubeVideo(stillImageURL:body:). */
236
+ function bodyEmbedsYouTubeVideo(stillImageURL, body) {
237
+ return (pathComponents(stillImageURL) ?? []).some((component) => /^[A-Za-z0-9_-]{11}$/.test(component) &&
238
+ YOUTUBE_EMBED_PATHS.some((path) => body.includes(path + component)));
239
+ }
240
+ function isEngadgetImage(value) {
241
+ const host = parseURL(value)
242
+ ?.host.toLowerCase()
243
+ .replace(/^www\./, "") ?? "";
244
+ return host === "engadget.com" || host.endsWith(".engadget.com");
245
+ }
246
+ /**
247
+ * The last two path components, lowercased, without a WordPress size suffix or Engadget
248
+ * size prefix. Ignores the host and query: resized copies often come through a service.
249
+ */
250
+ function imageIdentity(value) {
251
+ const components = pathComponents(value);
252
+ const last = components?.at(-1);
253
+ if (!components || last === undefined)
254
+ return undefined;
255
+ const name = (last.startsWith(ENGADGET_LARGE_IMAGE_PREFIX) && isEngadgetImage(value)
256
+ ? last.slice(ENGADGET_LARGE_IMAGE_PREFIX.length)
257
+ : last).replace(WORDPRESS_SIZE_SUFFIX, "");
258
+ return [...components.slice(0, -1).slice(-1), name].join("/").toLowerCase();
259
+ }
260
+ /** ArticleRenderer.bodyImageIdentities: for spotting a resized copy of a body image. */
261
+ export function bodyImageIdentities(body, baseURL) {
262
+ return new Set(significantImageURLs(body, baseURL)
263
+ .map(imageIdentity)
264
+ .filter((identity) => identity !== undefined));
265
+ }
266
+ function bodyContains(url, body, identities) {
267
+ if (bodyContainsURL(body, url) || bodyEmbedsYouTubeVideo(url, body))
268
+ return true;
269
+ const identity = imageIdentity(url);
270
+ return identity !== undefined && identities.has(identity);
271
+ }
224
272
  function enclosureHTML(attachment, posterURL) {
225
273
  const youTube = youTubeEmbedURL(attachment.url);
226
274
  if (youTube !== undefined) {
@@ -259,32 +307,118 @@ function enclosureHTML(attachment, posterURL) {
259
307
  return `${html}</div>`;
260
308
  }
261
309
  /**
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.
310
+ * HTML for the [[enclosures]] substitution, empty when there's nothing to show. Skips
311
+ * attachments already in the body, including resized copies. posterURL is a video's poster.
265
312
  */
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
- }
313
+ export function enclosuresHTML(attachments, body, identities, posterURL) {
314
+ const enclosures = attachments
315
+ .filter((attachment) => !bodyContains(attachment.url, body, identities))
316
+ .map((attachment) => enclosureHTML(attachment, posterURL))
317
+ .filter((enclosure) => enclosure !== undefined);
284
318
  if (!enclosures.length)
285
319
  return "";
286
320
  return `<div class="nnw-enclosures">\n${enclosures.join("\n")}\n</div>`;
287
321
  }
322
+ /** Untitled and short, as Mastodon posts are: their images follow the text. */
323
+ function isShortUntitledPost(title, body) {
324
+ if (title)
325
+ return false;
326
+ return [...new Intl.Segmenter().segment(body)].length < SHORT_POST_MAXIMUM_BODY_LENGTH;
327
+ }
328
+ function isVideo(attachment) {
329
+ return displayMedium(attachment) === "video" || youTubeEmbedURL(attachment.url) !== undefined;
330
+ }
331
+ /** The article image, else the first image attachment, else the thumbnail; undefined if in the body. */
332
+ function primaryImage(imageLink, thumbnailLink, attachments, body, identities) {
333
+ let image;
334
+ if (imageLink) {
335
+ image = attachments.find((attachment) => attachment.url === imageLink) ?? {
336
+ url: imageLink,
337
+ medium: "image",
338
+ };
339
+ }
340
+ else {
341
+ image = attachments.find((attachment) => displayMedium(attachment) === "image");
342
+ if (!image && thumbnailLink)
343
+ image = { url: thumbnailLink, medium: "image" };
344
+ }
345
+ return image && !bodyContains(image.url, body, identities) ? image : undefined;
346
+ }
347
+ function collapsingWhitespace(value) {
348
+ return value.split(/\s+/).filter(Boolean).join(" ").toLowerCase();
349
+ }
350
+ function primaryImageHTML(image, articleTitle) {
351
+ let attributes = `src="${escapeXML(image.url)}"`;
352
+ let caption = "";
353
+ if (image.title) {
354
+ const title = escapeXML(image.title);
355
+ attributes += ` alt="${title}" title="${title}"`;
356
+ if (collapsingWhitespace(image.title) !== collapsingWhitespace(articleTitle)) {
357
+ caption = `<div class="nnw-primary-image-caption">${title}</div>`;
358
+ }
359
+ }
360
+ else {
361
+ attributes += ' alt=""';
362
+ }
363
+ return `<div class="nnw-primary-image"><img ${attributes}${dimensionAttributes(image)}>${caption}</div>`;
364
+ }
365
+ /** A video, else audio with the article image, else the article image. */
366
+ function primaryMedia(article, identities, includesImage) {
367
+ const { attachments, imageLink, thumbnailLink, title, body } = article;
368
+ const wrap = (html) => `<div class="nnw-primary-media">${html}</div>`;
369
+ const playable = attachments.filter(({ url }) => !bodyContains(url, body, identities));
370
+ const posterURL = imageLink ?? thumbnailLink;
371
+ const video = playable.find(isVideo);
372
+ const videoHTML = video && enclosureHTML(video, posterURL);
373
+ if (video && videoHTML !== undefined) {
374
+ const urls = new Set([video.url]);
375
+ if (posterURL !== undefined)
376
+ urls.add(posterURL);
377
+ return { html: wrap(videoHTML), urls };
378
+ }
379
+ let imageHTML = "";
380
+ const urls = new Set();
381
+ const image = includesImage
382
+ ? primaryImage(imageLink, thumbnailLink, attachments, body, identities)
383
+ : undefined;
384
+ if (image) {
385
+ imageHTML = primaryImageHTML(image, title);
386
+ urls.add(image.url);
387
+ }
388
+ const audio = playable.find((attachment) => displayMedium(attachment) === "audio");
389
+ const audioHTML = audio && enclosureHTML(audio, undefined);
390
+ if (audio && audioHTML !== undefined) {
391
+ urls.add(audio.url);
392
+ return { html: wrap(imageHTML + audioHTML), urls };
393
+ }
394
+ return imageHTML ? { html: wrap(imageHTML), urls } : undefined;
395
+ }
396
+ /**
397
+ * The [[primary_media]] and [[enclosures]] HTML, as articleSubstitutions() builds them.
398
+ * A template without [[primary_media]], or a short untitled post, gets the article image
399
+ * in the enclosures instead; only [[primary_media]] uses the thumbnail as an image.
400
+ */
401
+ export function articleMediaHTML(article, hasPrimaryMedia) {
402
+ const { imageLink, thumbnailLink, title, body } = article;
403
+ const identities = bodyImageIdentities(body, article.baseURL);
404
+ let attachments = [...article.attachments];
405
+ const showsPrimaryImage = hasPrimaryMedia && !isShortUntitledPost(title, body);
406
+ const primary = hasPrimaryMedia
407
+ ? primaryMedia(article, identities, showsPrimaryImage)
408
+ : undefined;
409
+ if (primary)
410
+ attachments = attachments.filter(({ url }) => !primary.urls.has(url));
411
+ if (!showsPrimaryImage) {
412
+ const image = primaryImage(imageLink, undefined, attachments, body, identities);
413
+ if (image && !primary?.urls.has(image.url) && !attachments.includes(image)) {
414
+ attachments.unshift(image);
415
+ }
416
+ }
417
+ return {
418
+ primaryMedia: primary?.html ?? "",
419
+ enclosures: enclosuresHTML(attachments, body, identities, imageLink ?? thumbnailLink),
420
+ };
421
+ }
288
422
  /**
289
423
  * The body and [[enclosures]] values, as ArticleRenderer.articleSubstitutions() sets
290
424
  * them. A template without the macro gets its enclosures after the body, which a
package/dist/project.js CHANGED
@@ -5,6 +5,8 @@ import { parse as parseToml } from "smol-toml";
5
5
  import { BUILT_IN_FIXTURE_NAMES, CATALOG_FIXTURE_NAMES } from "./commands.js";
6
6
  import { buildPlist, parsePlist } from "./plist.js";
7
7
  export const REQUIRED_THEME_FILES = ["Info.plist", "template.html", "stylesheet.css"];
8
+ /** NetNewsWire 7.3 reads this instead of template.html; older releases ignore it. */
9
+ export const TEMPLATE_7_3_FILE = "template7.3.html";
8
10
  export const PLACEHOLDER_MARKER = ".nnw-theme-uninitialized";
9
11
  export const IDENTITY_START = "<!-- nnw-theme-identity:start -->";
10
12
  export const IDENTITY_END = "<!-- nnw-theme-identity:end -->";
package/dist/render.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
3
  import { extname, isAbsolute, join, relative } from "node:path";
4
4
  import { decodeHTML } from "entities";
5
5
  import { Parser } from "htmlparser2";
6
6
  import { DELIVERIES, DELIVERY_NAMES, deliver } from "./delivery.js";
7
- import { enclosuresHTML, fixtureAttachments, fixtureImageURL, placeEnclosures, } from "./enclosures.js";
7
+ import { articleMediaHTML, fixtureAttachments, fixtureImages, placeEnclosures, } from "./enclosures.js";
8
8
  import { renderingInputs } from "./netnewswire.js";
9
- import { fixturePath, readFixture, repositoryFixtures, ThemeError, themeStem, } from "./project.js";
9
+ import { fixturePath, readFixture, repositoryFixtures, TEMPLATE_7_3_FILE, ThemeError, themeStem, } from "./project.js";
10
10
  import { formatG, pyStr } from "./pyformat.js";
11
11
  const MACRO_RE = /\[\[([a-zA-Z0-9_-]+)\]\]/g;
12
12
  // A whole <img> or <source> tag; quoted attribute values may contain ">".
@@ -124,8 +124,9 @@ const SCENARIOS = [
124
124
  [
125
125
  "older-netnewswire",
126
126
  "Older NetNewsWire",
127
- "The article fixture as NetNewsWire releases before enclosure support render it: " +
128
- "they leave the template's [[enclosures]] macro as literal text, which must not show.",
127
+ "The article fixture as NetNewsWire releases before enclosures and primary media " +
128
+ "render it: template.html, with [[enclosures]] and [[primary_media]] left as " +
129
+ "literal text, which must not show.",
129
130
  ],
130
131
  [
131
132
  "article-javascript-off",
@@ -153,7 +154,7 @@ export class RenderTarget {
153
154
  height;
154
155
  largeText;
155
156
  themeScripts;
156
- /** Rendered as NetNewsWire before [[enclosures]]: the macro stays literal. */
157
+ /** Rendered as older NetNewsWire: template.html, macros it lacks left literal. */
157
158
  olderNetNewsWire;
158
159
  /** A 2x capture of the card viewport alone, for the marketplace card. */
159
160
  card;
@@ -280,11 +281,10 @@ export function repositoryTargets(root) {
280
281
  return normalTargets(extras, readDeliveries(root, allFixtures(extras)));
281
282
  }
282
283
  /**
283
- * Everything check renders. A template that places [[enclosures]] itself also gets
284
- * the older-NetNewsWire cases, since releases before enclosure support leave that
285
- * macro as text in every article.
284
+ * Everything check renders. A theme whose older releases render differently (see
285
+ * rendersDifferentlyWhenOlder) also gets the older-NetNewsWire cases.
286
286
  */
287
- export function checkTargets(extras = [], { enclosuresMacro = false, deliveries = {} } = {}) {
287
+ export function checkTargets(extras = [], { olderNetNewsWire = false, deliveries = {} } = {}) {
288
288
  const targets = [
289
289
  ...normalTargets(extras, deliveries),
290
290
  new RenderTarget("kitchen-sink", "mac", "light", { largeText: true }),
@@ -292,14 +292,27 @@ export function checkTargets(extras = [], { enclosuresMacro = false, deliveries
292
292
  new RenderTarget("article", "mac", "light", { themeScripts: false }),
293
293
  new RenderTarget("article", "iphone", "light", { themeScripts: false }),
294
294
  ];
295
- if (enclosuresMacro) {
295
+ if (olderNetNewsWire) {
296
296
  targets.push(new RenderTarget("article", "mac", "light", { olderNetNewsWire: true }), new RenderTarget("article", "iphone", "dark", { olderNetNewsWire: true }));
297
297
  }
298
298
  return targets;
299
299
  }
300
- /** Whether a theme places enclosures itself, as NetNewsWire decides it. */
301
- export function placesEnclosures(theme) {
302
- return read(join(theme, "template.html")).includes("[[enclosures]]");
300
+ /**
301
+ * Whether older NetNewsWire renders this theme differently: it ignores template7.3.html,
302
+ * and leaves a macro it doesn't know as text in every article.
303
+ */
304
+ export function rendersDifferentlyWhenOlder(theme) {
305
+ if (existsSync(join(theme, TEMPLATE_7_3_FILE)))
306
+ return true;
307
+ const template = read(join(theme, "template.html"));
308
+ return ["[[enclosures]]", "[[primary_media]]"].some((macro) => template.includes(macro));
309
+ }
310
+ /** The template NetNewsWire reads: 7.3 prefers template7.3.html, older releases can't. */
311
+ function themeTemplate(theme, target) {
312
+ const preferred = join(theme, TEMPLATE_7_3_FILE);
313
+ return read(!target.olderNetNewsWire && existsSync(preferred)
314
+ ? preferred
315
+ : join(theme, "template.html"));
303
316
  }
304
317
  /** Perform NetNewsWire's single-pass, non-recursive macro substitution. */
305
318
  export function substitute(template, mapping) {
@@ -460,10 +473,17 @@ function fixtureMapping(fixture, target, template, media) {
460
473
  if (target.delivery)
461
474
  body = deliver(body, target.delivery, mapping.preferred_link ?? "");
462
475
  mapping.body = offlineMedia(body, media);
463
- // NetNewsWire before enclosure support ignores attachments and leaves the macro.
476
+ // NetNewsWire before these macros ignores attachments and leaves them literal.
464
477
  if (!target.olderNetNewsWire) {
465
478
  const name = `fixtures/${target.fixture}.toml`;
466
- const enclosures = enclosuresHTML(fixtureAttachments(fixture, name), body, fixtureImageURL(fixture, name));
479
+ const { primaryMedia, enclosures } = articleMediaHTML({
480
+ attachments: fixtureAttachments(fixture, name),
481
+ ...fixtureImages(fixture, name),
482
+ title: mapping.title ?? "",
483
+ body,
484
+ baseURL: pageBaseURL(fixture, target),
485
+ }, template.includes("[[primary_media]]"));
486
+ mapping.primary_media = offlineEnclosures(primaryMedia, media);
467
487
  Object.assign(mapping, placeEnclosures(template, mapping.body, offlineEnclosures(enclosures, media)));
468
488
  }
469
489
  if (!mapping.avatar_src || fetches(mapping.avatar_src)) {
@@ -588,7 +608,7 @@ export function renderPage(theme, fixture, target, snapshot = renderingInputs().
588
608
  styleSubstitutions["font-size"] = formatG(Number(fixture.font_size ?? size));
589
609
  }
590
610
  const style = substitute(`${read(join(snapshot, "Shared", "core.css"))}\n${stylesheet}`, styleSubstitutions);
591
- const template = read(join(theme, "template.html"));
611
+ const template = themeTemplate(theme, target);
592
612
  let body = substitute(template, fixtureMapping(fixture, target, template, media));
593
613
  if (!target.themeScripts)
594
614
  body = removeScripts(body);
package/dist/validate.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from "node
2
2
  import { dirname, join } from "node:path";
3
3
  import { Parser } from "htmlparser2";
4
4
  import { PlistReal, parsePlist } from "./plist.js";
5
- import { PLACEHOLDER_MARKER, REQUIRED_THEME_FILES, readPlist, ThemeError, themeStem, } from "./project.js";
5
+ import { PLACEHOLDER_MARKER, REQUIRED_THEME_FILES, readPlist, TEMPLATE_7_3_FILE, ThemeError, themeStem, } from "./project.js";
6
6
  import { pyRepr } from "./pyformat.js";
7
7
  import { urlsplit } from "./urlparse.js";
8
8
  import { readZip } from "./zip.js";
@@ -67,6 +67,7 @@ function isRemote(value) {
67
67
  }
68
68
  function isAllowedFile(name) {
69
69
  return (REQUIRED_THEME_FILES.includes(name) ||
70
+ name === TEMPLATE_7_3_FILE ||
70
71
  OPTIONAL_THEME_FILE.test(name.toUpperCase()));
71
72
  }
72
73
  function isInt(value) {
@@ -148,38 +149,40 @@ export function validateSource(theme, { allowRemoteMedia = false } = {}) {
148
149
  const metadataReport = validateMetadata(readPlist(theme), stem);
149
150
  report.errors.push(...metadataReport.errors);
150
151
  report.warnings.push(...metadataReport.warnings);
151
- const templatePath = join(theme, "template.html");
152
- if (isRegularFile(templatePath)) {
153
- const template = readFileSync(templatePath, "utf8");
154
- if (!template.includes("[[")) {
155
- report.warnings.push("template.html contains no NetNewsWire macros");
156
- }
157
- const commented = commentedMacros(template);
158
- if (commented.length) {
159
- report.errors.push(`template.html has macros inside an HTML comment (${commented[0]}` +
160
- `${commented.length > 1 ? ` and ${commented.length - 1} more` : ""}). ` +
161
- "NetNewsWire substitutes them there too, so an article containing --> " +
162
- "ends the comment early and the rest shows as text; " +
163
- "write macro names without double brackets");
164
- }
165
- const references = resourceReferences(template);
166
- if (/<style\b[^>]*>[\s\S]*?@import\s/i.test(template)) {
167
- report.errors.push("CSS @import is not allowed in template.html");
168
- }
169
- for (const [tag, attribute, value] of references) {
170
- if (!value ||
171
- ["data:", "#", "mailto:", "tel:"].some((prefix) => value.startsWith(prefix))) {
172
- continue;
152
+ for (const templateName of ["template.html", TEMPLATE_7_3_FILE]) {
153
+ const templatePath = join(theme, templateName);
154
+ if (isRegularFile(templatePath)) {
155
+ const template = readFileSync(templatePath, "utf8");
156
+ if (!template.includes("[[")) {
157
+ report.warnings.push(`${templateName} contains no NetNewsWire macros`);
173
158
  }
174
- if (tag === "script" || tag === "stylesheet") {
175
- report.errors.push(`external ${tag} is not allowed: ${value}`);
159
+ const commented = commentedMacros(template);
160
+ if (commented.length) {
161
+ report.errors.push(`${templateName} has macros inside an HTML comment (${commented[0]}` +
162
+ `${commented.length > 1 ? ` and ${commented.length - 1} more` : ""}). ` +
163
+ "NetNewsWire substitutes them there too, so an article containing --> " +
164
+ "ends the comment early and the rest shows as text; " +
165
+ "write macro names without double brackets");
176
166
  }
177
- else if (isRemote(value)) {
178
- remoteMessage(report, `remote theme-owned ${tag} may not load in NetNewsWire: ${value}`, allowRemoteMedia);
167
+ const references = resourceReferences(template);
168
+ if (/<style\b[^>]*>[\s\S]*?@import\s/i.test(template)) {
169
+ report.errors.push(`CSS @import is not allowed in ${templateName}`);
179
170
  }
180
- else if (!value.includes("[[")) {
181
- report.errors.push("bundle-local resource references do not work in NetNewsWire: " +
182
- `${attribute}=${pyRepr(value)}`);
171
+ for (const [tag, attribute, value] of references) {
172
+ if (!value ||
173
+ ["data:", "#", "mailto:", "tel:"].some((prefix) => value.startsWith(prefix))) {
174
+ continue;
175
+ }
176
+ if (tag === "script" || tag === "stylesheet") {
177
+ report.errors.push(`external ${tag} is not allowed: ${value}`);
178
+ }
179
+ else if (isRemote(value)) {
180
+ remoteMessage(report, `remote theme-owned ${tag} may not load in NetNewsWire: ${value}`, allowRemoteMedia);
181
+ }
182
+ else if (!value.includes("[[")) {
183
+ report.errors.push("bundle-local resource references do not work in NetNewsWire: " +
184
+ `${attribute}=${pyRepr(value)}`);
185
+ }
183
186
  }
184
187
  }
185
188
  }
package/lldb/nnwdump.py CHANGED
@@ -6,7 +6,7 @@ writes it as a TOML fixture. `npx nnw-theme@2 capture` prints the setup steps.
6
6
 
7
7
  NetNewsWire with enclosure support renders an article's enclosures into that
8
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
9
+ [[enclosures]] macro, and 7.3b6 and later add `primary_media`. nnwdump keeps none of them. It records the article's attachments
10
10
  and image instead, as [[attachments]] tables and image_url/thumbnail_url, so nnw-theme
11
11
  renders them for whichever template previews the fixture. When the page's base URL
12
12
  differs from the article's link, as for an article synced through the Reader API, it
@@ -34,6 +34,7 @@ DEFAULT_OUTPUT = "fixtures/nnw-capture.toml"
34
34
  # Sorted last so the short metadata stays at the top of the file.
35
35
  _HTML_KEYS = {"body"}
36
36
  _ENCLOSURES_KEY = "enclosures"
37
+ _PRIMARY_MEDIA_KEY = "primary_media"
37
38
  # ArticleRenderer's body for a template without [[enclosures]]: <div>BODY</div>ENCLOSURES.
38
39
  _WRAPPED_BODY_TAIL = '</div><div class="nnw-enclosures">'
39
40
  # Attachment's Codable keys, in fixture order, with their fixture names.
@@ -88,6 +89,7 @@ def without_enclosures(pairs: list[tuple[str, str]]) -> list[tuple[str, str]]:
88
89
  if body.startswith("<div>") and at != -1:
89
90
  values["body"] = body[len("<div>") : at]
90
91
  values.pop(_ENCLOSURES_KEY, None)
92
+ values.pop(_PRIMARY_MEDIA_KEY, None)
91
93
  return list(values.items())
92
94
 
93
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nnw-theme",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Create, preview, check, and publish a NetNewsWire theme.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Marquard",