nnw-theme 2.1.0 → 2.2.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 CHANGED
@@ -33,7 +33,7 @@ version, name it: `npx nnw-theme@2.0.0 check`.
33
33
  | `preview` | Serve the gallery on localhost, open it, and rebuild and reload it on save |
34
34
  | `render [fixtures…]` | Write the gallery to `build/preview/` once |
35
35
  | `check` | Validate and package, then render and test every case in WebKit |
36
- | `screenshot` | Check one case and save its image; `--promote` makes it the marketplace card |
36
+ | `screenshot` | Check one case (any fixture and platform) and save its image; `--card` crops to the marketplace card, `--promote` makes it the card |
37
37
  | `package` | Validate and build `dist/<Name>.nnwtheme.zip` |
38
38
  | `fixture list` / `fixture add <name>…` | Add ready-made fixtures modeled on WordPress, Ghost, Substack, and Medium markup |
39
39
  | `capture` | Explain how to capture a real article from NetNewsWire as a fixture |
@@ -42,6 +42,12 @@ usually runs it. Rendering swaps every image that would load over the network fo
42
42
  same-size placeholder and a network feed icon for a generated one; do not inline or
43
43
  fetch the originals.
44
44
 
45
+ To show a real image, as a marketplace screenshot needs, commit it under
46
+ `fixtures/media/` and reference it by that relative path:
47
+ `<img src="media/sunset.jpg" width="1600" height="900">`. Rendering inlines it, so
48
+ previews stay offline; a missing file shows as a placeholder. Use only images the theme may redistribute, and keep them small:
49
+ each is copied into every page that renders the fixture.
50
+
45
51
  Footnote markers are the links NetNewsWire's `newsfoot.js` handles: `a.footnote`.
46
52
  Its `main.js` adds that class to local `sup > a[href*='#fn']` links and to Substack's
47
53
  `a[id^='footnote-anchor-']` links; a theme script may add it to other formats. `check` requires every visible marker to resolve to a
@@ -24,6 +24,12 @@ stable GitHub release; and the valid ZIP asset. A useful repository description
24
24
  metadata warns but does not block publication. `npx nnw-theme@2 marketplace enable`
25
25
  adds the recommended topic through the author’s authenticated `gh` session.
26
26
 
27
+ `npx nnw-theme@2 screenshot --fixture NAME --card --promote` makes the card image from
28
+ any fixture: an 800×552 viewport at 2x, the shape of the card, which crops from the top. A
29
+ fixture written for the card, with images in `fixtures/media/`, shows the theme best;
30
+ `--platform ipad` or `iphone` renders it on another platform. The **Refresh marketplace
31
+ screenshot** workflow takes the same fixture name and card choice.
32
+
27
33
  The screenshot workflow pushes a branch and opens a pull request for review, so the
28
34
  repository needs **Settings → Actions → General → Workflow permissions → Allow GitHub
29
35
  Actions to create and approve pull requests**. Without it the run pushes the branch and
@@ -5,15 +5,18 @@ on:
5
5
  workflow_dispatch:
6
6
  inputs:
7
7
  fixture:
8
- description: Fixture to capture
9
- type: choice
10
- options: [article, kitchen-sink]
8
+ description: Fixture to capture (article, kitchen-sink, or a fixtures/*.toml name)
9
+ type: string
11
10
  default: article
12
11
  appearance:
13
12
  description: Appearance to capture
14
13
  type: choice
15
14
  options: [light, dark]
16
15
  default: light
16
+ card:
17
+ description: Capture the marketplace card's shape instead of the full page
18
+ type: boolean
19
+ default: true
17
20
 
18
21
  permissions:
19
22
  contents: write
@@ -25,3 +28,4 @@ jobs:
25
28
  with:
26
29
  fixture: ${{ inputs.fixture }}
27
30
  appearance: ${{ inputs.appearance }}
31
+ card: ${{ inputs.card }}
package/dist/browser.js CHANGED
@@ -255,6 +255,7 @@ export async function checkPages(site, targets, progress, expectations = {}) {
255
255
  async function checkPage(browser, origin, url, target, screenshot, footnotes) {
256
256
  const context = await browser.newContext({
257
257
  viewport: { width: target.width, height: target.height },
258
+ deviceScaleFactor: target.card ? 2 : 1,
258
259
  colorScheme: target.appearance,
259
260
  serviceWorkers: "block",
260
261
  });
@@ -282,7 +283,7 @@ async function checkPage(browser, origin, url, target, screenshot, footnotes) {
282
283
  page.on("pageerror", (error) => pageErrors.push(String(error)));
283
284
  await page.goto(url, { waitUntil: "networkidle" });
284
285
  await page.evaluate(loadImages, 10_000);
285
- await page.screenshot({ path: screenshot, fullPage: true });
286
+ await page.screenshot({ path: screenshot, fullPage: !target.card });
286
287
  const state = await page.evaluate(pageState);
287
288
  // Last: it clicks footnotes open, so it must not disturb the screenshot.
288
289
  const found = (await page.evaluate(`(${FOOTNOTE_CHECK})(${JSON.stringify(footnotes)})`));
@@ -2,18 +2,17 @@ import { copyFileSync, mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { checkPages } from "../browser.js";
4
4
  import { findRoot, findTheme, ThemeError } from "../project.js";
5
- import { extraFixtures, normalTargets, renderSite } from "../render.js";
5
+ import { extraFixtures, FIXTURE_NAMES, RenderTarget, renderSite, } from "../render.js";
6
6
  import { expectations } from "./check.js";
7
+ // Any fixture on any platform: a screenshot is a deliberate one-off, not the check matrix.
7
8
  function selectTarget(root, values) {
8
- const targets = normalTargets(extraFixtures(root));
9
- const { fixture, platform, appearance } = values;
10
- if (!targets.some((target) => target.fixture === fixture)) {
9
+ const { fixture, platform, appearance, card } = values;
10
+ if (![...Object.keys(FIXTURE_NAMES), ...extraFixtures(root)].includes(fixture)) {
11
11
  throw new ThemeError(`unknown fixture: ${fixture}`);
12
12
  }
13
- const target = targets.find((item) => item.fixture === fixture && item.platform === platform && item.appearance === appearance);
14
- if (!target)
15
- throw new ThemeError(`${fixture} is checked on Mac and iPhone only`);
16
- return target;
13
+ return new RenderTarget(fixture, platform, appearance, {
14
+ card: !!card,
15
+ });
17
16
  }
18
17
  export default async function screenshot({ values }) {
19
18
  const root = findRoot();
package/dist/commands.js CHANGED
@@ -109,8 +109,10 @@ export const COMMANDS = [
109
109
  {
110
110
  name: "screenshot",
111
111
  summary: "check one case in WebKit and save its image",
112
- description: "Render and check a single case in WebKit and save its full-page screenshot. " +
113
- "With --promote, it becomes screenshots/theme-preview.png, the marketplace card.",
112
+ 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 " +
114
+ "shape of the marketplace card. With --promote, it becomes " +
115
+ "screenshots/theme-preview.png, the marketplace card.",
114
116
  options: [
115
117
  {
116
118
  name: "fixture",
@@ -121,6 +123,7 @@ export const COMMANDS = [
121
123
  },
122
124
  { name: "platform", type: "string", choices: ["mac", "iphone", "ipad"], default: "mac" },
123
125
  { name: "appearance", type: "string", choices: ["light", "dark"], default: "light" },
126
+ { name: "card", type: "boolean", help: "capture the marketplace card's shape at 2x" },
124
127
  { name: "promote", type: "boolean", help: "use it as the marketplace screenshot" },
125
128
  ],
126
129
  },
package/dist/render.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
2
+ import { mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
+ import { extname, isAbsolute, join, relative } from "node:path";
4
4
  import { decodeHTML } from "entities";
5
5
  import { Parser } from "htmlparser2";
6
6
  import { renderingInputs } from "./netnewswire.js";
@@ -61,6 +61,18 @@ const VIEWPORTS = {
61
61
  iphone: [393, 852],
62
62
  ipad: [834, 1112],
63
63
  };
64
+ // The marketplace card shows its image at 347x240, cropped from the top.
65
+ const CARD_VIEWPORT = [800, 552];
66
+ // Fixture images a fixture may reference as media/NAME, from fixtures/media/NAME.
67
+ const MEDIA_TYPES = {
68
+ ".avif": "image/avif",
69
+ ".gif": "image/gif",
70
+ ".jpeg": "image/jpeg",
71
+ ".jpg": "image/jpeg",
72
+ ".png": "image/png",
73
+ ".svg": "image/svg+xml",
74
+ ".webp": "image/webp",
75
+ };
64
76
  // The template's own fixtures get the full matrix and the stress cases. Any other
65
77
  // fixture a theme adds is an extra: checked on Mac and iPhone in both appearances.
66
78
  export const FIXTURE_NAMES = {
@@ -114,13 +126,16 @@ export class RenderTarget {
114
126
  height;
115
127
  largeText;
116
128
  themeScripts;
117
- constructor(fixture, platform, appearance, { largeText = false, themeScripts = true } = {}) {
129
+ /** A 2x capture of the card viewport alone, for the marketplace card. */
130
+ card;
131
+ constructor(fixture, platform, appearance, { largeText = false, themeScripts = true, card = false } = {}) {
118
132
  this.fixture = fixture;
119
133
  this.platform = platform;
120
134
  this.appearance = appearance;
121
- [this.width, this.height] = VIEWPORTS[platform];
135
+ [this.width, this.height] = card ? CARD_VIEWPORT : VIEWPORTS[platform];
122
136
  this.largeText = largeText;
123
137
  this.themeScripts = themeScripts;
138
+ this.card = card;
124
139
  }
125
140
  get ios() {
126
141
  return this.platform === "iphone" || this.platform === "ipad";
@@ -135,6 +150,8 @@ export class RenderTarget {
135
150
  parts.push("large text");
136
151
  if (!this.themeScripts)
137
152
  parts.push("Article JavaScript off");
153
+ if (this.card)
154
+ parts.push("card");
138
155
  return parts.join(" · ");
139
156
  }
140
157
  get scenario() {
@@ -150,6 +167,8 @@ export class RenderTarget {
150
167
  parts.push("large-text");
151
168
  if (!this.themeScripts)
152
169
  parts.push("no-article-js");
170
+ if (this.card)
171
+ parts.push("card");
153
172
  return parts.join("-");
154
173
  }
155
174
  }
@@ -235,14 +254,34 @@ function placeholder(width, height) {
235
254
  '<rect width="100%" height="100%" fill="#8a8f98" fill-opacity=".35"/></svg>';
236
255
  return `data:image/svg+xml;base64,${base64(svg)}`;
237
256
  }
257
+ /**
258
+ * A fixture's own image, media/NAME, as a data URI read from fixtures/media/NAME.
259
+ * Undefined, so a placeholder as before, unless it is an image inside that directory.
260
+ */
261
+ function mediaDataURI(media, src) {
262
+ const type = MEDIA_TYPES[extname(src).toLowerCase()];
263
+ if (!src.startsWith("media/") || !type)
264
+ return undefined;
265
+ try {
266
+ const path = realpathSync(join(media, src.slice("media/".length)));
267
+ const inside = relative(realpathSync(media), path);
268
+ if (inside.startsWith("..") || isAbsolute(inside))
269
+ return undefined;
270
+ return `data:${type};base64,${readFileSync(path).toString("base64")}`;
271
+ }
272
+ catch {
273
+ return undefined;
274
+ }
275
+ }
238
276
  /**
239
277
  * Swap a fixture's network images for same-size placeholders.
240
278
  *
241
279
  * Previews never touch the network, so a captured article's images would otherwise
242
280
  * render broken. Each <img> keeps its attributes and width/height (16:9 when it has
243
281
  * none), so layout matches; a <source> that would fetch is dropped for its <img>.
282
+ * With a media directory, an <img src="media/NAME"> shows that file instead.
244
283
  */
245
- export function offlineMedia(body) {
284
+ export function offlineMedia(body, media) {
246
285
  return body.replace(MEDIA_TAG_RE, (whole, tagName, rest) => {
247
286
  const tag = tagName.toLowerCase();
248
287
  const attributes = [...rest.matchAll(ATTRIBUTE_RE)].map((match) => [
@@ -253,8 +292,9 @@ export function offlineMedia(body) {
253
292
  name.toLowerCase(),
254
293
  decodeHTML(value.replace(/^["']+|["']+$/g, "")),
255
294
  ]));
295
+ const src = values.get("src") ?? "";
256
296
  const srcset = (values.get("srcset") ?? "").split(",").some(fetches);
257
- if (!(srcset || fetches(values.get("src") ?? "")))
297
+ if (!(srcset || fetches(src)))
258
298
  return whole;
259
299
  if (tag === "source")
260
300
  return "";
@@ -262,17 +302,18 @@ export function offlineMedia(body) {
262
302
  .filter(([name]) => !["src", "srcset", "sizes"].includes(name.toLowerCase()))
263
303
  .map(([name, value]) => (value ? ` ${name}=${value}` : ` ${name}`))
264
304
  .join("");
265
- return `<${tagName}${kept} src="${placeholder(values.get("width"), values.get("height"))}">`;
305
+ const local = media === undefined ? undefined : mediaDataURI(media, src);
306
+ return `<${tagName}${kept} src="${local ?? placeholder(values.get("width"), values.get("height"))}">`;
266
307
  });
267
308
  }
268
- function fixtureMapping(fixture, target) {
309
+ function fixtureMapping(fixture, target, media) {
269
310
  const mapping = {};
270
311
  for (const key of TEMPLATE_KEYS)
271
312
  mapping[key] = pyStr(fixture[key] ?? "");
272
313
  if (!mapping.dateline_style) {
273
314
  mapping.dateline_style = mapping.title ? "articleDateline" : "articleDatelineTitle";
274
315
  }
275
- mapping.body = offlineMedia(mapping.body ?? "");
316
+ mapping.body = offlineMedia(mapping.body ?? "", media);
276
317
  if (!mapping.avatar_src || fetches(mapping.avatar_src)) {
277
318
  mapping.avatar_src = avatarDataURI(mapping.feed_link_title ?? "");
278
319
  }
@@ -340,7 +381,7 @@ function isFile(path) {
340
381
  return false;
341
382
  }
342
383
  }
343
- export function renderPage(theme, fixture, target, snapshot = renderingInputs().path) {
384
+ export function renderPage(theme, fixture, target, snapshot = renderingInputs().path, media) {
344
385
  const pagePath = join(snapshot, target.ios ? "iOS" : "Mac", "page.html");
345
386
  const required = [
346
387
  pagePath,
@@ -360,7 +401,7 @@ export function renderPage(theme, fixture, target, snapshot = renderingInputs().
360
401
  }
361
402
  const style = substitute(`${read(join(snapshot, "Shared", "core.css"))}\n${stylesheet}`, styleSubstitutions);
362
403
  const template = read(join(theme, "template.html"));
363
- let body = substitute(template, fixtureMapping(fixture, target));
404
+ let body = substitute(template, fixtureMapping(fixture, target, media));
364
405
  if (!target.themeScripts)
365
406
  body = removeScripts(body);
366
407
  let page = substitute(skeleton, {
@@ -390,7 +431,7 @@ export function renderSite(root, theme, targets, snapshot = renderingInputs().pa
390
431
  const name = themeStem(theme);
391
432
  for (const target of targets) {
392
433
  const fixture = readFixture(fixturePath(root, target.fixture));
393
- writeFileSync(join(pages, `${target.slug}.html`), renderPage(theme, fixture, target, snapshot), "utf8");
434
+ writeFileSync(join(pages, `${target.slug}.html`), renderPage(theme, fixture, target, snapshot, join(root, "fixtures", "media")), "utf8");
394
435
  const label = escapeHTML(target.label);
395
436
  const viewer = `<!doctype html>
396
437
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nnw-theme",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Create, preview, check, and publish a NetNewsWire theme.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Marquard",