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/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 MEDIA_TYPES = {
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
- constructor(fixture, platform, appearance, { largeText = false, themeScripts = true, card = false } = {}) {
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
- export function normalTargets(extras = []) {
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 Object.keys(FIXTURE_NAMES)) {
187
- for (const platform of PLATFORMS) {
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
- for (const fixture of extras) {
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
- export function checkTargets(extras = []) {
203
- return [
204
- ...normalTargets(extras),
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 = MEDIA_TYPES[extname(src).toLowerCase()];
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
- function fixtureMapping(fixture, target, media) {
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
- mapping.body = offlineMedia(mapping.body ?? "", media);
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.preferred_link || fixture.feed_link || "",
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 (!known.has(target.scenario) && !scenarios.has(target.scenario)) {
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 fixture_text(pairs: list[tuple[str, str]], name: str) -> str:
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
- return header + "\n".join(toml_line(key, value) for key, value in ordered) + "\n"
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 = " with the feed's icon"
218
+ note += " with the feed's icon"
137
219
  else:
138
- note = "; the feed has no icon, so previews generate one"
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nnw-theme",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "description": "Create, preview, check, and publish a NetNewsWire theme.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Marquard",