nnw-theme 1.0.2 → 1.1.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 CHANGED
@@ -18,7 +18,7 @@ Run it with `npx`, from anywhere inside the theme repository. It needs Node 24 o
18
18
  ```sh
19
19
  npx nnw-theme@1 init # once: name the theme and choose its permanent identifier
20
20
  npx nnw-theme@1 setup # once per machine: install the WebKit build checks use
21
- npx nnw-theme@1 preview # live gallery that rebuilds on save
21
+ npx nnw-theme@1 preview # live gallery that reloads on save
22
22
  npx nnw-theme@1 check # the release gate: package, render, and test every case
23
23
  ```
24
24
 
@@ -30,7 +30,7 @@ version, name it: `npx nnw-theme@1.4.2 check`.
30
30
  | --- | --- |
31
31
  | `init` | Personalize a fresh copy of the template (run once) |
32
32
  | `setup` | Install the WebKit browser that `check` and `screenshot` use (`--with-deps` on Linux CI) |
33
- | `preview` | Serve the gallery on localhost, open it, and rebuild on save |
33
+ | `preview` | Serve the gallery on localhost, open it, and rebuild and reload it on save |
34
34
  | `render [fixtures…]` | Write the gallery to `build/preview/` once |
35
35
  | `check` | Validate and package, then render and test every case in WebKit |
36
36
  | `screenshot` | Check one case and save its image; `--promote` makes it the marketplace card |
@@ -0,0 +1,62 @@
1
+ // Live reload for `nnw-theme preview`. The preview server injects this into the
2
+ // gallery and the full-size views only; rendered theme pages stay exactly as NNW
3
+ // would show them. Each build event carries the build number and, when the latest
4
+ // rebuild failed, its error.
5
+ (() => {
6
+ const events = new EventSource("/__live");
7
+ const style = document.createElement("style");
8
+ // Scroll anchoring keeps the case in view steady while the error banner comes and
9
+ // goes. The banner itself can't be the anchor, and neither can the scaled thumbnail
10
+ // frames: WebKit anchors on them but then doesn't adjust.
11
+ style.textContent = `#nnw-live-error, .thumb iframe { overflow-anchor: none; }
12
+ #nnw-live-error { background: #cf222e; color: #fff; font: .9rem/1.4 system-ui;
13
+ margin: 0 0 1rem; padding: .6rem .9rem; position: sticky; top: 0;
14
+ white-space: pre-wrap; z-index: 1; }
15
+ body:not(:has(.case)) #nnw-live-error { left: 0; margin: 0; position: fixed; right: 0; }`;
16
+ document.head.append(style);
17
+
18
+ let build;
19
+ events.addEventListener("build", (event) => {
20
+ const state = JSON.parse(event.data);
21
+ showError(state.error);
22
+ if (build !== undefined && state.build !== build) refresh();
23
+ build = state.build;
24
+ });
25
+
26
+ function showError(message) {
27
+ let banner = document.getElementById("nnw-live-error");
28
+ if (!message) return banner?.remove();
29
+ if (!banner) {
30
+ banner = document.createElement("div");
31
+ banner.id = "nnw-live-error";
32
+ banner.setAttribute("role", "alert");
33
+ document.body.prepend(banner);
34
+ }
35
+ banner.textContent = `Rebuild failed; showing the last good build.\n${message}`;
36
+ }
37
+
38
+ // Setting src, even to its current value, navigates the frame again.
39
+ const reload = (frames) => {
40
+ for (const frame of frames) frame.setAttribute("src", frame.getAttribute("src"));
41
+ };
42
+
43
+ async function refresh() {
44
+ const cases = (root) => [...root.querySelectorAll(".case")].map((c) => c.id).join();
45
+ if (!document.querySelector(".case")) {
46
+ // A full-size view: reload its page in place.
47
+ reload(document.querySelectorAll("iframe"));
48
+ return;
49
+ }
50
+ // Refresh the thumbnails in place when the cases are unchanged, so nothing
51
+ // moves; a fixture added or removed changes the gallery, so reload it.
52
+ try {
53
+ const response = await fetch(location.pathname, { cache: "no-store" });
54
+ const next = new DOMParser().parseFromString(await response.text(), "text/html");
55
+ if (response.ok && cases(next) === cases(document)) {
56
+ reload(document.querySelectorAll(".case iframe"));
57
+ return;
58
+ }
59
+ } catch {}
60
+ location.reload();
61
+ }
62
+ })();
package/dist/browser.js CHANGED
@@ -10,6 +10,7 @@ import { extname, join, normalize, sep } from "node:path";
10
10
  import { packagePath, ThemeError } from "./project.js";
11
11
  import { pyReprList } from "./pyformat.js";
12
12
  const FOOTNOTE_CHECK = readFileSync(packagePath("assets", "footnotes.js"), "utf8");
13
+ const LIVE_RELOAD = readFileSync(packagePath("assets", "live-reload.js"), "utf8");
13
14
  const NOT_INSTALLED = "WebKit is not installed; run `npx nnw-theme@1 setup`";
14
15
  const CONTENT_TYPES = {
15
16
  ".html": "text/html; charset=utf-8",
@@ -20,9 +21,11 @@ const CONTENT_TYPES = {
20
21
  ".json": "application/json",
21
22
  ".txt": "text/plain; charset=utf-8",
22
23
  };
23
- /** Serve a directory on 127.0.0.1 at a free port. */
24
- export async function serve(directory, port = 0) {
24
+ export async function serve(directory, { live = false } = {}) {
25
25
  const root = normalize(directory);
26
+ const listeners = new Set();
27
+ let state = { build: 0 };
28
+ const send = (response) => response.write(`event: build\ndata: ${JSON.stringify(state)}\n\n`);
26
29
  const server = createServer((request, response) => {
27
30
  let path;
28
31
  try {
@@ -32,6 +35,16 @@ export async function serve(directory, port = 0) {
32
35
  response.writeHead(400).end();
33
36
  return;
34
37
  }
38
+ if (live && path === "/__live" && request.method === "GET") {
39
+ response.writeHead(200, {
40
+ "Content-Type": "text/event-stream",
41
+ "Cache-Control": "no-store",
42
+ });
43
+ listeners.add(response);
44
+ response.once("close", () => listeners.delete(response));
45
+ send(response);
46
+ return;
47
+ }
35
48
  if (path.endsWith("/"))
36
49
  path += "index.html";
37
50
  const file = normalize(join(root, path));
@@ -48,6 +61,12 @@ export async function serve(directory, port = 0) {
48
61
  response.writeHead(404).end();
49
62
  return;
50
63
  }
64
+ if (live && extname(file) === ".html" && !file.startsWith(join(root, "pages") + sep)) {
65
+ const html = content.toString("utf8");
66
+ const at = html.lastIndexOf("</body>");
67
+ const script = `<script>${LIVE_RELOAD}</script>`;
68
+ content = Buffer.from(at === -1 ? html + script : html.slice(0, at) + script + html.slice(at), "utf8");
69
+ }
51
70
  response.writeHead(200, {
52
71
  "Content-Type": CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream",
53
72
  "Content-Length": content.length,
@@ -57,7 +76,7 @@ export async function serve(directory, port = 0) {
57
76
  });
58
77
  await new Promise((resolve, reject) => {
59
78
  server.once("error", reject);
60
- server.listen(port, "127.0.0.1", resolve);
79
+ server.listen(0, "127.0.0.1", resolve);
61
80
  });
62
81
  const address = server.address();
63
82
  if (!address || typeof address === "string")
@@ -68,6 +87,11 @@ export async function serve(directory, port = 0) {
68
87
  server.closeAllConnections();
69
88
  server.close(() => resolve());
70
89
  }),
90
+ publish(error) {
91
+ state = error === undefined ? { build: state.build + 1 } : { ...state, error };
92
+ for (const listener of listeners)
93
+ send(listener);
94
+ },
71
95
  };
72
96
  }
73
97
  async function playwright() {
@@ -1,6 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
3
+ import { join, relative } from "node:path";
4
4
  import { setupWebkit } from "../browser.js";
5
5
  import { interactive, promptConfirm, promptText } from "../interactive.js";
6
6
  import { findRoot, findTheme, IDENTITY_END, IDENTITY_START, PLACEHOLDER_MARKER, readPlist, ThemeError, writePlist, } from "../project.js";
@@ -168,5 +168,5 @@ export default async function init({ values }) {
168
168
  "Finish preview setup later with `npx nnw-theme@1 setup`.");
169
169
  }
170
170
  }
171
- console.log("Next: describe the design you want, then run `npx nnw-theme@1 preview`.");
171
+ console.log(`Next: work on your design in ${relative(process.cwd(), theme) || "."}, then run \`npx nnw-theme@1 preview\`.`);
172
172
  }
@@ -17,11 +17,11 @@ export default async function preview({ values }) {
17
17
  const theme = findTheme(root);
18
18
  const build = () => renderSite(root, theme, normalTargets(extraFixtures(root)));
19
19
  const site = build();
20
- const server = await serve(site);
20
+ const server = await serve(site, { live: true });
21
21
  console.log(`Preview: ${server.url}`);
22
22
  if (!values["no-open"])
23
23
  openInBrowser(server.url);
24
- console.log("Watching theme and fixtures. Press Ctrl-C to stop.");
24
+ console.log("Watching theme and fixtures; open pages reload on changes. Press Ctrl-C to stop.");
25
25
  let timer;
26
26
  const watcher = watch(root, { recursive: true }, (_event, name) => {
27
27
  if (!name || !watched(root, `${root}${sep}${name}`))
@@ -30,10 +30,13 @@ export default async function preview({ values }) {
30
30
  timer = setTimeout(() => {
31
31
  try {
32
32
  build();
33
+ server.publish();
33
34
  console.log("Rebuilt preview.");
34
35
  }
35
36
  catch (error) {
36
- console.error(`error: ${error.message}`);
37
+ const { message } = error;
38
+ server.publish(message);
39
+ console.error(`error: ${message}`);
37
40
  }
38
41
  }, 200);
39
42
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nnw-theme",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Create, preview, check, and publish a NetNewsWire theme.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Marquard",