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 +1 -1
- package/assets/guide/fixtures.md +6 -0
- package/assets/guide/publishing.md +6 -0
- package/assets/stubs/.github/workflows/screenshot.yml +7 -3
- package/dist/browser.js +2 -1
- package/dist/commands/screenshot.js +7 -8
- package/dist/commands.js +5 -2
- package/dist/render.js +53 -12
- package/package.json +1 -1
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
|
|
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 |
|
package/assets/guide/fixtures.md
CHANGED
|
@@ -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:
|
|
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:
|
|
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,
|
|
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
|
|
9
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 --
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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">
|