nnw-theme 2.1.0 → 2.3.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/commands.js CHANGED
@@ -19,6 +19,8 @@ export const BUILT_IN_FIXTURE_NAMES = ["article", "kitchen-sink"];
19
19
  export const CATALOG_FIXTURES = {
20
20
  ghost: "Ghost cards, galleries, and lazy images",
21
21
  medium: "Medium figures, unsized images, and footer",
22
+ "media-rss": "Image, video, and YouTube enclosures with captions",
23
+ podcast: "Podcast audio enclosure and episode artwork",
22
24
  substack: "Substack images, widgets, and footnotes",
23
25
  wordpress: "WordPress block and classic editor markup",
24
26
  };
@@ -109,8 +111,10 @@ export const COMMANDS = [
109
111
  {
110
112
  name: "screenshot",
111
113
  summary: "check one case in WebKit and save its image",
112
- description: "Render and check a single case in WebKit and save its full-page screenshot. " +
113
- "With --promote, it becomes screenshots/theme-preview.png, the marketplace card.",
114
+ description: "Render and check a single case in WebKit and save its full-page screenshot: any " +
115
+ "fixture, on any platform. With --card, capture an 800x552 viewport at 2x, the " +
116
+ "shape of the marketplace card. With --promote, it becomes " +
117
+ "screenshots/theme-preview.png, the marketplace card.",
114
118
  options: [
115
119
  {
116
120
  name: "fixture",
@@ -121,6 +125,7 @@ export const COMMANDS = [
121
125
  },
122
126
  { name: "platform", type: "string", choices: ["mac", "iphone", "ipad"], default: "mac" },
123
127
  { name: "appearance", type: "string", choices: ["light", "dark"], default: "light" },
128
+ { name: "card", type: "boolean", help: "capture the marketplace card's shape at 2x" },
124
129
  { name: "promote", type: "boolean", help: "use it as the marketplace screenshot" },
125
130
  ],
126
131
  },
@@ -0,0 +1,299 @@
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.
4
+ // Keep it line for line with the Swift, so a diff of the two stays easy to review.
5
+ import { ThemeError } from "./project.js";
6
+ export const MEDIA = ["image", "audio", "video", "document", "executable"];
7
+ const STRING_KEYS = ["url", "mime_type", "title"];
8
+ const INTEGER_KEYS = ["size_in_bytes", "duration_in_seconds", "width", "height"];
9
+ const ATTACHMENT_KEYS = [...STRING_KEYS, ...INTEGER_KEYS, "medium"];
10
+ function isTable(value) {
11
+ return (!!value && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date));
12
+ }
13
+ /**
14
+ * A fixture's [[attachments]], validated. Like Attachment(url:…), an empty url makes
15
+ * no attachment, so NetNewsWire never sees it.
16
+ */
17
+ export function fixtureAttachments(fixture, name) {
18
+ const value = fixture.attachments;
19
+ if (value === undefined)
20
+ return [];
21
+ if (!Array.isArray(value) || !value.every(isTable)) {
22
+ throw new ThemeError(`${name}: attachments must be [[attachments]] tables`);
23
+ }
24
+ return value.flatMap((table, index) => {
25
+ const where = `${name}: attachments[${index}]`;
26
+ const unknown = Object.keys(table)
27
+ .filter((key) => !ATTACHMENT_KEYS.includes(key))
28
+ .sort();
29
+ if (unknown.length)
30
+ throw new ThemeError(`${where}: unknown key(s): ${unknown.join(", ")}`);
31
+ for (const key of STRING_KEYS) {
32
+ if (key in table && typeof table[key] !== "string") {
33
+ throw new ThemeError(`${where}: ${key} must be a string`);
34
+ }
35
+ }
36
+ for (const key of INTEGER_KEYS) {
37
+ if (key in table && !Number.isSafeInteger(table[key])) {
38
+ throw new ThemeError(`${where}: ${key} must be an integer`);
39
+ }
40
+ }
41
+ if ("medium" in table && !MEDIA.includes(table.medium)) {
42
+ throw new ThemeError(`${where}: medium must be one of ${MEDIA.join(", ")}`);
43
+ }
44
+ if (typeof table.url !== "string")
45
+ throw new ThemeError(`${where}: url is required`);
46
+ return table.url ? [table] : [];
47
+ });
48
+ }
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"]) {
52
+ const value = fixture[key];
53
+ if (value === undefined)
54
+ continue;
55
+ if (typeof value !== "string")
56
+ throw new ThemeError(`${name}: ${key} must be a string`);
57
+ return value;
58
+ }
59
+ return undefined;
60
+ }
61
+ const AUDIO_EXTENSIONS = new Set(["mp3", "m4a", "aac", "wav", "ogg", "oga", "opus", "flac"]);
62
+ const VIDEO_EXTENSIONS = new Set(["mp4", "m4v", "mov", "webm"]);
63
+ const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "webp", "heic", "avif"]);
64
+ const VISUAL_ELEMENT_TAGS = ["<img", "<picture", "<video", "<iframe"];
65
+ const YOUTUBE_FLASH_PLAYER_PATH_PREFIX = "/v/";
66
+ const YOUTUBE_EMBED_URL_PREFIX = "https://www.youtube-nocookie.com/embed/";
67
+ /** Foundation's URL(string:), enough for host and path: relative URLs have no host. */
68
+ function parseURL(value) {
69
+ try {
70
+ const url = new URL(value);
71
+ return { host: url.hostname, path: url.pathname };
72
+ }
73
+ catch {
74
+ if (/[\s<>"]/.test(value))
75
+ return undefined;
76
+ return { host: "", path: value.replace(/[?#].*$/s, "") };
77
+ }
78
+ }
79
+ function lastPathComponent(path) {
80
+ const parts = path.split("/").filter(Boolean);
81
+ return parts.at(-1) ?? (path.startsWith("/") ? "/" : "");
82
+ }
83
+ function pathExtension(path) {
84
+ const last = lastPathComponent(path);
85
+ const dot = last.lastIndexOf(".");
86
+ return dot > 0 ? last.slice(dot + 1) : "";
87
+ }
88
+ /** How the article view shows an attachment, or undefined for anything else. */
89
+ export function displayMedium(attachment) {
90
+ const mimeType = attachment.mime_type?.toLowerCase();
91
+ if (mimeType !== undefined) {
92
+ if (mimeType.startsWith("audio/"))
93
+ return "audio";
94
+ if (mimeType.startsWith("video/"))
95
+ return "video";
96
+ if (mimeType.startsWith("image/"))
97
+ return "image";
98
+ }
99
+ const { medium } = attachment;
100
+ if (medium === "audio" || medium === "video" || medium === "image")
101
+ return medium;
102
+ const path = parseURL(attachment.url)?.path;
103
+ const extension = path === undefined ? "" : pathExtension(path).toLowerCase();
104
+ if (!extension)
105
+ return undefined;
106
+ if (AUDIO_EXTENSIONS.has(extension))
107
+ return "audio";
108
+ if (VIDEO_EXTENSIONS.has(extension))
109
+ return "video";
110
+ if (IMAGE_EXTENSIONS.has(extension))
111
+ return "image";
112
+ return undefined;
113
+ }
114
+ /** RSCore's escapingSpecialXMLCharacters: drops characters XML cannot hold. */
115
+ export function escapeXML(value) {
116
+ let escaped = "";
117
+ for (const character of value) {
118
+ const code = character.codePointAt(0) ?? 0;
119
+ if (character === "&")
120
+ escaped += "&amp;";
121
+ else if (character === "<")
122
+ escaped += "&lt;";
123
+ else if (character === ">")
124
+ escaped += "&gt;";
125
+ else if (character === '"')
126
+ escaped += "&quot;";
127
+ else if (character === "'")
128
+ escaped += "&apos;";
129
+ else if (code === 0x9 ||
130
+ code === 0xa ||
131
+ code === 0xd ||
132
+ (code >= 0x20 && code <= 0xd7ff) ||
133
+ (code >= 0xe000 && code <= 0xfffd) ||
134
+ code >= 0x10000) {
135
+ escaped += character;
136
+ }
137
+ }
138
+ return escaped;
139
+ }
140
+ /** ArticleRenderingSpecialCases.youTubeEmbedURL(forAttachmentURL:). */
141
+ export function youTubeEmbedURL(value) {
142
+ const url = parseURL(value);
143
+ const host = url?.host.toLowerCase().replace(/^www\./, "");
144
+ if (!url || !host || !(host === "youtube.com" || host.endsWith(".youtube.com"))) {
145
+ return undefined;
146
+ }
147
+ if (!url.path.startsWith(YOUTUBE_FLASH_PLAYER_PATH_PREFIX))
148
+ return undefined;
149
+ const videoID = url.path.slice(YOUTUBE_FLASH_PLAYER_PATH_PREFIX.length);
150
+ if (!videoID || !/^[A-Za-z0-9_-]+$/.test(videoID))
151
+ return undefined;
152
+ return YOUTUBE_EMBED_URL_PREFIX + videoID;
153
+ }
154
+ /** Foundation's ByteCountFormatter.string(fromByteCount:countStyle: .file), en_US. */
155
+ export function byteCount(bytes) {
156
+ if (bytes === 0)
157
+ return "Zero KB";
158
+ if (Math.abs(bytes) < 1000)
159
+ return `${bytes} ${Math.abs(bytes) === 1 ? "byte" : "bytes"}`;
160
+ const units = ["KB", "MB", "GB", "TB", "PB", "EB"];
161
+ let value = bytes / 1000;
162
+ let unit = 0;
163
+ while (Math.abs(value) >= 1000 && unit < units.length - 1) {
164
+ value /= 1000;
165
+ unit += 1;
166
+ }
167
+ const digits = Math.min(unit, 2);
168
+ const text = value.toLocaleString("en-US", { maximumFractionDigits: digits });
169
+ return `${text} ${units[unit]}`;
170
+ }
171
+ function durationString(durationInSeconds) {
172
+ const hours = Math.trunc(durationInSeconds / 3600);
173
+ const minutes = Math.trunc((durationInSeconds % 3600) / 60);
174
+ const seconds = durationInSeconds % 60;
175
+ const pad = (value) => String(value).padStart(2, "0");
176
+ return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
177
+ }
178
+ function fileName(value) {
179
+ const path = parseURL(value)?.path;
180
+ if (path === undefined)
181
+ return undefined;
182
+ const last = lastPathComponent(path);
183
+ if (!last || last === "/")
184
+ return undefined;
185
+ try {
186
+ return decodeURIComponent(last);
187
+ }
188
+ catch {
189
+ return last;
190
+ }
191
+ }
192
+ /** Title (or file name), duration, and size, such as “Episode 311 · 1:54:09 · 54.9 MB”. */
193
+ function audioCaption(attachment) {
194
+ const components = [];
195
+ const name = attachment.title || fileName(attachment.url);
196
+ if (name)
197
+ components.push(name);
198
+ if (attachment.duration_in_seconds !== undefined) {
199
+ components.push(durationString(attachment.duration_in_seconds));
200
+ }
201
+ if (attachment.size_in_bytes !== undefined)
202
+ components.push(byteCount(attachment.size_in_bytes));
203
+ return components.join(" · ");
204
+ }
205
+ function dimensionAttributes(attachment) {
206
+ if (attachment.width === undefined || attachment.height === undefined)
207
+ return "";
208
+ return ` width="${attachment.width}" height="${attachment.height}"`;
209
+ }
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
+ /** Body HTML escapes `&` in attributes, so check both forms of the URL. */
221
+ function bodyContainsURL(body, url) {
222
+ return body.includes(url) || body.includes(escapeXML(url));
223
+ }
224
+ function enclosureHTML(attachment, posterURL) {
225
+ const youTube = youTubeEmbedURL(attachment.url);
226
+ if (youTube !== undefined) {
227
+ const iframe = `<iframe src="${youTube}"${dimensionAttributes(attachment)} allowfullscreen></iframe>`;
228
+ return `<div class="nnw-enclosure nnw-enclosure-video">${iframe}</div>`;
229
+ }
230
+ const medium = displayMedium(attachment);
231
+ if (medium === undefined)
232
+ return undefined;
233
+ const src = escapeXML(attachment.url);
234
+ let mediaElement;
235
+ let caption;
236
+ switch (medium) {
237
+ case "audio":
238
+ mediaElement = `<audio controls preload="none" src="${src}"></audio>`;
239
+ caption = audioCaption(attachment);
240
+ break;
241
+ case "video": {
242
+ let attributes = `controls preload="metadata" playsinline src="${src}"${dimensionAttributes(attachment)}`;
243
+ if (posterURL)
244
+ attributes += ` poster="${escapeXML(posterURL)}"`;
245
+ mediaElement = `<video ${attributes}></video>`;
246
+ caption = attachment.title;
247
+ break;
248
+ }
249
+ case "image":
250
+ mediaElement = `<img src="${src}" alt=""${dimensionAttributes(attachment)}>`;
251
+ caption = attachment.title;
252
+ break;
253
+ default:
254
+ return undefined;
255
+ }
256
+ let html = `<div class="nnw-enclosure nnw-enclosure-${medium}">${mediaElement}`;
257
+ if (caption)
258
+ html += `<div class="nnw-enclosure-caption">${escapeXML(caption)}</div>`;
259
+ return `${html}</div>`;
260
+ }
261
+ /**
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.
265
+ */
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
+ }
284
+ if (!enclosures.length)
285
+ return "";
286
+ return `<div class="nnw-enclosures">\n${enclosures.join("\n")}\n</div>`;
287
+ }
288
+ /**
289
+ * The body and [[enclosures]] values, as ArticleRenderer.articleSubstitutions() sets
290
+ * them. A template without the macro gets its enclosures after the body, which a
291
+ * wrapper <div> closes so an unclosed tag in it can't swallow them.
292
+ */
293
+ export function placeEnclosures(template, body, enclosures) {
294
+ if (template.includes("[[enclosures]]"))
295
+ return { body, enclosures };
296
+ if (!enclosures)
297
+ return { body };
298
+ return { body: `<div>${body}</div>${enclosures}` };
299
+ }
package/dist/render.js CHANGED
@@ -1,8 +1,9 @@
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
+ import { enclosuresHTML, fixtureAttachments, fixtureImageURL, placeEnclosures, } from "./enclosures.js";
6
7
  import { renderingInputs } from "./netnewswire.js";
7
8
  import { fixturePath, readFixture, repositoryFixtures, ThemeError, themeStem, } from "./project.js";
8
9
  import { formatG, pyStr } from "./pyformat.js";
@@ -61,6 +62,35 @@ const VIEWPORTS = {
61
62
  iphone: [393, 852],
62
63
  ipad: [834, 1112],
63
64
  };
65
+ // The marketplace card shows its image at 347x240, cropped from the top.
66
+ const CARD_VIEWPORT = [800, 552];
67
+ // Fixture images a fixture may reference as media/NAME, from fixtures/media/NAME.
68
+ const IMAGE_TYPES = {
69
+ ".avif": "image/avif",
70
+ ".gif": "image/gif",
71
+ ".jpeg": "image/jpeg",
72
+ ".jpg": "image/jpeg",
73
+ ".png": "image/png",
74
+ ".svg": "image/svg+xml",
75
+ ".webp": "image/webp",
76
+ };
77
+ // Enclosure players may also play a fixture's own audio or video file.
78
+ const PLAYABLE_TYPES = {
79
+ ".aac": "audio/aac",
80
+ ".flac": "audio/flac",
81
+ ".m4a": "audio/mp4",
82
+ ".m4v": "video/mp4",
83
+ ".mov": "video/quicktime",
84
+ ".mp3": "audio/mpeg",
85
+ ".mp4": "video/mp4",
86
+ ".oga": "audio/ogg",
87
+ ".ogg": "audio/ogg",
88
+ ".opus": "audio/ogg",
89
+ ".wav": "audio/wav",
90
+ ".webm": "video/webm",
91
+ };
92
+ // An enclosure's own tag, as enclosuresHTML writes it.
93
+ const ENCLOSURE_TAG_RE = /<(video|audio|iframe)\b([^>]*)>/g;
64
94
  // The template's own fixtures get the full matrix and the stress cases. Any other
65
95
  // fixture a theme adds is an extra: checked on Mac and iPhone in both appearances.
66
96
  export const FIXTURE_NAMES = {
@@ -90,6 +120,12 @@ const SCENARIOS = [
90
120
  "The kitchen-sink fixture at a large reading size: the xxLargeText size class on " +
91
121
  "Mac and 23 pt Dynamic Type on iPhone.",
92
122
  ],
123
+ [
124
+ "older-netnewswire",
125
+ "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.",
128
+ ],
93
129
  [
94
130
  "article-javascript-off",
95
131
  "Article JavaScript off",
@@ -105,6 +141,8 @@ const CHECKS = [
105
141
  "no broken images",
106
142
  "no external requests",
107
143
  "no JavaScript errors",
144
+ "enclosure players and images show, fit the window, and keep their shape",
145
+ "enclosure captions are legible",
108
146
  ];
109
147
  export class RenderTarget {
110
148
  fixture;
@@ -114,13 +152,19 @@ export class RenderTarget {
114
152
  height;
115
153
  largeText;
116
154
  themeScripts;
117
- constructor(fixture, platform, appearance, { largeText = false, themeScripts = true } = {}) {
155
+ /** Rendered as NetNewsWire before [[enclosures]]: the macro stays literal. */
156
+ olderNetNewsWire;
157
+ /** A 2x capture of the card viewport alone, for the marketplace card. */
158
+ card;
159
+ constructor(fixture, platform, appearance, { largeText = false, themeScripts = true, olderNetNewsWire = false, card = false } = {}) {
118
160
  this.fixture = fixture;
119
161
  this.platform = platform;
120
162
  this.appearance = appearance;
121
- [this.width, this.height] = VIEWPORTS[platform];
163
+ [this.width, this.height] = card ? CARD_VIEWPORT : VIEWPORTS[platform];
122
164
  this.largeText = largeText;
123
165
  this.themeScripts = themeScripts;
166
+ this.olderNetNewsWire = olderNetNewsWire;
167
+ this.card = card;
124
168
  }
125
169
  get ios() {
126
170
  return this.platform === "iphone" || this.platform === "ipad";
@@ -135,6 +179,10 @@ export class RenderTarget {
135
179
  parts.push("large text");
136
180
  if (!this.themeScripts)
137
181
  parts.push("Article JavaScript off");
182
+ if (this.olderNetNewsWire)
183
+ parts.push("older NetNewsWire");
184
+ if (this.card)
185
+ parts.push("card");
138
186
  return parts.join(" · ");
139
187
  }
140
188
  get scenario() {
@@ -142,6 +190,8 @@ export class RenderTarget {
142
190
  return "large-text";
143
191
  if (!this.themeScripts)
144
192
  return "article-javascript-off";
193
+ if (this.olderNetNewsWire)
194
+ return "older-netnewswire";
145
195
  return this.fixture;
146
196
  }
147
197
  get slug() {
@@ -150,6 +200,10 @@ export class RenderTarget {
150
200
  parts.push("large-text");
151
201
  if (!this.themeScripts)
152
202
  parts.push("no-article-js");
203
+ if (this.olderNetNewsWire)
204
+ parts.push("older-netnewswire");
205
+ if (this.card)
206
+ parts.push("card");
153
207
  return parts.join("-");
154
208
  }
155
209
  }
@@ -180,14 +234,27 @@ export function normalTargets(extras = []) {
180
234
  }
181
235
  return targets;
182
236
  }
183
- export function checkTargets(extras = []) {
184
- return [
237
+ /**
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.
241
+ */
242
+ export function checkTargets(extras = [], { enclosuresMacro = false } = {}) {
243
+ const targets = [
185
244
  ...normalTargets(extras),
186
245
  new RenderTarget("kitchen-sink", "mac", "light", { largeText: true }),
187
246
  new RenderTarget("kitchen-sink", "iphone", "light", { largeText: true }),
188
247
  new RenderTarget("article", "mac", "light", { themeScripts: false }),
189
248
  new RenderTarget("article", "iphone", "light", { themeScripts: false }),
190
249
  ];
250
+ if (enclosuresMacro) {
251
+ targets.push(new RenderTarget("article", "mac", "light", { olderNetNewsWire: true }), new RenderTarget("article", "iphone", "dark", { olderNetNewsWire: true }));
252
+ }
253
+ return targets;
254
+ }
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]]");
191
258
  }
192
259
  /** Perform NetNewsWire's single-pass, non-recursive macro substitution. */
193
260
  export function substitute(template, mapping) {
@@ -235,14 +302,34 @@ function placeholder(width, height) {
235
302
  '<rect width="100%" height="100%" fill="#8a8f98" fill-opacity=".35"/></svg>';
236
303
  return `data:image/svg+xml;base64,${base64(svg)}`;
237
304
  }
305
+ /**
306
+ * A fixture's own image, media/NAME, as a data URI read from fixtures/media/NAME.
307
+ * Undefined, so a placeholder as before, unless it is an image inside that directory.
308
+ */
309
+ function mediaDataURI(media, src, types = IMAGE_TYPES) {
310
+ const type = types[extname(src).toLowerCase()];
311
+ if (!src.startsWith("media/") || !type)
312
+ return undefined;
313
+ try {
314
+ const path = realpathSync(join(media, src.slice("media/".length)));
315
+ const inside = relative(realpathSync(media), path);
316
+ if (inside.startsWith("..") || isAbsolute(inside))
317
+ return undefined;
318
+ return `data:${type};base64,${readFileSync(path).toString("base64")}`;
319
+ }
320
+ catch {
321
+ return undefined;
322
+ }
323
+ }
238
324
  /**
239
325
  * Swap a fixture's network images for same-size placeholders.
240
326
  *
241
327
  * Previews never touch the network, so a captured article's images would otherwise
242
328
  * render broken. Each <img> keeps its attributes and width/height (16:9 when it has
243
329
  * none), so layout matches; a <source> that would fetch is dropped for its <img>.
330
+ * With a media directory, an <img src="media/NAME"> shows that file instead.
244
331
  */
245
- export function offlineMedia(body) {
332
+ export function offlineMedia(body, media) {
246
333
  return body.replace(MEDIA_TAG_RE, (whole, tagName, rest) => {
247
334
  const tag = tagName.toLowerCase();
248
335
  const attributes = [...rest.matchAll(ATTRIBUTE_RE)].map((match) => [
@@ -253,8 +340,9 @@ export function offlineMedia(body) {
253
340
  name.toLowerCase(),
254
341
  decodeHTML(value.replace(/^["']+|["']+$/g, "")),
255
342
  ]));
343
+ const src = values.get("src") ?? "";
256
344
  const srcset = (values.get("srcset") ?? "").split(",").some(fetches);
257
- if (!(srcset || fetches(values.get("src") ?? "")))
345
+ if (!(srcset || fetches(src)))
258
346
  return whole;
259
347
  if (tag === "source")
260
348
  return "";
@@ -262,17 +350,75 @@ export function offlineMedia(body) {
262
350
  .filter(([name]) => !["src", "srcset", "sizes"].includes(name.toLowerCase()))
263
351
  .map(([name, value]) => (value ? ` ${name}=${value}` : ` ${name}`))
264
352
  .join("");
265
- return `<${tagName}${kept} src="${placeholder(values.get("width"), values.get("height"))}">`;
353
+ const local = media === undefined ? undefined : mediaDataURI(media, src);
354
+ return `<${tagName}${kept} src="${local ?? placeholder(values.get("width"), values.get("height"))}">`;
355
+ });
356
+ }
357
+ /** A tag's attributes, in order, with their raw (still quoted) values. */
358
+ function parseAttributes(rest) {
359
+ return [...rest.matchAll(ATTRIBUTE_RE)].map((match) => [
360
+ match[1] ?? "",
361
+ match[2] ?? "",
362
+ ]);
363
+ }
364
+ function attributeValue(attributes, name) {
365
+ const found = attributes.find(([key]) => key.toLowerCase() === name);
366
+ return found && decodeHTML(found[1].replace(/^["']+|["']+$/g, ""));
367
+ }
368
+ /**
369
+ * Take enclosures offline, as offlineMedia does a body. Images and a video's poster
370
+ * become placeholders (media/NAME shows the fixture's file); a player that would
371
+ * fetch loses its source, as if the file had not loaded, unless it plays a media/
372
+ * file; a video with nothing to show gets a placeholder poster at its own size, for
373
+ * the frame it would load; and the YouTube player becomes a same-size placeholder.
374
+ */
375
+ export function offlineEnclosures(html, media) {
376
+ const local = (src, types) => media === undefined ? undefined : mediaDataURI(media, src, types);
377
+ return offlineMedia(html, media).replace(ENCLOSURE_TAG_RE, (_whole, tagName, rest) => {
378
+ const attributes = parseAttributes(rest);
379
+ const width = attributeValue(attributes, "width");
380
+ const height = attributeValue(attributes, "height");
381
+ const src = attributeValue(attributes, "src") ?? "";
382
+ if (tagName === "iframe") {
383
+ const label = `${width && height ? `${width} × ${height} ` : ""}YouTube player`;
384
+ const page = "<body style='margin:0;display:grid;place-items:center;height:100vh;" +
385
+ `background:#8a8f98;color:#fff;font:600 16px system-ui'>${label}`;
386
+ const kept = attributes.filter(([name]) => name.toLowerCase() !== "src");
387
+ return `<iframe${kept.map(([n, v]) => (v ? ` ${n}=${v}` : ` ${n}`)).join("")} srcdoc="${escapeHTML(page)}">`;
388
+ }
389
+ const playable = fetches(src) ? local(src, PLAYABLE_TYPES) : src;
390
+ const kept = attributes.filter(([name]) => !["src", "poster"].includes(name.toLowerCase()));
391
+ let tag = `<${tagName}${kept.map(([n, v]) => (v ? ` ${n}=${v}` : ` ${n}`)).join("")}`;
392
+ if (playable)
393
+ tag += ` src="${escapeHTML(playable)}"`;
394
+ if (tagName === "video") {
395
+ const poster = attributeValue(attributes, "poster");
396
+ let shown;
397
+ if (poster)
398
+ shown = fetches(poster) ? (local(poster) ?? placeholder(width, height)) : poster;
399
+ else if (!playable)
400
+ shown = placeholder(width, height);
401
+ if (shown)
402
+ tag += ` poster="${escapeHTML(shown)}"`;
403
+ }
404
+ return `${tag}>`;
266
405
  });
267
406
  }
268
- function fixtureMapping(fixture, target) {
407
+ function fixtureMapping(fixture, target, template, media) {
269
408
  const mapping = {};
270
409
  for (const key of TEMPLATE_KEYS)
271
410
  mapping[key] = pyStr(fixture[key] ?? "");
272
411
  if (!mapping.dateline_style) {
273
412
  mapping.dateline_style = mapping.title ? "articleDateline" : "articleDatelineTitle";
274
413
  }
275
- mapping.body = offlineMedia(mapping.body ?? "");
414
+ const body = mapping.body ?? "";
415
+ mapping.body = offlineMedia(body, media);
416
+ // NetNewsWire before enclosure support ignores attachments and leaves the macro.
417
+ if (!target.olderNetNewsWire) {
418
+ const name = `fixtures/${target.fixture}.toml`;
419
+ const enclosures = enclosuresHTML(fixtureAttachments(fixture, name), body, fixtureImageURL(fixture, name));
420
+ Object.assign(mapping, placeEnclosures(template, mapping.body, offlineEnclosures(enclosures, media)));
421
+ }
276
422
  if (!mapping.avatar_src || fetches(mapping.avatar_src)) {
277
423
  mapping.avatar_src = avatarDataURI(mapping.feed_link_title ?? "");
278
424
  }
@@ -340,7 +486,7 @@ function isFile(path) {
340
486
  return false;
341
487
  }
342
488
  }
343
- export function renderPage(theme, fixture, target, snapshot = renderingInputs().path) {
489
+ export function renderPage(theme, fixture, target, snapshot = renderingInputs().path, media) {
344
490
  const pagePath = join(snapshot, target.ios ? "iOS" : "Mac", "page.html");
345
491
  const required = [
346
492
  pagePath,
@@ -360,7 +506,7 @@ export function renderPage(theme, fixture, target, snapshot = renderingInputs().
360
506
  }
361
507
  const style = substitute(`${read(join(snapshot, "Shared", "core.css"))}\n${stylesheet}`, styleSubstitutions);
362
508
  const template = read(join(theme, "template.html"));
363
- let body = substitute(template, fixtureMapping(fixture, target));
509
+ let body = substitute(template, fixtureMapping(fixture, target, template, media));
364
510
  if (!target.themeScripts)
365
511
  body = removeScripts(body);
366
512
  let page = substitute(skeleton, {
@@ -390,7 +536,7 @@ export function renderSite(root, theme, targets, snapshot = renderingInputs().pa
390
536
  const name = themeStem(theme);
391
537
  for (const target of targets) {
392
538
  const fixture = readFixture(fixturePath(root, target.fixture));
393
- writeFileSync(join(pages, `${target.slug}.html`), renderPage(theme, fixture, target, snapshot), "utf8");
539
+ writeFileSync(join(pages, `${target.slug}.html`), renderPage(theme, fixture, target, snapshot, join(root, "fixtures", "media")), "utf8");
394
540
  const label = escapeHTML(target.label);
395
541
  const viewer = `<!doctype html>
396
542
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">