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.
- package/README.md +2 -2
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/assets/catalog/mastodon.toml +50 -0
- package/assets/catalog/media-rss.toml +79 -0
- package/assets/catalog/podcast.toml +52 -0
- package/assets/enclosures.js +113 -0
- package/assets/footnotes.js +39 -6
- package/assets/guide/design-checklist.md +6 -0
- package/assets/guide/fixtures.md +64 -4
- package/assets/guide/skill.md +2 -1
- package/assets/guide/theme-format.md +48 -2
- package/assets/netnewswire/Mac/main_mac.js +5 -0
- package/assets/netnewswire/Shared/core.css +25 -7
- package/assets/netnewswire/Shared/main.js +73 -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/browser.js +30 -6
- package/dist/commands/check.js +26 -7
- 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 +12 -2
- package/dist/delivery.js +516 -0
- package/dist/enclosures.js +299 -0
- package/dist/project.js +11 -2
- package/dist/render.js +220 -21
- package/lldb/nnwdump.py +87 -5
- package/package.json +1 -1
package/dist/render.js
CHANGED
|
@@ -3,6 +3,8 @@ import { mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync,
|
|
|
3
3
|
import { extname, isAbsolute, join, relative } from "node:path";
|
|
4
4
|
import { decodeHTML } from "entities";
|
|
5
5
|
import { Parser } from "htmlparser2";
|
|
6
|
+
import { DELIVERIES, DELIVERY_NAMES, deliver } from "./delivery.js";
|
|
7
|
+
import { enclosuresHTML, fixtureAttachments, fixtureImageURL, placeEnclosures, } from "./enclosures.js";
|
|
6
8
|
import { renderingInputs } from "./netnewswire.js";
|
|
7
9
|
import { fixturePath, readFixture, repositoryFixtures, ThemeError, themeStem, } from "./project.js";
|
|
8
10
|
import { formatG, pyStr } from "./pyformat.js";
|
|
@@ -64,7 +66,7 @@ const VIEWPORTS = {
|
|
|
64
66
|
// The marketplace card shows its image at 347x240, cropped from the top.
|
|
65
67
|
const CARD_VIEWPORT = [800, 552];
|
|
66
68
|
// Fixture images a fixture may reference as media/NAME, from fixtures/media/NAME.
|
|
67
|
-
const
|
|
69
|
+
const IMAGE_TYPES = {
|
|
68
70
|
".avif": "image/avif",
|
|
69
71
|
".gif": "image/gif",
|
|
70
72
|
".jpeg": "image/jpeg",
|
|
@@ -73,6 +75,23 @@ const MEDIA_TYPES = {
|
|
|
73
75
|
".svg": "image/svg+xml",
|
|
74
76
|
".webp": "image/webp",
|
|
75
77
|
};
|
|
78
|
+
// Enclosure players may also play a fixture's own audio or video file.
|
|
79
|
+
const PLAYABLE_TYPES = {
|
|
80
|
+
".aac": "audio/aac",
|
|
81
|
+
".flac": "audio/flac",
|
|
82
|
+
".m4a": "audio/mp4",
|
|
83
|
+
".m4v": "video/mp4",
|
|
84
|
+
".mov": "video/quicktime",
|
|
85
|
+
".mp3": "audio/mpeg",
|
|
86
|
+
".mp4": "video/mp4",
|
|
87
|
+
".oga": "audio/ogg",
|
|
88
|
+
".ogg": "audio/ogg",
|
|
89
|
+
".opus": "audio/ogg",
|
|
90
|
+
".wav": "audio/wav",
|
|
91
|
+
".webm": "video/webm",
|
|
92
|
+
};
|
|
93
|
+
// An enclosure's own tag, as enclosuresHTML writes it.
|
|
94
|
+
const ENCLOSURE_TAG_RE = /<(video|audio|iframe)\b([^>]*)>/g;
|
|
76
95
|
// The template's own fixtures get the full matrix and the stress cases. Any other
|
|
77
96
|
// fixture a theme adds is an extra: checked on Mac and iPhone in both appearances.
|
|
78
97
|
export const FIXTURE_NAMES = {
|
|
@@ -102,6 +121,12 @@ const SCENARIOS = [
|
|
|
102
121
|
"The kitchen-sink fixture at a large reading size: the xxLargeText size class on " +
|
|
103
122
|
"Mac and 23 pt Dynamic Type on iPhone.",
|
|
104
123
|
],
|
|
124
|
+
[
|
|
125
|
+
"older-netnewswire",
|
|
126
|
+
"Older NetNewsWire",
|
|
127
|
+
"The article fixture as NetNewsWire releases before enclosure support render it: " +
|
|
128
|
+
"they leave the template's [[enclosures]] macro as literal text, which must not show.",
|
|
129
|
+
],
|
|
105
130
|
[
|
|
106
131
|
"article-javascript-off",
|
|
107
132
|
"Article JavaScript off",
|
|
@@ -117,6 +142,8 @@ const CHECKS = [
|
|
|
117
142
|
"no broken images",
|
|
118
143
|
"no external requests",
|
|
119
144
|
"no JavaScript errors",
|
|
145
|
+
"enclosure players and images show, fit the window, and keep their shape",
|
|
146
|
+
"enclosure captions are legible",
|
|
120
147
|
];
|
|
121
148
|
export class RenderTarget {
|
|
122
149
|
fixture;
|
|
@@ -126,16 +153,22 @@ export class RenderTarget {
|
|
|
126
153
|
height;
|
|
127
154
|
largeText;
|
|
128
155
|
themeScripts;
|
|
156
|
+
/** Rendered as NetNewsWire before [[enclosures]]: the macro stays literal. */
|
|
157
|
+
olderNetNewsWire;
|
|
129
158
|
/** A 2x capture of the card viewport alone, for the marketplace card. */
|
|
130
159
|
card;
|
|
131
|
-
|
|
160
|
+
/** The sync service the article arrives through, rewritten as it delivers it. */
|
|
161
|
+
delivery;
|
|
162
|
+
constructor(fixture, platform, appearance, { largeText = false, themeScripts = true, olderNetNewsWire = false, card = false, delivery = undefined, } = {}) {
|
|
132
163
|
this.fixture = fixture;
|
|
133
164
|
this.platform = platform;
|
|
134
165
|
this.appearance = appearance;
|
|
135
166
|
[this.width, this.height] = card ? CARD_VIEWPORT : VIEWPORTS[platform];
|
|
136
167
|
this.largeText = largeText;
|
|
137
168
|
this.themeScripts = themeScripts;
|
|
169
|
+
this.olderNetNewsWire = olderNetNewsWire;
|
|
138
170
|
this.card = card;
|
|
171
|
+
this.delivery = delivery;
|
|
139
172
|
}
|
|
140
173
|
get ios() {
|
|
141
174
|
return this.platform === "iphone" || this.platform === "ipad";
|
|
@@ -150,6 +183,10 @@ export class RenderTarget {
|
|
|
150
183
|
parts.push("large text");
|
|
151
184
|
if (!this.themeScripts)
|
|
152
185
|
parts.push("Article JavaScript off");
|
|
186
|
+
if (this.olderNetNewsWire)
|
|
187
|
+
parts.push("older NetNewsWire");
|
|
188
|
+
if (this.delivery)
|
|
189
|
+
parts.push(`via ${DELIVERY_NAMES[this.delivery]}`);
|
|
153
190
|
if (this.card)
|
|
154
191
|
parts.push("card");
|
|
155
192
|
return parts.join(" · ");
|
|
@@ -159,6 +196,10 @@ export class RenderTarget {
|
|
|
159
196
|
return "large-text";
|
|
160
197
|
if (!this.themeScripts)
|
|
161
198
|
return "article-javascript-off";
|
|
199
|
+
if (this.olderNetNewsWire)
|
|
200
|
+
return "older-netnewswire";
|
|
201
|
+
if (this.delivery)
|
|
202
|
+
return `${this.fixture} via ${DELIVERY_NAMES[this.delivery]}`;
|
|
162
203
|
return this.fixture;
|
|
163
204
|
}
|
|
164
205
|
get slug() {
|
|
@@ -167,6 +208,10 @@ export class RenderTarget {
|
|
|
167
208
|
parts.push("large-text");
|
|
168
209
|
if (!this.themeScripts)
|
|
169
210
|
parts.push("no-article-js");
|
|
211
|
+
if (this.olderNetNewsWire)
|
|
212
|
+
parts.push("older-netnewswire");
|
|
213
|
+
if (this.delivery)
|
|
214
|
+
parts.push(this.delivery);
|
|
170
215
|
if (this.card)
|
|
171
216
|
parts.push("card");
|
|
172
217
|
return parts.join("-");
|
|
@@ -181,32 +226,80 @@ export function extraFixtures(root) {
|
|
|
181
226
|
}
|
|
182
227
|
return extras;
|
|
183
228
|
}
|
|
184
|
-
|
|
229
|
+
/** A fixture's deliveries = [...] key, validated: the services to also render it through. */
|
|
230
|
+
export function fixtureDeliveries(fixture, name) {
|
|
231
|
+
const value = fixture.deliveries;
|
|
232
|
+
if (value === undefined)
|
|
233
|
+
return [];
|
|
234
|
+
if (!Array.isArray(value) ||
|
|
235
|
+
!value.every((item) => DELIVERIES.includes(item)) ||
|
|
236
|
+
new Set(value).size !== value.length) {
|
|
237
|
+
throw new ThemeError(`${name}: deliveries must list distinct services from ${DELIVERIES.join(", ")}`);
|
|
238
|
+
}
|
|
239
|
+
return value;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Each fixture's deliveries = [...], for those that list any. fixtures are the names
|
|
243
|
+
* to read: the template's own and the extras.
|
|
244
|
+
*/
|
|
245
|
+
export function readDeliveries(root, fixtures) {
|
|
246
|
+
const result = {};
|
|
247
|
+
for (const name of fixtures) {
|
|
248
|
+
const deliveries = fixtureDeliveries(readFixture(fixturePath(root, name)), `fixtures/${name}.toml`);
|
|
249
|
+
if (deliveries.length)
|
|
250
|
+
result[name] = deliveries;
|
|
251
|
+
}
|
|
252
|
+
return result;
|
|
253
|
+
}
|
|
254
|
+
/** The fixtures a repository renders: the template's own, then its extras. */
|
|
255
|
+
export function allFixtures(extras) {
|
|
256
|
+
return [...Object.keys(FIXTURE_NAMES), ...extras];
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Every fixture's matrix. A fixture that lists deliveries is also rendered through
|
|
260
|
+
* each service, on Mac in light and iPhone in dark, right after its own cases.
|
|
261
|
+
*/
|
|
262
|
+
export function normalTargets(extras = [], deliveries = {}) {
|
|
185
263
|
const targets = [];
|
|
186
|
-
for (const fixture of
|
|
187
|
-
|
|
264
|
+
for (const fixture of allFixtures(extras)) {
|
|
265
|
+
const platforms = fixture in FIXTURE_NAMES ? PLATFORMS : EXTRA_PLATFORMS;
|
|
266
|
+
for (const platform of platforms) {
|
|
188
267
|
for (const appearance of APPEARANCES) {
|
|
189
268
|
targets.push(new RenderTarget(fixture, platform, appearance));
|
|
190
269
|
}
|
|
191
270
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
for (const platform of EXTRA_PLATFORMS) {
|
|
195
|
-
for (const appearance of APPEARANCES) {
|
|
196
|
-
targets.push(new RenderTarget(fixture, platform, appearance));
|
|
197
|
-
}
|
|
271
|
+
for (const delivery of deliveries[fixture] ?? []) {
|
|
272
|
+
targets.push(new RenderTarget(fixture, "mac", "light", { delivery }), new RenderTarget(fixture, "iphone", "dark", { delivery }));
|
|
198
273
|
}
|
|
199
274
|
}
|
|
200
275
|
return targets;
|
|
201
276
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
277
|
+
/** A repository's normal matrix, including each fixture's deliveries. */
|
|
278
|
+
export function repositoryTargets(root) {
|
|
279
|
+
const extras = extraFixtures(root);
|
|
280
|
+
return normalTargets(extras, readDeliveries(root, allFixtures(extras)));
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Everything check renders. A template that places [[enclosures]] itself also gets
|
|
284
|
+
* the older-NetNewsWire cases, since releases before enclosure support leave that
|
|
285
|
+
* macro as text in every article.
|
|
286
|
+
*/
|
|
287
|
+
export function checkTargets(extras = [], { enclosuresMacro = false, deliveries = {} } = {}) {
|
|
288
|
+
const targets = [
|
|
289
|
+
...normalTargets(extras, deliveries),
|
|
205
290
|
new RenderTarget("kitchen-sink", "mac", "light", { largeText: true }),
|
|
206
291
|
new RenderTarget("kitchen-sink", "iphone", "light", { largeText: true }),
|
|
207
292
|
new RenderTarget("article", "mac", "light", { themeScripts: false }),
|
|
208
293
|
new RenderTarget("article", "iphone", "light", { themeScripts: false }),
|
|
209
294
|
];
|
|
295
|
+
if (enclosuresMacro) {
|
|
296
|
+
targets.push(new RenderTarget("article", "mac", "light", { olderNetNewsWire: true }), new RenderTarget("article", "iphone", "dark", { olderNetNewsWire: true }));
|
|
297
|
+
}
|
|
298
|
+
return targets;
|
|
299
|
+
}
|
|
300
|
+
/** Whether a theme places enclosures itself, as NetNewsWire decides it. */
|
|
301
|
+
export function placesEnclosures(theme) {
|
|
302
|
+
return read(join(theme, "template.html")).includes("[[enclosures]]");
|
|
210
303
|
}
|
|
211
304
|
/** Perform NetNewsWire's single-pass, non-recursive macro substitution. */
|
|
212
305
|
export function substitute(template, mapping) {
|
|
@@ -258,8 +351,8 @@ function placeholder(width, height) {
|
|
|
258
351
|
* A fixture's own image, media/NAME, as a data URI read from fixtures/media/NAME.
|
|
259
352
|
* Undefined, so a placeholder as before, unless it is an image inside that directory.
|
|
260
353
|
*/
|
|
261
|
-
function mediaDataURI(media, src) {
|
|
262
|
-
const type =
|
|
354
|
+
function mediaDataURI(media, src, types = IMAGE_TYPES) {
|
|
355
|
+
const type = types[extname(src).toLowerCase()];
|
|
263
356
|
if (!src.startsWith("media/") || !type)
|
|
264
357
|
return undefined;
|
|
265
358
|
try {
|
|
@@ -306,14 +399,73 @@ export function offlineMedia(body, media) {
|
|
|
306
399
|
return `<${tagName}${kept} src="${local ?? placeholder(values.get("width"), values.get("height"))}">`;
|
|
307
400
|
});
|
|
308
401
|
}
|
|
309
|
-
|
|
402
|
+
/** A tag's attributes, in order, with their raw (still quoted) values. */
|
|
403
|
+
function parseAttributes(rest) {
|
|
404
|
+
return [...rest.matchAll(ATTRIBUTE_RE)].map((match) => [
|
|
405
|
+
match[1] ?? "",
|
|
406
|
+
match[2] ?? "",
|
|
407
|
+
]);
|
|
408
|
+
}
|
|
409
|
+
function attributeValue(attributes, name) {
|
|
410
|
+
const found = attributes.find(([key]) => key.toLowerCase() === name);
|
|
411
|
+
return found && decodeHTML(found[1].replace(/^["']+|["']+$/g, ""));
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Take enclosures offline, as offlineMedia does a body. Images and a video's poster
|
|
415
|
+
* become placeholders (media/NAME shows the fixture's file); a player that would
|
|
416
|
+
* fetch loses its source, as if the file had not loaded, unless it plays a media/
|
|
417
|
+
* file; a video with nothing to show gets a placeholder poster at its own size, for
|
|
418
|
+
* the frame it would load; and the YouTube player becomes a same-size placeholder.
|
|
419
|
+
*/
|
|
420
|
+
export function offlineEnclosures(html, media) {
|
|
421
|
+
const local = (src, types) => media === undefined ? undefined : mediaDataURI(media, src, types);
|
|
422
|
+
return offlineMedia(html, media).replace(ENCLOSURE_TAG_RE, (_whole, tagName, rest) => {
|
|
423
|
+
const attributes = parseAttributes(rest);
|
|
424
|
+
const width = attributeValue(attributes, "width");
|
|
425
|
+
const height = attributeValue(attributes, "height");
|
|
426
|
+
const src = attributeValue(attributes, "src") ?? "";
|
|
427
|
+
if (tagName === "iframe") {
|
|
428
|
+
const label = `${width && height ? `${width} × ${height} ` : ""}YouTube player`;
|
|
429
|
+
const page = "<body style='margin:0;display:grid;place-items:center;height:100vh;" +
|
|
430
|
+
`background:#8a8f98;color:#fff;font:600 16px system-ui'>${label}`;
|
|
431
|
+
const kept = attributes.filter(([name]) => name.toLowerCase() !== "src");
|
|
432
|
+
return `<iframe${kept.map(([n, v]) => (v ? ` ${n}=${v}` : ` ${n}`)).join("")} srcdoc="${escapeHTML(page)}">`;
|
|
433
|
+
}
|
|
434
|
+
const playable = fetches(src) ? local(src, PLAYABLE_TYPES) : src;
|
|
435
|
+
const kept = attributes.filter(([name]) => !["src", "poster"].includes(name.toLowerCase()));
|
|
436
|
+
let tag = `<${tagName}${kept.map(([n, v]) => (v ? ` ${n}=${v}` : ` ${n}`)).join("")}`;
|
|
437
|
+
if (playable)
|
|
438
|
+
tag += ` src="${escapeHTML(playable)}"`;
|
|
439
|
+
if (tagName === "video") {
|
|
440
|
+
const poster = attributeValue(attributes, "poster");
|
|
441
|
+
let shown;
|
|
442
|
+
if (poster)
|
|
443
|
+
shown = fetches(poster) ? (local(poster) ?? placeholder(width, height)) : poster;
|
|
444
|
+
else if (!playable)
|
|
445
|
+
shown = placeholder(width, height);
|
|
446
|
+
if (shown)
|
|
447
|
+
tag += ` poster="${escapeHTML(shown)}"`;
|
|
448
|
+
}
|
|
449
|
+
return `${tag}>`;
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
function fixtureMapping(fixture, target, template, media) {
|
|
310
453
|
const mapping = {};
|
|
311
454
|
for (const key of TEMPLATE_KEYS)
|
|
312
455
|
mapping[key] = pyStr(fixture[key] ?? "");
|
|
313
456
|
if (!mapping.dateline_style) {
|
|
314
457
|
mapping.dateline_style = mapping.title ? "articleDateline" : "articleDatelineTitle";
|
|
315
458
|
}
|
|
316
|
-
|
|
459
|
+
let body = mapping.body ?? "";
|
|
460
|
+
if (target.delivery)
|
|
461
|
+
body = deliver(body, target.delivery, mapping.preferred_link ?? "");
|
|
462
|
+
mapping.body = offlineMedia(body, media);
|
|
463
|
+
// NetNewsWire before enclosure support ignores attachments and leaves the macro.
|
|
464
|
+
if (!target.olderNetNewsWire) {
|
|
465
|
+
const name = `fixtures/${target.fixture}.toml`;
|
|
466
|
+
const enclosures = enclosuresHTML(fixtureAttachments(fixture, name), body, fixtureImageURL(fixture, name));
|
|
467
|
+
Object.assign(mapping, placeEnclosures(template, mapping.body, offlineEnclosures(enclosures, media)));
|
|
468
|
+
}
|
|
317
469
|
if (!mapping.avatar_src || fetches(mapping.avatar_src)) {
|
|
318
470
|
mapping.avatar_src = avatarDataURI(mapping.feed_link_title ?? "");
|
|
319
471
|
}
|
|
@@ -381,6 +533,42 @@ function isFile(path) {
|
|
|
381
533
|
return false;
|
|
382
534
|
}
|
|
383
535
|
}
|
|
536
|
+
/**
|
|
537
|
+
* Article.baseURL, made absolute as NetNewsWire does: no fragment, and nothing unless
|
|
538
|
+
* http or https.
|
|
539
|
+
*/
|
|
540
|
+
export function articleBaseURL(value) {
|
|
541
|
+
let url;
|
|
542
|
+
try {
|
|
543
|
+
url = new URL(value);
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
return "";
|
|
547
|
+
}
|
|
548
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
549
|
+
return "";
|
|
550
|
+
url.hash = "";
|
|
551
|
+
return url.href;
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* The page's base URL: the fixture's base_url when it gives one, else its link. An
|
|
555
|
+
* article synced through the Reader API (FreshRSS, Miniflux, Inoreader) has only an
|
|
556
|
+
* external link, so NetNewsWire falls back to the feed's home page: a delivered case
|
|
557
|
+
* uses feed_link, unless base_url gives the home page the service reports instead.
|
|
558
|
+
*/
|
|
559
|
+
function pageBaseURL(fixture, target) {
|
|
560
|
+
const name = `fixtures/${target.fixture}.toml`;
|
|
561
|
+
const baseURL = fixture.base_url;
|
|
562
|
+
if (baseURL !== undefined && typeof baseURL !== "string") {
|
|
563
|
+
throw new ThemeError(`${name}: base_url must be a string`);
|
|
564
|
+
}
|
|
565
|
+
if (baseURL !== undefined)
|
|
566
|
+
return articleBaseURL(baseURL);
|
|
567
|
+
if (target.delivery && typeof fixture.feed_link === "string" && fixture.feed_link) {
|
|
568
|
+
return articleBaseURL(fixture.feed_link);
|
|
569
|
+
}
|
|
570
|
+
return pyStr(fixture.preferred_link || fixture.feed_link || "");
|
|
571
|
+
}
|
|
384
572
|
export function renderPage(theme, fixture, target, snapshot = renderingInputs().path, media) {
|
|
385
573
|
const pagePath = join(snapshot, target.ios ? "iOS" : "Mac", "page.html");
|
|
386
574
|
const required = [
|
|
@@ -401,14 +589,14 @@ export function renderPage(theme, fixture, target, snapshot = renderingInputs().
|
|
|
401
589
|
}
|
|
402
590
|
const style = substitute(`${read(join(snapshot, "Shared", "core.css"))}\n${stylesheet}`, styleSubstitutions);
|
|
403
591
|
const template = read(join(theme, "template.html"));
|
|
404
|
-
let body = substitute(template, fixtureMapping(fixture, target, media));
|
|
592
|
+
let body = substitute(template, fixtureMapping(fixture, target, template, media));
|
|
405
593
|
if (!target.themeScripts)
|
|
406
594
|
body = removeScripts(body);
|
|
407
595
|
let page = substitute(skeleton, {
|
|
408
596
|
title: fixture.title ?? "Theme preview",
|
|
409
597
|
style,
|
|
410
598
|
body,
|
|
411
|
-
baseURL: fixture
|
|
599
|
+
baseURL: pageBaseURL(fixture, target),
|
|
412
600
|
windowScrollY: "0",
|
|
413
601
|
});
|
|
414
602
|
page = injectScripts(page, snapshot, target.platform);
|
|
@@ -498,7 +686,18 @@ export function writeGallery(site, themeName, targets, results = null) {
|
|
|
498
686
|
const known = new Set(RESERVED_FIXTURE_NAMES);
|
|
499
687
|
const scenarios = new Map(SCENARIOS.map(([scenario, heading, text]) => [scenario, [heading, text]]));
|
|
500
688
|
for (const target of targets) {
|
|
501
|
-
if (
|
|
689
|
+
if (known.has(target.scenario) || scenarios.has(target.scenario))
|
|
690
|
+
continue;
|
|
691
|
+
if (target.delivery) {
|
|
692
|
+
const service = DELIVERY_NAMES[target.delivery];
|
|
693
|
+
scenarios.set(target.scenario, [
|
|
694
|
+
target.scenario,
|
|
695
|
+
`fixtures/${target.fixture}.toml as ${service} delivers it through the Reader ` +
|
|
696
|
+
`API: its body rewritten by ${service}'s sanitizer, and the feed's home page ` +
|
|
697
|
+
"as the page's base URL.",
|
|
698
|
+
]);
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
502
701
|
scenarios.set(target.scenario, [target.fixture, `Your fixtures/${target.fixture}.toml.`]);
|
|
503
702
|
}
|
|
504
703
|
}
|
package/lldb/nnwdump.py
CHANGED
|
@@ -4,6 +4,14 @@ Stopped at the final `return d` of ArticleRenderer.articleSubstitutions(), this
|
|
|
4
4
|
reads the substitution dictionary NetNewsWire built for the selected article and
|
|
5
5
|
writes it as a TOML fixture. `npx nnw-theme@2 capture` prints the setup steps.
|
|
6
6
|
|
|
7
|
+
NetNewsWire with enclosure support renders an article's enclosures into that
|
|
8
|
+
dictionary: as `enclosures`, or appended to a wrapped `body` when the template has no
|
|
9
|
+
[[enclosures]] macro. nnwdump keeps neither. It records the article's attachments
|
|
10
|
+
and image instead, as [[attachments]] tables and image_url/thumbnail_url, so nnw-theme
|
|
11
|
+
renders them for whichever template previews the fixture. When the page's base URL
|
|
12
|
+
differs from the article's link, as for an article synced through the Reader API, it
|
|
13
|
+
also records base_url.
|
|
14
|
+
|
|
7
15
|
Values cross the debugger boundary base64-encoded, so nothing is truncated or
|
|
8
16
|
mis-escaped. HTML values are written as '''literal''' strings, so fixtures stay
|
|
9
17
|
readable and diff cleanly.
|
|
@@ -16,6 +24,7 @@ helpers can be tested without a debugger.
|
|
|
16
24
|
from __future__ import annotations
|
|
17
25
|
|
|
18
26
|
import base64
|
|
27
|
+
import json
|
|
19
28
|
import optparse
|
|
20
29
|
import shlex
|
|
21
30
|
from pathlib import Path
|
|
@@ -24,6 +33,20 @@ USAGE = "nnwdump [--var NAME] [--article NAME] [--no-icon] [OUTPUT.toml]"
|
|
|
24
33
|
DEFAULT_OUTPUT = "fixtures/nnw-capture.toml"
|
|
25
34
|
# Sorted last so the short metadata stays at the top of the file.
|
|
26
35
|
_HTML_KEYS = {"body"}
|
|
36
|
+
_ENCLOSURES_KEY = "enclosures"
|
|
37
|
+
# ArticleRenderer's body for a template without [[enclosures]]: <div>BODY</div>ENCLOSURES.
|
|
38
|
+
_WRAPPED_BODY_TAIL = '</div><div class="nnw-enclosures">'
|
|
39
|
+
# Attachment's Codable keys, in fixture order, with their fixture names.
|
|
40
|
+
_ATTACHMENT_KEYS = (
|
|
41
|
+
("url", "url"),
|
|
42
|
+
("mimeType", "mime_type"),
|
|
43
|
+
("title", "title"),
|
|
44
|
+
("sizeInBytes", "size_in_bytes"),
|
|
45
|
+
("durationInSeconds", "duration_in_seconds"),
|
|
46
|
+
("width", "width"),
|
|
47
|
+
("height", "height"),
|
|
48
|
+
("medium", "medium"),
|
|
49
|
+
)
|
|
27
50
|
|
|
28
51
|
|
|
29
52
|
def resolve_output(argument: str) -> Path:
|
|
@@ -56,13 +79,55 @@ def toml_line(key: str, value: str) -> str:
|
|
|
56
79
|
return f'{key} = "{_toml_basic(value)}"'
|
|
57
80
|
|
|
58
81
|
|
|
59
|
-
def
|
|
82
|
+
def without_enclosures(pairs: list[tuple[str, str]]) -> list[tuple[str, str]]:
|
|
83
|
+
"""The substitutions as they were before NetNewsWire rendered the enclosures."""
|
|
84
|
+
values = dict(pairs)
|
|
85
|
+
if _ENCLOSURES_KEY not in values:
|
|
86
|
+
body = values.get("body", "")
|
|
87
|
+
at = body.rfind(_WRAPPED_BODY_TAIL)
|
|
88
|
+
if body.startswith("<div>") and at != -1:
|
|
89
|
+
values["body"] = body[len("<div>") : at]
|
|
90
|
+
values.pop(_ENCLOSURES_KEY, None)
|
|
91
|
+
return list(values.items())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def attachments_from_json(text: str) -> list[dict]:
|
|
95
|
+
"""Attachment JSON, as [Attachment].json() writes it, as fixture tables."""
|
|
96
|
+
tables = []
|
|
97
|
+
for attachment in json.loads(text or "[]") or []:
|
|
98
|
+
table = {
|
|
99
|
+
name: attachment[key]
|
|
100
|
+
for key, name in _ATTACHMENT_KEYS
|
|
101
|
+
if attachment.get(key) is not None
|
|
102
|
+
}
|
|
103
|
+
if table.get("url"):
|
|
104
|
+
tables.append(table)
|
|
105
|
+
return tables
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def base_url_pairs(pairs: list[tuple[str, str]], base_url: str) -> list[tuple[str, str]]:
|
|
109
|
+
"""base_url, when the rendered page's base differs from preferred_link."""
|
|
110
|
+
preferred = dict(pairs).get("preferred_link", "")
|
|
111
|
+
return [] if base_url == preferred else [("base_url", base_url)]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _toml_value_line(key: str, value) -> str:
|
|
115
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
116
|
+
return toml_line(key, str(value))
|
|
117
|
+
return f"{key} = {value}"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def fixture_text(pairs: list[tuple[str, str]], name: str, attachments: list[dict] = ()) -> str:
|
|
60
121
|
ordered = sorted(pairs, key=lambda pair: (pair[0] in _HTML_KEYS, pair[0]))
|
|
61
122
|
header = (
|
|
62
123
|
"# NetNewsWire article captured with nnwdump.\n"
|
|
63
124
|
f"# Preview with: npx nnw-theme@2 render {name}\n\n"
|
|
64
125
|
)
|
|
65
|
-
|
|
126
|
+
text = header + "\n".join(toml_line(key, value) for key, value in ordered) + "\n"
|
|
127
|
+
for table in attachments:
|
|
128
|
+
lines = [_toml_value_line(key, value) for key, value in table.items()]
|
|
129
|
+
text += "\n[[attachments]]\n" + "\n".join(lines) + "\n"
|
|
130
|
+
return text
|
|
66
131
|
|
|
67
132
|
|
|
68
133
|
def _evaluate(interpreter, expression: str):
|
|
@@ -127,19 +192,36 @@ def nnwdump(debugger, command, result, internal_dict):
|
|
|
127
192
|
result.SetError(f"`{options.var}` is empty; is this the substitution dictionary?")
|
|
128
193
|
return
|
|
129
194
|
|
|
195
|
+
# Older NetNewsWire has no attachments to read; capture the article without them.
|
|
196
|
+
pairs = without_enclosures(pairs)
|
|
197
|
+
attachments = []
|
|
198
|
+
raw, _ = _evaluate(interpreter, f'({options.article}.attachments ?? []).json() ?? "[]"')
|
|
199
|
+
if raw:
|
|
200
|
+
attachments = attachments_from_json(raw)
|
|
201
|
+
for key, link in (("image_url", "imageLink"), ("thumbnail_url", "thumbnailLink")):
|
|
202
|
+
value, _ = _evaluate(interpreter, f'{options.article}.{link} ?? ""')
|
|
203
|
+
if value:
|
|
204
|
+
pairs.append((key, value))
|
|
205
|
+
# The renderer's own base: the article's link, else the feed's home page.
|
|
206
|
+
base_url, _ = _evaluate(interpreter, 'self.baseURL?.absoluteString ?? ""')
|
|
207
|
+
if base_url is not None:
|
|
208
|
+
pairs.extend(base_url_pairs(pairs, base_url))
|
|
209
|
+
|
|
130
210
|
note = ""
|
|
211
|
+
if attachments:
|
|
212
|
+
note = f" and {len(attachments)} attachment{'s' if len(attachments) > 1 else ''}"
|
|
131
213
|
if not options.no_icon:
|
|
132
214
|
icon = _icon_data_url(interpreter, options.article)
|
|
133
215
|
if icon:
|
|
134
216
|
pairs = [(key, value) for key, value in pairs if key != "avatar_src"]
|
|
135
217
|
pairs.append(("avatar_src", icon))
|
|
136
|
-
note
|
|
218
|
+
note += " with the feed's icon"
|
|
137
219
|
else:
|
|
138
|
-
note
|
|
220
|
+
note += "; the feed has no icon, so previews generate one"
|
|
139
221
|
|
|
140
222
|
try:
|
|
141
223
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
-
output.write_text(fixture_text(pairs, output.stem), encoding="utf-8")
|
|
224
|
+
output.write_text(fixture_text(pairs, output.stem, attachments), encoding="utf-8")
|
|
143
225
|
except OSError as error:
|
|
144
226
|
result.SetError(f"could not write {output}: {error}")
|
|
145
227
|
return
|