nnw-theme 2.3.0 → 2.5.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.
@@ -1,7 +1,9 @@
1
1
  // A port of NetNewsWire's enclosure rendering: Attachment.displayMedium
2
- // (Modules/Articles/Sources/Articles/Attachment.swift) and the [[enclosures]] HTML
3
- // (Shared/Article Rendering/ArticleRenderer+Enclosures.swift), at the pinned commit.
2
+ // (Modules/Articles/Sources/Articles/Attachment.swift) and the [[enclosures]] and
3
+ // [[primary_media]] HTML (Shared/Article Rendering/ArticleRenderer+Enclosures.swift and
4
+ // ArticleRenderer+PrimaryMedia.swift), at the pinned commit.
4
5
  // Keep it line for line with the Swift, so a diff of the two stays easy to review.
6
+ import { significantImageURLs } from "./body-images.js";
5
7
  import { ThemeError } from "./project.js";
6
8
  export const MEDIA = ["image", "audio", "video", "document", "executable"];
7
9
  const STRING_KEYS = ["url", "mime_type", "title"];
@@ -46,24 +48,26 @@ export function fixtureAttachments(fixture, name) {
46
48
  return table.url ? [table] : [];
47
49
  });
48
50
  }
49
- /** A fixture's image_url, else its thumbnail_url: Article.imageLink ?? thumbnailLink. */
50
- export function fixtureImageURL(fixture, name) {
51
- for (const key of ["image_url", "thumbnail_url"]) {
51
+ /** A fixture's image_url and thumbnail_url: Article.imageLink and thumbnailLink. */
52
+ export function fixtureImages(fixture, name) {
53
+ const text = (key) => {
52
54
  const value = fixture[key];
53
- if (value === undefined)
54
- continue;
55
- if (typeof value !== "string")
55
+ if (value !== undefined && typeof value !== "string") {
56
56
  throw new ThemeError(`${name}: ${key} must be a string`);
57
+ }
57
58
  return value;
58
- }
59
- return undefined;
59
+ };
60
+ return { imageLink: text("image_url"), thumbnailLink: text("thumbnail_url") };
60
61
  }
61
62
  const AUDIO_EXTENSIONS = new Set(["mp3", "m4a", "aac", "wav", "ogg", "oga", "opus", "flac"]);
62
63
  const VIDEO_EXTENSIONS = new Set(["mp4", "m4v", "mov", "webm"]);
63
64
  const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "webp", "heic", "avif"]);
64
- const VISUAL_ELEMENT_TAGS = ["<img", "<picture", "<video", "<iframe"];
65
65
  const YOUTUBE_FLASH_PLAYER_PATH_PREFIX = "/v/";
66
66
  const YOUTUBE_EMBED_URL_PREFIX = "https://www.youtube-nocookie.com/embed/";
67
+ const YOUTUBE_EMBED_PATHS = ["youtube.com/embed/", "youtube-nocookie.com/embed/"];
68
+ const WORDPRESS_SIZE_SUFFIX = /-\d+x\d+(?=\.[A-Za-z0-9]+$)/;
69
+ const ENGADGET_LARGE_IMAGE_PREFIX = "l-";
70
+ const SHORT_POST_MAXIMUM_BODY_LENGTH = 2048;
67
71
  /** Foundation's URL(string:), enough for host and path: relative URLs have no host. */
68
72
  function parseURL(value) {
69
73
  try {
@@ -207,20 +211,64 @@ function dimensionAttributes(attachment) {
207
211
  return "";
208
212
  return ` width="${attachment.width}" height="${attachment.height}"`;
209
213
  }
210
- function isVisual(attachment) {
211
- if (youTubeEmbedURL(attachment.url) !== undefined)
212
- return true;
213
- const medium = displayMedium(attachment);
214
- return medium === "image" || medium === "video";
215
- }
216
- function bodyContainsVisualElement(body) {
217
- const lower = body.toLowerCase();
218
- return VISUAL_ELEMENT_TAGS.some((tag) => lower.includes(tag));
219
- }
220
214
  /** Body HTML escapes `&` in attributes, so check both forms of the URL. */
221
215
  function bodyContainsURL(body, url) {
222
216
  return body.includes(url) || body.includes(escapeXML(url));
223
217
  }
218
+ /** URL.pathComponents without the root, percent-decoded. */
219
+ function pathComponents(value) {
220
+ const path = parseURL(value)?.path;
221
+ if (path === undefined)
222
+ return undefined;
223
+ return path
224
+ .split("/")
225
+ .filter(Boolean)
226
+ .map((component) => {
227
+ try {
228
+ return decodeURIComponent(component);
229
+ }
230
+ catch {
231
+ return component;
232
+ }
233
+ });
234
+ }
235
+ /** ArticleRenderingSpecialCases.bodyEmbedsYouTubeVideo(stillImageURL:body:). */
236
+ function bodyEmbedsYouTubeVideo(stillImageURL, body) {
237
+ return (pathComponents(stillImageURL) ?? []).some((component) => /^[A-Za-z0-9_-]{11}$/.test(component) &&
238
+ YOUTUBE_EMBED_PATHS.some((path) => body.includes(path + component)));
239
+ }
240
+ function isEngadgetImage(value) {
241
+ const host = parseURL(value)
242
+ ?.host.toLowerCase()
243
+ .replace(/^www\./, "") ?? "";
244
+ return host === "engadget.com" || host.endsWith(".engadget.com");
245
+ }
246
+ /**
247
+ * The last two path components, lowercased, without a WordPress size suffix or Engadget
248
+ * size prefix. Ignores the host and query: resized copies often come through a service.
249
+ */
250
+ function imageIdentity(value) {
251
+ const components = pathComponents(value);
252
+ const last = components?.at(-1);
253
+ if (!components || last === undefined)
254
+ return undefined;
255
+ const name = (last.startsWith(ENGADGET_LARGE_IMAGE_PREFIX) && isEngadgetImage(value)
256
+ ? last.slice(ENGADGET_LARGE_IMAGE_PREFIX.length)
257
+ : last).replace(WORDPRESS_SIZE_SUFFIX, "");
258
+ return [...components.slice(0, -1).slice(-1), name].join("/").toLowerCase();
259
+ }
260
+ /** ArticleRenderer.bodyImageIdentities: for spotting a resized copy of a body image. */
261
+ export function bodyImageIdentities(body, baseURL) {
262
+ return new Set(significantImageURLs(body, baseURL)
263
+ .map(imageIdentity)
264
+ .filter((identity) => identity !== undefined));
265
+ }
266
+ function bodyContains(url, body, identities) {
267
+ if (bodyContainsURL(body, url) || bodyEmbedsYouTubeVideo(url, body))
268
+ return true;
269
+ const identity = imageIdentity(url);
270
+ return identity !== undefined && identities.has(identity);
271
+ }
224
272
  function enclosureHTML(attachment, posterURL) {
225
273
  const youTube = youTubeEmbedURL(attachment.url);
226
274
  if (youTube !== undefined) {
@@ -259,32 +307,118 @@ function enclosureHTML(attachment, posterURL) {
259
307
  return `${html}</div>`;
260
308
  }
261
309
  /**
262
- * HTML for the [[enclosures]] substitution, empty when there's nothing to show.
263
- * Skips attachments whose URLs already appear in the body. imageURL is shown only
264
- * when nothing else visual is: no image or video enclosure, nor one in the body.
310
+ * HTML for the [[enclosures]] substitution, empty when there's nothing to show. Skips
311
+ * attachments already in the body, including resized copies. posterURL is a video's poster.
265
312
  */
266
- export function enclosuresHTML(attachments, body, imageURL) {
267
- const enclosures = [];
268
- let hasVisualEnclosure = false;
269
- for (const attachment of attachments) {
270
- if (bodyContainsURL(body, attachment.url))
271
- continue;
272
- const enclosure = enclosureHTML(attachment, imageURL);
273
- if (enclosure === undefined)
274
- continue;
275
- enclosures.push(enclosure);
276
- hasVisualEnclosure ||= isVisual(attachment);
277
- }
278
- if (!hasVisualEnclosure &&
279
- imageURL &&
280
- !bodyContainsVisualElement(body) &&
281
- !bodyContainsURL(body, imageURL)) {
282
- enclosures.unshift(`<div class="nnw-enclosure nnw-enclosure-image"><img src="${escapeXML(imageURL)}" alt=""></div>`);
283
- }
313
+ export function enclosuresHTML(attachments, body, identities, posterURL) {
314
+ const enclosures = attachments
315
+ .filter((attachment) => !bodyContains(attachment.url, body, identities))
316
+ .map((attachment) => enclosureHTML(attachment, posterURL))
317
+ .filter((enclosure) => enclosure !== undefined);
284
318
  if (!enclosures.length)
285
319
  return "";
286
320
  return `<div class="nnw-enclosures">\n${enclosures.join("\n")}\n</div>`;
287
321
  }
322
+ /** Untitled and short, as Mastodon posts are: their images follow the text. */
323
+ function isShortUntitledPost(title, body) {
324
+ if (title)
325
+ return false;
326
+ return [...new Intl.Segmenter().segment(body)].length < SHORT_POST_MAXIMUM_BODY_LENGTH;
327
+ }
328
+ function isVideo(attachment) {
329
+ return displayMedium(attachment) === "video" || youTubeEmbedURL(attachment.url) !== undefined;
330
+ }
331
+ /** The article image, else the first image attachment, else the thumbnail; undefined if in the body. */
332
+ function primaryImage(imageLink, thumbnailLink, attachments, body, identities) {
333
+ let image;
334
+ if (imageLink) {
335
+ image = attachments.find((attachment) => attachment.url === imageLink) ?? {
336
+ url: imageLink,
337
+ medium: "image",
338
+ };
339
+ }
340
+ else {
341
+ image = attachments.find((attachment) => displayMedium(attachment) === "image");
342
+ if (!image && thumbnailLink)
343
+ image = { url: thumbnailLink, medium: "image" };
344
+ }
345
+ return image && !bodyContains(image.url, body, identities) ? image : undefined;
346
+ }
347
+ function collapsingWhitespace(value) {
348
+ return value.split(/\s+/).filter(Boolean).join(" ").toLowerCase();
349
+ }
350
+ function primaryImageHTML(image, articleTitle) {
351
+ let attributes = `src="${escapeXML(image.url)}"`;
352
+ let caption = "";
353
+ if (image.title) {
354
+ const title = escapeXML(image.title);
355
+ attributes += ` alt="${title}" title="${title}"`;
356
+ if (collapsingWhitespace(image.title) !== collapsingWhitespace(articleTitle)) {
357
+ caption = `<div class="nnw-primary-image-caption">${title}</div>`;
358
+ }
359
+ }
360
+ else {
361
+ attributes += ' alt=""';
362
+ }
363
+ return `<div class="nnw-primary-image"><img ${attributes}${dimensionAttributes(image)}>${caption}</div>`;
364
+ }
365
+ /** A video, else audio with the article image, else the article image. */
366
+ function primaryMedia(article, identities, includesImage) {
367
+ const { attachments, imageLink, thumbnailLink, title, body } = article;
368
+ const wrap = (html) => `<div class="nnw-primary-media">${html}</div>`;
369
+ const playable = attachments.filter(({ url }) => !bodyContains(url, body, identities));
370
+ const posterURL = imageLink ?? thumbnailLink;
371
+ const video = playable.find(isVideo);
372
+ const videoHTML = video && enclosureHTML(video, posterURL);
373
+ if (video && videoHTML !== undefined) {
374
+ const urls = new Set([video.url]);
375
+ if (posterURL !== undefined)
376
+ urls.add(posterURL);
377
+ return { html: wrap(videoHTML), urls };
378
+ }
379
+ let imageHTML = "";
380
+ const urls = new Set();
381
+ const image = includesImage
382
+ ? primaryImage(imageLink, thumbnailLink, attachments, body, identities)
383
+ : undefined;
384
+ if (image) {
385
+ imageHTML = primaryImageHTML(image, title);
386
+ urls.add(image.url);
387
+ }
388
+ const audio = playable.find((attachment) => displayMedium(attachment) === "audio");
389
+ const audioHTML = audio && enclosureHTML(audio, undefined);
390
+ if (audio && audioHTML !== undefined) {
391
+ urls.add(audio.url);
392
+ return { html: wrap(imageHTML + audioHTML), urls };
393
+ }
394
+ return imageHTML ? { html: wrap(imageHTML), urls } : undefined;
395
+ }
396
+ /**
397
+ * The [[primary_media]] and [[enclosures]] HTML, as articleSubstitutions() builds them.
398
+ * A template without [[primary_media]], or a short untitled post, gets the article image
399
+ * in the enclosures instead; only [[primary_media]] uses the thumbnail as an image.
400
+ */
401
+ export function articleMediaHTML(article, hasPrimaryMedia) {
402
+ const { imageLink, thumbnailLink, title, body } = article;
403
+ const identities = bodyImageIdentities(body, article.baseURL);
404
+ let attachments = [...article.attachments];
405
+ const showsPrimaryImage = hasPrimaryMedia && !isShortUntitledPost(title, body);
406
+ const primary = hasPrimaryMedia
407
+ ? primaryMedia(article, identities, showsPrimaryImage)
408
+ : undefined;
409
+ if (primary)
410
+ attachments = attachments.filter(({ url }) => !primary.urls.has(url));
411
+ if (!showsPrimaryImage) {
412
+ const image = primaryImage(imageLink, undefined, attachments, body, identities);
413
+ if (image && !primary?.urls.has(image.url) && !attachments.includes(image)) {
414
+ attachments.unshift(image);
415
+ }
416
+ }
417
+ return {
418
+ primaryMedia: primary?.html ?? "",
419
+ enclosures: enclosuresHTML(attachments, body, identities, imageLink ?? thumbnailLink),
420
+ };
421
+ }
288
422
  /**
289
423
  * The body and [[enclosures]] values, as ArticleRenderer.articleSubstitutions() sets
290
424
  * them. A template without the macro gets its enclosures after the body, which a
package/dist/project.js CHANGED
@@ -5,6 +5,8 @@ import { parse as parseToml } from "smol-toml";
5
5
  import { BUILT_IN_FIXTURE_NAMES, CATALOG_FIXTURE_NAMES } from "./commands.js";
6
6
  import { buildPlist, parsePlist } from "./plist.js";
7
7
  export const REQUIRED_THEME_FILES = ["Info.plist", "template.html", "stylesheet.css"];
8
+ /** NetNewsWire 7.3 reads this instead of template.html; older releases ignore it. */
9
+ export const TEMPLATE_7_3_FILE = "template7.3.html";
8
10
  export const PLACEHOLDER_MARKER = ".nnw-theme-uninitialized";
9
11
  export const IDENTITY_START = "<!-- nnw-theme-identity:start -->";
10
12
  export const IDENTITY_END = "<!-- nnw-theme-identity:end -->";
@@ -153,6 +155,7 @@ export function fixturePath(root, name) {
153
155
  }
154
156
  throw new ThemeError(`fixture not found: ${own}`);
155
157
  }
158
+ const FOOTNOTE_KEYS = ["notes", "plain_links", "keep_with_word", "all_converted"];
156
159
  function isTable(value) {
157
160
  return (!!value && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date));
158
161
  }
@@ -161,7 +164,9 @@ function isTable(value) {
161
164
  *
162
165
  * notes maps each footnote marker's rendered text to the note text its popover must
163
166
  * show; plain_links lists CSS selectors for links that must not become footnotes;
164
- * keep_with_word requires markers written against a word to stay on its line.
167
+ * keep_with_word requires markers written against a word to stay on its line;
168
+ * all_converted fails, rather than warns about, a footnote link that never became a
169
+ * marker.
165
170
  */
166
171
  export function footnoteExpectations(fixture, name) {
167
172
  const expect = fixture.expect ?? {};
@@ -172,7 +177,7 @@ export function footnoteExpectations(fixture, name) {
172
177
  throw new ThemeError(`${name}: [expect] may contain only a [expect.footnotes] table`);
173
178
  }
174
179
  const unknown = Object.keys(footnotes)
175
- .filter((key) => !["notes", "plain_links", "keep_with_word"].includes(key))
180
+ .filter((key) => !FOOTNOTE_KEYS.includes(key))
176
181
  .sort();
177
182
  if (unknown.length) {
178
183
  throw new ThemeError(`${name}: unknown [expect.footnotes] key(s): ${unknown.join(", ")}`);
@@ -198,5 +203,11 @@ export function footnoteExpectations(fixture, name) {
198
203
  }
199
204
  result.keep_with_word = footnotes.keep_with_word;
200
205
  }
206
+ if ("all_converted" in footnotes) {
207
+ if (typeof footnotes.all_converted !== "boolean") {
208
+ throw new ThemeError(`${name}: expect.footnotes.all_converted must be true or false`);
209
+ }
210
+ result.all_converted = footnotes.all_converted;
211
+ }
201
212
  return result;
202
213
  }
package/dist/render.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
3
  import { extname, isAbsolute, join, relative } from "node:path";
4
4
  import { decodeHTML } from "entities";
5
5
  import { Parser } from "htmlparser2";
6
- import { enclosuresHTML, fixtureAttachments, fixtureImageURL, placeEnclosures, } from "./enclosures.js";
6
+ import { DELIVERIES, DELIVERY_NAMES, deliver } from "./delivery.js";
7
+ import { articleMediaHTML, fixtureAttachments, fixtureImages, placeEnclosures, } from "./enclosures.js";
7
8
  import { renderingInputs } from "./netnewswire.js";
8
- import { fixturePath, readFixture, repositoryFixtures, ThemeError, themeStem, } from "./project.js";
9
+ import { fixturePath, readFixture, repositoryFixtures, TEMPLATE_7_3_FILE, ThemeError, themeStem, } from "./project.js";
9
10
  import { formatG, pyStr } from "./pyformat.js";
10
11
  const MACRO_RE = /\[\[([a-zA-Z0-9_-]+)\]\]/g;
11
12
  // A whole <img> or <source> tag; quoted attribute values may contain ">".
@@ -123,8 +124,9 @@ const SCENARIOS = [
123
124
  [
124
125
  "older-netnewswire",
125
126
  "Older NetNewsWire",
126
- "The article fixture as NetNewsWire releases before enclosure support render it: " +
127
- "they leave the template's [[enclosures]] macro as literal text, which must not show.",
127
+ "The article fixture as NetNewsWire releases before enclosures and primary media " +
128
+ "render it: template.html, with [[enclosures]] and [[primary_media]] left as " +
129
+ "literal text, which must not show.",
128
130
  ],
129
131
  [
130
132
  "article-javascript-off",
@@ -152,11 +154,13 @@ export class RenderTarget {
152
154
  height;
153
155
  largeText;
154
156
  themeScripts;
155
- /** Rendered as NetNewsWire before [[enclosures]]: the macro stays literal. */
157
+ /** Rendered as older NetNewsWire: template.html, macros it lacks left literal. */
156
158
  olderNetNewsWire;
157
159
  /** A 2x capture of the card viewport alone, for the marketplace card. */
158
160
  card;
159
- constructor(fixture, platform, appearance, { largeText = false, themeScripts = true, olderNetNewsWire = false, card = false } = {}) {
161
+ /** The sync service the article arrives through, rewritten as it delivers it. */
162
+ delivery;
163
+ constructor(fixture, platform, appearance, { largeText = false, themeScripts = true, olderNetNewsWire = false, card = false, delivery = undefined, } = {}) {
160
164
  this.fixture = fixture;
161
165
  this.platform = platform;
162
166
  this.appearance = appearance;
@@ -165,6 +169,7 @@ export class RenderTarget {
165
169
  this.themeScripts = themeScripts;
166
170
  this.olderNetNewsWire = olderNetNewsWire;
167
171
  this.card = card;
172
+ this.delivery = delivery;
168
173
  }
169
174
  get ios() {
170
175
  return this.platform === "iphone" || this.platform === "ipad";
@@ -181,6 +186,8 @@ export class RenderTarget {
181
186
  parts.push("Article JavaScript off");
182
187
  if (this.olderNetNewsWire)
183
188
  parts.push("older NetNewsWire");
189
+ if (this.delivery)
190
+ parts.push(`via ${DELIVERY_NAMES[this.delivery]}`);
184
191
  if (this.card)
185
192
  parts.push("card");
186
193
  return parts.join(" · ");
@@ -192,6 +199,8 @@ export class RenderTarget {
192
199
  return "article-javascript-off";
193
200
  if (this.olderNetNewsWire)
194
201
  return "older-netnewswire";
202
+ if (this.delivery)
203
+ return `${this.fixture} via ${DELIVERY_NAMES[this.delivery]}`;
195
204
  return this.fixture;
196
205
  }
197
206
  get slug() {
@@ -202,6 +211,8 @@ export class RenderTarget {
202
211
  parts.push("no-article-js");
203
212
  if (this.olderNetNewsWire)
204
213
  parts.push("older-netnewswire");
214
+ if (this.delivery)
215
+ parts.push(this.delivery);
205
216
  if (this.card)
206
217
  parts.push("card");
207
218
  return parts.join("-");
@@ -216,45 +227,92 @@ export function extraFixtures(root) {
216
227
  }
217
228
  return extras;
218
229
  }
219
- export function normalTargets(extras = []) {
230
+ /** A fixture's deliveries = [...] key, validated: the services to also render it through. */
231
+ export function fixtureDeliveries(fixture, name) {
232
+ const value = fixture.deliveries;
233
+ if (value === undefined)
234
+ return [];
235
+ if (!Array.isArray(value) ||
236
+ !value.every((item) => DELIVERIES.includes(item)) ||
237
+ new Set(value).size !== value.length) {
238
+ throw new ThemeError(`${name}: deliveries must list distinct services from ${DELIVERIES.join(", ")}`);
239
+ }
240
+ return value;
241
+ }
242
+ /**
243
+ * Each fixture's deliveries = [...], for those that list any. fixtures are the names
244
+ * to read: the template's own and the extras.
245
+ */
246
+ export function readDeliveries(root, fixtures) {
247
+ const result = {};
248
+ for (const name of fixtures) {
249
+ const deliveries = fixtureDeliveries(readFixture(fixturePath(root, name)), `fixtures/${name}.toml`);
250
+ if (deliveries.length)
251
+ result[name] = deliveries;
252
+ }
253
+ return result;
254
+ }
255
+ /** The fixtures a repository renders: the template's own, then its extras. */
256
+ export function allFixtures(extras) {
257
+ return [...Object.keys(FIXTURE_NAMES), ...extras];
258
+ }
259
+ /**
260
+ * Every fixture's matrix. A fixture that lists deliveries is also rendered through
261
+ * each service, on Mac in light and iPhone in dark, right after its own cases.
262
+ */
263
+ export function normalTargets(extras = [], deliveries = {}) {
220
264
  const targets = [];
221
- for (const fixture of Object.keys(FIXTURE_NAMES)) {
222
- for (const platform of PLATFORMS) {
265
+ for (const fixture of allFixtures(extras)) {
266
+ const platforms = fixture in FIXTURE_NAMES ? PLATFORMS : EXTRA_PLATFORMS;
267
+ for (const platform of platforms) {
223
268
  for (const appearance of APPEARANCES) {
224
269
  targets.push(new RenderTarget(fixture, platform, appearance));
225
270
  }
226
271
  }
227
- }
228
- for (const fixture of extras) {
229
- for (const platform of EXTRA_PLATFORMS) {
230
- for (const appearance of APPEARANCES) {
231
- targets.push(new RenderTarget(fixture, platform, appearance));
232
- }
272
+ for (const delivery of deliveries[fixture] ?? []) {
273
+ targets.push(new RenderTarget(fixture, "mac", "light", { delivery }), new RenderTarget(fixture, "iphone", "dark", { delivery }));
233
274
  }
234
275
  }
235
276
  return targets;
236
277
  }
278
+ /** A repository's normal matrix, including each fixture's deliveries. */
279
+ export function repositoryTargets(root) {
280
+ const extras = extraFixtures(root);
281
+ return normalTargets(extras, readDeliveries(root, allFixtures(extras)));
282
+ }
237
283
  /**
238
- * Everything check renders. A template that places [[enclosures]] itself also gets
239
- * the older-NetNewsWire cases, since releases before enclosure support leave that
240
- * macro as text in every article.
284
+ * Everything check renders. A theme whose older releases render differently (see
285
+ * rendersDifferentlyWhenOlder) also gets the older-NetNewsWire cases.
241
286
  */
242
- export function checkTargets(extras = [], { enclosuresMacro = false } = {}) {
287
+ export function checkTargets(extras = [], { olderNetNewsWire = false, deliveries = {} } = {}) {
243
288
  const targets = [
244
- ...normalTargets(extras),
289
+ ...normalTargets(extras, deliveries),
245
290
  new RenderTarget("kitchen-sink", "mac", "light", { largeText: true }),
246
291
  new RenderTarget("kitchen-sink", "iphone", "light", { largeText: true }),
247
292
  new RenderTarget("article", "mac", "light", { themeScripts: false }),
248
293
  new RenderTarget("article", "iphone", "light", { themeScripts: false }),
249
294
  ];
250
- if (enclosuresMacro) {
295
+ if (olderNetNewsWire) {
251
296
  targets.push(new RenderTarget("article", "mac", "light", { olderNetNewsWire: true }), new RenderTarget("article", "iphone", "dark", { olderNetNewsWire: true }));
252
297
  }
253
298
  return targets;
254
299
  }
255
- /** Whether a theme places enclosures itself, as NetNewsWire decides it. */
256
- export function placesEnclosures(theme) {
257
- return read(join(theme, "template.html")).includes("[[enclosures]]");
300
+ /**
301
+ * Whether older NetNewsWire renders this theme differently: it ignores template7.3.html,
302
+ * and leaves a macro it doesn't know as text in every article.
303
+ */
304
+ export function rendersDifferentlyWhenOlder(theme) {
305
+ if (existsSync(join(theme, TEMPLATE_7_3_FILE)))
306
+ return true;
307
+ const template = read(join(theme, "template.html"));
308
+ return ["[[enclosures]]", "[[primary_media]]"].some((macro) => template.includes(macro));
309
+ }
310
+ /** The template NetNewsWire reads: 7.3 prefers template7.3.html, older releases can't. */
311
+ function themeTemplate(theme, target) {
312
+ const preferred = join(theme, TEMPLATE_7_3_FILE);
313
+ return read(!target.olderNetNewsWire && existsSync(preferred)
314
+ ? preferred
315
+ : join(theme, "template.html"));
258
316
  }
259
317
  /** Perform NetNewsWire's single-pass, non-recursive macro substitution. */
260
318
  export function substitute(template, mapping) {
@@ -411,12 +469,21 @@ function fixtureMapping(fixture, target, template, media) {
411
469
  if (!mapping.dateline_style) {
412
470
  mapping.dateline_style = mapping.title ? "articleDateline" : "articleDatelineTitle";
413
471
  }
414
- const body = mapping.body ?? "";
472
+ let body = mapping.body ?? "";
473
+ if (target.delivery)
474
+ body = deliver(body, target.delivery, mapping.preferred_link ?? "");
415
475
  mapping.body = offlineMedia(body, media);
416
- // NetNewsWire before enclosure support ignores attachments and leaves the macro.
476
+ // NetNewsWire before these macros ignores attachments and leaves them literal.
417
477
  if (!target.olderNetNewsWire) {
418
478
  const name = `fixtures/${target.fixture}.toml`;
419
- const enclosures = enclosuresHTML(fixtureAttachments(fixture, name), body, fixtureImageURL(fixture, name));
479
+ const { primaryMedia, enclosures } = articleMediaHTML({
480
+ attachments: fixtureAttachments(fixture, name),
481
+ ...fixtureImages(fixture, name),
482
+ title: mapping.title ?? "",
483
+ body,
484
+ baseURL: pageBaseURL(fixture, target),
485
+ }, template.includes("[[primary_media]]"));
486
+ mapping.primary_media = offlineEnclosures(primaryMedia, media);
420
487
  Object.assign(mapping, placeEnclosures(template, mapping.body, offlineEnclosures(enclosures, media)));
421
488
  }
422
489
  if (!mapping.avatar_src || fetches(mapping.avatar_src)) {
@@ -486,6 +553,42 @@ function isFile(path) {
486
553
  return false;
487
554
  }
488
555
  }
556
+ /**
557
+ * Article.baseURL, made absolute as NetNewsWire does: no fragment, and nothing unless
558
+ * http or https.
559
+ */
560
+ export function articleBaseURL(value) {
561
+ let url;
562
+ try {
563
+ url = new URL(value);
564
+ }
565
+ catch {
566
+ return "";
567
+ }
568
+ if (url.protocol !== "http:" && url.protocol !== "https:")
569
+ return "";
570
+ url.hash = "";
571
+ return url.href;
572
+ }
573
+ /**
574
+ * The page's base URL: the fixture's base_url when it gives one, else its link. An
575
+ * article synced through the Reader API (FreshRSS, Miniflux, Inoreader) has only an
576
+ * external link, so NetNewsWire falls back to the feed's home page: a delivered case
577
+ * uses feed_link, unless base_url gives the home page the service reports instead.
578
+ */
579
+ function pageBaseURL(fixture, target) {
580
+ const name = `fixtures/${target.fixture}.toml`;
581
+ const baseURL = fixture.base_url;
582
+ if (baseURL !== undefined && typeof baseURL !== "string") {
583
+ throw new ThemeError(`${name}: base_url must be a string`);
584
+ }
585
+ if (baseURL !== undefined)
586
+ return articleBaseURL(baseURL);
587
+ if (target.delivery && typeof fixture.feed_link === "string" && fixture.feed_link) {
588
+ return articleBaseURL(fixture.feed_link);
589
+ }
590
+ return pyStr(fixture.preferred_link || fixture.feed_link || "");
591
+ }
489
592
  export function renderPage(theme, fixture, target, snapshot = renderingInputs().path, media) {
490
593
  const pagePath = join(snapshot, target.ios ? "iOS" : "Mac", "page.html");
491
594
  const required = [
@@ -505,7 +608,7 @@ export function renderPage(theme, fixture, target, snapshot = renderingInputs().
505
608
  styleSubstitutions["font-size"] = formatG(Number(fixture.font_size ?? size));
506
609
  }
507
610
  const style = substitute(`${read(join(snapshot, "Shared", "core.css"))}\n${stylesheet}`, styleSubstitutions);
508
- const template = read(join(theme, "template.html"));
611
+ const template = themeTemplate(theme, target);
509
612
  let body = substitute(template, fixtureMapping(fixture, target, template, media));
510
613
  if (!target.themeScripts)
511
614
  body = removeScripts(body);
@@ -513,7 +616,7 @@ export function renderPage(theme, fixture, target, snapshot = renderingInputs().
513
616
  title: fixture.title ?? "Theme preview",
514
617
  style,
515
618
  body,
516
- baseURL: fixture.preferred_link || fixture.feed_link || "",
619
+ baseURL: pageBaseURL(fixture, target),
517
620
  windowScrollY: "0",
518
621
  });
519
622
  page = injectScripts(page, snapshot, target.platform);
@@ -603,7 +706,18 @@ export function writeGallery(site, themeName, targets, results = null) {
603
706
  const known = new Set(RESERVED_FIXTURE_NAMES);
604
707
  const scenarios = new Map(SCENARIOS.map(([scenario, heading, text]) => [scenario, [heading, text]]));
605
708
  for (const target of targets) {
606
- if (!known.has(target.scenario) && !scenarios.has(target.scenario)) {
709
+ if (known.has(target.scenario) || scenarios.has(target.scenario))
710
+ continue;
711
+ if (target.delivery) {
712
+ const service = DELIVERY_NAMES[target.delivery];
713
+ scenarios.set(target.scenario, [
714
+ target.scenario,
715
+ `fixtures/${target.fixture}.toml as ${service} delivers it through the Reader ` +
716
+ `API: its body rewritten by ${service}'s sanitizer, and the feed's home page ` +
717
+ "as the page's base URL.",
718
+ ]);
719
+ }
720
+ else {
607
721
  scenarios.set(target.scenario, [target.fixture, `Your fixtures/${target.fixture}.toml.`]);
608
722
  }
609
723
  }