nnw-theme 2.3.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/README.md +2 -2
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/assets/catalog/mastodon.toml +50 -0
- package/assets/enclosures.js +6 -4
- package/assets/footnotes.js +39 -6
- package/assets/guide/design-checklist.md +6 -1
- package/assets/guide/fixtures.md +40 -7
- package/assets/guide/skill.md +2 -2
- package/assets/guide/theme-format.md +42 -7
- package/assets/netnewswire/Mac/main_mac.js +5 -0
- package/assets/netnewswire/Shared/core.css +20 -7
- package/assets/netnewswire/Shared/main.js +86 -1
- package/assets/netnewswire/Shared/newsfoot.js +24 -27
- package/assets/netnewswire/iOS/main_ios.js +1 -1
- package/assets/netnewswire/netnewswire.json +7 -7
- package/dist/body-images.js +209 -0
- package/dist/browser.js +29 -12
- package/dist/commands/check.js +25 -8
- package/dist/commands/preview.js +2 -2
- package/dist/commands/render.js +2 -2
- package/dist/commands/screenshot.js +7 -2
- package/dist/commands.js +10 -2
- package/dist/delivery.js +516 -0
- package/dist/enclosures.js +176 -42
- package/dist/project.js +13 -2
- package/dist/render.js +145 -31
- package/dist/validate.js +33 -30
- package/lldb/nnwdump.py +16 -2
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release": "
|
|
3
|
-
"commit": "
|
|
2
|
+
"release": "mac-7.3b6",
|
|
3
|
+
"commit": "9a07d406177e896826e33952fb16c06e21c19000",
|
|
4
4
|
"files": [
|
|
5
5
|
{
|
|
6
6
|
"destination": "LICENSE",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"destination": "Mac/main_mac.js",
|
|
12
12
|
"source": "Mac/MainWindow/Detail/main_mac.js",
|
|
13
|
-
"sha256": "
|
|
13
|
+
"sha256": "51c8812b9063d70aeb9a163a224bc83954f127dfb78736b2016d6572dd9ab63f"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"destination": "Mac/page.html",
|
|
@@ -20,22 +20,22 @@
|
|
|
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",
|
|
32
32
|
"source": "Shared/Article Rendering/newsfoot.js",
|
|
33
|
-
"sha256": "
|
|
33
|
+
"sha256": "8e0a5fd14b2b5e3b57e6d420388afa2999a8df8af56f957c16303834ad53ed61"
|
|
34
34
|
},
|
|
35
35
|
{
|
|
36
36
|
"destination": "iOS/main_ios.js",
|
|
37
37
|
"source": "iOS/Resources/main_ios.js",
|
|
38
|
-
"sha256": "
|
|
38
|
+
"sha256": "ab60f228e06b11589aefaac579a5ea42d1ff5ecf43aab44e2eba7679a18d6ef2"
|
|
39
39
|
},
|
|
40
40
|
{
|
|
41
41
|
"destination": "iOS/page.html",
|
|
@@ -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)
|
|
@@ -232,8 +239,9 @@ function pageState({ olderNetNewsWire, macro }) {
|
|
|
232
239
|
*
|
|
233
240
|
* expectations maps a fixture name to its [expect.footnotes] table. They describe
|
|
234
241
|
* the theme's own footnote handling, so they are skipped with theme scripts off.
|
|
242
|
+
* warnings, when given, receives each target's warnings by slug.
|
|
235
243
|
*/
|
|
236
|
-
export async function checkPages(site, targets, progress, expectations = {}) {
|
|
244
|
+
export async function checkPages(site, targets, progress, expectations = {}, warnings) {
|
|
237
245
|
const results = {};
|
|
238
246
|
const screenshots = join(site, "screenshots");
|
|
239
247
|
mkdirSync(screenshots, { recursive: true });
|
|
@@ -250,6 +258,8 @@ export async function checkPages(site, targets, progress, expectations = {}) {
|
|
|
250
258
|
const state = await checkPage(browser, server.url, url, target, join(screenshots, `${target.slug}.png`), footnotes);
|
|
251
259
|
const found = failures(state);
|
|
252
260
|
results[target.slug] = found;
|
|
261
|
+
if (warnings)
|
|
262
|
+
warnings[target.slug] = state.warnings ?? [];
|
|
253
263
|
progress?.finish(index, target, found);
|
|
254
264
|
}
|
|
255
265
|
}
|
|
@@ -296,12 +306,19 @@ async function checkPage(browser, origin, url, target, screenshot, footnotes) {
|
|
|
296
306
|
await page.screenshot({ path: screenshot, fullPage: !target.card });
|
|
297
307
|
const state = await page.evaluate(pageState, {
|
|
298
308
|
olderNetNewsWire: target.olderNetNewsWire,
|
|
299
|
-
|
|
309
|
+
macros: Object.keys(NEWER_MACROS),
|
|
300
310
|
});
|
|
301
311
|
const enclosures = (await page.evaluate(`(${ENCLOSURE_CHECK})()`));
|
|
302
312
|
// Last: it clicks footnotes open, so it must not disturb the screenshot.
|
|
303
313
|
const found = (await page.evaluate(`(${FOOTNOTE_CHECK})(${JSON.stringify(footnotes)})`));
|
|
304
|
-
return {
|
|
314
|
+
return {
|
|
315
|
+
...state,
|
|
316
|
+
enclosures,
|
|
317
|
+
footnotes: found.failures,
|
|
318
|
+
warnings: found.warnings,
|
|
319
|
+
blocked,
|
|
320
|
+
pageErrors,
|
|
321
|
+
};
|
|
305
322
|
}
|
|
306
323
|
finally {
|
|
307
324
|
await context.close();
|
package/dist/commands/check.js
CHANGED
|
@@ -6,14 +6,23 @@ import { interactive, openInBrowser, promptConfirm } from "../interactive.js";
|
|
|
6
6
|
import { version } from "../main.js";
|
|
7
7
|
import { buildArchive } from "../package.js";
|
|
8
8
|
import { findRoot, findTheme, fixturePath, footnoteExpectations, readFixture, repositoryFixtures, ThemeError, themeStem, } from "../project.js";
|
|
9
|
-
import { checkTargets, extraFixtures,
|
|
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";
|
|
13
|
+
/**
|
|
14
|
+
* Each fixture's [expect.footnotes]. A fixture that models a sync service, with
|
|
15
|
+
* base_url or deliveries, fails a footnote link that never became a marker unless it
|
|
16
|
+
* sets all_converted = false; any other fixture only warns about one, unless it sets
|
|
17
|
+
* all_converted = true.
|
|
18
|
+
*/
|
|
13
19
|
export function expectations(root, targets) {
|
|
14
20
|
const result = {};
|
|
15
21
|
for (const name of new Set(targets.map((target) => target.fixture))) {
|
|
16
|
-
|
|
22
|
+
const fixture = readFixture(fixturePath(root, name));
|
|
23
|
+
const expected = footnoteExpectations(fixture, `fixtures/${name}.toml`);
|
|
24
|
+
expected.all_converted ??= "base_url" in fixture || "deliveries" in fixture;
|
|
25
|
+
result[name] = expected;
|
|
17
26
|
}
|
|
18
27
|
return result;
|
|
19
28
|
}
|
|
@@ -22,7 +31,7 @@ export function fixtureHint(root) {
|
|
|
22
31
|
if (repositoryFixtures(root).length)
|
|
23
32
|
return undefined;
|
|
24
33
|
return ("Hint: this theme has no fixtures of its own. `npx nnw-theme@2 fixture list` " +
|
|
25
|
-
"shows ready-made ones modeled on WordPress, Ghost, Substack, and
|
|
34
|
+
"shows ready-made ones modeled on WordPress, Ghost, Substack, Medium, and Mastodon markup.");
|
|
26
35
|
}
|
|
27
36
|
async function offerToOpen(index, choice) {
|
|
28
37
|
const open = choice ?? (interactive() && (await promptConfirm("Open the preview?", true)));
|
|
@@ -34,8 +43,10 @@ export default async function check({ values }) {
|
|
|
34
43
|
const theme = findTheme(root);
|
|
35
44
|
console.log(`nnw-theme ${version()}`);
|
|
36
45
|
printWarnings(staleStubs(root));
|
|
37
|
-
const
|
|
38
|
-
|
|
46
|
+
const extras = extraFixtures(root);
|
|
47
|
+
const targets = checkTargets(extras, {
|
|
48
|
+
olderNetNewsWire: rendersDifferentlyWhenOlder(theme),
|
|
49
|
+
deliveries: readDeliveries(root, allFixtures(extras)),
|
|
39
50
|
});
|
|
40
51
|
const expected = expectations(root, targets);
|
|
41
52
|
const progress = new CheckProgress(targets.length);
|
|
@@ -48,23 +59,29 @@ export default async function check({ values }) {
|
|
|
48
59
|
progress.status(`Rendering ${targets.length} pages…`);
|
|
49
60
|
const site = renderSite(root, theme, targets);
|
|
50
61
|
let results;
|
|
62
|
+
const warned = {};
|
|
51
63
|
try {
|
|
52
|
-
results = await checkPages(site, targets, progress, expected);
|
|
64
|
+
results = await checkPages(site, targets, progress, expected, warned);
|
|
53
65
|
}
|
|
54
66
|
finally {
|
|
55
67
|
progress.done();
|
|
56
68
|
}
|
|
57
69
|
writeGallery(site, themeStem(theme), targets, results);
|
|
58
70
|
const failures = targets.flatMap((target) => (results[target.slug] ?? []).map((message) => `${target.label}: ${message}`));
|
|
71
|
+
const warnings = targets.flatMap((target) => (warned[target.slug] ?? []).map((message) => `Warning: ${target.label}: ${message}`));
|
|
72
|
+
for (const warning of warnings)
|
|
73
|
+
console.log(warning);
|
|
74
|
+
const warningLines = warnings.map((warning) => `${warning}\n`).join("");
|
|
59
75
|
const passed = targets.filter((target) => !results[target.slug]?.length).length;
|
|
60
76
|
const packageName = basename(archive.path);
|
|
61
77
|
const footer = `nnw-theme ${version()}\n`;
|
|
62
78
|
const report = join(root, "build", "check-report.txt");
|
|
63
79
|
if (failures.length) {
|
|
64
|
-
writeFileSync(report, `FAIL\n${failures.join("\n")}\n${footer}`, "utf8");
|
|
80
|
+
writeFileSync(report, `FAIL\n${failures.join("\n")}\n${warningLines}${footer}`, "utf8");
|
|
65
81
|
}
|
|
66
82
|
else {
|
|
67
|
-
writeFileSync(report, `PASS\n${targets.length} WebKit renders checked\nPackage: ${packageName}\n
|
|
83
|
+
writeFileSync(report, `PASS\n${targets.length} WebKit renders checked\nPackage: ${packageName}\n` +
|
|
84
|
+
`${warningLines}${footer}`, "utf8");
|
|
68
85
|
console.log(`PASS: ${targets.length} WebKit renders and ${packageName}`);
|
|
69
86
|
}
|
|
70
87
|
const hint = fixtureHint(root);
|
package/dist/commands/preview.js
CHANGED
|
@@ -3,7 +3,7 @@ import { relative, sep } from "node:path";
|
|
|
3
3
|
import { serve } from "../browser.js";
|
|
4
4
|
import { openInBrowser } from "../interactive.js";
|
|
5
5
|
import { findRoot, findTheme } from "../project.js";
|
|
6
|
-
import {
|
|
6
|
+
import { renderSite, repositoryTargets } from "../render.js";
|
|
7
7
|
/** Whether a changed path is theme or fixture content the gallery is built from. */
|
|
8
8
|
export function watched(root, path) {
|
|
9
9
|
const parts = relative(root, path).split(sep);
|
|
@@ -15,7 +15,7 @@ export function watched(root, path) {
|
|
|
15
15
|
export default async function preview({ values }) {
|
|
16
16
|
const root = findRoot();
|
|
17
17
|
const theme = findTheme(root);
|
|
18
|
-
const build = () => renderSite(root, theme,
|
|
18
|
+
const build = () => renderSite(root, theme, repositoryTargets(root));
|
|
19
19
|
const site = build();
|
|
20
20
|
const server = await serve(site, { live: true });
|
|
21
21
|
console.log(`Preview: ${server.url}`);
|
package/dist/commands/render.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { findRoot, findTheme, ThemeError } from "../project.js";
|
|
2
|
-
import {
|
|
2
|
+
import { renderSite, repositoryTargets } from "../render.js";
|
|
3
3
|
export default function render({ positionals }) {
|
|
4
4
|
const root = findRoot();
|
|
5
5
|
const theme = findTheme(root);
|
|
6
|
-
let targets =
|
|
6
|
+
let targets = repositoryTargets(root);
|
|
7
7
|
if (positionals.length) {
|
|
8
8
|
const requested = new Set(positionals);
|
|
9
9
|
targets = targets.filter((target) => requested.has(target.fixture));
|
|
@@ -6,12 +6,13 @@ import { extraFixtures, FIXTURE_NAMES, RenderTarget, renderSite, } from "../rend
|
|
|
6
6
|
import { expectations } from "./check.js";
|
|
7
7
|
// Any fixture on any platform: a screenshot is a deliberate one-off, not the check matrix.
|
|
8
8
|
function selectTarget(root, values) {
|
|
9
|
-
const { fixture, platform, appearance, card } = values;
|
|
9
|
+
const { fixture, platform, appearance, card, delivery } = values;
|
|
10
10
|
if (![...Object.keys(FIXTURE_NAMES), ...extraFixtures(root)].includes(fixture)) {
|
|
11
11
|
throw new ThemeError(`unknown fixture: ${fixture}`);
|
|
12
12
|
}
|
|
13
13
|
return new RenderTarget(fixture, platform, appearance, {
|
|
14
14
|
card: !!card,
|
|
15
|
+
delivery: delivery,
|
|
15
16
|
});
|
|
16
17
|
}
|
|
17
18
|
export default async function screenshot({ values }) {
|
|
@@ -19,7 +20,11 @@ export default async function screenshot({ values }) {
|
|
|
19
20
|
const theme = findTheme(root);
|
|
20
21
|
const target = selectTarget(root, values);
|
|
21
22
|
const site = renderSite(root, theme, [target]);
|
|
22
|
-
const
|
|
23
|
+
const warned = {};
|
|
24
|
+
const expected = expectations(root, [target]);
|
|
25
|
+
const results = await checkPages(site, [target], undefined, expected, warned);
|
|
26
|
+
for (const warning of warned[target.slug] ?? [])
|
|
27
|
+
console.log(`Warning: ${warning}`);
|
|
23
28
|
const failures = results[target.slug] ?? [];
|
|
24
29
|
if (failures.length) {
|
|
25
30
|
throw new ThemeError(`screenshot checks failed:\n- ${failures.join("\n- ")}`);
|
package/dist/commands.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The one description of the command line: the parser, help, and shell completion
|
|
2
2
|
// are all generated from it, so they cannot drift apart.
|
|
3
|
+
import { DELIVERIES } from "./delivery.js";
|
|
3
4
|
export const PROGRAM = "nnw-theme";
|
|
4
5
|
export const DESCRIPTION = "Create, preview, check, and publish a NetNewsWire theme.";
|
|
5
6
|
export const GUIDE_TOPICS = [
|
|
@@ -18,6 +19,7 @@ export const BUILT_IN_FIXTURE_NAMES = ["article", "kitchen-sink"];
|
|
|
18
19
|
*/
|
|
19
20
|
export const CATALOG_FIXTURES = {
|
|
20
21
|
ghost: "Ghost cards, galleries, and lazy images",
|
|
22
|
+
mastodon: "Mastodon post: mentions, hashtags, custom emoji, image enclosures",
|
|
21
23
|
medium: "Medium figures, unsized images, and footer",
|
|
22
24
|
"media-rss": "Image, video, and YouTube enclosures with captions",
|
|
23
25
|
podcast: "Podcast audio enclosure and episode artwork",
|
|
@@ -112,7 +114,7 @@ export const COMMANDS = [
|
|
|
112
114
|
name: "screenshot",
|
|
113
115
|
summary: "check one case in WebKit and save its image",
|
|
114
116
|
description: "Render and check a single case in WebKit and save its full-page screenshot: any " +
|
|
115
|
-
"fixture, on any platform. With --card, capture an 800x552 viewport at 2x, the " +
|
|
117
|
+
"fixture, on any platform, directly or --delivery through a sync service. With --card, capture an 800x552 viewport at 2x, the " +
|
|
116
118
|
"shape of the marketplace card. With --promote, it becomes " +
|
|
117
119
|
"screenshots/theme-preview.png, the marketplace card.",
|
|
118
120
|
options: [
|
|
@@ -125,6 +127,12 @@ export const COMMANDS = [
|
|
|
125
127
|
},
|
|
126
128
|
{ name: "platform", type: "string", choices: ["mac", "iphone", "ipad"], default: "mac" },
|
|
127
129
|
{ name: "appearance", type: "string", choices: ["light", "dark"], default: "light" },
|
|
130
|
+
{
|
|
131
|
+
name: "delivery",
|
|
132
|
+
type: "string",
|
|
133
|
+
choices: DELIVERIES,
|
|
134
|
+
help: "render the fixture as this sync service delivers it",
|
|
135
|
+
},
|
|
128
136
|
{ name: "card", type: "boolean", help: "capture the marketplace card's shape at 2x" },
|
|
129
137
|
{ name: "promote", type: "boolean", help: "use it as the marketplace screenshot" },
|
|
130
138
|
],
|
|
@@ -136,7 +144,7 @@ export const COMMANDS = [
|
|
|
136
144
|
},
|
|
137
145
|
{
|
|
138
146
|
name: "fixture",
|
|
139
|
-
summary: "add fixtures modeled on WordPress, Ghost, Substack, and
|
|
147
|
+
summary: "add fixtures modeled on WordPress, Ghost, Substack, Medium, and Mastodon",
|
|
140
148
|
description: "Copy ready-made fixtures with markup modeled on what major publishing platforms " +
|
|
141
149
|
"send into fixtures/, where preview and check pick them up. Each one adds four " +
|
|
142
150
|
"renders to every check.",
|