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,516 @@
1
+ // Article HTML as a sync service delivers it to NetNewsWire. FreshRSS and Miniflux
2
+ // sanitize every article before NetNewsWire sees it, and both rewrites can break a
3
+ // theme that relies on the publisher's ids, classes, wrappers, or relative links.
4
+ //
5
+ // Each policy follows its service's sanitizer, closely enough for a preview: Miniflux's
6
+ // internal/reader/sanitizer/sanitizer.go (2.3.5) and FreshRSS's SimplePie settings in
7
+ // app/Models/SimplePieCustom.php (1.30.4). Neither removes tracking parameters or
8
+ // rewrites iframe URLs, which no theme can see in an offline preview. No imports from
9
+ // the project: commands.ts lists DELIVERIES, and project.ts imports commands.ts.
10
+ import { Parser } from "htmlparser2";
11
+ export const DELIVERIES = ["freshrss", "miniflux"];
12
+ export const DELIVERY_NAMES = {
13
+ freshrss: "FreshRSS",
14
+ miniflux: "Miniflux",
15
+ };
16
+ const VOID_ELEMENTS = new Set([
17
+ "area",
18
+ "base",
19
+ "br",
20
+ "col",
21
+ "embed",
22
+ "hr",
23
+ "img",
24
+ "input",
25
+ "link",
26
+ "meta",
27
+ "param",
28
+ "source",
29
+ "track",
30
+ "wbr",
31
+ ]);
32
+ function escapeText(value) {
33
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
34
+ }
35
+ function escapeAttribute(value) {
36
+ return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;");
37
+ }
38
+ function absolute(value, base) {
39
+ try {
40
+ return new URL(value.trim(), base || undefined).href;
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ function scheme(url) {
47
+ return url.slice(0, Math.max(url.indexOf(":"), 0)).toLowerCase();
48
+ }
49
+ /** Rewrite html element by element, as policy decides; comments are always dropped. */
50
+ function rewrite(html, policy, articleURL) {
51
+ let output = "";
52
+ // One entry per open element: whether its end tag is written, or "drop" while its subtree is skipped.
53
+ const stack = [];
54
+ const dropping = () => stack.includes("drop");
55
+ const parser = new Parser({
56
+ onopentag(name, attribs) {
57
+ if (dropping()) {
58
+ stack.push("drop");
59
+ return;
60
+ }
61
+ const decision = policy(name, Object.entries(attribs), articleURL);
62
+ if (decision === "drop") {
63
+ stack.push("drop");
64
+ }
65
+ else if (decision === "unwrap") {
66
+ stack.push(false);
67
+ }
68
+ else {
69
+ const attributes = decision.keep
70
+ .map(([key, value]) => value === "" ? ` ${key}` : ` ${key}="${escapeAttribute(value)}"`)
71
+ .join("");
72
+ output += `<${name}${attributes}>`;
73
+ stack.push(!VOID_ELEMENTS.has(name));
74
+ }
75
+ },
76
+ ontext(text) {
77
+ if (!dropping())
78
+ output += escapeText(text);
79
+ },
80
+ onclosetag(name) {
81
+ const entry = stack.pop();
82
+ if (entry === true)
83
+ output += `</${name}>`;
84
+ },
85
+ }, { decodeEntities: true });
86
+ parser.end(html);
87
+ return output;
88
+ }
89
+ // Miniflux: an allowlist of elements and attributes; anything else is unwrapped.
90
+ const MINIFLUX_ALLOWED = {
91
+ a: ["href", "title", "id"],
92
+ abbr: ["title"],
93
+ acronym: ["title"],
94
+ aside: [],
95
+ audio: ["src"],
96
+ blockquote: [],
97
+ b: [],
98
+ br: [],
99
+ caption: [],
100
+ cite: [],
101
+ code: [],
102
+ dd: ["id"],
103
+ del: [],
104
+ dfn: [],
105
+ dl: ["id"],
106
+ dt: ["id"],
107
+ em: [],
108
+ figcaption: [],
109
+ figure: [],
110
+ h1: ["id"],
111
+ h2: ["id"],
112
+ h3: ["id"],
113
+ h4: ["id"],
114
+ h5: ["id"],
115
+ h6: ["id"],
116
+ hr: [],
117
+ i: [],
118
+ iframe: ["width", "height", "frameborder", "src", "allowfullscreen"],
119
+ img: [
120
+ "alt",
121
+ "title",
122
+ "src",
123
+ "srcset",
124
+ "sizes",
125
+ "width",
126
+ "height",
127
+ "fetchpriority",
128
+ "decoding",
129
+ ],
130
+ ins: [],
131
+ kbd: [],
132
+ li: ["id"],
133
+ ol: ["id"],
134
+ p: [],
135
+ picture: [],
136
+ pre: [],
137
+ q: ["cite"],
138
+ rp: [],
139
+ rt: [],
140
+ rtc: [],
141
+ ruby: [],
142
+ s: [],
143
+ small: [],
144
+ samp: [],
145
+ source: ["src", "type", "srcset", "sizes", "media"],
146
+ strong: [],
147
+ sub: [],
148
+ sup: ["id"],
149
+ table: [],
150
+ td: ["rowspan", "colspan"],
151
+ tfoot: [],
152
+ th: ["rowspan", "colspan"],
153
+ thead: [],
154
+ time: ["datetime"],
155
+ tr: [],
156
+ u: [],
157
+ ul: ["id"],
158
+ var: [],
159
+ video: ["poster", "height", "width", "src"],
160
+ wbr: [],
161
+ };
162
+ const MINIFLUX_IFRAME_HOSTS = new Set([
163
+ "bandcamp.com",
164
+ "cdn.embedly.com",
165
+ "dailymotion.com",
166
+ "framatube.org",
167
+ "open.spotify.com",
168
+ "player.bilibili.com",
169
+ "player.twitch.tv",
170
+ "player.vimeo.com",
171
+ "soundcloud.com",
172
+ "vk.com",
173
+ "w.soundcloud.com",
174
+ "youtube-nocookie.com",
175
+ "youtube.com",
176
+ ]);
177
+ const MINIFLUX_BLOCKED_URLS = [
178
+ "api.flattr.com",
179
+ "www.facebook.com/sharer.php",
180
+ "feeds.feedburner.com",
181
+ "feedsportal.com",
182
+ "linkedin.com/shareArticle",
183
+ "pinterest.com/pin/create/button/",
184
+ "stats.wordpress.com",
185
+ "twitter.com/intent/tweet",
186
+ "twitter.com/share",
187
+ "x.com/intent/tweet",
188
+ "x.com/share",
189
+ ];
190
+ const MINIFLUX_SCHEMES = new Set(("https http apt bitcoin callto dav davs ed2k facetime feed ftp geo git gopher irc irc6 " +
191
+ "ircs itms-apps itms magnet mailto news nntp rtmp sftp sip sips shortcuts skype spotify " +
192
+ "ssh steam svn svn+ssh tel webcal xmpp opener hack").split(" "));
193
+ const MINIFLUX_DATA_IMAGES = [
194
+ "data:image/avif",
195
+ "data:image/apng",
196
+ "data:image/png",
197
+ "data:image/svg",
198
+ "data:image/jpg",
199
+ "data:image/jpeg",
200
+ "data:image/gif",
201
+ "data:image/webp",
202
+ ];
203
+ const URL_ATTRIBUTES = new Set(["src", "href", "poster", "cite"]);
204
+ function positiveInteger(value) {
205
+ return /^\+?[0-9]+$/.test(value) && Number(value) > 0;
206
+ }
207
+ function minifluxSrcset(value, base) {
208
+ return value
209
+ .split(",")
210
+ .map((candidate) => candidate.trim().split(/\s+/))
211
+ .flatMap(([url = "", ...descriptors]) => {
212
+ const resolved = url ? absolute(url, base) : undefined;
213
+ if (!resolved || !MINIFLUX_SCHEMES.has(scheme(resolved)))
214
+ return [];
215
+ if (MINIFLUX_BLOCKED_URLS.some((blocked) => resolved.includes(blocked)))
216
+ return [];
217
+ return [[resolved, ...descriptors].join(" ")];
218
+ })
219
+ .join(", ");
220
+ }
221
+ const miniflux = (tag, attributes, articleURL) => {
222
+ if (["script", "style", "noscript"].includes(tag))
223
+ return "drop";
224
+ if (attributes.some(([key]) => key === "hidden"))
225
+ return "drop";
226
+ if (tag === "img") {
227
+ const tiny = (key) => attributes.some(([name, value]) => name === key && (value === "0" || value === "1"));
228
+ if (tiny("width") && tiny("height"))
229
+ return "drop";
230
+ }
231
+ const allowed = MINIFLUX_ALLOWED[tag];
232
+ if (!allowed)
233
+ return "unwrap";
234
+ const keep = [];
235
+ let anchorLink = false;
236
+ let youTube = false;
237
+ for (const [key, raw] of attributes) {
238
+ if (!allowed.includes(key))
239
+ continue;
240
+ let value = raw;
241
+ if (tag === "img") {
242
+ if (key === "fetchpriority" && !["high", "low", "auto"].includes(value))
243
+ continue;
244
+ if (key === "decoding" && !["sync", "async", "auto"].includes(value))
245
+ continue;
246
+ if ((key === "width" || key === "height") && !positiveInteger(value))
247
+ continue;
248
+ }
249
+ if ((tag === "img" || tag === "source") && key === "srcset") {
250
+ value = minifluxSrcset(value, articleURL);
251
+ if (!value)
252
+ continue;
253
+ }
254
+ if (URL_ATTRIBUTES.has(key)) {
255
+ if (tag === "iframe") {
256
+ let host = "";
257
+ try {
258
+ host = new URL(raw, "https://invalid.example").hostname.replace(/^www\./, "");
259
+ }
260
+ catch {
261
+ // Not a URL: not an allowed host.
262
+ }
263
+ if (!MINIFLUX_IFRAME_HOSTS.has(host))
264
+ return "drop";
265
+ youTube = host === "youtube.com" || host === "youtube-nocookie.com";
266
+ }
267
+ else if (tag === "img" &&
268
+ key === "src" &&
269
+ MINIFLUX_DATA_IMAGES.some((p) => raw.startsWith(p))) {
270
+ // Allowed data image.
271
+ }
272
+ else if (tag === "a" && key === "href" && raw.startsWith("#")) {
273
+ anchorLink = true;
274
+ }
275
+ else {
276
+ if (MINIFLUX_BLOCKED_URLS.some((blocked) => raw.includes(blocked))) {
277
+ return tag === "iframe" ? "drop" : "unwrap";
278
+ }
279
+ const resolved = absolute(raw, articleURL);
280
+ if (!resolved || !MINIFLUX_SCHEMES.has(scheme(resolved)))
281
+ continue;
282
+ value = resolved;
283
+ }
284
+ }
285
+ keep.push([key, value]);
286
+ }
287
+ const has = (key) => keep.some(([name]) => name === key);
288
+ const required = tag === "a"
289
+ ? has("href")
290
+ : tag === "iframe"
291
+ ? has("src")
292
+ : tag === "img" || tag === "source"
293
+ ? has("src") || has("srcset")
294
+ : true;
295
+ if (!required)
296
+ return tag === "iframe" ? "drop" : "unwrap";
297
+ if (!anchorLink) {
298
+ if (tag === "a") {
299
+ keep.push(["rel", "noopener noreferrer"], ["referrerpolicy", "no-referrer"]);
300
+ // The default for Miniflux's "Open external links in a new tab" setting.
301
+ keep.push(["target", "_blank"]);
302
+ }
303
+ else if (tag === "video" || tag === "audio") {
304
+ keep.push(["controls", ""]);
305
+ }
306
+ else if (tag === "iframe") {
307
+ keep.push([
308
+ "sandbox",
309
+ "allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox",
310
+ ], ["loading", "lazy"]);
311
+ if (youTube)
312
+ keep.push(["referrerpolicy", "strict-origin-when-cross-origin"]);
313
+ }
314
+ else if (tag === "img") {
315
+ keep.push(["loading", "lazy"]);
316
+ }
317
+ }
318
+ return { keep };
319
+ };
320
+ // FreshRSS: SimplePie with FreshRSS's allowlist. id and class survive only renamed,
321
+ // as data-sanitized-id and data-sanitized-class; data-* and aria-* attributes stay.
322
+ const FRESHRSS_GLOBAL = ["dir", "draggable", "hidden", "lang", "role", "title"];
323
+ const FRESHRSS_ALLOWED = {
324
+ a: ["href", "hreflang", "type"],
325
+ abbr: [],
326
+ acronym: [],
327
+ address: [],
328
+ article: [],
329
+ aside: [],
330
+ audio: ["controlslist", "loop", "muted", "src"],
331
+ b: [],
332
+ bdi: [],
333
+ bdo: [],
334
+ big: [],
335
+ blink: [],
336
+ blockquote: ["cite"],
337
+ br: ["clear"],
338
+ button: ["disabled"],
339
+ canvas: ["width", "height"],
340
+ caption: ["align"],
341
+ center: [],
342
+ cite: [],
343
+ code: [],
344
+ col: ["span", "align", "valign", "width"],
345
+ colgroup: ["span", "align", "valign", "width"],
346
+ data: ["value"],
347
+ datalist: [],
348
+ dd: [],
349
+ del: ["cite", "datetime"],
350
+ details: ["open"],
351
+ dfn: [],
352
+ dialog: [],
353
+ dir: [],
354
+ div: ["align"],
355
+ dl: [],
356
+ dt: [],
357
+ em: [],
358
+ fieldset: ["disabled"],
359
+ figcaption: [],
360
+ figure: [],
361
+ footer: [],
362
+ h1: [],
363
+ h2: [],
364
+ h3: [],
365
+ h4: [],
366
+ h5: [],
367
+ h6: [],
368
+ header: [],
369
+ hgroup: [],
370
+ hr: ["align", "noshade", "size", "width"],
371
+ i: [],
372
+ iframe: [
373
+ "src",
374
+ "align",
375
+ "frameborder",
376
+ "longdesc",
377
+ "marginheight",
378
+ "marginwidth",
379
+ "scrolling",
380
+ "allowfullscreen",
381
+ ],
382
+ img: ["src", "alt", "width", "height", "align", "border", "hspace", "longdesc", "vspace"],
383
+ ins: ["cite", "datetime"],
384
+ kbd: [],
385
+ label: [],
386
+ legend: [],
387
+ li: ["value", "type"],
388
+ main: [],
389
+ mark: [],
390
+ menu: [],
391
+ meter: ["value", "min", "max", "low", "high", "optimum"],
392
+ nav: [],
393
+ nobr: [],
394
+ ol: ["reversed", "start", "type"],
395
+ output: [],
396
+ p: ["align"],
397
+ picture: [],
398
+ pre: ["width", "wrap"],
399
+ progress: ["max", "value"],
400
+ q: ["cite"],
401
+ rb: [],
402
+ rp: [],
403
+ rt: [],
404
+ rtc: [],
405
+ ruby: [],
406
+ s: [],
407
+ samp: [],
408
+ search: [],
409
+ section: [],
410
+ small: [],
411
+ source: ["type", "src", "media", "height", "width"],
412
+ span: [],
413
+ strike: [],
414
+ strong: [],
415
+ sub: [],
416
+ summary: [],
417
+ sup: [],
418
+ table: ["align", "border", "cellpadding", "cellspacing", "rules", "summary", "width"],
419
+ tbody: ["align", "char", "charoff", "valign"],
420
+ td: ["colspan", "headers", "rowspan", "abbr", "align", "height", "scope", "valign", "width"],
421
+ tfoot: ["align", "valign"],
422
+ th: ["abbr", "colspan", "rowspan", "scope", "align", "height", "valign", "width"],
423
+ thead: ["align", "valign"],
424
+ time: ["datetime"],
425
+ tr: ["align", "valign"],
426
+ track: ["default", "kind", "srclang", "label", "src"],
427
+ tt: [],
428
+ u: [],
429
+ ul: ["type"],
430
+ var: [],
431
+ video: ["src", "poster", "controlslist", "height", "loop", "muted", "playsinline", "width"],
432
+ wbr: [],
433
+ };
434
+ const FRESHRSS_STRIPPED_DATA = new Set([
435
+ "data-auto-leave-validation",
436
+ "data-leave-validation",
437
+ "data-no-leave-validation",
438
+ "data-original",
439
+ ]);
440
+ const FRESHRSS_URLS = {
441
+ a: ["href"],
442
+ audio: ["src"],
443
+ blockquote: ["cite"],
444
+ del: ["cite"],
445
+ iframe: ["src"],
446
+ img: ["longdesc", "src"],
447
+ ins: ["cite"],
448
+ q: ["cite"],
449
+ source: ["src"],
450
+ track: ["src"],
451
+ video: ["poster", "src"],
452
+ };
453
+ const FRESHRSS_ADDED = {
454
+ audio: [
455
+ ["controls", "controls"],
456
+ ["preload", "none"],
457
+ ],
458
+ iframe: [
459
+ [
460
+ "allow",
461
+ "accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
462
+ ],
463
+ ["sandbox", "allow-scripts allow-same-origin"],
464
+ ["allowfullscreen", "allowfullscreen"],
465
+ ],
466
+ video: [
467
+ ["controls", "controls"],
468
+ ["preload", "none"],
469
+ ],
470
+ };
471
+ const freshRSS = (tag, attributes, articleURL) => {
472
+ if (tag === "script" || tag === "style")
473
+ return "drop";
474
+ const allowed = FRESHRSS_ALLOWED[tag];
475
+ if (!allowed)
476
+ return "unwrap";
477
+ const urls = FRESHRSS_URLS[tag] ?? [];
478
+ const keep = [];
479
+ for (const [key, raw] of attributes) {
480
+ let name = key;
481
+ let value = raw;
482
+ if (key === "id" || key === "class") {
483
+ name = `data-sanitized-${key}`;
484
+ }
485
+ else if (key.startsWith("data-")) {
486
+ if (FRESHRSS_STRIPPED_DATA.has(key))
487
+ continue;
488
+ }
489
+ else if (!key.startsWith("aria-") &&
490
+ !allowed.includes(key) &&
491
+ !FRESHRSS_GLOBAL.includes(key)) {
492
+ continue;
493
+ }
494
+ if (urls.includes(key)) {
495
+ // SimplePie makes every URL absolute against the article, fragments included.
496
+ const resolved = absolute(raw, articleURL);
497
+ if (!resolved)
498
+ continue;
499
+ const kind = scheme(resolved);
500
+ if (kind === "javascript" || (kind === "data" && !(tag === "img" && key === "src"))) {
501
+ continue;
502
+ }
503
+ value = resolved;
504
+ }
505
+ keep.push([name, value]);
506
+ }
507
+ const added = FRESHRSS_ADDED[tag] ?? [];
508
+ const names = new Set(keep.map(([name]) => name));
509
+ keep.push(...added.filter(([name]) => !names.has(name)));
510
+ return { keep };
511
+ };
512
+ const POLICIES = { freshrss: freshRSS, miniflux };
513
+ /** body as delivery would hand it to NetNewsWire for the article at articleURL. */
514
+ export function deliver(body, delivery, articleURL) {
515
+ return rewrite(body, POLICIES[delivery], articleURL);
516
+ }