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.
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/assets/enclosures.js +6 -4
- package/assets/guide/design-checklist.md +2 -1
- package/assets/guide/fixtures.md +5 -3
- package/assets/guide/theme-format.md +42 -9
- package/assets/netnewswire/Shared/core.css +19 -0
- package/assets/netnewswire/Shared/main.js +13 -0
- package/assets/netnewswire/netnewswire.json +4 -4
- package/dist/body-images.js +209 -0
- package/dist/browser.js +17 -10
- package/dist/commands/check.js +2 -2
- package/dist/enclosures.js +176 -42
- package/dist/project.js +2 -0
- package/dist/render.js +37 -17
- package/dist/validate.js +33 -30
- package/lldb/nnwdump.py +3 -1
- package/package.json +1 -1
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) release `mac-7.
|
|
5
|
-
commit `
|
|
4
|
+
[NetNewsWire](https://github.com/Ranchero-Software/NetNewsWire) release `mac-7.3b6`,
|
|
5
|
+
commit `9a07d406177e896826e33952fb16c06e21c19000`, under `assets/netnewswire/`.
|
|
6
6
|
`netnewswire.json` in the source repository records the SHA-256 of every file, and the
|
|
7
7
|
build verifies each one, including NetNewsWire’s MIT license, which ships beside them as
|
|
8
8
|
`assets/netnewswire/LICENSE`. These files are used only to preview themes; they are
|
package/assets/enclosures.js
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
// list of failure messages. NetNewsWire writes each enclosure as
|
|
3
3
|
// div.nnw-enclosure > (img | video | audio | iframe), with an optional
|
|
4
4
|
// div.nnw-enclosure-caption, inside one div.nnw-enclosures: at [[enclosures]] in the
|
|
5
|
-
// template, or after the body when the template has no such macro.
|
|
6
|
-
//
|
|
5
|
+
// template, or after the body when the template has no such macro. [[primary_media]]
|
|
6
|
+
// reuses those players, and adds div.nnw-primary-image > img with an optional
|
|
7
|
+
// div.nnw-primary-image-caption. A theme script may move any of them elsewhere, so
|
|
8
|
+
// each is found wherever it ended up.
|
|
7
9
|
() => {
|
|
8
10
|
const failures = [];
|
|
9
11
|
const fail = (message) => failures.push(message);
|
|
@@ -14,7 +16,7 @@
|
|
|
14
16
|
return Number.isFinite(number) && number > 0 ? number : 0;
|
|
15
17
|
};
|
|
16
18
|
|
|
17
|
-
const enclosures = [...document.querySelectorAll(".nnw-enclosure")];
|
|
19
|
+
const enclosures = [...document.querySelectorAll(".nnw-enclosure, .nnw-primary-image")];
|
|
18
20
|
enclosures.forEach((enclosure, index) => {
|
|
19
21
|
const media = enclosure.querySelector(":scope > :is(img, video, audio, iframe)");
|
|
20
22
|
if (!media) return;
|
|
@@ -89,7 +91,7 @@
|
|
|
89
91
|
return base.slice(0, 3);
|
|
90
92
|
};
|
|
91
93
|
const distance = (a, b) => [0, 1, 2].reduce((sum, i) => sum + Math.abs(a[i] - b[i]), 0);
|
|
92
|
-
const captions = [...document.querySelectorAll(".nnw-enclosure-caption")];
|
|
94
|
+
const captions = [...document.querySelectorAll(".nnw-enclosure-caption, .nnw-primary-image-caption")];
|
|
93
95
|
for (const caption of captions) {
|
|
94
96
|
if (!caption.getClientRects().length || !caption.innerText.trim()) continue;
|
|
95
97
|
const name = JSON.stringify(caption.innerText.trim().slice(0, 40));
|
|
@@ -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
|
package/assets/guide/fixtures.md
CHANGED
|
@@ -36,11 +36,13 @@ footnotes stay plain links until a theme script turns them into `a.footnote` mar
|
|
|
36
36
|
## Enclosures
|
|
37
37
|
|
|
38
38
|
An article's enclosures are data, not markup: NetNewsWire renders them into the
|
|
39
|
-
`enclosures`
|
|
40
|
-
them after its other keys, in feed order, and may give
|
|
39
|
+
`enclosures` and `primary_media` macros, or after the body, from the article's
|
|
40
|
+
attachments. A fixture lists them after its other keys, in feed order, and may give
|
|
41
|
+
the article's image and thumbnail, which NetNewsWire treats differently:
|
|
41
42
|
|
|
42
43
|
```toml
|
|
43
|
-
image_url = "https://example.org/episode-art.jpg"
|
|
44
|
+
image_url = "https://example.org/episode-art.jpg"
|
|
45
|
+
thumbnail_url = "https://example.org/thumb.jpg" # a primary_media image only
|
|
44
46
|
|
|
45
47
|
[[attachments]]
|
|
46
48
|
url = "https://example.org/episode-311.mp3" # required
|
|
@@ -64,11 +64,14 @@ The markup is fixed and carries no inline styles:
|
|
|
64
64
|
Images and video carry the feed's `width` and `height` when it gives both; a caption
|
|
65
65
|
appears only when there is text for it. The audio caption joins the title (else the
|
|
66
66
|
file name), duration, and size with ` · `. A YouTube channel enclosure becomes the
|
|
67
|
-
iframe player. Enclosures
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
iframe player. Enclosures already in the body are skipped, including a resized
|
|
68
|
+
copy at another URL or the still of a YouTube video the body embeds, and documents are
|
|
69
|
+
never shown. A video uses the article's image, else its thumbnail, as its poster.
|
|
70
|
+
|
|
71
|
+
A template without `primary_media`, and a short untitled post, gets the article's image
|
|
72
|
+
as the first image enclosure unless the body already shows it, even beside other
|
|
73
|
+
enclosures or a video that uses it as its poster. The thumbnail is not shown there:
|
|
74
|
+
only `primary_media` uses it as an image.
|
|
72
75
|
|
|
73
76
|
NetNewsWire releases before enclosure support leave the macro as literal text in
|
|
74
77
|
every article, so a theme that places it should hide its container when it holds no
|
|
@@ -79,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.
|
|
85
|
-
`
|
|
86
|
-
Enclosure support starts with 7.3b3,
|
|
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.
|
|
3
|
-
"commit": "
|
|
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": "
|
|
23
|
+
"sha256": "3a1a56fe6e76e9e11b4180399e6d90b702075224fd3da4e678cf627e83939c5b"
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
"destination": "Shared/main.js",
|
|
27
27
|
"source": "Shared/Article Rendering/main.js",
|
|
28
|
-
"sha256": "
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
found.push(
|
|
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
|
|
189
|
-
//
|
|
190
|
-
function pageState({ olderNetNewsWire,
|
|
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
|
|
200
|
-
|
|
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
|
-
|
|
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.
|
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 { allFixtures, checkTargets, extraFixtures,
|
|
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
|
-
|
|
48
|
+
olderNetNewsWire: rendersDifferentlyWhenOlder(theme),
|
|
49
49
|
deliveries: readDeliveries(root, allFixtures(extras)),
|
|
50
50
|
});
|
|
51
51
|
const expected = expectations(root, targets);
|
package/dist/enclosures.js
CHANGED
|
@@ -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]]
|
|
3
|
-
// (Shared/Article Rendering/ArticleRenderer+Enclosures.swift
|
|
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
|
|
50
|
-
export function
|
|
51
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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,
|
|
267
|
-
const enclosures =
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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 {
|
|
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
|
|
128
|
-
"
|
|
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
|
|
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
|
|
284
|
-
* the older-NetNewsWire cases
|
|
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 = [], {
|
|
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 (
|
|
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
|
-
/**
|
|
301
|
-
|
|
302
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
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
|
|