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.
@@ -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/project.js CHANGED
@@ -153,6 +153,7 @@ export function fixturePath(root, name) {
153
153
  }
154
154
  throw new ThemeError(`fixture not found: ${own}`);
155
155
  }
156
+ const FOOTNOTE_KEYS = ["notes", "plain_links", "keep_with_word", "all_converted"];
156
157
  function isTable(value) {
157
158
  return (!!value && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date));
158
159
  }
@@ -161,7 +162,9 @@ function isTable(value) {
161
162
  *
162
163
  * notes maps each footnote marker's rendered text to the note text its popover must
163
164
  * 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.
165
+ * keep_with_word requires markers written against a word to stay on its line;
166
+ * all_converted fails, rather than warns about, a footnote link that never became a
167
+ * marker.
165
168
  */
166
169
  export function footnoteExpectations(fixture, name) {
167
170
  const expect = fixture.expect ?? {};
@@ -172,7 +175,7 @@ export function footnoteExpectations(fixture, name) {
172
175
  throw new ThemeError(`${name}: [expect] may contain only a [expect.footnotes] table`);
173
176
  }
174
177
  const unknown = Object.keys(footnotes)
175
- .filter((key) => !["notes", "plain_links", "keep_with_word"].includes(key))
178
+ .filter((key) => !FOOTNOTE_KEYS.includes(key))
176
179
  .sort();
177
180
  if (unknown.length) {
178
181
  throw new ThemeError(`${name}: unknown [expect.footnotes] key(s): ${unknown.join(", ")}`);
@@ -198,5 +201,11 @@ export function footnoteExpectations(fixture, name) {
198
201
  }
199
202
  result.keep_with_word = footnotes.keep_with_word;
200
203
  }
204
+ if ("all_converted" in footnotes) {
205
+ if (typeof footnotes.all_converted !== "boolean") {
206
+ throw new ThemeError(`${name}: expect.footnotes.all_converted must be true or false`);
207
+ }
208
+ result.all_converted = footnotes.all_converted;
209
+ }
201
210
  return result;
202
211
  }