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/README.md +2 -2
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/assets/catalog/media-rss.toml +79 -0
- package/assets/catalog/podcast.toml +52 -0
- package/assets/enclosures.js +113 -0
- package/assets/guide/design-checklist.md +2 -0
- package/assets/guide/fixtures.md +37 -2
- package/assets/guide/publishing.md +6 -0
- package/assets/guide/skill.md +2 -1
- package/assets/guide/theme-format.md +46 -2
- package/assets/netnewswire/Shared/core.css +24 -0
- package/assets/netnewswire/netnewswire.json +3 -3
- package/assets/stubs/.github/workflows/screenshot.yml +7 -3
- package/dist/browser.js +21 -6
- package/dist/commands/check.js +4 -2
- package/dist/commands/screenshot.js +7 -8
- package/dist/commands.js +7 -2
- package/dist/enclosures.js +299 -0
- package/dist/render.js +160 -14
- package/lldb/nnwdump.py +75 -5
- package/package.json +1 -1
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 --
|
|
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 += "&";
|
|
121
|
+
else if (character === "<")
|
|
122
|
+
escaped += "<";
|
|
123
|
+
else if (character === ">")
|
|
124
|
+
escaped += ">";
|
|
125
|
+
else if (character === '"')
|
|
126
|
+
escaped += """;
|
|
127
|
+
else if (character === "'")
|
|
128
|
+
escaped += "'";
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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">
|