nnw-theme 0.0.0 → 1.0.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 +34 -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,155 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { setupWebkit } from "../browser.js";
|
|
5
|
+
import { interactive, promptConfirm, promptText } from "../interactive.js";
|
|
6
|
+
import { findRoot, findTheme, IDENTITY_END, IDENTITY_START, PLACEHOLDER_MARKER, readPlist, ThemeError, writePlist, } from "../project.js";
|
|
7
|
+
import { pyRepr } from "../pyformat.js";
|
|
8
|
+
import { urlsplit } from "../urlparse.js";
|
|
9
|
+
import { marketplaceEnable } from "./marketplace.js";
|
|
10
|
+
function slug(value) {
|
|
11
|
+
return value
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
14
|
+
.replace(/-+/g, "-")
|
|
15
|
+
.replace(/^-+|-+$/g, "");
|
|
16
|
+
}
|
|
17
|
+
function identifierSlug(value) {
|
|
18
|
+
return slug(value).replace(/[^a-z0-9-]+/g, "") || "theme";
|
|
19
|
+
}
|
|
20
|
+
/** Owner and fork status of the current repository, when gh can report them. */
|
|
21
|
+
function repository() {
|
|
22
|
+
const result = spawnSync("gh", ["repo", "view", "--json", "owner,isFork"], {
|
|
23
|
+
encoding: "utf8",
|
|
24
|
+
});
|
|
25
|
+
if (result.error || result.status)
|
|
26
|
+
return {};
|
|
27
|
+
try {
|
|
28
|
+
const value = JSON.parse(result.stdout);
|
|
29
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function defaultIdentifier(name, homepage, githubUser) {
|
|
36
|
+
const suffix = identifierSlug(name);
|
|
37
|
+
if (githubUser)
|
|
38
|
+
return `io.github.${identifierSlug(githubUser)}.${suffix}`;
|
|
39
|
+
const hostname = (urlsplit(homepage).hostname ?? "example.com").toLowerCase().split(".");
|
|
40
|
+
const domain = hostname.map(identifierSlug).reverse().join(".");
|
|
41
|
+
return `${domain}.${suffix}`;
|
|
42
|
+
}
|
|
43
|
+
export function absoluteHomepage(value) {
|
|
44
|
+
const parsed = urlsplit(value);
|
|
45
|
+
if (!["http", "https"].includes(parsed.scheme) || !parsed.netloc) {
|
|
46
|
+
throw new ThemeError("creator home page must be an absolute HTTP(S) URL");
|
|
47
|
+
}
|
|
48
|
+
if (parsed.hostname === "example.com" || parsed.hostname === "www.example.com") {
|
|
49
|
+
throw new ThemeError("creator home page must not use the example.com placeholder");
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
function updateReadme(root, name, creator, homepage) {
|
|
54
|
+
const path = join(root, "README.md");
|
|
55
|
+
const text = readFileSync(path, "utf8");
|
|
56
|
+
const start = text.indexOf(IDENTITY_START);
|
|
57
|
+
const end = text.indexOf(IDENTITY_END);
|
|
58
|
+
if (start < 0 || end < start)
|
|
59
|
+
throw new ThemeError("README identity markers are missing");
|
|
60
|
+
const replacement = `${IDENTITY_START}\n# ${name}\n\n` +
|
|
61
|
+
`A NetNewsWire theme by [${creator}](${homepage}).\n${IDENTITY_END}`;
|
|
62
|
+
writeFileSync(path, text.slice(0, start) + replacement + text.slice(end + IDENTITY_END.length), "utf8");
|
|
63
|
+
}
|
|
64
|
+
async function ask(value, flag, label, fallback) {
|
|
65
|
+
if (value)
|
|
66
|
+
return value;
|
|
67
|
+
if (!interactive())
|
|
68
|
+
throw new ThemeError(`pass --${flag} (no terminal to ask for ${label.toLowerCase()})`);
|
|
69
|
+
return await promptText(label, fallback);
|
|
70
|
+
}
|
|
71
|
+
export default async function init({ values }) {
|
|
72
|
+
const root = findRoot();
|
|
73
|
+
let theme = findTheme(root);
|
|
74
|
+
if (!existsSync(join(root, PLACEHOLDER_MARKER))) {
|
|
75
|
+
throw new ThemeError("this repository is already initialized; run `npx nnw-theme@1 setup` to install preview tools");
|
|
76
|
+
}
|
|
77
|
+
const flag = (name) => values[name];
|
|
78
|
+
const prompting = !(flag("name") && flag("creator") && flag("homepage"));
|
|
79
|
+
const repo = repository();
|
|
80
|
+
if (repo.isFork) {
|
|
81
|
+
console.error("Warning: this repository is a fork. The theme marketplace skips forks, so " +
|
|
82
|
+
"the theme will never be discovered. Start from GitHub's \"Use this " +
|
|
83
|
+
'template" button instead, then rerun initialization.');
|
|
84
|
+
}
|
|
85
|
+
const githubUser = flag("github-user") || repo.owner?.login || undefined;
|
|
86
|
+
const name = await ask(flag("name"), "name", "Theme name", "Quiet Reader");
|
|
87
|
+
const creator = await ask(flag("creator"), "creator", "Your name", "Theme Author");
|
|
88
|
+
const homepage = absoluteHomepage(await ask(flag("homepage"), "homepage", "Your HTTP(S) home page (for example, your GitHub profile)", githubUser ? `https://github.com/${githubUser}` : ""));
|
|
89
|
+
const proposed = flag("identifier") || defaultIdentifier(name, homepage, githubUser);
|
|
90
|
+
let identifier = proposed;
|
|
91
|
+
if (prompting && !flag("identifier"))
|
|
92
|
+
identifier = await promptText("Stable theme identifier", proposed);
|
|
93
|
+
const confirmation = flag("confirm-identifier");
|
|
94
|
+
if (confirmation) {
|
|
95
|
+
if (confirmation !== identifier) {
|
|
96
|
+
throw new ThemeError("--confirm-identifier must exactly match the chosen identifier");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else if (!interactive()) {
|
|
100
|
+
throw new ThemeError("pass --confirm-identifier with the identifier to keep permanently");
|
|
101
|
+
}
|
|
102
|
+
else if (!(await promptConfirm(`Use ${pyRepr(identifier)} permanently? It cannot change after the first release.`, true))) {
|
|
103
|
+
throw new ThemeError("choose a permanent identifier and rerun initialization");
|
|
104
|
+
}
|
|
105
|
+
if (!name.trim() || name === "." || name === ".." || /[/\\\0]/.test(name)) {
|
|
106
|
+
throw new ThemeError("theme name must be non-empty and cannot contain path separators");
|
|
107
|
+
}
|
|
108
|
+
const destination = join(root, `${name}.nnwtheme`);
|
|
109
|
+
if (existsSync(destination) && destination !== theme) {
|
|
110
|
+
throw new ThemeError(`theme bundle already exists: ${destination}`);
|
|
111
|
+
}
|
|
112
|
+
const metadata = readPlist(theme);
|
|
113
|
+
Object.assign(metadata, {
|
|
114
|
+
ThemeIdentifier: identifier,
|
|
115
|
+
Name: name,
|
|
116
|
+
CreatorHomePage: homepage,
|
|
117
|
+
CreatorName: creator,
|
|
118
|
+
Version: 1,
|
|
119
|
+
});
|
|
120
|
+
if (destination !== theme) {
|
|
121
|
+
renameSync(theme, destination);
|
|
122
|
+
theme = destination;
|
|
123
|
+
}
|
|
124
|
+
writePlist(join(theme, "Info.plist"), metadata);
|
|
125
|
+
updateReadme(root, name, creator, homepage);
|
|
126
|
+
rmSync(join(root, "screenshots", "theme-preview.png"), { force: true });
|
|
127
|
+
rmSync(join(root, PLACEHOLDER_MARKER));
|
|
128
|
+
console.log(`Initialized ${name}.nnwtheme with identifier ${identifier}.`);
|
|
129
|
+
console.log("Create a deliberate marketplace image with `npx nnw-theme@1 screenshot --promote`.");
|
|
130
|
+
let marketplace = flag("marketplace");
|
|
131
|
+
if (marketplace === undefined) {
|
|
132
|
+
marketplace =
|
|
133
|
+
interactive() && (await promptConfirm("Add this repository to the theme marketplace?"))
|
|
134
|
+
? "yes"
|
|
135
|
+
: "no";
|
|
136
|
+
}
|
|
137
|
+
if (marketplace === "yes")
|
|
138
|
+
marketplaceEnable();
|
|
139
|
+
let install = values["install-browser"];
|
|
140
|
+
if (install === undefined && prompting && interactive()) {
|
|
141
|
+
install = await promptConfirm("Set up WebKit for previews now?");
|
|
142
|
+
}
|
|
143
|
+
if (install) {
|
|
144
|
+
// Initialization is already complete; a preview setup failure must not look like
|
|
145
|
+
// an init failure, because rerunning init is refused from here on.
|
|
146
|
+
try {
|
|
147
|
+
await setupWebkit();
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
console.error(`Warning: initialization succeeded, but preview setup did not: ${error.message}\n` +
|
|
151
|
+
"Finish preview setup later with `npx nnw-theme@1 setup`.");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
console.log("Next: describe the design you want, then run `npx nnw-theme@1 preview`.");
|
|
155
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
/** Add the marketplace discovery topic with gh; warn rather than fail. */
|
|
3
|
+
export function marketplaceEnable() {
|
|
4
|
+
const result = spawnSync("gh", ["repo", "edit", "--add-topic", "netnewswire-theme"], {
|
|
5
|
+
encoding: "utf8",
|
|
6
|
+
});
|
|
7
|
+
if (result.error) {
|
|
8
|
+
console.error("Warning: GitHub CLI is not installed. Run `npx nnw-theme@1 marketplace enable` " +
|
|
9
|
+
"after installing and authenticating gh.");
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
if (result.status) {
|
|
13
|
+
console.error("Warning: could not add the marketplace topic. " +
|
|
14
|
+
`Run \`npx nnw-theme@1 marketplace enable\` later.\n${(result.stderr || result.stdout).trim()}`);
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
console.log("Added the netnewswire-theme GitHub topic.");
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
export default function marketplace() {
|
|
21
|
+
marketplaceEnable();
|
|
22
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { buildArchive } from "../package.js";
|
|
3
|
+
import { findRoot, findTheme } from "../project.js";
|
|
4
|
+
export function printWarnings(warnings) {
|
|
5
|
+
for (const warning of warnings)
|
|
6
|
+
console.error(`warning: ${warning}`);
|
|
7
|
+
}
|
|
8
|
+
export default function packageTheme({ values }) {
|
|
9
|
+
const root = findRoot();
|
|
10
|
+
const theme = findTheme(root);
|
|
11
|
+
const { path, warnings } = buildArchive(theme, resolve(root, String(values["output-dir"])), {
|
|
12
|
+
allowRemoteMedia: values["allow-remote-media"] === true,
|
|
13
|
+
});
|
|
14
|
+
printWarnings(warnings);
|
|
15
|
+
console.log(path);
|
|
16
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { watch } from "node:fs";
|
|
2
|
+
import { relative, sep } from "node:path";
|
|
3
|
+
import { serve } from "../browser.js";
|
|
4
|
+
import { openInBrowser } from "../interactive.js";
|
|
5
|
+
import { findRoot, findTheme } from "../project.js";
|
|
6
|
+
import { extraFixtures, normalTargets, renderSite } from "../render.js";
|
|
7
|
+
/** Whether a changed path is theme or fixture content the gallery is built from. */
|
|
8
|
+
export function watched(root, path) {
|
|
9
|
+
const parts = relative(root, path).split(sep);
|
|
10
|
+
if (parts.length !== 2)
|
|
11
|
+
return false;
|
|
12
|
+
const [directory = "", name = ""] = parts;
|
|
13
|
+
return (directory.endsWith(".nnwtheme") || (directory === "fixtures" && name.endsWith(".toml")));
|
|
14
|
+
}
|
|
15
|
+
export default async function preview({ values }) {
|
|
16
|
+
const root = findRoot();
|
|
17
|
+
const theme = findTheme(root);
|
|
18
|
+
const build = () => renderSite(root, theme, normalTargets(extraFixtures(root)));
|
|
19
|
+
const site = build();
|
|
20
|
+
const server = await serve(site);
|
|
21
|
+
console.log(`Preview: ${server.url}`);
|
|
22
|
+
if (!values["no-open"])
|
|
23
|
+
openInBrowser(server.url);
|
|
24
|
+
console.log("Watching theme and fixtures. Press Ctrl-C to stop.");
|
|
25
|
+
let timer;
|
|
26
|
+
const watcher = watch(root, { recursive: true }, (_event, name) => {
|
|
27
|
+
if (!name || !watched(root, `${root}${sep}${name}`))
|
|
28
|
+
return;
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
timer = setTimeout(() => {
|
|
31
|
+
try {
|
|
32
|
+
build();
|
|
33
|
+
console.log("Rebuilt preview.");
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
console.error(`error: ${error.message}`);
|
|
37
|
+
}
|
|
38
|
+
}, 200);
|
|
39
|
+
});
|
|
40
|
+
await new Promise((resolve) => {
|
|
41
|
+
process.once("SIGINT", () => {
|
|
42
|
+
console.log("\nPreview stopped.");
|
|
43
|
+
resolve();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
watcher.close();
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
await server.close();
|
|
49
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** One line rewritten in place on a terminal; one plain line per case elsewhere. */
|
|
2
|
+
export class CheckProgress {
|
|
3
|
+
total;
|
|
4
|
+
stream;
|
|
5
|
+
live;
|
|
6
|
+
constructor(total, stream = process.stderr) {
|
|
7
|
+
this.total = total;
|
|
8
|
+
this.stream = stream;
|
|
9
|
+
this.live = stream.isTTY === true;
|
|
10
|
+
}
|
|
11
|
+
status(message) {
|
|
12
|
+
this.stream.write(this.live ? `\r\x1b[K${message}` : `${message}\n`);
|
|
13
|
+
}
|
|
14
|
+
start(index, target) {
|
|
15
|
+
if (this.live)
|
|
16
|
+
this.stream.write(`\r\x1b[KChecking ${index}/${this.total} · ${target.label}`);
|
|
17
|
+
}
|
|
18
|
+
finish(index, target, failures) {
|
|
19
|
+
if (this.live && failures.length) {
|
|
20
|
+
this.stream.write(`\r\x1b[K✗ ${target.label}: ${failures.join("; ")}\n`);
|
|
21
|
+
}
|
|
22
|
+
else if (!this.live) {
|
|
23
|
+
const outcome = failures.length ? `FAILED: ${failures.join("; ")}` : "passed";
|
|
24
|
+
this.stream.write(`[${index}/${this.total}] ${target.label} … ${outcome}\n`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
done() {
|
|
28
|
+
if (this.live)
|
|
29
|
+
this.stream.write("\r\x1b[K");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { parsePlist } from "../plist.js";
|
|
4
|
+
import { findRoot, findTheme, readPlist, ThemeError } from "../project.js";
|
|
5
|
+
import { readZip } from "../zip.js";
|
|
6
|
+
export default function releaseCheck({ values }) {
|
|
7
|
+
const root = findRoot();
|
|
8
|
+
const theme = findTheme(root);
|
|
9
|
+
const current = readPlist(theme);
|
|
10
|
+
const path = String(values["previous-asset"]);
|
|
11
|
+
let content;
|
|
12
|
+
try {
|
|
13
|
+
content = readFileSync(path);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
throw new ThemeError(`could not read the previous release: ${error.message}`);
|
|
17
|
+
}
|
|
18
|
+
let entries;
|
|
19
|
+
try {
|
|
20
|
+
entries = readZip(content);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
throw new ThemeError(`previous release is not a valid ZIP: ${error.message}`);
|
|
24
|
+
}
|
|
25
|
+
const infos = entries.filter((entry) => entry.name.endsWith("/Info.plist"));
|
|
26
|
+
const [info] = infos;
|
|
27
|
+
if (infos.length !== 1 || !info) {
|
|
28
|
+
throw new ThemeError("previous release must contain exactly one Info.plist");
|
|
29
|
+
}
|
|
30
|
+
const previous = parsePlist(new TextDecoder().decode(info.read()));
|
|
31
|
+
const oldBundle = info.name.split("/", 1)[0];
|
|
32
|
+
const bundle = basename(theme);
|
|
33
|
+
if (oldBundle !== bundle) {
|
|
34
|
+
throw new ThemeError(`bundle filename changed after release: ${oldBundle} -> ${bundle}`);
|
|
35
|
+
}
|
|
36
|
+
if (previous.ThemeIdentifier !== current.ThemeIdentifier) {
|
|
37
|
+
throw new ThemeError("ThemeIdentifier cannot change after the first release");
|
|
38
|
+
}
|
|
39
|
+
const version = current.Version;
|
|
40
|
+
const previousVersion = typeof previous.Version === "number" ? previous.Version : 0;
|
|
41
|
+
if (typeof version !== "number" || !Number.isInteger(version) || version <= previousVersion) {
|
|
42
|
+
throw new ThemeError("Info.plist Version must increase after the previous release");
|
|
43
|
+
}
|
|
44
|
+
console.log(`Release identity is stable and Version increases ${previous.Version ?? "None"} -> ${version}.`);
|
|
45
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { findRoot, findTheme, ThemeError } from "../project.js";
|
|
2
|
+
import { extraFixtures, normalTargets, renderSite } from "../render.js";
|
|
3
|
+
export default function render({ positionals }) {
|
|
4
|
+
const root = findRoot();
|
|
5
|
+
const theme = findTheme(root);
|
|
6
|
+
let targets = normalTargets(extraFixtures(root));
|
|
7
|
+
if (positionals.length) {
|
|
8
|
+
const requested = new Set(positionals);
|
|
9
|
+
targets = targets.filter((target) => requested.has(target.fixture));
|
|
10
|
+
const found = new Set(targets.map((target) => target.fixture));
|
|
11
|
+
const unknown = [...requested].filter((name) => !found.has(name)).sort();
|
|
12
|
+
if (unknown.length)
|
|
13
|
+
throw new ThemeError(`unknown fixture(s): ${unknown.join(", ")}`);
|
|
14
|
+
}
|
|
15
|
+
const site = renderSite(root, theme, targets);
|
|
16
|
+
console.log(`${site}/index.html`);
|
|
17
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { copyFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { checkPages } from "../browser.js";
|
|
4
|
+
import { findRoot, findTheme, ThemeError } from "../project.js";
|
|
5
|
+
import { extraFixtures, normalTargets, renderSite } from "../render.js";
|
|
6
|
+
import { expectations } from "./check.js";
|
|
7
|
+
function selectTarget(root, values) {
|
|
8
|
+
const targets = normalTargets(extraFixtures(root));
|
|
9
|
+
const { fixture, platform, appearance } = values;
|
|
10
|
+
if (!targets.some((target) => target.fixture === fixture)) {
|
|
11
|
+
throw new ThemeError(`unknown fixture: ${fixture}`);
|
|
12
|
+
}
|
|
13
|
+
const target = targets.find((item) => item.fixture === fixture && item.platform === platform && item.appearance === appearance);
|
|
14
|
+
if (!target)
|
|
15
|
+
throw new ThemeError(`${fixture} is checked on Mac and iPhone only`);
|
|
16
|
+
return target;
|
|
17
|
+
}
|
|
18
|
+
export default async function screenshot({ values }) {
|
|
19
|
+
const root = findRoot();
|
|
20
|
+
const theme = findTheme(root);
|
|
21
|
+
const target = selectTarget(root, values);
|
|
22
|
+
const site = renderSite(root, theme, [target]);
|
|
23
|
+
const results = await checkPages(site, [target], undefined, expectations(root, [target]));
|
|
24
|
+
const failures = results[target.slug] ?? [];
|
|
25
|
+
if (failures.length) {
|
|
26
|
+
throw new ThemeError(`screenshot checks failed:\n- ${failures.join("\n- ")}`);
|
|
27
|
+
}
|
|
28
|
+
const source = join(site, "screenshots", `${target.slug}.png`);
|
|
29
|
+
console.log(source);
|
|
30
|
+
if (values.promote) {
|
|
31
|
+
const destination = join(root, "screenshots", "theme-preview.png");
|
|
32
|
+
mkdirSync(join(root, "screenshots"), { recursive: true });
|
|
33
|
+
copyFileSync(source, destination);
|
|
34
|
+
console.log(`Promoted marketplace screenshot: ${destination}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ThemeError } from "../project.js";
|
|
2
|
+
export default function update() {
|
|
3
|
+
throw new ThemeError("update was removed: the tooling now runs from npm with `npx nnw-theme@1`, so " +
|
|
4
|
+
"fixes arrive without changing this repository. Delete src/, tests/, " +
|
|
5
|
+
"pyproject.toml, uv.lock, and .python-version, and replace the stubs `check` " +
|
|
6
|
+
"warns about.");
|
|
7
|
+
}
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// The one description of the command line: the parser, help, and shell completion
|
|
2
|
+
// are all generated from it, so they cannot drift apart.
|
|
3
|
+
export const PROGRAM = "nnw-theme";
|
|
4
|
+
export const DESCRIPTION = "Create, preview, check, and publish a NetNewsWire theme.";
|
|
5
|
+
export const GUIDE_TOPICS = [
|
|
6
|
+
"skill",
|
|
7
|
+
"theme-format",
|
|
8
|
+
"fixtures",
|
|
9
|
+
"design-checklist",
|
|
10
|
+
"publishing",
|
|
11
|
+
];
|
|
12
|
+
export const SHELLS = ["fish", "zsh", "bash"];
|
|
13
|
+
/** Fixtures that ship in the package; also in project.ts, which reads them. */
|
|
14
|
+
export const BUILT_IN_FIXTURE_NAMES = ["article", "kitchen-sink"];
|
|
15
|
+
const allowRemoteMedia = {
|
|
16
|
+
name: "allow-remote-media",
|
|
17
|
+
type: "boolean",
|
|
18
|
+
help: "acknowledge warnings for theme-owned remote fonts/images/media",
|
|
19
|
+
};
|
|
20
|
+
export const COMMANDS = [
|
|
21
|
+
{
|
|
22
|
+
name: "init",
|
|
23
|
+
summary: "personalize a fresh copy of the template (run once)",
|
|
24
|
+
description: "Name the theme, choose its permanent identifier, and optionally join the " +
|
|
25
|
+
"marketplace and install WebKit. Runs once, while .nnw-theme-uninitialized exists.",
|
|
26
|
+
options: [
|
|
27
|
+
{ name: "name", type: "string" },
|
|
28
|
+
{ name: "creator", type: "string" },
|
|
29
|
+
{ name: "homepage", type: "string" },
|
|
30
|
+
{ name: "identifier", type: "string" },
|
|
31
|
+
{ name: "confirm-identifier", type: "string" },
|
|
32
|
+
{ name: "github-user", type: "string" },
|
|
33
|
+
{ name: "marketplace", type: "string", choices: ["yes", "no"] },
|
|
34
|
+
{ name: "install-browser", type: "boolean", negatable: true },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "setup",
|
|
39
|
+
summary: "install the WebKit browser that check and screenshot use",
|
|
40
|
+
description: "Install the WebKit build this version of nnw-theme checks with. " +
|
|
41
|
+
"--with-deps also installs its Linux system libraries (for CI). Safe to rerun.",
|
|
42
|
+
options: [
|
|
43
|
+
{
|
|
44
|
+
name: "with-deps",
|
|
45
|
+
type: "boolean",
|
|
46
|
+
help: "also install WebKit's Linux system dependencies",
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "preview",
|
|
52
|
+
summary: "live gallery for editing: serves, opens, and rebuilds on save",
|
|
53
|
+
description: "Serve the preview gallery on localhost, open it, and rebuild whenever the theme " +
|
|
54
|
+
"or fixtures change. Pages render live in your own browser; nothing is checked. " +
|
|
55
|
+
"Runs until Ctrl-C.",
|
|
56
|
+
options: [{ name: "no-open", type: "boolean", help: "do not open a browser" }],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "render",
|
|
60
|
+
summary: "write the preview gallery once, without serving or checking",
|
|
61
|
+
description: "Write the same gallery as preview to build/preview/ and exit. Useful for scripts " +
|
|
62
|
+
"and agents, since preview never exits.",
|
|
63
|
+
options: [],
|
|
64
|
+
positionals: {
|
|
65
|
+
name: "fixtures",
|
|
66
|
+
help: "fixtures to include (default: all), e.g. article",
|
|
67
|
+
variadic: true,
|
|
68
|
+
completeFixtures: true,
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "check",
|
|
73
|
+
summary: "release gate: package and test every case in WebKit",
|
|
74
|
+
description: "Validate and package the theme, then render 16 cases (the article and " +
|
|
75
|
+
"kitchen-sink fixtures on Mac, iPhone, and iPad in light and dark, plus large " +
|
|
76
|
+
"text and Article JavaScript off) and four more for each fixture you add (Mac " +
|
|
77
|
+
"and iPhone, light and dark) in WebKit. Screenshot each, and fail on missing " +
|
|
78
|
+
"content, unresolved macros, overflow, broken images, footnotes that do not " +
|
|
79
|
+
"open, external requests, or JavaScript errors. Results go into the gallery in " +
|
|
80
|
+
"build/preview/.",
|
|
81
|
+
options: [
|
|
82
|
+
allowRemoteMedia,
|
|
83
|
+
{
|
|
84
|
+
name: "open",
|
|
85
|
+
type: "boolean",
|
|
86
|
+
negatable: true,
|
|
87
|
+
help: "open the gallery when done (default: ask in a terminal)",
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "screenshot",
|
|
93
|
+
summary: "check one case in WebKit and save its image",
|
|
94
|
+
description: "Render and check a single case in WebKit and save its full-page screenshot. " +
|
|
95
|
+
"With --promote, it becomes screenshots/theme-preview.png, the marketplace card.",
|
|
96
|
+
options: [
|
|
97
|
+
{
|
|
98
|
+
name: "fixture",
|
|
99
|
+
type: "string",
|
|
100
|
+
default: "article",
|
|
101
|
+
help: "fixture name",
|
|
102
|
+
completeFixtures: true,
|
|
103
|
+
},
|
|
104
|
+
{ name: "platform", type: "string", choices: ["mac", "iphone", "ipad"], default: "mac" },
|
|
105
|
+
{ name: "appearance", type: "string", choices: ["light", "dark"], default: "light" },
|
|
106
|
+
{ name: "promote", type: "boolean", help: "use it as the marketplace screenshot" },
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: "package",
|
|
111
|
+
summary: "validate the theme and build its release ZIP (no WebKit)",
|
|
112
|
+
options: [{ name: "output-dir", type: "string", default: "dist" }, allowRemoteMedia],
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: "capture",
|
|
116
|
+
summary: "explain how to capture a real article from NetNewsWire as a fixture",
|
|
117
|
+
options: [],
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: "bump",
|
|
121
|
+
summary: "increase the Info.plist Version before a release",
|
|
122
|
+
options: [{ name: "yes", type: "boolean", help: "skip the confirmation" }],
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: "marketplace",
|
|
126
|
+
summary: "manage marketplace participation",
|
|
127
|
+
options: [],
|
|
128
|
+
subcommands: [
|
|
129
|
+
{ name: "enable", summary: "add the discovery topic on GitHub", options: [] },
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: "guide",
|
|
134
|
+
summary: "print the theme authoring guide for agents and people",
|
|
135
|
+
description: "Print the guidance for building a theme with this tool. Topics: " +
|
|
136
|
+
`${GUIDE_TOPICS.join(", ")} (default: skill).`,
|
|
137
|
+
options: [],
|
|
138
|
+
positionals: {
|
|
139
|
+
name: "topic",
|
|
140
|
+
help: "guide topic (default: skill)",
|
|
141
|
+
variadic: false,
|
|
142
|
+
choices: GUIDE_TOPICS,
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: "completion",
|
|
147
|
+
summary: "print shell completion and an npx wrapper for fish, zsh, or bash",
|
|
148
|
+
description: "Print a script that defines an nnw-theme command running `npx --yes nnw-theme@1` " +
|
|
149
|
+
"(skipped when nnw-theme is already installed) and completes its commands, " +
|
|
150
|
+
"options, and fixture names.",
|
|
151
|
+
options: [],
|
|
152
|
+
positionals: {
|
|
153
|
+
name: "shell",
|
|
154
|
+
help: "fish, zsh, or bash",
|
|
155
|
+
variadic: false,
|
|
156
|
+
choices: SHELLS,
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: "update",
|
|
161
|
+
summary: "removed: tooling now runs from npm",
|
|
162
|
+
options: [],
|
|
163
|
+
hidden: true,
|
|
164
|
+
},
|
|
165
|
+
// Used only by the Publish workflow.
|
|
166
|
+
{
|
|
167
|
+
name: "release-check",
|
|
168
|
+
summary: "compare the theme with its previous release",
|
|
169
|
+
options: [{ name: "previous-asset", type: "string", required: true }],
|
|
170
|
+
hidden: true,
|
|
171
|
+
},
|
|
172
|
+
];
|
|
173
|
+
export function findCommand(name) {
|
|
174
|
+
return COMMANDS.find((command) => command.name === name);
|
|
175
|
+
}
|