nnw-theme 0.0.0 → 0.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/LICENSE +201 -0
- package/README.md +75 -1
- package/THIRD_PARTY_NOTICES.md +14 -0
- package/assets/fixtures/article.toml +24 -0
- package/assets/fixtures/kitchen-sink.toml +36 -0
- package/assets/footnotes.js +174 -0
- package/assets/guide/design-checklist.md +19 -0
- package/assets/guide/fixtures.md +42 -0
- package/assets/guide/publishing.md +29 -0
- package/assets/guide/skill.md +44 -0
- package/assets/guide/theme-format.md +52 -0
- package/assets/netnewswire/LICENSE +21 -0
- package/assets/netnewswire/Mac/main_mac.js +43 -0
- package/assets/netnewswire/Mac/page.html +12 -0
- package/assets/netnewswire/Shared/core.css +186 -0
- package/assets/netnewswire/Shared/main.js +221 -0
- package/assets/netnewswire/Shared/newsfoot.js +173 -0
- package/assets/netnewswire/iOS/main_ios.js +520 -0
- package/assets/netnewswire/iOS/page.html +19 -0
- package/assets/netnewswire/netnewswire.json +46 -0
- package/assets/stubs/.agents/skills/creating-nnw-themes/SKILL.md +22 -0
- package/assets/stubs/.agents/skills/creating-nnw-themes/agents/openai.yaml +4 -0
- package/assets/stubs/.github/workflows/check.yml +14 -0
- package/assets/stubs/.github/workflows/pages.yml +24 -0
- package/assets/stubs/.github/workflows/release.yml +24 -0
- package/assets/stubs/.github/workflows/screenshot.yml +27 -0
- package/assets/stubs/AGENTS.md +24 -0
- package/dist/browser.js +227 -0
- package/dist/cli.js +3 -0
- package/dist/commands/bump.js +23 -0
- package/dist/commands/capture.js +26 -0
- package/dist/commands/check.js +67 -0
- package/dist/commands/completion.js +8 -0
- package/dist/commands/guide.js +9 -0
- package/dist/commands/init.js +155 -0
- package/dist/commands/marketplace.js +22 -0
- package/dist/commands/package.js +16 -0
- package/dist/commands/preview.js +49 -0
- package/dist/commands/progress.js +31 -0
- package/dist/commands/release-check.js +45 -0
- package/dist/commands/render.js +17 -0
- package/dist/commands/screenshot.js +36 -0
- package/dist/commands/setup.js +4 -0
- package/dist/commands/update.js +7 -0
- package/dist/commands.js +175 -0
- package/dist/completion.js +203 -0
- package/dist/interactive.js +32 -0
- package/dist/main.js +229 -0
- package/dist/netnewswire.js +84 -0
- package/dist/package.js +28 -0
- package/dist/plist.js +175 -0
- package/dist/project.js +195 -0
- package/dist/pyformat.js +82 -0
- package/dist/render.js +516 -0
- package/dist/stubs.js +49 -0
- package/dist/urlparse.js +32 -0
- package/dist/validate.js +259 -0
- package/dist/zip.js +72 -0
- package/lldb/nnwdump.py +151 -0
- package/package.json +55 -2
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"release": "mac-7.1.3",
|
|
3
|
+
"commit": "f2daca0f24f29c08d9cb16e1bdc56293fd6fe286",
|
|
4
|
+
"files": [
|
|
5
|
+
{
|
|
6
|
+
"destination": "LICENSE",
|
|
7
|
+
"source": "LICENSE",
|
|
8
|
+
"sha256": "712d3c84a79fa1dd7e049341797dd638f9517acfe503bb531fb7f0e18cfa1b43"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"destination": "Mac/main_mac.js",
|
|
12
|
+
"source": "Mac/MainWindow/Detail/main_mac.js",
|
|
13
|
+
"sha256": "3ac1ef46523f6c4d8103fe7dbe6f45486d57a94c3d54d0fcb6211a35215bb676"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"destination": "Mac/page.html",
|
|
17
|
+
"source": "Mac/MainWindow/Detail/page.html",
|
|
18
|
+
"sha256": "51518b58d5b320355b7e4dbf76484ba27ebf81946e963cb25f0c9821e0c9804b"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"destination": "Shared/core.css",
|
|
22
|
+
"source": "Shared/Article Rendering/core.css",
|
|
23
|
+
"sha256": "5cd07a57d6511d9e043aaaf4dc6cd544da18b9733725375928edb9b11ae96e6b"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"destination": "Shared/main.js",
|
|
27
|
+
"source": "Shared/Article Rendering/main.js",
|
|
28
|
+
"sha256": "7a229f330ca4adc7043803f044776f36fb086483d236e39947ce46a0909f7329"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"destination": "Shared/newsfoot.js",
|
|
32
|
+
"source": "Shared/Article Rendering/newsfoot.js",
|
|
33
|
+
"sha256": "6a921854ef81214eebb983314023a20417e76c64eb78e2c4742815744476ae4f"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"destination": "iOS/main_ios.js",
|
|
37
|
+
"source": "iOS/Resources/main_ios.js",
|
|
38
|
+
"sha256": "e764183ea8e9ce75df13d268834741b176afdb440eb492efeaba8ccabd7dfb8b"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"destination": "iOS/page.html",
|
|
42
|
+
"source": "iOS/Resources/page.html",
|
|
43
|
+
"sha256": "c5470f24972ac03d0c9256a6c7dcca4344094e3b7bc2b4a5164321634a966279"
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: creating-nnw-themes
|
|
3
|
+
description: Create, restyle, preview, test, package, or publish the NetNewsWire theme in this repository. Do not use for generic web design or NetNewsWire app development.
|
|
4
|
+
---
|
|
5
|
+
<!-- nnw-theme-stub: skill v1 -->
|
|
6
|
+
|
|
7
|
+
# Create NetNewsWire themes
|
|
8
|
+
|
|
9
|
+
Read the full guide first: `npx nnw-theme@1 guide`. Details live in its topics
|
|
10
|
+
(`npx nnw-theme@1 guide theme-format`, `fixtures`, `design-checklist`, `publishing`),
|
|
11
|
+
which update with the tool.
|
|
12
|
+
|
|
13
|
+
1. If `.nnw-theme-uninitialized` exists, run `npx nnw-theme@1 init` and let the person
|
|
14
|
+
approve the permanent theme identifier.
|
|
15
|
+
2. Collect a compact design brief: mood, typography, color, reading needs.
|
|
16
|
+
3. Edit `stylesheet.css` first; change `template.html` only when structure requires it.
|
|
17
|
+
4. Run `npx nnw-theme@1 render` and inspect `build/preview/` in light and dark on each
|
|
18
|
+
device; explain the change and take the person's reaction.
|
|
19
|
+
5. Finish with `npx nnw-theme@1 check` and give the person the preview path.
|
|
20
|
+
|
|
21
|
+
Never change the bundle name or `ThemeIdentifier` after the first release. Confirm
|
|
22
|
+
with the person immediately before any release or other GitHub mutation.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# nnw-theme-stub: check v1
|
|
2
|
+
# Runs the nnw-theme release gate: https://github.com/dave-atx/nnw-theme
|
|
3
|
+
name: Theme checks
|
|
4
|
+
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
pull_request:
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
check:
|
|
14
|
+
uses: dave-atx/nnw-theme/.github/workflows/theme-check.yml@v1
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# nnw-theme-stub: pages v1
|
|
2
|
+
# Publishes the checked preview gallery when Pages builds from GitHub Actions.
|
|
3
|
+
name: Publish preview pages
|
|
4
|
+
|
|
5
|
+
on:
|
|
6
|
+
workflow_run:
|
|
7
|
+
workflows: [Theme checks]
|
|
8
|
+
types: [completed]
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
actions: read
|
|
13
|
+
pages: write
|
|
14
|
+
id-token: write
|
|
15
|
+
|
|
16
|
+
# One group per branch or tag: a tag pushed with main triggers a second run, which
|
|
17
|
+
# skips its deploy and must not cancel main's.
|
|
18
|
+
concurrency:
|
|
19
|
+
group: pages-${{ github.event.workflow_run.head_branch }}
|
|
20
|
+
cancel-in-progress: true
|
|
21
|
+
|
|
22
|
+
jobs:
|
|
23
|
+
pages:
|
|
24
|
+
uses: dave-atx/nnw-theme/.github/workflows/theme-pages.yml@v1
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# nnw-theme-stub: release v1
|
|
2
|
+
name: Publish theme
|
|
3
|
+
|
|
4
|
+
on:
|
|
5
|
+
workflow_dispatch:
|
|
6
|
+
inputs:
|
|
7
|
+
tag:
|
|
8
|
+
description: Release tag beginning with v (for example, v1.0.0)
|
|
9
|
+
required: true
|
|
10
|
+
type: string
|
|
11
|
+
notes:
|
|
12
|
+
description: Optional release notes (default generated by git-cliff from .github/cliff.toml, if present)
|
|
13
|
+
required: false
|
|
14
|
+
type: string
|
|
15
|
+
|
|
16
|
+
permissions:
|
|
17
|
+
contents: write
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
release:
|
|
21
|
+
uses: dave-atx/nnw-theme/.github/workflows/theme-release.yml@v1
|
|
22
|
+
with:
|
|
23
|
+
tag: ${{ inputs.tag }}
|
|
24
|
+
notes: ${{ inputs.notes }}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# nnw-theme-stub: screenshot v1
|
|
2
|
+
name: Refresh marketplace screenshot
|
|
3
|
+
|
|
4
|
+
on:
|
|
5
|
+
workflow_dispatch:
|
|
6
|
+
inputs:
|
|
7
|
+
fixture:
|
|
8
|
+
description: Fixture to capture
|
|
9
|
+
type: choice
|
|
10
|
+
options: [article, kitchen-sink]
|
|
11
|
+
default: article
|
|
12
|
+
appearance:
|
|
13
|
+
description: Appearance to capture
|
|
14
|
+
type: choice
|
|
15
|
+
options: [light, dark]
|
|
16
|
+
default: light
|
|
17
|
+
|
|
18
|
+
permissions:
|
|
19
|
+
contents: write
|
|
20
|
+
pull-requests: write
|
|
21
|
+
|
|
22
|
+
jobs:
|
|
23
|
+
screenshot:
|
|
24
|
+
uses: dave-atx/nnw-theme/.github/workflows/theme-screenshot.yml@v1
|
|
25
|
+
with:
|
|
26
|
+
fixture: ${{ inputs.fixture }}
|
|
27
|
+
appearance: ${{ inputs.appearance }}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<!-- nnw-theme-stub: agents v1 -->
|
|
2
|
+
# Repository guidance
|
|
3
|
+
|
|
4
|
+
This repository is one NetNewsWire theme: the single root `*.nnwtheme` bundle,
|
|
5
|
+
its `fixtures/`, and `screenshots/`. The tooling is the `nnw-theme` npm package;
|
|
6
|
+
run it as `npx nnw-theme@1 <command>`. Never install it globally and never add
|
|
7
|
+
it to this repository. Read `npx nnw-theme@1 guide` before theme work.
|
|
8
|
+
|
|
9
|
+
If `.nnw-theme-uninitialized` exists, run `npx nnw-theme@1 init` first and let
|
|
10
|
+
the user confirm the permanent theme identifier. Prefer CSS changes; change
|
|
11
|
+
`template.html` only when the requested structure or behavior requires it.
|
|
12
|
+
Finish theme work with `npx nnw-theme@1 check` and report the preview path.
|
|
13
|
+
|
|
14
|
+
Do not change `ThemeIdentifier` or the `.nnwtheme` bundle name after the first
|
|
15
|
+
release. Increase the plist `Version` (`npx nnw-theme@1 bump`) before every
|
|
16
|
+
release.
|
|
17
|
+
|
|
18
|
+
If the tooling misbehaves, do not work around it here: report it at
|
|
19
|
+
https://github.com/dave-atx/nnw-theme/issues with the `check` version line.
|
|
20
|
+
|
|
21
|
+
Local edits, renders, checks and packages are safe. Creating a GitHub release,
|
|
22
|
+
opening a pull request, changing repository topics, or otherwise mutating
|
|
23
|
+
GitHub requires the user's explicit intent. Theme and fixture HTML/JavaScript
|
|
24
|
+
are untrusted executable inputs; browser checks stay loopback-only.
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// WebKit checks through the Playwright library: one browser per run, a fresh context
|
|
2
|
+
// per case. Theme and fixture HTML/JavaScript are untrusted: pages are served from
|
|
3
|
+
// 127.0.0.1 only, every other request is blocked and fails the case, and the only
|
|
4
|
+
// outputs are screenshots and results.
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import { extname, join, normalize, sep } from "node:path";
|
|
10
|
+
import { packagePath, ThemeError } from "./project.js";
|
|
11
|
+
import { pyReprList } from "./pyformat.js";
|
|
12
|
+
const FOOTNOTE_CHECK = readFileSync(packagePath("assets", "footnotes.js"), "utf8");
|
|
13
|
+
const NOT_INSTALLED = "WebKit is not installed; run `npx nnw-theme@1 setup`";
|
|
14
|
+
const CONTENT_TYPES = {
|
|
15
|
+
".html": "text/html; charset=utf-8",
|
|
16
|
+
".png": "image/png",
|
|
17
|
+
".css": "text/css; charset=utf-8",
|
|
18
|
+
".js": "text/javascript; charset=utf-8",
|
|
19
|
+
".svg": "image/svg+xml",
|
|
20
|
+
".json": "application/json",
|
|
21
|
+
".txt": "text/plain; charset=utf-8",
|
|
22
|
+
};
|
|
23
|
+
/** Serve a directory on 127.0.0.1 at a free port. */
|
|
24
|
+
export async function serve(directory, port = 0) {
|
|
25
|
+
const root = normalize(directory);
|
|
26
|
+
const server = createServer((request, response) => {
|
|
27
|
+
let path;
|
|
28
|
+
try {
|
|
29
|
+
path = decodeURIComponent(new URL(request.url ?? "/", "http://127.0.0.1").pathname);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
response.writeHead(400).end();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (path.endsWith("/"))
|
|
36
|
+
path += "index.html";
|
|
37
|
+
const file = normalize(join(root, path));
|
|
38
|
+
if (!file.startsWith(root + sep) ||
|
|
39
|
+
(request.method !== "GET" && request.method !== "HEAD")) {
|
|
40
|
+
response.writeHead(404).end();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
let content;
|
|
44
|
+
try {
|
|
45
|
+
content = readFileSync(file);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
response.writeHead(404).end();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
response.writeHead(200, {
|
|
52
|
+
"Content-Type": CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream",
|
|
53
|
+
"Content-Length": content.length,
|
|
54
|
+
"Cache-Control": "no-store",
|
|
55
|
+
});
|
|
56
|
+
response.end(request.method === "HEAD" ? undefined : content);
|
|
57
|
+
});
|
|
58
|
+
await new Promise((resolve, reject) => {
|
|
59
|
+
server.once("error", reject);
|
|
60
|
+
server.listen(port, "127.0.0.1", resolve);
|
|
61
|
+
});
|
|
62
|
+
const address = server.address();
|
|
63
|
+
if (!address || typeof address === "string")
|
|
64
|
+
throw new Error("server has no port");
|
|
65
|
+
return {
|
|
66
|
+
url: `http://127.0.0.1:${address.port}`,
|
|
67
|
+
close: () => new Promise((resolve) => {
|
|
68
|
+
server.closeAllConnections();
|
|
69
|
+
server.close(() => resolve());
|
|
70
|
+
}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
async function playwright() {
|
|
74
|
+
return await import("playwright-core");
|
|
75
|
+
}
|
|
76
|
+
/** Whether this version's WebKit build is installed. */
|
|
77
|
+
export async function webkitInstalled() {
|
|
78
|
+
const { webkit } = await playwright();
|
|
79
|
+
return existsSync(webkit.executablePath());
|
|
80
|
+
}
|
|
81
|
+
async function launch() {
|
|
82
|
+
const { webkit } = await playwright();
|
|
83
|
+
if (!existsSync(webkit.executablePath()))
|
|
84
|
+
throw new ThemeError(NOT_INSTALLED);
|
|
85
|
+
try {
|
|
86
|
+
return await webkit.launch();
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
const message = error.message;
|
|
90
|
+
if (/Executable doesn't exist|install/i.test(message) && !/dependencies/i.test(message)) {
|
|
91
|
+
throw new ThemeError(NOT_INSTALLED);
|
|
92
|
+
}
|
|
93
|
+
throw new ThemeError(`could not start WebKit: ${message.split("\n")[0]}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Install this version's WebKit build (and, withDeps, its Linux system libraries). */
|
|
97
|
+
export async function setupWebkit({ withDeps = false } = {}) {
|
|
98
|
+
if (!withDeps && (await webkitInstalled())) {
|
|
99
|
+
try {
|
|
100
|
+
await (await launch()).close();
|
|
101
|
+
console.log("WebKit is already installed.");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// Installed but not runnable: reinstall below.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
console.log(withDeps
|
|
109
|
+
? "Installing WebKit and its Linux runtime dependencies…"
|
|
110
|
+
: "Installing the WebKit browser used by theme checks…");
|
|
111
|
+
const cli = join(createRequire(import.meta.url).resolve("playwright-core"), "..", "cli.js");
|
|
112
|
+
const args = [cli, "install", ...(withDeps ? ["--with-deps"] : []), "webkit"];
|
|
113
|
+
const status = await new Promise((resolve, reject) => {
|
|
114
|
+
const child = spawn(process.execPath, args, { stdio: "inherit" });
|
|
115
|
+
child.once("error", reject);
|
|
116
|
+
child.once("exit", resolve);
|
|
117
|
+
});
|
|
118
|
+
if (status !== 0)
|
|
119
|
+
throw new ThemeError("WebKit installation failed; see the output above");
|
|
120
|
+
}
|
|
121
|
+
export function failures(state) {
|
|
122
|
+
const found = [];
|
|
123
|
+
if (!state.article || state.textLength < 40)
|
|
124
|
+
found.push("article content is missing or unreadable");
|
|
125
|
+
if (state.unresolved)
|
|
126
|
+
found.push("unresolved theme macro");
|
|
127
|
+
if (state.overflow)
|
|
128
|
+
found.push("horizontal document overflow");
|
|
129
|
+
if (state.brokenImages.length)
|
|
130
|
+
found.push(`broken images: ${pyReprList(state.brokenImages)}`);
|
|
131
|
+
found.push(...(state.footnotes ?? []));
|
|
132
|
+
if (state.blocked.length)
|
|
133
|
+
found.push(`external requests: ${pyReprList(state.blocked)}`);
|
|
134
|
+
if (state.pageErrors.length)
|
|
135
|
+
found.push(`page errors: ${pyReprList(state.pageErrors)}`);
|
|
136
|
+
return found;
|
|
137
|
+
}
|
|
138
|
+
// Runs in the page after its screenshot.
|
|
139
|
+
function pageState() {
|
|
140
|
+
const article = document.querySelector(".articleBody");
|
|
141
|
+
const root = document.documentElement;
|
|
142
|
+
return {
|
|
143
|
+
article: Boolean(article),
|
|
144
|
+
textLength: (article?.innerText || "").trim().length,
|
|
145
|
+
// Body only: macOS CSS legitimately keeps the font-size macro literal. Match
|
|
146
|
+
// whole macro names, so a theme script's regex such as /[[(]/ is no macro.
|
|
147
|
+
unresolved: /\[\[[A-Za-z0-9_-]+\]\]/.test(document.body.innerHTML),
|
|
148
|
+
overflow: root.scrollWidth > root.clientWidth + 1,
|
|
149
|
+
brokenImages: [...document.images]
|
|
150
|
+
.filter((image) => image.complete && image.naturalWidth === 0)
|
|
151
|
+
.map((image) => image.currentSrc || image.src),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Each target's slug mapped to its failures (empty when it passed).
|
|
156
|
+
*
|
|
157
|
+
* expectations maps a fixture name to its [expect.footnotes] table. They describe
|
|
158
|
+
* the theme's own footnote handling, so they are skipped with theme scripts off.
|
|
159
|
+
*/
|
|
160
|
+
export async function checkPages(site, targets, progress, expectations = {}) {
|
|
161
|
+
const results = {};
|
|
162
|
+
const screenshots = join(site, "screenshots");
|
|
163
|
+
mkdirSync(screenshots, { recursive: true });
|
|
164
|
+
const server = await serve(site);
|
|
165
|
+
try {
|
|
166
|
+
progress?.status("Starting WebKit…");
|
|
167
|
+
const browser = await launch();
|
|
168
|
+
try {
|
|
169
|
+
for (const [offset, target] of targets.entries()) {
|
|
170
|
+
const index = offset + 1;
|
|
171
|
+
progress?.start(index, target);
|
|
172
|
+
const url = `${server.url}/pages/${encodeURIComponent(target.slug)}.html`;
|
|
173
|
+
const footnotes = target.themeScripts ? (expectations[target.fixture] ?? {}) : {};
|
|
174
|
+
const state = await checkPage(browser, server.url, url, target, join(screenshots, `${target.slug}.png`), footnotes);
|
|
175
|
+
const found = failures(state);
|
|
176
|
+
results[target.slug] = found;
|
|
177
|
+
progress?.finish(index, target, found);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
await browser.close();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
await server.close();
|
|
186
|
+
}
|
|
187
|
+
return results;
|
|
188
|
+
}
|
|
189
|
+
async function checkPage(browser, origin, url, target, screenshot, footnotes) {
|
|
190
|
+
const context = await browser.newContext({
|
|
191
|
+
viewport: { width: target.width, height: target.height },
|
|
192
|
+
colorScheme: target.appearance,
|
|
193
|
+
serviceWorkers: "block",
|
|
194
|
+
});
|
|
195
|
+
try {
|
|
196
|
+
const blocked = [];
|
|
197
|
+
const pageErrors = [];
|
|
198
|
+
await context.route("**/*", async (route) => {
|
|
199
|
+
const requestURL = route.request().url();
|
|
200
|
+
if (requestURL.startsWith("data:"))
|
|
201
|
+
return route.continue();
|
|
202
|
+
let requestOrigin = null;
|
|
203
|
+
try {
|
|
204
|
+
requestOrigin = new URL(requestURL).origin;
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// Not a URL with an origin: blocked below.
|
|
208
|
+
}
|
|
209
|
+
// Whole-origin match: a prefix test would accept 127.0.0.1:PORT.example.net.
|
|
210
|
+
if (requestOrigin === origin)
|
|
211
|
+
return route.continue();
|
|
212
|
+
blocked.push(requestURL);
|
|
213
|
+
return route.abort("blockedbyclient");
|
|
214
|
+
});
|
|
215
|
+
const page = await context.newPage();
|
|
216
|
+
page.on("pageerror", (error) => pageErrors.push(String(error)));
|
|
217
|
+
await page.goto(url, { waitUntil: "networkidle" });
|
|
218
|
+
await page.screenshot({ path: screenshot, fullPage: true });
|
|
219
|
+
const state = await page.evaluate(pageState);
|
|
220
|
+
// Last: it clicks footnotes open, so it must not disturb the screenshot.
|
|
221
|
+
const found = (await page.evaluate(`(${FOOTNOTE_CHECK})(${JSON.stringify(footnotes)})`));
|
|
222
|
+
return { ...state, footnotes: found, blocked, pageErrors };
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
await context.close();
|
|
226
|
+
}
|
|
227
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { interactive, promptConfirm } from "../interactive.js";
|
|
3
|
+
import { findRoot, findTheme, readPlist, ThemeError, writePlist } from "../project.js";
|
|
4
|
+
export default async function bump({ values }) {
|
|
5
|
+
const root = findRoot();
|
|
6
|
+
const theme = findTheme(root);
|
|
7
|
+
const metadata = readPlist(theme);
|
|
8
|
+
const current = metadata.Version;
|
|
9
|
+
if (typeof current !== "number" || !Number.isInteger(current)) {
|
|
10
|
+
throw new ThemeError("Info.plist Version must be an integer");
|
|
11
|
+
}
|
|
12
|
+
const proposed = current + 1;
|
|
13
|
+
if (!values.yes) {
|
|
14
|
+
if (!interactive())
|
|
15
|
+
throw new ThemeError("pass --yes to increase Version without a terminal");
|
|
16
|
+
if (!(await promptConfirm(`Increase Version from ${current} to ${proposed}?`))) {
|
|
17
|
+
throw new ThemeError("version bump cancelled");
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
metadata.Version = proposed;
|
|
21
|
+
writePlist(join(theme, "Info.plist"), metadata);
|
|
22
|
+
console.log(`Version is now ${proposed}. Commit this change before publishing.`);
|
|
23
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { findRoot, packagePath } from "../project.js";
|
|
3
|
+
export default function capture() {
|
|
4
|
+
const script = packagePath("lldb", "nnwdump.py");
|
|
5
|
+
const output = join(findRoot(), "fixtures", "my-article.toml");
|
|
6
|
+
console.log(`Capture a real article as a fixture from a NetNewsWire debug build (needs Xcode).
|
|
7
|
+
|
|
8
|
+
1. Clone https://github.com/Ranchero-Software/NetNewsWire and open it in Xcode.
|
|
9
|
+
In Shared/Article Rendering/ArticleRenderer.swift, set a breakpoint on the
|
|
10
|
+
\`return d\` line at the end of articleSubstitutions(), then run the app.
|
|
11
|
+
2. Select the article to capture. When the breakpoint stops, load the command
|
|
12
|
+
in the debugger console (once per debug session):
|
|
13
|
+
|
|
14
|
+
command script import ${script}
|
|
15
|
+
|
|
16
|
+
3. Write the fixture, then let the app continue:
|
|
17
|
+
|
|
18
|
+
nnwdump ${output}
|
|
19
|
+
continue
|
|
20
|
+
|
|
21
|
+
4. Preview it with \`npx nnw-theme@1 render my-article\`.
|
|
22
|
+
|
|
23
|
+
nnwdump embeds the feed's real icon; pass --no-icon to keep the generated one.
|
|
24
|
+
The debugger's working directory is /, so give nnwdump an absolute path.
|
|
25
|
+
The script lives in the npx cache; rerun capture if that path stops working.`);
|
|
26
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { checkPages } from "../browser.js";
|
|
5
|
+
import { interactive, openInBrowser, promptConfirm } from "../interactive.js";
|
|
6
|
+
import { version } from "../main.js";
|
|
7
|
+
import { buildArchive } from "../package.js";
|
|
8
|
+
import { findRoot, findTheme, fixturePath, footnoteExpectations, readFixture, ThemeError, themeStem, } from "../project.js";
|
|
9
|
+
import { checkTargets, extraFixtures, renderSite, writeGallery, } from "../render.js";
|
|
10
|
+
import { staleStubs } from "../stubs.js";
|
|
11
|
+
import { printWarnings } from "./package.js";
|
|
12
|
+
import { CheckProgress } from "./progress.js";
|
|
13
|
+
export function expectations(root, targets) {
|
|
14
|
+
const result = {};
|
|
15
|
+
for (const name of new Set(targets.map((target) => target.fixture))) {
|
|
16
|
+
result[name] = footnoteExpectations(readFixture(fixturePath(root, name)), `fixtures/${name}.toml`);
|
|
17
|
+
}
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
async function offerToOpen(index, choice) {
|
|
21
|
+
const open = choice ?? (interactive() && (await promptConfirm("Open the preview?", true)));
|
|
22
|
+
if (open)
|
|
23
|
+
openInBrowser(pathToFileURL(index).href);
|
|
24
|
+
}
|
|
25
|
+
export default async function check({ values }) {
|
|
26
|
+
const root = findRoot();
|
|
27
|
+
const theme = findTheme(root);
|
|
28
|
+
console.log(`nnw-theme ${version()}`);
|
|
29
|
+
printWarnings(staleStubs(root));
|
|
30
|
+
const targets = checkTargets(extraFixtures(root));
|
|
31
|
+
const expected = expectations(root, targets);
|
|
32
|
+
const progress = new CheckProgress(targets.length);
|
|
33
|
+
progress.status("Validating and packaging the theme…");
|
|
34
|
+
const archive = buildArchive(theme, join(root, "build", "release"), {
|
|
35
|
+
allowRemoteMedia: values["allow-remote-media"] === true,
|
|
36
|
+
});
|
|
37
|
+
progress.done();
|
|
38
|
+
printWarnings(archive.warnings);
|
|
39
|
+
progress.status(`Rendering ${targets.length} pages…`);
|
|
40
|
+
const site = renderSite(root, theme, targets);
|
|
41
|
+
let results;
|
|
42
|
+
try {
|
|
43
|
+
results = await checkPages(site, targets, progress, expected);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
progress.done();
|
|
47
|
+
}
|
|
48
|
+
writeGallery(site, themeStem(theme), targets, results);
|
|
49
|
+
const failures = targets.flatMap((target) => (results[target.slug] ?? []).map((message) => `${target.label}: ${message}`));
|
|
50
|
+
const passed = targets.filter((target) => !results[target.slug]?.length).length;
|
|
51
|
+
const packageName = basename(archive.path);
|
|
52
|
+
const footer = `nnw-theme ${version()}\n`;
|
|
53
|
+
const report = join(root, "build", "check-report.txt");
|
|
54
|
+
if (failures.length) {
|
|
55
|
+
writeFileSync(report, `FAIL\n${failures.join("\n")}\n${footer}`, "utf8");
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
writeFileSync(report, `PASS\n${targets.length} WebKit renders checked\nPackage: ${packageName}\n${footer}`, "utf8");
|
|
59
|
+
console.log(`PASS: ${targets.length} WebKit renders and ${packageName}`);
|
|
60
|
+
}
|
|
61
|
+
const index = join(site, "index.html");
|
|
62
|
+
console.log(`Preview: ${index}`);
|
|
63
|
+
await offerToOpen(index, values.open);
|
|
64
|
+
if (failures.length) {
|
|
65
|
+
throw new ThemeError(`${targets.length - passed} of ${targets.length} WebKit renders failed:\n- ${failures.join("\n- ")}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { SCRIPTS } from "../completion.js";
|
|
2
|
+
import { ThemeError } from "../project.js";
|
|
3
|
+
export default function completion({ positionals }) {
|
|
4
|
+
const shell = positionals[0];
|
|
5
|
+
if (!shell)
|
|
6
|
+
throw new ThemeError("name a shell: fish, zsh, or bash");
|
|
7
|
+
process.stdout.write(SCRIPTS[shell]());
|
|
8
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { GUIDE_TOPICS } from "../commands.js";
|
|
3
|
+
import { packagePath } from "../project.js";
|
|
4
|
+
export function guideText(topic = GUIDE_TOPICS[0]) {
|
|
5
|
+
return readFileSync(packagePath("assets", "guide", `${topic}.md`), "utf8");
|
|
6
|
+
}
|
|
7
|
+
export default function guide({ positionals }) {
|
|
8
|
+
process.stdout.write(guideText(positionals[0]));
|
|
9
|
+
}
|