nnw-theme 2.3.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.
- package/README.md +2 -2
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/assets/catalog/mastodon.toml +50 -0
- package/assets/footnotes.js +39 -6
- package/assets/guide/design-checklist.md +4 -0
- package/assets/guide/fixtures.md +35 -4
- package/assets/guide/skill.md +2 -2
- package/assets/guide/theme-format.md +4 -2
- package/assets/netnewswire/Mac/main_mac.js +5 -0
- package/assets/netnewswire/Shared/core.css +1 -7
- package/assets/netnewswire/Shared/main.js +73 -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/browser.js +12 -2
- package/dist/commands/check.js +24 -7
- 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/project.js +11 -2
- package/dist/render.js +109 -15
- package/lldb/nnwdump.py +13 -1
- package/package.json +1 -1
package/dist/commands/preview.js
CHANGED
|
@@ -3,7 +3,7 @@ import { relative, sep } from "node:path";
|
|
|
3
3
|
import { serve } from "../browser.js";
|
|
4
4
|
import { openInBrowser } from "../interactive.js";
|
|
5
5
|
import { findRoot, findTheme } from "../project.js";
|
|
6
|
-
import {
|
|
6
|
+
import { renderSite, repositoryTargets } from "../render.js";
|
|
7
7
|
/** Whether a changed path is theme or fixture content the gallery is built from. */
|
|
8
8
|
export function watched(root, path) {
|
|
9
9
|
const parts = relative(root, path).split(sep);
|
|
@@ -15,7 +15,7 @@ export function watched(root, path) {
|
|
|
15
15
|
export default async function preview({ values }) {
|
|
16
16
|
const root = findRoot();
|
|
17
17
|
const theme = findTheme(root);
|
|
18
|
-
const build = () => renderSite(root, theme,
|
|
18
|
+
const build = () => renderSite(root, theme, repositoryTargets(root));
|
|
19
19
|
const site = build();
|
|
20
20
|
const server = await serve(site, { live: true });
|
|
21
21
|
console.log(`Preview: ${server.url}`);
|
package/dist/commands/render.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { findRoot, findTheme, ThemeError } from "../project.js";
|
|
2
|
-
import {
|
|
2
|
+
import { renderSite, repositoryTargets } from "../render.js";
|
|
3
3
|
export default function render({ positionals }) {
|
|
4
4
|
const root = findRoot();
|
|
5
5
|
const theme = findTheme(root);
|
|
6
|
-
let targets =
|
|
6
|
+
let targets = repositoryTargets(root);
|
|
7
7
|
if (positionals.length) {
|
|
8
8
|
const requested = new Set(positionals);
|
|
9
9
|
targets = targets.filter((target) => requested.has(target.fixture));
|
|
@@ -6,12 +6,13 @@ import { extraFixtures, FIXTURE_NAMES, RenderTarget, renderSite, } from "../rend
|
|
|
6
6
|
import { expectations } from "./check.js";
|
|
7
7
|
// Any fixture on any platform: a screenshot is a deliberate one-off, not the check matrix.
|
|
8
8
|
function selectTarget(root, values) {
|
|
9
|
-
const { fixture, platform, appearance, card } = values;
|
|
9
|
+
const { fixture, platform, appearance, card, delivery } = values;
|
|
10
10
|
if (![...Object.keys(FIXTURE_NAMES), ...extraFixtures(root)].includes(fixture)) {
|
|
11
11
|
throw new ThemeError(`unknown fixture: ${fixture}`);
|
|
12
12
|
}
|
|
13
13
|
return new RenderTarget(fixture, platform, appearance, {
|
|
14
14
|
card: !!card,
|
|
15
|
+
delivery: delivery,
|
|
15
16
|
});
|
|
16
17
|
}
|
|
17
18
|
export default async function screenshot({ values }) {
|
|
@@ -19,7 +20,11 @@ export default async function screenshot({ values }) {
|
|
|
19
20
|
const theme = findTheme(root);
|
|
20
21
|
const target = selectTarget(root, values);
|
|
21
22
|
const site = renderSite(root, theme, [target]);
|
|
22
|
-
const
|
|
23
|
+
const warned = {};
|
|
24
|
+
const expected = expectations(root, [target]);
|
|
25
|
+
const results = await checkPages(site, [target], undefined, expected, warned);
|
|
26
|
+
for (const warning of warned[target.slug] ?? [])
|
|
27
|
+
console.log(`Warning: ${warning}`);
|
|
23
28
|
const failures = results[target.slug] ?? [];
|
|
24
29
|
if (failures.length) {
|
|
25
30
|
throw new ThemeError(`screenshot checks failed:\n- ${failures.join("\n- ")}`);
|
package/dist/commands.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The one description of the command line: the parser, help, and shell completion
|
|
2
2
|
// are all generated from it, so they cannot drift apart.
|
|
3
|
+
import { DELIVERIES } from "./delivery.js";
|
|
3
4
|
export const PROGRAM = "nnw-theme";
|
|
4
5
|
export const DESCRIPTION = "Create, preview, check, and publish a NetNewsWire theme.";
|
|
5
6
|
export const GUIDE_TOPICS = [
|
|
@@ -18,6 +19,7 @@ export const BUILT_IN_FIXTURE_NAMES = ["article", "kitchen-sink"];
|
|
|
18
19
|
*/
|
|
19
20
|
export const CATALOG_FIXTURES = {
|
|
20
21
|
ghost: "Ghost cards, galleries, and lazy images",
|
|
22
|
+
mastodon: "Mastodon post: mentions, hashtags, custom emoji, image enclosures",
|
|
21
23
|
medium: "Medium figures, unsized images, and footer",
|
|
22
24
|
"media-rss": "Image, video, and YouTube enclosures with captions",
|
|
23
25
|
podcast: "Podcast audio enclosure and episode artwork",
|
|
@@ -112,7 +114,7 @@ export const COMMANDS = [
|
|
|
112
114
|
name: "screenshot",
|
|
113
115
|
summary: "check one case in WebKit and save its image",
|
|
114
116
|
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 " +
|
|
117
|
+
"fixture, on any platform, directly or --delivery through a sync service. With --card, capture an 800x552 viewport at 2x, the " +
|
|
116
118
|
"shape of the marketplace card. With --promote, it becomes " +
|
|
117
119
|
"screenshots/theme-preview.png, the marketplace card.",
|
|
118
120
|
options: [
|
|
@@ -125,6 +127,12 @@ export const COMMANDS = [
|
|
|
125
127
|
},
|
|
126
128
|
{ name: "platform", type: "string", choices: ["mac", "iphone", "ipad"], default: "mac" },
|
|
127
129
|
{ name: "appearance", type: "string", choices: ["light", "dark"], default: "light" },
|
|
130
|
+
{
|
|
131
|
+
name: "delivery",
|
|
132
|
+
type: "string",
|
|
133
|
+
choices: DELIVERIES,
|
|
134
|
+
help: "render the fixture as this sync service delivers it",
|
|
135
|
+
},
|
|
128
136
|
{ name: "card", type: "boolean", help: "capture the marketplace card's shape at 2x" },
|
|
129
137
|
{ name: "promote", type: "boolean", help: "use it as the marketplace screenshot" },
|
|
130
138
|
],
|
|
@@ -136,7 +144,7 @@ export const COMMANDS = [
|
|
|
136
144
|
},
|
|
137
145
|
{
|
|
138
146
|
name: "fixture",
|
|
139
|
-
summary: "add fixtures modeled on WordPress, Ghost, Substack, and
|
|
147
|
+
summary: "add fixtures modeled on WordPress, Ghost, Substack, Medium, and Mastodon",
|
|
140
148
|
description: "Copy ready-made fixtures with markup modeled on what major publishing platforms " +
|
|
141
149
|
"send into fixtures/, where preview and check pick them up. Each one adds four " +
|
|
142
150
|
"renders to every check.",
|
package/dist/delivery.js
ADDED
|
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
34
|
+
}
|
|
35
|
+
function escapeAttribute(value) {
|
|
36
|
+
return value.replaceAll("&", "&").replaceAll('"', """);
|
|
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
|
+
}
|
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) => !
|
|
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
|
}
|