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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "release": "main-70c5dce",
3
- "commit": "70c5dce656487bdab9e0226c318acc1a15071cf4",
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": "3ac1ef46523f6c4d8103fe7dbe6f45486d57a94c3d54d0fcb6211a35215bb676"
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": "3cdc76a22a6bbcd1aeed6eec4611598bb64a457cade3e1553e7b88f03bc78ef9"
23
+ "sha256": "3a1a56fe6e76e9e11b4180399e6d90b702075224fd3da4e678cf627e83939c5b"
24
24
  },
25
25
  {
26
26
  "destination": "Shared/main.js",
27
27
  "source": "Shared/Article Rendering/main.js",
28
- "sha256": "847dc1f06ec0e7f96aee1004fd6186771db907445347cf0371abc67158aadb5a"
28
+ "sha256": "6be65bcb16be38b686ddf8d96bca3b0d844eee2e138758842b247ab50503dcc4"
29
29
  },
30
30
  {
31
31
  "destination": "Shared/newsfoot.js",
32
32
  "source": "Shared/Article Rendering/newsfoot.js",
33
- "sha256": "828e6889bd976ebce417d808ac726dd9252a801946d77e32de207ba4a14e21fa"
33
+ "sha256": "8e0a5fd14b2b5e3b57e6d420388afa2999a8df8af56f957c16303834ad53ed61"
34
34
  },
35
35
  {
36
36
  "destination": "iOS/main_ios.js",
37
37
  "source": "iOS/Resources/main_ios.js",
38
- "sha256": "e764183ea8e9ce75df13d268834741b176afdb440eb492efeaba8ccabd7dfb8b"
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
- 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)
@@ -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
- macro: ENCLOSURES_MACRO,
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 { ...state, enclosures, footnotes: found, blocked, pageErrors };
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();
@@ -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, placesEnclosures, 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";
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
- result[name] = footnoteExpectations(readFixture(fixturePath(root, name)), `fixtures/${name}.toml`);
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 Medium markup.");
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 targets = checkTargets(extraFixtures(root), {
38
- enclosuresMacro: placesEnclosures(theme),
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${footer}`, "utf8");
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);
@@ -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 { extraFixtures, normalTargets, renderSite } from "../render.js";
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, normalTargets(extraFixtures(root)));
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}`);
@@ -1,9 +1,9 @@
1
1
  import { findRoot, findTheme, ThemeError } from "../project.js";
2
- import { extraFixtures, normalTargets, renderSite } from "../render.js";
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 = normalTargets(extraFixtures(root));
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 results = await checkPages(site, [target], undefined, expectations(root, [target]));
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 Medium",
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.",