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.
Files changed (60) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +75 -1
  3. package/THIRD_PARTY_NOTICES.md +14 -0
  4. package/assets/fixtures/article.toml +24 -0
  5. package/assets/fixtures/kitchen-sink.toml +36 -0
  6. package/assets/footnotes.js +174 -0
  7. package/assets/guide/design-checklist.md +19 -0
  8. package/assets/guide/fixtures.md +42 -0
  9. package/assets/guide/publishing.md +34 -0
  10. package/assets/guide/skill.md +44 -0
  11. package/assets/guide/theme-format.md +52 -0
  12. package/assets/netnewswire/LICENSE +21 -0
  13. package/assets/netnewswire/Mac/main_mac.js +43 -0
  14. package/assets/netnewswire/Mac/page.html +12 -0
  15. package/assets/netnewswire/Shared/core.css +186 -0
  16. package/assets/netnewswire/Shared/main.js +221 -0
  17. package/assets/netnewswire/Shared/newsfoot.js +173 -0
  18. package/assets/netnewswire/iOS/main_ios.js +520 -0
  19. package/assets/netnewswire/iOS/page.html +19 -0
  20. package/assets/netnewswire/netnewswire.json +46 -0
  21. package/assets/stubs/.agents/skills/creating-nnw-themes/SKILL.md +22 -0
  22. package/assets/stubs/.agents/skills/creating-nnw-themes/agents/openai.yaml +4 -0
  23. package/assets/stubs/.github/workflows/check.yml +14 -0
  24. package/assets/stubs/.github/workflows/pages.yml +24 -0
  25. package/assets/stubs/.github/workflows/release.yml +24 -0
  26. package/assets/stubs/.github/workflows/screenshot.yml +27 -0
  27. package/assets/stubs/AGENTS.md +24 -0
  28. package/dist/browser.js +227 -0
  29. package/dist/cli.js +3 -0
  30. package/dist/commands/bump.js +23 -0
  31. package/dist/commands/capture.js +26 -0
  32. package/dist/commands/check.js +67 -0
  33. package/dist/commands/completion.js +8 -0
  34. package/dist/commands/guide.js +9 -0
  35. package/dist/commands/init.js +155 -0
  36. package/dist/commands/marketplace.js +22 -0
  37. package/dist/commands/package.js +16 -0
  38. package/dist/commands/preview.js +49 -0
  39. package/dist/commands/progress.js +31 -0
  40. package/dist/commands/release-check.js +45 -0
  41. package/dist/commands/render.js +17 -0
  42. package/dist/commands/screenshot.js +36 -0
  43. package/dist/commands/setup.js +4 -0
  44. package/dist/commands/update.js +7 -0
  45. package/dist/commands.js +175 -0
  46. package/dist/completion.js +203 -0
  47. package/dist/interactive.js +32 -0
  48. package/dist/main.js +229 -0
  49. package/dist/netnewswire.js +84 -0
  50. package/dist/package.js +28 -0
  51. package/dist/plist.js +175 -0
  52. package/dist/project.js +195 -0
  53. package/dist/pyformat.js +82 -0
  54. package/dist/render.js +516 -0
  55. package/dist/stubs.js +49 -0
  56. package/dist/urlparse.js +32 -0
  57. package/dist/validate.js +259 -0
  58. package/dist/zip.js +72 -0
  59. package/lldb/nnwdump.py +151 -0
  60. package/package.json +55 -2
@@ -0,0 +1,203 @@
1
+ // Shell completion scripts, generated from the same command spec the parser uses.
2
+ // Each script also defines an nnw-theme function that runs `npx --yes nnw-theme@1`
3
+ // unless a real nnw-theme is already on PATH. The scripts never call npx to complete,
4
+ // so completion is instant.
5
+ import { BUILT_IN_FIXTURE_NAMES, COMMANDS, PROGRAM, } from "./commands.js";
6
+ const RUN = "npx --yes nnw-theme@1";
7
+ const visible = () => COMMANDS.filter((command) => !command.hidden);
8
+ function flags(option) {
9
+ return option.negatable ? [`--${option.name}`, `--no-${option.name}`] : [`--${option.name}`];
10
+ }
11
+ function allFlags(command) {
12
+ return [...command.options.flatMap(flags), "--help"];
13
+ }
14
+ /** Words a command's positional argument completes to, or null for fixture names. */
15
+ function positionalWords(command) {
16
+ if (command.subcommands)
17
+ return command.subcommands.map((sub) => sub.name);
18
+ if (command.positionals?.completeFixtures)
19
+ return null;
20
+ return command.positionals?.choices;
21
+ }
22
+ const single = (text) => `'${text.replaceAll("'", `'\\''`)}'`;
23
+ // fish ---------------------------------------------------------------------------
24
+ function fishQuote(text) {
25
+ return `'${text.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
26
+ }
27
+ export function fish() {
28
+ const lines = [
29
+ "# nnw-theme completion for fish. Install: npx nnw-theme@1 completion fish > ~/.config/fish/conf.d/nnw-theme.fish",
30
+ `if not command -q ${PROGRAM}`,
31
+ ` function ${PROGRAM} --description 'Run nnw-theme through npx'`,
32
+ ` ${RUN} $argv`,
33
+ " end",
34
+ "end",
35
+ "function __nnw_theme_fixtures",
36
+ " # The theme root, as nnw-theme finds it: the nearest directory with a bundle.",
37
+ " set -l root $PWD",
38
+ " while test $root != / -a (count $root/*.nnwtheme) -eq 0",
39
+ " set root (dirname $root)",
40
+ " end",
41
+ ` begin; printf '%s\\n' ${BUILT_IN_FIXTURE_NAMES.join(" ")}`,
42
+ " for file in $root/fixtures/*.toml; basename $file .toml; end",
43
+ " end | sort -u",
44
+ "end",
45
+ `complete -c ${PROGRAM} -f`,
46
+ `complete -c ${PROGRAM} -n __fish_use_subcommand -l help -s h -d 'Show help'`,
47
+ `complete -c ${PROGRAM} -n __fish_use_subcommand -l version -d 'Print the version'`,
48
+ ];
49
+ for (const command of visible()) {
50
+ const when = `'__fish_seen_subcommand_from ${command.name}'`;
51
+ lines.push(`complete -c ${PROGRAM} -n __fish_use_subcommand -a ${command.name} -d ${fishQuote(command.summary)}`);
52
+ for (const option of command.options) {
53
+ const description = option.help ? ` -d ${fishQuote(option.help)}` : "";
54
+ let values = "";
55
+ if (option.completeFixtures)
56
+ values = " -x -a '(__nnw_theme_fixtures)'";
57
+ else if (option.choices)
58
+ values = ` -x -a ${fishQuote(option.choices.join(" "))}`;
59
+ else if (option.type === "string")
60
+ values = option.name.endsWith("dir") ? " -r -F" : " -x";
61
+ lines.push(`complete -c ${PROGRAM} -n ${when} -l ${option.name}${values}${description}`);
62
+ if (option.negatable)
63
+ lines.push(`complete -c ${PROGRAM} -n ${when} -l no-${option.name}`);
64
+ }
65
+ const words = positionalWords(command);
66
+ if (words === null)
67
+ lines.push(`complete -c ${PROGRAM} -n ${when} -a '(__nnw_theme_fixtures)'`);
68
+ else if (words) {
69
+ const subs = command.subcommands ?? [];
70
+ for (const word of words) {
71
+ const sub = subs.find((item) => item.name === word);
72
+ const description = sub ? ` -d ${fishQuote(sub.summary)}` : "";
73
+ lines.push(`complete -c ${PROGRAM} -n ${when} -a ${word}${description}`);
74
+ }
75
+ }
76
+ }
77
+ return `${lines.join("\n")}\n`;
78
+ }
79
+ // bash ---------------------------------------------------------------------------
80
+ export function bash() {
81
+ const cases = [];
82
+ for (const command of visible()) {
83
+ const valueCases = [];
84
+ for (const option of command.options) {
85
+ if (option.type !== "string")
86
+ continue;
87
+ let reply = "COMPREPLY=()";
88
+ if (option.completeFixtures)
89
+ reply = 'COMPREPLY=($(compgen -W "$(_nnw_theme_fixtures)" -- "$cur"))';
90
+ else if (option.choices)
91
+ reply = `COMPREPLY=($(compgen -W ${single(option.choices.join(" "))} -- "$cur"))`;
92
+ else if (option.name.endsWith("dir"))
93
+ reply = 'COMPREPLY=($(compgen -d -- "$cur"))';
94
+ valueCases.push(` --${option.name}) ${reply}; return ;;`);
95
+ }
96
+ const words = positionalWords(command);
97
+ let positional = "COMPREPLY=()";
98
+ if (words === null)
99
+ positional = 'COMPREPLY=($(compgen -W "$(_nnw_theme_fixtures)" -- "$cur"))';
100
+ else if (words)
101
+ positional = `COMPREPLY=($(compgen -W ${single(words.join(" "))} -- "$cur"))`;
102
+ cases.push(` ${command.name})`, ...(valueCases.length ? [' case "$prev" in', ...valueCases, " esac"] : []), ' if [[ "$cur" == -* ]]; then', ` COMPREPLY=($(compgen -W ${single(allFlags(command).join(" "))} -- "$cur"))`, " else", ` ${positional}`, " fi", " ;;");
103
+ }
104
+ return `# nnw-theme completion for bash. Install: source <(npx --yes nnw-theme@1 completion bash)
105
+ if ! type -P ${PROGRAM} >/dev/null 2>&1; then
106
+ ${PROGRAM}() { ${RUN} "$@"; }
107
+ fi
108
+ _nnw_theme_fixtures() {
109
+ local root=$PWD file
110
+ # The theme root, as nnw-theme finds it: the nearest directory with a bundle.
111
+ while [[ $root != / ]] && ! compgen -G "$root/*.nnwtheme" >/dev/null; do root=$(dirname "$root"); done
112
+ {
113
+ printf '%s\\n' ${BUILT_IN_FIXTURE_NAMES.join(" ")}
114
+ for file in "$root"/fixtures/*.toml; do [ -e "$file" ] && basename "$file" .toml; done
115
+ } | sort -u
116
+ }
117
+ _nnw_theme() {
118
+ local cur=\${COMP_WORDS[COMP_CWORD]} prev=\${COMP_WORDS[COMP_CWORD-1]}
119
+ if (( COMP_CWORD == 1 )); then
120
+ COMPREPLY=($(compgen -W ${single(`${visible()
121
+ .map((command) => command.name)
122
+ .join(" ")} --help --version`)} -- "$cur"))
123
+ return
124
+ fi
125
+ case "\${COMP_WORDS[1]}" in
126
+ ${cases.join("\n")}
127
+ esac
128
+ }
129
+ complete -F _nnw_theme ${PROGRAM}
130
+ `;
131
+ }
132
+ // zsh ----------------------------------------------------------------------------
133
+ function zshEscape(text) {
134
+ return text
135
+ .replaceAll("'", `'\\''`)
136
+ .replaceAll(":", "\\:")
137
+ .replaceAll("[", "\\[")
138
+ .replaceAll("]", "\\]");
139
+ }
140
+ function zshOption(option) {
141
+ const help = zshEscape(option.help ?? option.name);
142
+ let value = "";
143
+ if (option.completeFixtures)
144
+ value = ":fixture:_nnw_theme_fixtures";
145
+ else if (option.choices)
146
+ value = `:${option.name}:(${option.choices.join(" ")})`;
147
+ else if (option.type === "string")
148
+ value = option.name.endsWith("dir") ? `:${option.name}:_files -/` : `:${option.name}: `;
149
+ const specs = [`'--${option.name}[${help}]${value}'`];
150
+ if (option.negatable)
151
+ specs.push(`'--no-${option.name}[${help}]'`);
152
+ return specs;
153
+ }
154
+ export function zsh() {
155
+ const described = visible().map((command) => ` '${command.name}:${zshEscape(command.summary)}'`);
156
+ const cases = [];
157
+ for (const command of visible()) {
158
+ const specs = [...command.options.flatMap(zshOption), "'(- *)'{-h,--help}'[show help]'"];
159
+ const words = positionalWords(command);
160
+ if (words === null)
161
+ specs.push("'*:fixture:_nnw_theme_fixtures'");
162
+ else if (words)
163
+ specs.push(`'1:${command.positionals?.name ?? "command"}:(${words.join(" ")})'`);
164
+ cases.push(` ${command.name}) _arguments -s ${specs.join(" ")} ;;`);
165
+ }
166
+ return `# nnw-theme completion for zsh. Install: source <(npx --yes nnw-theme@1 completion zsh)
167
+ if (( ! $+commands[${PROGRAM}] )); then
168
+ ${PROGRAM}() { ${RUN} "$@"; }
169
+ fi
170
+ _nnw_theme_fixtures() {
171
+ local root=$PWD
172
+ local -a names
173
+ # The theme root, as nnw-theme finds it: the nearest directory with a bundle.
174
+ while [[ $root != / ]]; do
175
+ names=($root/*.nnwtheme(N/))
176
+ (( $#names )) && break
177
+ root=\${root:h}
178
+ done
179
+ names=(${BUILT_IN_FIXTURE_NAMES.join(" ")} $root/fixtures/*.toml(N:t:r))
180
+ compadd -- \${(u)names}
181
+ }
182
+ _nnw_theme() {
183
+ local -a commands
184
+ commands=(
185
+ ${described.join("\n")}
186
+ )
187
+ if (( CURRENT == 2 )); then
188
+ _describe -t commands 'nnw-theme command' commands
189
+ return
190
+ fi
191
+ local command=$words[2]
192
+ shift words
193
+ (( CURRENT-- ))
194
+ case $command in
195
+ ${cases.join("\n")}
196
+ esac
197
+ }
198
+ if (( $+functions[compdef] )); then
199
+ compdef _nnw_theme ${PROGRAM}
200
+ fi
201
+ `;
202
+ }
203
+ export const SCRIPTS = { fish, bash, zsh };
@@ -0,0 +1,32 @@
1
+ import { spawn } from "node:child_process";
2
+ import { ThemeError } from "./project.js";
3
+ /** Prompts appear only when stdin and stdout are both terminals. */
4
+ export function interactive() {
5
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
6
+ }
7
+ function cancelled(error) {
8
+ if (error?.name === "ExitPromptError")
9
+ throw new ThemeError("cancelled");
10
+ throw error;
11
+ }
12
+ export async function promptText(message, fallback) {
13
+ const { input } = await import("@inquirer/prompts");
14
+ const answer = await input({ message, default: fallback }).catch(cancelled);
15
+ return answer.trim();
16
+ }
17
+ export async function promptConfirm(message, fallback = true) {
18
+ const { confirm } = await import("@inquirer/prompts");
19
+ return await confirm({ message, default: fallback }).catch(cancelled);
20
+ }
21
+ /** Open a file path or URL in the default browser; failures are ignored. */
22
+ export function openInBrowser(target) {
23
+ const command = process.platform === "darwin" ? "open" : "xdg-open";
24
+ try {
25
+ const child = spawn(command, [target], { detached: true, stdio: "ignore" });
26
+ child.on("error", () => { });
27
+ child.unref();
28
+ }
29
+ catch {
30
+ // No browser to open; the path was printed.
31
+ }
32
+ }
package/dist/main.js ADDED
@@ -0,0 +1,229 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { parseArgs } from "node:util";
3
+ import { COMMANDS, DESCRIPTION, findCommand, PROGRAM, } from "./commands.js";
4
+ import { packagePath, ThemeError } from "./project.js";
5
+ // Loaded on demand, so a quick command never pays for Playwright or the prompts.
6
+ const HANDLERS = {
7
+ init: () => import("./commands/init.js"),
8
+ setup: () => import("./commands/setup.js"),
9
+ preview: () => import("./commands/preview.js"),
10
+ render: () => import("./commands/render.js"),
11
+ check: () => import("./commands/check.js"),
12
+ screenshot: () => import("./commands/screenshot.js"),
13
+ package: () => import("./commands/package.js"),
14
+ capture: () => import("./commands/capture.js"),
15
+ bump: () => import("./commands/bump.js"),
16
+ "marketplace enable": () => import("./commands/marketplace.js"),
17
+ guide: () => import("./commands/guide.js"),
18
+ completion: () => import("./commands/completion.js"),
19
+ update: () => import("./commands/update.js"),
20
+ "release-check": () => import("./commands/release-check.js"),
21
+ };
22
+ class UsageError extends Error {
23
+ }
24
+ export function version() {
25
+ const manifest = JSON.parse(readFileSync(packagePath("package.json"), "utf8"));
26
+ return manifest.version;
27
+ }
28
+ function optionUsage(option) {
29
+ const name = option.negatable ? `--${option.name} | --no-${option.name}` : `--${option.name}`;
30
+ if (option.type === "boolean")
31
+ return name;
32
+ const value = option.choices
33
+ ? `{${option.choices.join(",")}}`
34
+ : option.name.toUpperCase().replaceAll("-", "_");
35
+ return `${name} ${value}`;
36
+ }
37
+ function wrap(text, indent, width = 80) {
38
+ const lines = [];
39
+ let line = "";
40
+ for (const word of text.split(/\s+/)) {
41
+ if (line && indent + line.length + 1 + word.length > width) {
42
+ lines.push(line);
43
+ line = word;
44
+ }
45
+ else
46
+ line = line ? `${line} ${word}` : word;
47
+ }
48
+ if (line)
49
+ lines.push(line);
50
+ return lines.map((item) => " ".repeat(indent) + item).join("\n");
51
+ }
52
+ function rows(items) {
53
+ const column = Math.min(24, Math.max(...items.map(([left]) => left.length)) + 4);
54
+ return items
55
+ .map(([left, right]) => {
56
+ if (!right)
57
+ return ` ${left}`;
58
+ if (left.length + 4 > column)
59
+ return ` ${left}\n${wrap(right, column)}`;
60
+ return ` ${left.padEnd(column - 2)}${wrap(right, column).trimStart()}`;
61
+ })
62
+ .join("\n");
63
+ }
64
+ export function mainHelp() {
65
+ const visible = COMMANDS.filter((command) => !command.hidden);
66
+ return [
67
+ `usage: ${PROGRAM} [-h] [--version] COMMAND ...`,
68
+ "",
69
+ DESCRIPTION,
70
+ "",
71
+ "commands:",
72
+ rows(visible.map((command) => [command.name, command.summary])),
73
+ "",
74
+ "options:",
75
+ rows([
76
+ ["-h, --help", "show this help message and exit"],
77
+ ["--version", "print the nnw-theme version and exit"],
78
+ ]),
79
+ "",
80
+ `Run \`${PROGRAM} COMMAND --help\` for a command's options.`,
81
+ `Run it through npx: \`npx nnw-theme@1 COMMAND\`.`,
82
+ "",
83
+ ].join("\n");
84
+ }
85
+ export function commandHelp(command, path) {
86
+ const usage = [`usage: ${PROGRAM} ${path.join(" ")} [-h]`];
87
+ for (const option of command.options) {
88
+ const text = optionUsage(option);
89
+ usage.push(option.required ? text : `[${text}]`);
90
+ }
91
+ if (command.subcommands)
92
+ usage.push(`{${command.subcommands.map((sub) => sub.name).join(",")}}`);
93
+ if (command.positionals) {
94
+ const name = command.positionals.name.toUpperCase();
95
+ usage.push(command.positionals.variadic ? `[${name} ...]` : `[${name}]`);
96
+ }
97
+ const sections = [
98
+ wrap(usage.join(" "), 0),
99
+ "",
100
+ wrap(command.description ?? command.summary, 0),
101
+ ];
102
+ if (command.subcommands) {
103
+ sections.push("", "commands:", rows(command.subcommands.map((sub) => [sub.name, sub.summary])));
104
+ }
105
+ if (command.positionals) {
106
+ sections.push("", "positional arguments:", rows([[command.positionals.name, command.positionals.help]]));
107
+ }
108
+ const options = [["-h, --help", "show this help message and exit"]];
109
+ for (const option of command.options) {
110
+ const help = option.help ?? "";
111
+ const fallback = option.default ? ` (default: ${option.default})` : "";
112
+ options.push([optionUsage(option), help + (help ? fallback : fallback.trim())]);
113
+ }
114
+ sections.push("", "options:", rows(options), "");
115
+ return sections.join("\n");
116
+ }
117
+ function parseCommand(command, argv) {
118
+ const options = {
119
+ help: { type: "boolean", short: "h" },
120
+ };
121
+ for (const option of command.options)
122
+ options[option.name] = { type: option.type };
123
+ let parsed;
124
+ try {
125
+ parsed = parseArgs({
126
+ args: argv,
127
+ options,
128
+ allowPositionals: true,
129
+ allowNegative: command.options.some((option) => option.negatable),
130
+ strict: true,
131
+ });
132
+ }
133
+ catch (error) {
134
+ const message = error.message;
135
+ const unknown = /Unknown option '([^']+)'/.exec(message);
136
+ throw new UsageError(unknown ? `unrecognized arguments: ${unknown[1]}` : message);
137
+ }
138
+ const values = parsed.values;
139
+ if (values.help)
140
+ return "help";
141
+ delete values.help;
142
+ for (const option of command.options) {
143
+ const value = values[option.name];
144
+ if (value === false && option.type === "boolean" && !option.negatable) {
145
+ throw new UsageError(`unrecognized arguments: --no-${option.name}`);
146
+ }
147
+ if (value === undefined && option.default !== undefined)
148
+ values[option.name] = option.default;
149
+ if (typeof value === "string" && option.choices && !option.choices.includes(value)) {
150
+ throw new UsageError(`argument --${option.name}: invalid choice: '${value}' (choose from ${option.choices.map((choice) => `'${choice}'`).join(", ")})`);
151
+ }
152
+ if (option.required && value === undefined) {
153
+ throw new UsageError(`the following arguments are required: --${option.name}`);
154
+ }
155
+ }
156
+ const positionals = parsed.positionals;
157
+ const spec = command.positionals;
158
+ if (!spec && positionals.length) {
159
+ throw new UsageError(`unrecognized arguments: ${positionals.join(" ")}`);
160
+ }
161
+ if (spec && !spec.variadic && positionals.length > 1) {
162
+ throw new UsageError(`unrecognized arguments: ${positionals.slice(1).join(" ")}`);
163
+ }
164
+ if (spec?.choices) {
165
+ for (const value of positionals) {
166
+ if (!spec.choices.includes(value)) {
167
+ throw new UsageError(`argument ${spec.name}: invalid choice: '${value}' (choose from ${spec.choices.map((choice) => `'${choice}'`).join(", ")})`);
168
+ }
169
+ }
170
+ }
171
+ return { values, positionals };
172
+ }
173
+ export async function main(argv = process.argv.slice(2)) {
174
+ const [name, ...rest] = argv;
175
+ if (name === undefined || name === "-h" || name === "--help") {
176
+ process.stdout.write(mainHelp());
177
+ return name === undefined ? 2 : 0;
178
+ }
179
+ if (name === "--version" || name === "-V") {
180
+ console.log(version());
181
+ return 0;
182
+ }
183
+ try {
184
+ let command = findCommand(name);
185
+ const path = [name];
186
+ let args = rest;
187
+ if (!command) {
188
+ const choices = COMMANDS.filter((item) => !item.hidden).map((item) => `'${item.name}'`);
189
+ throw new UsageError(`argument COMMAND: invalid choice: '${name}' (choose from ${choices.join(", ")})`);
190
+ }
191
+ if (command.subcommands) {
192
+ const [subName, ...subRest] = rest;
193
+ if (subName === undefined || subName === "-h" || subName === "--help") {
194
+ process.stdout.write(commandHelp(command, path));
195
+ return subName === undefined ? 2 : 0;
196
+ }
197
+ const sub = command.subcommands.find((item) => item.name === subName);
198
+ if (!sub) {
199
+ const choices = command.subcommands.map((item) => `'${item.name}'`).join(", ");
200
+ throw new UsageError(`argument ${name}: invalid choice: '${subName}' (choose from ${choices})`);
201
+ }
202
+ command = sub;
203
+ path.push(subName);
204
+ args = subRest;
205
+ }
206
+ const parsed = parseCommand(command, args);
207
+ if (parsed === "help") {
208
+ process.stdout.write(commandHelp(command, path));
209
+ return 0;
210
+ }
211
+ const load = HANDLERS[path.join(" ")];
212
+ if (!load)
213
+ throw new UsageError(`${path.join(" ")} is not implemented yet`);
214
+ const handler = (await load()).default;
215
+ await handler(parsed);
216
+ return 0;
217
+ }
218
+ catch (error) {
219
+ if (error instanceof UsageError) {
220
+ process.stderr.write(`usage: ${PROGRAM} ${name} [-h] ...\n${PROGRAM}: error: ${error.message}\n`);
221
+ return 2;
222
+ }
223
+ if (error instanceof ThemeError) {
224
+ process.stderr.write(`error: ${error.message}\n`);
225
+ return 1;
226
+ }
227
+ throw error;
228
+ }
229
+ }
@@ -0,0 +1,84 @@
1
+ // The NetNewsWire rendering files the previews use. They ship in the package under
2
+ // assets/netnewswire/, fetched and SHA-256-verified at build time from the pin in
3
+ // netnewswire.json (see scripts/fetch-netnewswire.ts).
4
+ import { createHash } from "node:crypto";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { packagePath, ThemeError } from "./project.js";
8
+ function safeRelativePath(value, field) {
9
+ if (typeof value !== "string" || !value) {
10
+ throw new ThemeError(`snapshot configuration ${field} must be a non-empty string`);
11
+ }
12
+ const parts = value.split("/").filter((part) => part && part !== ".");
13
+ if (value.startsWith("/") || parts.includes("..") || !parts.length) {
14
+ throw new ThemeError(`snapshot configuration ${field} contains an unsafe path`);
15
+ }
16
+ return parts.join("/");
17
+ }
18
+ export function readPin(text, name = "netnewswire.json") {
19
+ let value;
20
+ try {
21
+ value = JSON.parse(text);
22
+ }
23
+ catch (error) {
24
+ throw new ThemeError(`${name}: invalid NetNewsWire snapshot configuration: ${error}`);
25
+ }
26
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
27
+ throw new ThemeError(`${name}: NetNewsWire snapshot configuration must be an object`);
28
+ }
29
+ const { release, commit, files } = value;
30
+ if (typeof release !== "string" || !release) {
31
+ throw new ThemeError(`${name}: release must be a non-empty string`);
32
+ }
33
+ if (typeof commit !== "string" || !/^[0-9a-f]{40}$/.test(commit)) {
34
+ throw new ThemeError(`${name}: commit must be a full lowercase Git SHA`);
35
+ }
36
+ if (!Array.isArray(files) || !files.length) {
37
+ throw new ThemeError(`${name}: files must be a non-empty array`);
38
+ }
39
+ const parsed = [];
40
+ for (const details of files) {
41
+ if (!details || typeof details !== "object") {
42
+ throw new ThemeError(`${name}: each file must be an object`);
43
+ }
44
+ const destination = safeRelativePath(details.destination, "destination");
45
+ if (parsed.some((file) => file.destination === destination)) {
46
+ throw new ThemeError(`${name}: duplicate destination ${destination}`);
47
+ }
48
+ const source = safeRelativePath(details.source, `source for ${destination}`);
49
+ const sha256 = details.sha256;
50
+ if (typeof sha256 !== "string" || !/^[0-9a-f]{64}$/.test(sha256)) {
51
+ throw new ThemeError(`${name}: sha256 for ${destination} is invalid`);
52
+ }
53
+ parsed.push({ destination, source, sha256 });
54
+ }
55
+ return { release, commit, files: parsed };
56
+ }
57
+ let verified;
58
+ /** The bundled rendering files, verified against their pin once per process. */
59
+ export function renderingInputs() {
60
+ if (verified)
61
+ return verified;
62
+ const path = packagePath("assets", "netnewswire");
63
+ const manifest = join(path, "netnewswire.json");
64
+ if (!existsSync(manifest)) {
65
+ throw new ThemeError("the NetNewsWire rendering files are missing from this nnw-theme install; " +
66
+ "in a checkout of the tool, run `npm run fetch-netnewswire`");
67
+ }
68
+ const pin = readPin(readFileSync(manifest, "utf8"), manifest);
69
+ for (const file of pin.files) {
70
+ const filePath = join(path, ...file.destination.split("/"));
71
+ let actual = "";
72
+ try {
73
+ actual = createHash("sha256").update(readFileSync(filePath)).digest("hex");
74
+ }
75
+ catch {
76
+ // Reported below as a failed verification.
77
+ }
78
+ if (actual !== file.sha256) {
79
+ throw new ThemeError(`bundled NetNewsWire file failed SHA-256 verification: ${filePath}`);
80
+ }
81
+ }
82
+ verified = { path, release: pin.release };
83
+ return verified;
84
+ }
@@ -0,0 +1,28 @@
1
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { ThemeError } from "./project.js";
4
+ import { validateArchive, validateSource } from "./validate.js";
5
+ import { writeZip } from "./zip.js";
6
+ /** Build the deterministic ZIP. The only place package validation runs. */
7
+ export function archiveBytes(theme, { allowRemoteMedia = false } = {}) {
8
+ const sourceReport = validateSource(theme, { allowRemoteMedia });
9
+ sourceReport.requireOk();
10
+ const name = basename(theme);
11
+ const files = readdirSync(theme)
12
+ .sort()
13
+ .map((file) => [`${name}/${file}`, readFileSync(join(theme, file))]);
14
+ const content = writeZip(files);
15
+ const archiveReport = validateArchive(content, `${name}.zip`);
16
+ archiveReport.requireOk();
17
+ return { content, warnings: [...sourceReport.warnings, ...archiveReport.warnings] };
18
+ }
19
+ export function buildArchive(theme, outputDir, { allowRemoteMedia = false } = {}) {
20
+ const { content, warnings } = archiveBytes(theme, { allowRemoteMedia });
21
+ mkdirSync(outputDir, { recursive: true });
22
+ const path = join(outputDir, `${basename(theme)}.zip`);
23
+ writeFileSync(path, content);
24
+ if (!path.endsWith(".nnwtheme.zip")) {
25
+ throw new ThemeError("internal error: package has an invalid release asset name");
26
+ }
27
+ return { path, warnings };
28
+ }