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.
- package/README.md +2 -2
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/assets/catalog/mastodon.toml +50 -0
- package/assets/enclosures.js +6 -4
- package/assets/footnotes.js +39 -6
- package/assets/guide/design-checklist.md +6 -1
- package/assets/guide/fixtures.md +40 -7
- package/assets/guide/skill.md +2 -2
- package/assets/guide/theme-format.md +42 -7
- package/assets/netnewswire/Mac/main_mac.js +5 -0
- package/assets/netnewswire/Shared/core.css +20 -7
- package/assets/netnewswire/Shared/main.js +86 -1
- package/assets/netnewswire/Shared/newsfoot.js +24 -27
- package/assets/netnewswire/iOS/main_ios.js +1 -1
- package/assets/netnewswire/netnewswire.json +7 -7
- package/dist/body-images.js +209 -0
- package/dist/browser.js +29 -12
- package/dist/commands/check.js +25 -8
- package/dist/commands/preview.js +2 -2
- package/dist/commands/render.js +2 -2
- package/dist/commands/screenshot.js +7 -2
- package/dist/commands.js +10 -2
- package/dist/delivery.js +516 -0
- package/dist/enclosures.js +176 -42
- package/dist/project.js +13 -2
- package/dist/render.js +145 -31
- package/dist/validate.js +33 -30
- package/lldb/nnwdump.py +16 -2
- package/package.json +1 -1
package/dist/enclosures.js
CHANGED
|
@@ -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]]
|
|
3
|
-
// (Shared/Article Rendering/ArticleRenderer+Enclosures.swift
|
|
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
|
|
50
|
-
export function
|
|
51
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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,
|
|
267
|
-
const enclosures =
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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) => !
|
|
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 {
|
|
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
|
|
127
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
222
|
-
|
|
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
|
-
|
|
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
|
|
239
|
-
* the older-NetNewsWire cases
|
|
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 = [], {
|
|
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 (
|
|
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
|
-
/**
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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 (
|
|
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
|
}
|