nnw-theme 2.0.0 → 2.0.2

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.
@@ -1,8 +1,8 @@
1
1
  # Third-party notices
2
2
 
3
3
  The published package includes rendering files from
4
- [NetNewsWire](https://github.com/Ranchero-Software/NetNewsWire), release `mac-7.1.3`,
5
- commit `f2daca0f24f29c08d9cb16e1bdc56293fd6fe286`, under `assets/netnewswire/`.
4
+ [NetNewsWire](https://github.com/Ranchero-Software/NetNewsWire), release `mac-7.2b4`,
5
+ commit `b7f4d8c7d273e4247c5d033ee47014805072297b`, under `assets/netnewswire/`.
6
6
  `netnewswire.json` in the source repository records the SHA-256 of every file, and the
7
7
  build verifies each one, including NetNewsWire’s MIT license, which ships beside them as
8
8
  `assets/netnewswire/LICENSE`. These files are used only to preview themes; they are
@@ -29,6 +29,10 @@ body = """
29
29
  <table><thead><tr><th>Viewport</th><th>Purpose</th><th>Expected behavior</th></tr></thead><tbody><tr><td>macOS</td><td>Reading</td><td>Comfortable measure</td></tr><tr><td>iPhone</td><td>Small screen</td><td>No document overflow</td></tr><tr><td>iPad</td><td>Large text</td><td>Stable hierarchy</td></tr></tbody></table>
30
30
  <h2>Media and footnotes</h2>
31
31
  <figure><img alt="Abstract green and cream test pattern" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1200' height='675' viewBox='0 0 1200 675'%3E%3Crect width='1200' height='675' fill='%23d8d2c6'/%3E%3Ccircle cx='380' cy='337' r='240' fill='%232f6655'/%3E%3Cpath d='M0 560L1200 180V675H0Z' fill='%23f7f5f0'/%3E%3C/svg%3E"><figcaption>A synthetic image checks wide-media scaling.</figcaption></figure>
32
+ <p>Embedded players come in the shapes NetNewsWire’s <code>main.js</code> handles: a fixed-size frame that keeps its proportions as the column narrows, a full-width player whose height comes only from an inline <code>aspect-ratio</code>, and a hidden tracking frame that must take no space.</p>
33
+ <iframe width="560" height="315" title="Fixed-size video embed" srcdoc="&lt;body style='margin:0;display:grid;place-items:center;height:100vh;background:#8a8f98;color:#fff;font:600 16px system-ui'&gt;560 × 315 video embed"></iframe>
34
+ <iframe width="560" height="315" style="width: 100%; height: auto; aspect-ratio: 16 / 9;" title="Aspect-ratio video embed" srcdoc="&lt;body style='margin:0;display:grid;place-items:center;height:100vh;background:#8a8f98;color:#fff;font:600 16px system-ui'&gt;16:9 aspect-ratio video embed"></iframe>
35
+ <iframe style="display: none;" title="Hidden tracking frame" srcdoc=""></iframe>
32
36
  <p>The note marker uses common generated markup.<sup id="fnref:1"><a href="#fn:1" rel="footnote">1</a></sup></p>
33
37
  <div class="footnotes"><ol><li id="fn:1"><p>Footnote content should remain readable in place and in NetNewsWire’s popover. <a href="#fnref:1" class="reversefootnote">↩</a></p></li></ol></div>
34
38
  <!-- SC_OFF --><div class="md"><p>A comment-wrapped paragraph, as Reddit sends every post.</p></div><!-- SC_ON -->
@@ -39,8 +39,8 @@ comment early and shows the rest of it as text. `check` fails on them. NetNewsWi
39
39
  `:root`; a theme with its own typography should not, because that shorthand resets
40
40
  `font-family` and `line-height`.
41
41
 
42
- The renderer pins NetNewsWire `mac-7.1.3` commit
43
- `f2daca0f24f29c08d9cb16e1bdc56293fd6fe286`. It uses upstream `core.css`, macOS and
42
+ The renderer pins NetNewsWire `mac-7.2b4` commit
43
+ `b7f4d8c7d273e4247c5d033ee47014805072297b`. It uses upstream `core.css`, macOS and
44
44
  iOS page skeletons, and `main.js`, `main_mac.js`, `main_ios.js`, and `newsfoot.js`.
45
45
  These files ship inside the `nnw-theme` package, verified by SHA-256, so previews work
46
46
  offline. iPhone and iPad share the iOS skeleton. WebKit native
@@ -14,6 +14,10 @@ html {
14
14
  position: relative;
15
15
  }
16
16
 
17
+ pre, code {
18
+ tab-size: 4;
19
+ }
20
+
17
21
  /* This is the activity indicator (currently iOS only) that is used while image zooming */
18
22
 
19
23
  .activityIndicatorWrap {
@@ -1,6 +1,9 @@
1
1
  // Here we are making iframes responsive. Particularly useful for inline Youtube videos.
2
2
  function wrapFrames() {
3
3
  document.querySelectorAll("iframe").forEach(element => {
4
+ if (getComputedStyle(element).display === "none") {
5
+ return;
6
+ }
4
7
  if (parseInt(element.height) > 0) {
5
8
  preserveAspectRatioOfFixedSizeFrame(element);
6
9
  return;
@@ -39,7 +42,12 @@ function stripStylesFromElement(element, propertiesToStrip) {
39
42
  function stripStyles() {
40
43
  document.getElementsByTagName("body")[0].querySelectorAll("style, link[rel=stylesheet]").forEach(element => element.remove());
41
44
  // Removing "background" and "font" will also remove properties that would be reflected in them, e.g., "background-color" and "font-family"
42
- document.getElementsByTagName("body")[0].querySelectorAll("[style]").forEach(element => stripStylesFromElement(element, ["color", "background", "font", "max-width", "max-height", "position"]));
45
+ // The stylesheet forces images to height: auto, so an author aspect-ratio box can't be filled by its image — content after the box overlaps the overflowing image.
46
+ // Iframes keep their aspect-ratio: without it an iframe with no pixel height collapses to the default 150px.
47
+ document.getElementsByTagName("body")[0].querySelectorAll("[style]").forEach(element => {
48
+ const isIframe = element.tagName === "IFRAME";
49
+ stripStylesFromElement(element, isIframe ? ["color", "background", "font", "max-width", "max-height", "position"] : ["color", "background", "font", "max-width", "max-height", "position", "aspect-ratio"]);
50
+ });
43
51
  }
44
52
 
45
53
  // Constrain the height of iframes whose heights are defined relative to the document body to be at most
@@ -167,11 +175,13 @@ function error() {
167
175
 
168
176
  // Takes into account absoluting of URLs.
169
177
  function isLocalFootnote(target) {
170
- return target.hash.startsWith("#fn") && target.href.indexOf(document.baseURI) === 0;
178
+ return (target.hash.startsWith("#fn") || target.hash.startsWith("#footnote")) && target.href.indexOf(document.baseURI) === 0;
171
179
  }
172
180
 
173
181
  function styleLocalFootnotes() {
174
- for (elem of document.querySelectorAll("sup > a[href*='#fn'], sup > div > a[href*='#fn']")) {
182
+ // Multimarkdown-style: <sup><a href="#fn…">. Substack-style: a bare
183
+ // <a id="footnote-anchor-N" href="#footnote-N"> with no <sup> wrapper.
184
+ for (elem of document.querySelectorAll("sup > a[href*='#fn'], sup > div > a[href*='#fn'], a[id^='footnote-anchor-']")) {
175
185
  if (isLocalFootnote(elem)) {
176
186
  elem.classList.add("footnote");
177
187
  }
@@ -202,17 +212,24 @@ function removeWpSmiley() {
202
212
  }
203
213
  }
204
214
 
215
+ // <https://github.com/Ranchero-Software/NetNewsWire/issues/3501>
216
+ function removeInstapaperIgnoreElements() {
217
+ document.querySelectorAll(".instapaper_ignore").forEach(element => element.remove());
218
+ }
219
+
205
220
  function processPage() {
221
+ // stripStyles must run first — wrapFrames sets aspect-ratio on fixed-size iframes, which stripStyles would remove.
222
+ stripStyles();
206
223
  wrapFrames();
207
224
  wrapTables();
208
225
  inlineVideos();
209
- stripStyles();
210
226
  sizeTailwindSVGs();
211
227
  constrainBodyRelativeIframes();
212
228
  convertImgSrc();
213
229
  flattenPreElements();
214
230
  styleLocalFootnotes();
215
231
  removeWpSmiley()
232
+ removeInstapaperIgnoreElements();
216
233
  postRenderProcessing();
217
234
  }
218
235
 
@@ -125,12 +125,37 @@
125
125
  if (!target.hash) return;
126
126
  return decodeURIComponent(target.hash.substring(1));
127
127
  }
128
- /** @type {{fnref(target:HTMLAnchorElement): string|undefined}[]} */
128
+ /**
129
+ * @type {{
130
+ * fnref(target:HTMLAnchorElement): string|undefined,
131
+ * content(targetElement:HTMLElement): string
132
+ * }[]}
133
+ */
129
134
  const footnoteFormats = [
135
+ { // Substack: the ref <a id="footnote-anchor-N" href="#footnote-N">
136
+ // points at a backlink <a id="footnote-N">N</a> whose note text lives
137
+ // in the sibling elements that follow it, not inside the anchor.
138
+ fnref(target) {
139
+ if (!target.matches("a[id^='footnote-anchor-']")) return;
140
+ return idFromHash(target);
141
+ },
142
+ content(targetElement) {
143
+ let html = "";
144
+ let node = targetElement.nextElementSibling;
145
+ while (node && !node.matches("a[id^='footnote-']")) {
146
+ html += node.outerHTML;
147
+ node = node.nextElementSibling;
148
+ }
149
+ return html;
150
+ }
151
+ },
130
152
  { // Multimarkdown
131
153
  fnref(target) {
132
154
  if (!target.matches(".footnote")) return;
133
155
  return idFromHash(target);
156
+ },
157
+ content(targetElement) {
158
+ return targetElement.innerHTML;
134
159
  }
135
160
  }
136
161
  ];
@@ -140,22 +165,26 @@
140
165
  if (!(ev.target && ev.target instanceof HTMLAnchorElement)) return;
141
166
 
142
167
  let targetId = undefined;
168
+ let format = undefined;
143
169
  for(const f of footnoteFormats) {
144
170
  targetId = f.fnref(ev.target);
145
- if (targetId) break;
171
+ if (targetId) {
172
+ format = f;
173
+ break;
174
+ }
146
175
  }
147
176
  if (targetId === undefined) return;
148
-
177
+
149
178
  // Only override the default behaviour when we know we can find the
150
179
  // target element
151
180
  const targetElement = document.getElementById(targetId);
152
181
  if (targetElement === null) return;
153
-
182
+
154
183
  ev.preventDefault();
155
184
 
156
185
  installContainer(ev.target);
157
-
158
- void new Footnote(targetElement.innerHTML, ev.target);
186
+
187
+ void new Footnote(format.content(targetElement), ev.target);
159
188
  });
160
189
 
161
190
  // Handle clicks on the footnote reverse link
@@ -1,6 +1,6 @@
1
1
  {
2
- "release": "mac-7.1.3",
3
- "commit": "f2daca0f24f29c08d9cb16e1bdc56293fd6fe286",
2
+ "release": "mac-7.2b4",
3
+ "commit": "b7f4d8c7d273e4247c5d033ee47014805072297b",
4
4
  "files": [
5
5
  {
6
6
  "destination": "LICENSE",
@@ -20,17 +20,17 @@
20
20
  {
21
21
  "destination": "Shared/core.css",
22
22
  "source": "Shared/Article Rendering/core.css",
23
- "sha256": "5cd07a57d6511d9e043aaaf4dc6cd544da18b9733725375928edb9b11ae96e6b"
23
+ "sha256": "d67552259cb45261e1aacf0c1af6bc39b560b084c7b9e3943191907e0977cd4d"
24
24
  },
25
25
  {
26
26
  "destination": "Shared/main.js",
27
27
  "source": "Shared/Article Rendering/main.js",
28
- "sha256": "7a229f330ca4adc7043803f044776f36fb086483d236e39947ce46a0909f7329"
28
+ "sha256": "847dc1f06ec0e7f96aee1004fd6186771db907445347cf0371abc67158aadb5a"
29
29
  },
30
30
  {
31
31
  "destination": "Shared/newsfoot.js",
32
32
  "source": "Shared/Article Rendering/newsfoot.js",
33
- "sha256": "6a921854ef81214eebb983314023a20417e76c64eb78e2c4742815744476ae4f"
33
+ "sha256": "828e6889bd976ebce417d808ac726dd9252a801946d77e32de207ba4a14e21fa"
34
34
  },
35
35
  {
36
36
  "destination": "iOS/main_ios.js",
package/dist/browser.js CHANGED
@@ -154,6 +154,8 @@ export function failures(state) {
154
154
  found.push(`broken images: ${pyReprList(state.brokenImages)}`);
155
155
  if (state.collapsedImages.length)
156
156
  found.push(`images drawn at zero size: ${pyReprList(state.collapsedImages)}`);
157
+ if (state.unloadedImages.length)
158
+ found.push(`images still loading: ${pyReprList(state.unloadedImages)}`);
157
159
  found.push(...(state.footnotes ?? []));
158
160
  if (state.blocked.length)
159
161
  found.push(`external requests: ${pyReprList(state.blocked)}`);
@@ -161,6 +163,21 @@ export function failures(state) {
161
163
  found.push(`page errors: ${pyReprList(state.pageErrors)}`);
162
164
  return found;
163
165
  }
166
+ // Runs in the page before its screenshot. A loading="lazy" image outside the viewport
167
+ // never starts loading in a full-page screenshot, so it would draw as blank space and
168
+ // escape the image checks; NetNewsWire loads it as the reader scrolls, so load every
169
+ // image now and wait (up to timeout ms) for each to finish or fail.
170
+ async function loadImages(timeout) {
171
+ const images = [...document.images];
172
+ for (const image of images)
173
+ if (image.loading === "lazy")
174
+ image.loading = "eager";
175
+ const settled = images.map((image) => image.decode().catch(() => { }));
176
+ await Promise.race([
177
+ Promise.all(settled),
178
+ new Promise((resolve) => setTimeout(resolve, timeout)),
179
+ ]);
180
+ }
164
181
  // Runs in the page after its screenshot.
165
182
  function pageState() {
166
183
  const article = document.querySelector(".articleBody");
@@ -194,6 +211,10 @@ function pageState() {
194
211
  return box.width < 1 || box.height < 1;
195
212
  })
196
213
  .map((image) => image.currentSrc || image.src),
214
+ // Still loading after loadImages: neither drawn nor covered by the checks above.
215
+ unloadedImages: [...document.images]
216
+ .filter((image) => !image.complete)
217
+ .map((image) => image.currentSrc || image.src),
197
218
  };
198
219
  }
199
220
  /**
@@ -260,6 +281,7 @@ async function checkPage(browser, origin, url, target, screenshot, footnotes) {
260
281
  const page = await context.newPage();
261
282
  page.on("pageerror", (error) => pageErrors.push(String(error)));
262
283
  await page.goto(url, { waitUntil: "networkidle" });
284
+ await page.evaluate(loadImages, 10_000);
263
285
  await page.screenshot({ path: screenshot, fullPage: true });
264
286
  const state = await page.evaluate(pageState);
265
287
  // Last: it clicks footnotes open, so it must not disturb the screenshot.
package/dist/render.js CHANGED
@@ -81,7 +81,8 @@ const SCENARIOS = [
81
81
  "Stress test",
82
82
  "The kitchen-sink fixture: a very long headline, byline, and feed name; inline " +
83
83
  "formatting; nested lists; an unbroken identifier that exposes horizontal " +
84
- "overflow; a quotation, code block, table, figure, footnote, and HTML comments in the body.",
84
+ "overflow; a quotation, code block, table, figure, video embeds, footnote, and HTML comments " +
85
+ "in the body.",
85
86
  ],
86
87
  [
87
88
  "large-text",
@@ -295,7 +296,10 @@ function injectScripts(page, inputs, platform) {
295
296
  scripts.push(IOS_LABEL_SHIM);
296
297
  scripts.push(read(join(inputs, "Shared", "main.js")), read(ios ? join(inputs, "iOS", "main_ios.js") : join(inputs, "Mac", "main_mac.js")), read(join(inputs, "Shared", "newsfoot.js")));
297
298
  const bundle = scripts.map((script) => `<script>\n${script}\n</script>`).join("\n");
298
- return page.replace("</body>", () => `${bundle}\n</body>`);
299
+ // The theme and fixture are already substituted into the page, so an earlier
300
+ // </body> can be article text; the skeleton's own closing tag is the last one.
301
+ const end = page.lastIndexOf("</body>");
302
+ return `${page.slice(0, end)}${bundle}\n${page.slice(end)}`;
299
303
  }
300
304
  function isFile(path) {
301
305
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nnw-theme",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "Create, preview, check, and publish a NetNewsWire theme.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Marquard",