nnw-theme 2.2.0 → 2.4.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.
@@ -63,6 +63,30 @@ img.twemoji, img.emoji {
63
63
  width: auto;
64
64
  }
65
65
 
66
+ /* Enclosures: audio and video players and images after the article body */
67
+
68
+ .nnw-enclosures {
69
+ margin-top: 1.5em;
70
+ }
71
+
72
+ .nnw-enclosure {
73
+ margin: 1em 0;
74
+ }
75
+
76
+ .nnw-enclosure audio {
77
+ width: 100%;
78
+ }
79
+
80
+ .nnw-enclosure video,
81
+ .nnw-enclosure img {
82
+ height: auto;
83
+ }
84
+
85
+ .nnw-enclosure-caption {
86
+ margin: 0.5em 0 0;
87
+ font-size: 0.9em;
88
+ }
89
+
66
90
  /* Hide the external link at the bottom of Daring Fireball posts */
67
91
 
68
92
  .x-netnewswire-hide {
@@ -128,25 +152,19 @@ img[src*="/img/social/"] {
128
152
  position: absolute;
129
153
  display: block;
130
154
  padding: 0em 1em;
131
- margin: 1em auto;
155
+ margin: 1em 0;
132
156
  top: 0.75em;
133
157
  max-width: 44em;
134
158
  border-radius: 0.3em;
135
159
  box-sizing: border-box;
136
160
  }
137
161
 
138
- .newsfoot-footnote-popover {
139
- left: calc(-1 * (50vw - 1em));
140
- right: calc(-1 * (50vw - 1em));
141
- }
142
-
143
162
  .newsfoot-footnote-popover-arrow {
144
163
  content: '';
145
164
  display: block;
146
165
  width: 1em;
147
166
  position: absolute;
148
167
  top: -0.5em;
149
- left: calc(50% - 0.5em);
150
168
  height: 1em !important;
151
169
  transform: rotate(45deg);
152
170
  z-index:0;
@@ -69,12 +69,23 @@ function constrainBodyRelativeIframes() {
69
69
  }
70
70
  }
71
71
 
72
+ // An image served from the timeline’s copy falls back to its original URL when the copy can’t be loaded.
73
+ // Registered at document start, and capturing, since error events don’t bubble.
74
+ document.addEventListener("error", event => {
75
+ const element = event.target;
76
+ if (element instanceof HTMLImageElement && element.hasAttribute("data-nnw-src")) {
77
+ const originalSrc = element.getAttribute("data-nnw-src");
78
+ element.removeAttribute("data-nnw-src");
79
+ element.src = originalSrc;
80
+ }
81
+ }, true);
82
+
72
83
  // Convert all Feedbin proxy images to be used as src, otherwise change image locations to be absolute if not already
73
84
  function convertImgSrc() {
74
85
  document.querySelectorAll("img").forEach(element => {
75
86
  if (element.hasAttribute("data-canonical-src")) {
76
87
  element.src = element.getAttribute("data-canonical-src")
77
- } else if (!/^[a-z]+\:\/\//i.test(element.src)) {
88
+ } else if (!element.hasAttribute("data-nnw-src") && !/^[a-z]+\:\/\//i.test(element.src)) {
78
89
  element.src = new URL(element.src, document.baseURI).href;
79
90
  }
80
91
  });
@@ -169,6 +180,67 @@ function stopMediaPlayback() {
169
180
  });
170
181
  }
171
182
 
183
+ // The element at the top of the viewport, how far through it the viewport top is,
184
+ // and any remaining distance. A scroll offset alone means nothing once the article
185
+ // reflows at a different width.
186
+ let scrollAnchorElement = null;
187
+ let scrollAnchorFraction = 0;
188
+ let scrollAnchorOffset = 0;
189
+
190
+ const scrollAnchorSampleSpacing = 16;
191
+
192
+ // False for a container hit in the gap between blocks: it has element children
193
+ // and only whitespace text of its own.
194
+ function isScrollAnchorCandidate(element) {
195
+ if (element.children.length === 0) {
196
+ return true;
197
+ }
198
+ for (const node of element.childNodes) {
199
+ if (node.nodeType === Node.TEXT_NODE && node.textContent.trim() !== "") {
200
+ return true;
201
+ }
202
+ }
203
+ return false;
204
+ }
205
+
206
+ function findScrollAnchorElement() {
207
+ const x = window.innerWidth / 2;
208
+ const maximumSampleDepth = window.innerHeight / 4;
209
+ let firstHit = null;
210
+ for (let y = 1; y <= maximumSampleDepth; y += scrollAnchorSampleSpacing) {
211
+ const element = document.elementFromPoint(x, y);
212
+ if (!element) {
213
+ continue;
214
+ }
215
+ if (isScrollAnchorCandidate(element)) {
216
+ return element;
217
+ }
218
+ if (!firstHit) {
219
+ firstHit = element;
220
+ }
221
+ }
222
+ return firstHit;
223
+ }
224
+
225
+ function recordScrollAnchor() {
226
+ scrollAnchorElement = findScrollAnchorElement();
227
+ if (!scrollAnchorElement) {
228
+ return;
229
+ }
230
+ const rect = scrollAnchorElement.getBoundingClientRect();
231
+ scrollAnchorFraction = rect.height > 0 ? Math.min(Math.max(-rect.top / rect.height, 0), 1) : 0;
232
+ scrollAnchorOffset = -(rect.top + scrollAnchorFraction * rect.height);
233
+ }
234
+
235
+ // Scrolls back to the same place in the recorded element after a reflow.
236
+ function restoreScrollAnchor() {
237
+ if (!scrollAnchorElement || !scrollAnchorElement.isConnected) {
238
+ return;
239
+ }
240
+ const rect = scrollAnchorElement.getBoundingClientRect();
241
+ window.scrollTo(0, window.scrollY + rect.top + scrollAnchorFraction * rect.height + scrollAnchorOffset);
242
+ }
243
+
172
244
  function error() {
173
245
  document.body.innerHTML = "error";
174
246
  }
@@ -15,17 +15,6 @@
15
15
  return el;
16
16
  }
17
17
 
18
- /** @type {<T extends any[]>(fn: (...args: T) => void, t: number) => ((...args: T) => void)} */
19
- function debounce(f, ms) {
20
- let t = Date.now();
21
- return (...args) => {
22
- const now = Date.now();
23
- if (now - t < ms) return;
24
- t = now;
25
- f(...args);
26
- };
27
- }
28
-
29
18
  const clsPrefix = "newsfoot-footnote-";
30
19
  const CONTAINER_CLS = `${clsPrefix}container`;
31
20
  const POPOVER_CLS = `${clsPrefix}popover`;
@@ -76,7 +65,7 @@
76
65
  }
77
66
  document.addEventListener("click", this.clickoutHandler, {capture: true});
78
67
 
79
- this.resizeHandler = debounce(() => this.reposition(), 20);
68
+ this.resizeHandler = () => this.reposition();
80
69
  window.addEventListener("resize", this.resizeHandler);
81
70
  }
82
71
 
@@ -92,23 +81,31 @@
92
81
 
93
82
  reposition() {
94
83
  const refRect = this.fnref.getBoundingClientRect();
95
- const center = refRect.left + (refRect.width / 2);
96
- const popoverHalfWidth = this.popover.clientWidth / 2;
97
- const marginLeft = stripPx(this.style.marginLeft);
98
- const marginRight = stripPx(this.style.marginRight);
84
+ const containerRect = this.fnref.closest(`.${CONTAINER_CLS}`).getBoundingClientRect();
85
+ const articleBody = this.fnref.closest(".articleBody");
86
+ const visibleRect = articleBody ? articleBody.getBoundingClientRect() : document.documentElement.getBoundingClientRect();
87
+ const maximumWidth = stripPx(this.style.maxWidth);
88
+ const popoverWidth = Math.min(visibleRect.width, maximumWidth);
89
+ const refCenter = refRect.left + (refRect.width / 2);
90
+ const minimumLeft = visibleRect.left;
91
+ const maximumLeft = visibleRect.right - popoverWidth;
92
+ const popoverLeft = Math.min(
93
+ Math.max(refCenter - (popoverWidth / 2), minimumLeft),
94
+ maximumLeft
95
+ );
99
96
 
100
- const rightOverhang = center + popoverHalfWidth + marginRight > window.innerWidth;
101
- const leftOverhang = center - (popoverHalfWidth + marginLeft) < 0;
97
+ // Keep the popover inside the visible article column while centering it on the
98
+ // footnote reference whenever there is enough room.
99
+ this.popover.style.left = `${popoverLeft - containerRect.left}px`;
100
+ this.popover.style.width = `${popoverWidth}px`;
102
101
 
103
- let offset = 0;
104
- if (leftOverhang || rightOverhang) {
105
- // Popover can't be centered on the ref without overflowing the viewport.
106
- // Center it horizontally in the viewport so left/right margins are balanced.
107
- // The arrow gets the inverse transform below and stays anchored to the ref.
108
- offset = (window.innerWidth / 2) - center;
109
- }
110
- this.popover.style.transform = `translate(${offset}px)`;
111
- this.arrow.style.transform = `translate(${-offset}px) rotate(45deg)`;
102
+ const arrowWidth = this.arrow.clientWidth;
103
+ const arrowCenter = refCenter - popoverLeft;
104
+ const insetArrowCenter = Math.min(
105
+ Math.max(arrowCenter, arrowWidth),
106
+ popoverWidth - arrowWidth
107
+ );
108
+ this.arrow.style.left = `${insetArrowCenter - (arrowWidth / 2)}px`;
112
109
  }
113
110
  }
114
111
 
@@ -37,7 +37,7 @@ class ImageViewer {
37
37
  const rect = this.img.getBoundingClientRect();
38
38
 
39
39
  // Instead of trying to convert to canvas (which fails with CORS),
40
- // send the original image src URL
40
+ // send the image's src URL. Swift checks a routed timeline image's signature.
41
41
  const message = {
42
42
  x: rect.x,
43
43
  y: rect.y,
@@ -1,6 +1,6 @@
1
1
  {
2
- "release": "mac-7.2b4",
3
- "commit": "b7f4d8c7d273e4247c5d033ee47014805072297b",
2
+ "release": "mac-7.3b4",
3
+ "commit": "e530be1ab19f68fe322850bf5fb95bca4b36fb91",
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": "d67552259cb45261e1aacf0c1af6bc39b560b084c7b9e3943191907e0977cd4d"
23
+ "sha256": "1cc7d654559b3cd6db1202391c6fcfb8041eaae6fc81850df1be72ebb6921090"
24
24
  },
25
25
  {
26
26
  "destination": "Shared/main.js",
27
27
  "source": "Shared/Article Rendering/main.js",
28
- "sha256": "847dc1f06ec0e7f96aee1004fd6186771db907445347cf0371abc67158aadb5a"
28
+ "sha256": "1c3c1084e295f83668a427b8ae89813a1df621cfb053e8192bbad3f044be3581"
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",
package/dist/browser.js CHANGED
@@ -10,6 +10,8 @@ import { extname, join, normalize, sep } from "node:path";
10
10
  import { packagePath, ThemeError } from "./project.js";
11
11
  import { pyReprList } from "./pyformat.js";
12
12
  const FOOTNOTE_CHECK = readFileSync(packagePath("assets", "footnotes.js"), "utf8");
13
+ const ENCLOSURE_CHECK = readFileSync(packagePath("assets", "enclosures.js"), "utf8");
14
+ const ENCLOSURES_MACRO = "[[enclosures]]";
13
15
  const LIVE_RELOAD = readFileSync(packagePath("assets", "live-reload.js"), "utf8");
14
16
  const NOT_INSTALLED = "WebKit is not installed; run `npx nnw-theme@2 setup`";
15
17
  const CONTENT_TYPES = {
@@ -148,6 +150,10 @@ export function failures(state) {
148
150
  found.push("article content is missing or unreadable");
149
151
  if (state.unresolved)
150
152
  found.push("unresolved theme macro");
153
+ if (state.literalEnclosures) {
154
+ found.push("[[enclosures]] shows as text in NetNewsWire releases without enclosure support; " +
155
+ "hide its element when it holds no .nnw-enclosures");
156
+ }
151
157
  if (state.overflow)
152
158
  found.push("horizontal document overflow");
153
159
  if (state.brokenImages.length)
@@ -156,6 +162,7 @@ export function failures(state) {
156
162
  found.push(`images drawn at zero size: ${pyReprList(state.collapsedImages)}`);
157
163
  if (state.unloadedImages.length)
158
164
  found.push(`images still loading: ${pyReprList(state.unloadedImages)}`);
165
+ found.push(...(state.enclosures ?? []));
159
166
  found.push(...(state.footnotes ?? []));
160
167
  if (state.blocked.length)
161
168
  found.push(`external requests: ${pyReprList(state.blocked)}`);
@@ -178,16 +185,19 @@ async function loadImages(timeout) {
178
185
  new Promise((resolve) => setTimeout(resolve, timeout)),
179
186
  ]);
180
187
  }
181
- // Runs in the page after its screenshot.
182
- function pageState() {
188
+ // Runs in the page after its screenshot. With olderNetNewsWire, the template's
189
+ // [[enclosures]] is expected in the markup and fails only if it shows as text.
190
+ function pageState({ olderNetNewsWire, macro }) {
183
191
  const article = document.querySelector(".articleBody");
184
192
  const root = document.documentElement;
193
+ const html = document.body.innerHTML;
185
194
  return {
186
195
  article: Boolean(article),
187
196
  textLength: (article?.innerText || "").trim().length,
188
197
  // Body only: macOS CSS legitimately keeps the font-size macro literal. Match
189
198
  // whole macro names, so a theme script's regex such as /[[(]/ is no macro.
190
- unresolved: /\[\[[A-Za-z0-9_-]+\]\]/.test(document.body.innerHTML),
199
+ unresolved: /\[\[[A-Za-z0-9_-]+\]\]/.test(olderNetNewsWire ? html.replaceAll(macro, "") : html),
200
+ literalEnclosures: olderNetNewsWire && document.body.innerText.includes(macro),
191
201
  overflow: root.scrollWidth > root.clientWidth + 1,
192
202
  brokenImages: [...document.images]
193
203
  .filter((image) => image.complete && image.naturalWidth === 0)
@@ -222,8 +232,9 @@ function pageState() {
222
232
  *
223
233
  * expectations maps a fixture name to its [expect.footnotes] table. They describe
224
234
  * the theme's own footnote handling, so they are skipped with theme scripts off.
235
+ * warnings, when given, receives each target's warnings by slug.
225
236
  */
226
- export async function checkPages(site, targets, progress, expectations = {}) {
237
+ export async function checkPages(site, targets, progress, expectations = {}, warnings) {
227
238
  const results = {};
228
239
  const screenshots = join(site, "screenshots");
229
240
  mkdirSync(screenshots, { recursive: true });
@@ -240,6 +251,8 @@ export async function checkPages(site, targets, progress, expectations = {}) {
240
251
  const state = await checkPage(browser, server.url, url, target, join(screenshots, `${target.slug}.png`), footnotes);
241
252
  const found = failures(state);
242
253
  results[target.slug] = found;
254
+ if (warnings)
255
+ warnings[target.slug] = state.warnings ?? [];
243
256
  progress?.finish(index, target, found);
244
257
  }
245
258
  }
@@ -284,10 +297,21 @@ async function checkPage(browser, origin, url, target, screenshot, footnotes) {
284
297
  await page.goto(url, { waitUntil: "networkidle" });
285
298
  await page.evaluate(loadImages, 10_000);
286
299
  await page.screenshot({ path: screenshot, fullPage: !target.card });
287
- const state = await page.evaluate(pageState);
300
+ const state = await page.evaluate(pageState, {
301
+ olderNetNewsWire: target.olderNetNewsWire,
302
+ macro: ENCLOSURES_MACRO,
303
+ });
304
+ const enclosures = (await page.evaluate(`(${ENCLOSURE_CHECK})()`));
288
305
  // Last: it clicks footnotes open, so it must not disturb the screenshot.
289
306
  const found = (await page.evaluate(`(${FOOTNOTE_CHECK})(${JSON.stringify(footnotes)})`));
290
- return { ...state, footnotes: found, blocked, pageErrors };
307
+ return {
308
+ ...state,
309
+ enclosures,
310
+ footnotes: found.failures,
311
+ warnings: found.warnings,
312
+ blocked,
313
+ pageErrors,
314
+ };
291
315
  }
292
316
  finally {
293
317
  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, renderSite, writeGallery, } from "../render.js";
9
+ import { allFixtures, checkTargets, extraFixtures, placesEnclosures, readDeliveries, renderSite, writeGallery, } from "../render.js";
10
10
  import { staleStubs } from "../stubs.js";
11
11
  import { printWarnings } from "./package.js";
12
12
  import { CheckProgress } from "./progress.js";
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,7 +43,11 @@ 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));
46
+ const extras = extraFixtures(root);
47
+ const targets = checkTargets(extras, {
48
+ enclosuresMacro: placesEnclosures(theme),
49
+ deliveries: readDeliveries(root, allFixtures(extras)),
50
+ });
38
51
  const expected = expectations(root, targets);
39
52
  const progress = new CheckProgress(targets.length);
40
53
  progress.status("Validating and packaging the theme…");
@@ -46,23 +59,29 @@ export default async function check({ values }) {
46
59
  progress.status(`Rendering ${targets.length} pages…`);
47
60
  const site = renderSite(root, theme, targets);
48
61
  let results;
62
+ const warned = {};
49
63
  try {
50
- results = await checkPages(site, targets, progress, expected);
64
+ results = await checkPages(site, targets, progress, expected, warned);
51
65
  }
52
66
  finally {
53
67
  progress.done();
54
68
  }
55
69
  writeGallery(site, themeStem(theme), targets, results);
56
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("");
57
75
  const passed = targets.filter((target) => !results[target.slug]?.length).length;
58
76
  const packageName = basename(archive.path);
59
77
  const footer = `nnw-theme ${version()}\n`;
60
78
  const report = join(root, "build", "check-report.txt");
61
79
  if (failures.length) {
62
- writeFileSync(report, `FAIL\n${failures.join("\n")}\n${footer}`, "utf8");
80
+ writeFileSync(report, `FAIL\n${failures.join("\n")}\n${warningLines}${footer}`, "utf8");
63
81
  }
64
82
  else {
65
- 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");
66
85
  console.log(`PASS: ${targets.length} WebKit renders and ${packageName}`);
67
86
  }
68
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,7 +19,10 @@ 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",
24
+ "media-rss": "Image, video, and YouTube enclosures with captions",
25
+ podcast: "Podcast audio enclosure and episode artwork",
22
26
  substack: "Substack images, widgets, and footnotes",
23
27
  wordpress: "WordPress block and classic editor markup",
24
28
  };
@@ -110,7 +114,7 @@ export const COMMANDS = [
110
114
  name: "screenshot",
111
115
  summary: "check one case in WebKit and save its image",
112
116
  description: "Render and check a single case in WebKit and save its full-page screenshot: any " +
113
- "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 " +
114
118
  "shape of the marketplace card. With --promote, it becomes " +
115
119
  "screenshots/theme-preview.png, the marketplace card.",
116
120
  options: [
@@ -123,6 +127,12 @@ export const COMMANDS = [
123
127
  },
124
128
  { name: "platform", type: "string", choices: ["mac", "iphone", "ipad"], default: "mac" },
125
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
+ },
126
136
  { name: "card", type: "boolean", help: "capture the marketplace card's shape at 2x" },
127
137
  { name: "promote", type: "boolean", help: "use it as the marketplace screenshot" },
128
138
  ],
@@ -134,7 +144,7 @@ export const COMMANDS = [
134
144
  },
135
145
  {
136
146
  name: "fixture",
137
- summary: "add fixtures modeled on WordPress, Ghost, Substack, and Medium",
147
+ summary: "add fixtures modeled on WordPress, Ghost, Substack, Medium, and Mastodon",
138
148
  description: "Copy ready-made fixtures with markup modeled on what major publishing platforms " +
139
149
  "send into fixtures/, where preview and check pick them up. Each one adds four " +
140
150
  "renders to every check.",