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.
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 +29 -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,259 @@
1
+ import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { Parser } from "htmlparser2";
4
+ import { PlistReal, parsePlist } from "./plist.js";
5
+ import { PLACEHOLDER_MARKER, REQUIRED_THEME_FILES, readPlist, ThemeError, themeStem, } from "./project.js";
6
+ import { pyRepr } from "./pyformat.js";
7
+ import { urlsplit } from "./urlparse.js";
8
+ import { readZip } from "./zip.js";
9
+ const MAX_ASSET_BYTES = 25 * 1024 * 1024;
10
+ const MAX_UNCOMPRESSED_BYTES = 50 * 1024 * 1024;
11
+ const REQUIRED_PLIST_FIELDS = [
12
+ ["ThemeIdentifier", "str"],
13
+ ["Name", "str"],
14
+ ["CreatorHomePage", "str"],
15
+ ["CreatorName", "str"],
16
+ ["Version", "int"],
17
+ ];
18
+ const PLACEHOLDERS = ["starter", "example.com", "your name", "change me", "todo"];
19
+ const SAFE_BUNDLE_NAME = /^[^/\\\0]+$/;
20
+ const OPTIONAL_THEME_FILE = /^(?:LICENSE|NOTICE)(?:\.[A-Za-z0-9-]+)?$/;
21
+ const S_IFMT = 0o170000;
22
+ const S_IFLNK = 0o120000;
23
+ export class ValidationReport {
24
+ errors = [];
25
+ warnings = [];
26
+ requireOk() {
27
+ if (this.errors.length) {
28
+ throw new ThemeError(`theme validation failed:\n- ${this.errors.join("\n- ")}`);
29
+ }
30
+ }
31
+ }
32
+ function resourceReferences(template) {
33
+ const references = [];
34
+ let attributes = new Map();
35
+ const parser = new Parser({
36
+ onattribute(name, value) {
37
+ attributes.set(name.toLowerCase(), value ?? "");
38
+ },
39
+ onopentag(name) {
40
+ const tag = name.toLowerCase();
41
+ if (tag === "link" && (attributes.get("rel") ?? "").toLowerCase() === "stylesheet") {
42
+ references.push(["stylesheet", "href", attributes.get("href") ?? ""]);
43
+ }
44
+ for (const attribute of ["src", "poster"]) {
45
+ const value = attributes.get(attribute);
46
+ if (value !== undefined)
47
+ references.push([tag, attribute, value]);
48
+ }
49
+ attributes = new Map();
50
+ },
51
+ }, { decodeEntities: true, lowerCaseTags: true, lowerCaseAttributeNames: true });
52
+ parser.end(template);
53
+ return references;
54
+ }
55
+ function isRemote(value) {
56
+ return value.startsWith("//") || ["http", "https"].includes(urlsplit(value).scheme);
57
+ }
58
+ function isAllowedFile(name) {
59
+ return (REQUIRED_THEME_FILES.includes(name) ||
60
+ OPTIONAL_THEME_FILE.test(name.toUpperCase()));
61
+ }
62
+ function isInt(value) {
63
+ return typeof value === "number" && Number.isInteger(value);
64
+ }
65
+ export function validateMetadata(metadata, bundleStem) {
66
+ const report = new ValidationReport();
67
+ for (const [field, type] of REQUIRED_PLIST_FIELDS) {
68
+ const value = metadata[field];
69
+ const valid = type === "int" ? isInt(value) : typeof value === "string";
70
+ if (!valid || (typeof value === "string" && !value.trim())) {
71
+ report.errors.push(`Info.plist: ${field} must be a non-empty ${type}`);
72
+ }
73
+ }
74
+ if (report.errors.length)
75
+ return report;
76
+ const text = (field) => metadata[field];
77
+ if (text("Name") !== bundleStem) {
78
+ report.errors.push(`Info.plist Name (${pyRepr(text("Name"))}) must match bundle name (${pyRepr(bundleStem)})`);
79
+ }
80
+ const home = urlsplit(text("CreatorHomePage"));
81
+ if (!["http", "https"].includes(home.scheme) || !home.netloc) {
82
+ report.errors.push("Info.plist CreatorHomePage must be an absolute HTTP(S) URL");
83
+ }
84
+ for (const field of ["ThemeIdentifier", "Name", "CreatorHomePage", "CreatorName"]) {
85
+ const lowered = text(field).trim().toLowerCase();
86
+ if (PLACEHOLDERS.some((token) => lowered.includes(token))) {
87
+ report.errors.push(`Info.plist ${field} still contains placeholder metadata`);
88
+ }
89
+ }
90
+ if (metadata.Version < 1) {
91
+ report.errors.push("Info.plist Version must be an integer of at least 1");
92
+ }
93
+ return report;
94
+ }
95
+ function remoteMessage(report, message, allowRemoteMedia) {
96
+ if (allowRemoteMedia)
97
+ report.warnings.push(message);
98
+ else
99
+ report.errors.push(`${message} (pass --allow-remote-media to acknowledge)`);
100
+ }
101
+ function isRegularFile(path) {
102
+ try {
103
+ return statSync(path).isFile();
104
+ }
105
+ catch {
106
+ return false;
107
+ }
108
+ }
109
+ export function validateSource(theme, { allowRemoteMedia = false } = {}) {
110
+ const report = new ValidationReport();
111
+ const stem = themeStem(theme);
112
+ if (!SAFE_BUNDLE_NAME.test(stem) || stem === "." || stem === "..") {
113
+ report.errors.push("theme bundle name contains path-unsafe characters");
114
+ }
115
+ if (existsSync(join(dirname(theme), PLACEHOLDER_MARKER))) {
116
+ report.errors.push("run `npx nnw-theme@1 init` before packaging this theme");
117
+ }
118
+ const names = readdirSync(theme);
119
+ for (const required of REQUIRED_THEME_FILES) {
120
+ if (!names.includes(required)) {
121
+ report.errors.push(`theme is missing exact required file ${required}`);
122
+ }
123
+ }
124
+ for (const name of names) {
125
+ const path = join(theme, name);
126
+ if (lstatSync(path).isSymbolicLink()) {
127
+ report.errors.push(`theme contains symbolic link ${name}`);
128
+ }
129
+ else if (!isRegularFile(path)) {
130
+ report.errors.push(`theme contains unsupported directory ${name}`);
131
+ }
132
+ else if (!isAllowedFile(name)) {
133
+ report.errors.push(`theme contains unsupported file ${name}`);
134
+ }
135
+ }
136
+ if (report.errors.length && !names.includes("Info.plist"))
137
+ return report;
138
+ const metadataReport = validateMetadata(readPlist(theme), stem);
139
+ report.errors.push(...metadataReport.errors);
140
+ report.warnings.push(...metadataReport.warnings);
141
+ const templatePath = join(theme, "template.html");
142
+ if (isRegularFile(templatePath)) {
143
+ const template = readFileSync(templatePath, "utf8");
144
+ if (!template.includes("[[")) {
145
+ report.warnings.push("template.html contains no NetNewsWire macros");
146
+ }
147
+ const references = resourceReferences(template);
148
+ if (/<style\b[^>]*>[\s\S]*?@import\s/i.test(template)) {
149
+ report.errors.push("CSS @import is not allowed in template.html");
150
+ }
151
+ for (const [tag, attribute, value] of references) {
152
+ if (!value ||
153
+ ["data:", "#", "mailto:", "tel:"].some((prefix) => value.startsWith(prefix))) {
154
+ continue;
155
+ }
156
+ if (tag === "script" || tag === "stylesheet") {
157
+ report.errors.push(`external ${tag} is not allowed: ${value}`);
158
+ }
159
+ else if (isRemote(value)) {
160
+ remoteMessage(report, `remote theme-owned ${tag} may not load in NetNewsWire: ${value}`, allowRemoteMedia);
161
+ }
162
+ else if (!value.includes("[[")) {
163
+ report.errors.push("bundle-local resource references do not work in NetNewsWire: " +
164
+ `${attribute}=${pyRepr(value)}`);
165
+ }
166
+ }
167
+ }
168
+ const stylesheetPath = join(theme, "stylesheet.css");
169
+ if (isRegularFile(stylesheetPath)) {
170
+ const css = readFileSync(stylesheetPath, "utf8");
171
+ for (const match of css.matchAll(/url\(\s*['"]?([^)'"]+)/gi)) {
172
+ const value = (match[1] ?? "").trim();
173
+ if (value.startsWith("data:"))
174
+ continue;
175
+ if (isRemote(value)) {
176
+ remoteMessage(report, `remote CSS resource may not load in NetNewsWire: ${value}`, allowRemoteMedia);
177
+ }
178
+ else {
179
+ report.errors.push(`bundle-local CSS resource does not work in NetNewsWire: ${value}`);
180
+ }
181
+ }
182
+ if (/@import\s/i.test(css))
183
+ report.errors.push("CSS @import is not allowed");
184
+ }
185
+ return report;
186
+ }
187
+ /** A ZIP member name as PurePosixPath sees it: parts without empty or "." segments. */
188
+ function posixParts(name) {
189
+ return {
190
+ parts: name.split("/").filter((part) => part && part !== "."),
191
+ absolute: name.startsWith("/"),
192
+ };
193
+ }
194
+ export function validateArchive(content, assetName) {
195
+ const report = new ValidationReport();
196
+ if (!assetName.endsWith(".nnwtheme.zip")) {
197
+ // Everything below derives the bundle name from this suffix.
198
+ report.errors.push("release asset name must end in .nnwtheme.zip");
199
+ return report;
200
+ }
201
+ if (content.length > MAX_ASSET_BYTES) {
202
+ report.errors.push("release asset exceeds the 25 MiB compressed limit");
203
+ return report;
204
+ }
205
+ try {
206
+ const infos = readZip(content);
207
+ if (infos.reduce((sum, info) => sum + info.size, 0) > MAX_UNCOMPRESSED_BYTES) {
208
+ report.errors.push("release asset exceeds the 50 MiB expanded limit");
209
+ }
210
+ if (infos.some((info) => (info.mode & S_IFMT) === S_IFLNK)) {
211
+ report.errors.push("release asset contains a symbolic link");
212
+ }
213
+ const files = infos.filter((info) => !info.isDirectory);
214
+ const paths = files.map((info) => posixParts(info.name));
215
+ const keys = paths.map((path) => `${path.absolute ? "/" : ""}${path.parts.join("/")}`);
216
+ if (new Set(keys).size !== keys.length) {
217
+ report.errors.push("release asset contains duplicate paths");
218
+ }
219
+ if (paths.some((path) => path.absolute || path.parts.includes(".."))) {
220
+ report.errors.push("release asset contains an unsafe path");
221
+ return report;
222
+ }
223
+ const roots = new Set(paths.map((path) => path.parts[0]).filter((part) => part?.endsWith(".nnwtheme")));
224
+ const expectedStem = assetName.slice(0, -".nnwtheme.zip".length);
225
+ const expectedRoot = `${expectedStem}.nnwtheme`;
226
+ if (roots.size !== 1 || !roots.has(expectedRoot)) {
227
+ report.errors.push(`archive must contain exactly one top-level ${expectedRoot} bundle`);
228
+ return report;
229
+ }
230
+ const names = new Set(keys);
231
+ for (const required of REQUIRED_THEME_FILES) {
232
+ if (!names.has(`${expectedRoot}/${required}`)) {
233
+ report.errors.push(`archive is missing exact required file ${required}`);
234
+ }
235
+ }
236
+ for (const [index, path] of paths.entries()) {
237
+ if (path.parts.length !== 2 || !isAllowedFile(path.parts.at(-1) ?? "")) {
238
+ report.errors.push(`archive contains unsupported path ${keys[index]}`);
239
+ }
240
+ }
241
+ if (!report.errors.length) {
242
+ const info = files.find((entry) => entry.name === `${expectedRoot}/Info.plist`);
243
+ if (!info)
244
+ throw new Error(`There is no item named '${expectedRoot}/Info.plist' in the archive`);
245
+ const metadata = parsePlist(new TextDecoder().decode(info.read()));
246
+ if (!metadata ||
247
+ typeof metadata !== "object" ||
248
+ metadata instanceof PlistReal ||
249
+ Array.isArray(metadata)) {
250
+ throw new Error("Info.plist must be a dictionary");
251
+ }
252
+ report.errors.push(...validateMetadata(metadata, expectedStem).errors);
253
+ }
254
+ }
255
+ catch (error) {
256
+ report.errors.push(`invalid theme archive: ${error.message}`);
257
+ }
258
+ return report;
259
+ }
package/dist/zip.js ADDED
@@ -0,0 +1,72 @@
1
+ // Deterministic ZIP writing, and a small reader that sees what validation needs:
2
+ // every central-directory entry, including duplicates and Unix mode bits, which
3
+ // fflate's own unzip hides.
4
+ import { inflateSync, zipSync } from "fflate";
5
+ /**
6
+ * Write a ZIP whose bytes depend only on its content: names in the given order,
7
+ * 1980-01-01 00:00 timestamps, Unix mode 0644, maximum compression.
8
+ */
9
+ export function writeZip(files) {
10
+ const entries = {};
11
+ // fflate writes DOS times from local-time fields, so this is 1980-01-01 00:00 exactly.
12
+ const mtime = new Date(1980, 0, 1, 0, 0, 0);
13
+ for (const [name, content] of files) {
14
+ entries[name] = [content, { level: 9, mtime, os: 3, attrs: 0o100644 << 16 }];
15
+ }
16
+ return zipSync(entries);
17
+ }
18
+ export class ZipFormatError extends Error {
19
+ }
20
+ export function readZip(content) {
21
+ const view = new DataView(content.buffer, content.byteOffset, content.byteLength);
22
+ let end = -1;
23
+ for (let offset = content.length - 22; offset >= Math.max(0, content.length - 65557); offset--) {
24
+ if (view.getUint32(offset, true) === 0x06054b50) {
25
+ end = offset;
26
+ break;
27
+ }
28
+ }
29
+ if (end < 0)
30
+ throw new ZipFormatError("File is not a zip file");
31
+ const count = view.getUint16(end + 10, true);
32
+ let offset = view.getUint32(end + 16, true);
33
+ const decoder = new TextDecoder();
34
+ const entries = [];
35
+ for (let index = 0; index < count; index++) {
36
+ if (offset + 46 > content.length || view.getUint32(offset, true) !== 0x02014b50) {
37
+ throw new ZipFormatError("Bad magic number for central directory");
38
+ }
39
+ const method = view.getUint16(offset + 10, true);
40
+ const compressedSize = view.getUint32(offset + 20, true);
41
+ const size = view.getUint32(offset + 24, true);
42
+ const nameLength = view.getUint16(offset + 28, true);
43
+ const extraLength = view.getUint16(offset + 30, true);
44
+ const commentLength = view.getUint16(offset + 32, true);
45
+ const external = view.getUint32(offset + 38, true);
46
+ const localOffset = view.getUint32(offset + 42, true);
47
+ const name = decoder.decode(content.subarray(offset + 46, offset + 46 + nameLength));
48
+ offset += 46 + nameLength + extraLength + commentLength;
49
+ entries.push({
50
+ name,
51
+ size,
52
+ mode: external >>> 16,
53
+ isDirectory: name.endsWith("/"),
54
+ read() {
55
+ if (view.getUint32(localOffset, true) !== 0x04034b50) {
56
+ throw new ZipFormatError("Bad magic number for file header");
57
+ }
58
+ const start = localOffset +
59
+ 30 +
60
+ view.getUint16(localOffset + 26, true) +
61
+ view.getUint16(localOffset + 28, true);
62
+ const data = content.subarray(start, start + compressedSize);
63
+ if (method === 0)
64
+ return data;
65
+ if (method === 8)
66
+ return inflateSync(data, { out: new Uint8Array(size) });
67
+ throw new ZipFormatError(`compression method ${method} is not supported`);
68
+ },
69
+ });
70
+ }
71
+ return entries;
72
+ }
@@ -0,0 +1,151 @@
1
+ """nnwdump: capture a NetNewsWire article as a fixture, from inside lldb.
2
+
3
+ Stopped at the final `return d` of ArticleRenderer.articleSubstitutions(), this
4
+ reads the substitution dictionary NetNewsWire built for the selected article and
5
+ writes it as a TOML fixture. `npx nnw-theme@1 capture` prints the setup steps.
6
+
7
+ Values cross the debugger boundary base64-encoded, so nothing is truncated or
8
+ mis-escaped. HTML values are written as '''literal''' strings, so fixtures stay
9
+ readable and diff cleanly.
10
+
11
+ This file runs in Xcode's lldb Python, not the project's: keep it standard-library
12
+ only and compatible with Python 3.9. It imports lldb lazily so the formatting
13
+ helpers can be tested without a debugger.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import optparse
20
+ import shlex
21
+ from pathlib import Path
22
+
23
+ USAGE = "nnwdump [--var NAME] [--article NAME] [--no-icon] [OUTPUT.toml]"
24
+ DEFAULT_OUTPUT = "fixtures/nnw-capture.toml"
25
+ # Sorted last so the short metadata stays at the top of the file.
26
+ _HTML_KEYS = {"body"}
27
+
28
+
29
+ def resolve_output(argument: str) -> Path:
30
+ """Relative paths resolve against the working directory.
31
+
32
+ This file ships in the nnw-theme package, not the theme repository, and lldb's
33
+ working directory under Xcode is /, so `npx nnw-theme@1 capture` prints an absolute
34
+ output path to pass instead.
35
+ """
36
+ path = Path(argument).expanduser()
37
+ return path if path.is_absolute() else Path.cwd() / path
38
+
39
+
40
+ def _toml_basic(value: str) -> str:
41
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
42
+ escaped = escaped.replace("\n", "\\n").replace("\t", "\\t").replace("\r", "\\r")
43
+ return "".join(c if ord(c) >= 0x20 and c != "\x7f" else f"\\u{ord(c):04x}" for c in escaped)
44
+
45
+
46
+ def toml_line(key: str, value: str) -> str:
47
+ """One `key = value` line in the most readable string form that is exact."""
48
+ # Literal strings cannot hold control characters (multiline ones allow newlines).
49
+ literal = not any((ord(c) < 0x20 and c not in "\t\n") or c == "\x7f" for c in value)
50
+ if literal and ("\n" in value or "<" in value) and "'''" not in value:
51
+ # TOML drops the newline right after the opening delimiter, so this one is
52
+ # layout, and a value's own leading newline survives.
53
+ return f"{key} = '''\n{value}'''"
54
+ if literal and "\n" not in value and "'" not in value:
55
+ return f"{key} = '{value}'"
56
+ return f'{key} = "{_toml_basic(value)}"'
57
+
58
+
59
+ def fixture_text(pairs: list[tuple[str, str]], name: str) -> str:
60
+ ordered = sorted(pairs, key=lambda pair: (pair[0] in _HTML_KEYS, pair[0]))
61
+ header = (
62
+ "# NetNewsWire article captured with nnwdump.\n"
63
+ f"# Preview with: npx nnw-theme@1 render {name}\n\n"
64
+ )
65
+ return header + "\n".join(toml_line(key, value) for key, value in ordered) + "\n"
66
+
67
+
68
+ def _evaluate(interpreter, expression: str):
69
+ """The exact text of a Swift String expression, or (None, error)."""
70
+ import lldb
71
+
72
+ wrapped = f"Data(({expression}).utf8).base64EncodedString()"
73
+ result = lldb.SBCommandReturnObject()
74
+ interpreter.HandleCommand("expression -l Swift -O -- " + wrapped, result)
75
+ if not result.Succeeded():
76
+ return None, (result.GetError() or "").strip()
77
+ token = (result.GetOutput() or "").strip().strip('"').strip()
78
+ try:
79
+ return base64.b64decode(token).decode("utf-8"), None
80
+ except ValueError as error:
81
+ return None, f"could not decode debugger output: {error}"
82
+
83
+
84
+ def _icon_data_url(interpreter, article: str):
85
+ # Mirrors ArticleIconSchemeHandler: the feed icon as PNG data.
86
+ encoded, _ = _evaluate(
87
+ interpreter,
88
+ f'{article}.iconImage()?.image.dataRepresentation()?.base64EncodedString() ?? ""',
89
+ )
90
+ return f"data:image/png;base64,{encoded}" if encoded else None
91
+
92
+
93
+ def nnwdump(debugger, command, result, internal_dict):
94
+ parser = optparse.OptionParser(prog="nnwdump", usage=USAGE)
95
+ parser.add_option("--var", default="d", help="substitution dictionary (default: d)")
96
+ parser.add_option("--article", default="article", help="Article value (default: article)")
97
+ parser.add_option("--no-icon", action="store_true", help="keep the generated feed icon")
98
+ try:
99
+ options, arguments = parser.parse_args(shlex.split(command))
100
+ except SystemExit:
101
+ result.SetError(f"usage: {USAGE}")
102
+ return
103
+ output = resolve_output(arguments[0] if arguments else DEFAULT_OUTPUT)
104
+
105
+ frame = debugger.GetSelectedTarget().GetProcess().GetSelectedThread().GetSelectedFrame()
106
+ if not frame or not frame.IsValid():
107
+ result.SetError("no stack frame; stop at the breakpoint first")
108
+ return
109
+ debugger.HandleCommand("settings set target.max-string-summary-length 0")
110
+
111
+ # One "key<TAB>base64(value)" line per entry; base64 never contains either separator.
112
+ interpreter = debugger.GetCommandInterpreter()
113
+ raw, error = _evaluate(
114
+ interpreter,
115
+ f'{options.var}.map {{ $0.key + "\\t" + Data($0.value.utf8).base64EncodedString() }}'
116
+ '.joined(separator: "\\n")',
117
+ )
118
+ if raw is None:
119
+ result.SetError(f"could not read `{options.var}`: {error}. Stop at `return d` first.")
120
+ return
121
+ pairs = []
122
+ for line in raw.strip().splitlines():
123
+ key, _, encoded = line.partition("\t")
124
+ if key and encoded:
125
+ pairs.append((key.strip(), base64.b64decode(encoded).decode("utf-8")))
126
+ if not pairs:
127
+ result.SetError(f"`{options.var}` is empty; is this the substitution dictionary?")
128
+ return
129
+
130
+ note = ""
131
+ if not options.no_icon:
132
+ icon = _icon_data_url(interpreter, options.article)
133
+ if icon:
134
+ pairs = [(key, value) for key, value in pairs if key != "avatar_src"]
135
+ pairs.append(("avatar_src", icon))
136
+ note = " with the feed's icon"
137
+ else:
138
+ note = "; the feed has no icon, so previews generate one"
139
+
140
+ try:
141
+ output.parent.mkdir(parents=True, exist_ok=True)
142
+ output.write_text(fixture_text(pairs, output.stem), encoding="utf-8")
143
+ except OSError as error:
144
+ result.SetError(f"could not write {output}: {error}")
145
+ return
146
+ result.AppendMessage(f"nnwdump: wrote {len(pairs)} values to {output}{note}")
147
+
148
+
149
+ def __lldb_init_module(debugger, internal_dict):
150
+ debugger.HandleCommand("command script add -f nnwdump.nnwdump nnwdump")
151
+ print(f"nnwdump is ready. At the breakpoint: {USAGE}")
package/package.json CHANGED
@@ -1,2 +1,55 @@
1
- {"name":"nnw-theme","version":"0.0.0","description":"Placeholder for https://github.com/dave-atx/nnw-theme","license"
2
- :"Apache-2.0","repository":{"type":"git","url":"git+https://github.com/dave-atx/nnw-theme.git"}}
1
+ {
2
+ "name": "nnw-theme",
3
+ "version": "0.1.0",
4
+ "description": "Create, preview, check, and publish a NetNewsWire theme.",
5
+ "license": "Apache-2.0",
6
+ "author": "Dave Marquard",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/dave-atx/nnw-theme.git"
10
+ },
11
+ "homepage": "https://github.com/dave-atx/nnw-theme#readme",
12
+ "bugs": "https://github.com/dave-atx/nnw-theme/issues",
13
+ "keywords": [
14
+ "netnewswire",
15
+ "theme",
16
+ "nnwtheme"
17
+ ],
18
+ "type": "module",
19
+ "bin": {
20
+ "nnw-theme": "dist/cli.js"
21
+ },
22
+ "files": [
23
+ "dist/",
24
+ "assets/",
25
+ "lldb/nnwdump.py",
26
+ "THIRD_PARTY_NOTICES.md",
27
+ "LICENSE",
28
+ "README.md"
29
+ ],
30
+ "engines": {
31
+ "node": ">=24"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.build.json && node scripts/fetch-netnewswire.ts",
35
+ "fetch-netnewswire": "node scripts/fetch-netnewswire.ts",
36
+ "lint": "biome check .",
37
+ "fix": "biome check --write .",
38
+ "typecheck": "tsc --noEmit",
39
+ "test": "node --test \"test/**/*.test.ts\"",
40
+ "prepack": "npm run build"
41
+ },
42
+ "dependencies": {
43
+ "@inquirer/prompts": "8.7.2",
44
+ "entities": "8.1.0",
45
+ "fflate": "0.8.3",
46
+ "htmlparser2": "12.0.0",
47
+ "playwright-core": "1.64.0-alpha-2026-09-14",
48
+ "smol-toml": "1.8.0"
49
+ },
50
+ "devDependencies": {
51
+ "@biomejs/biome": "2.5.14",
52
+ "@types/node": "24.13.6",
53
+ "typescript": "7.0.2"
54
+ }
55
+ }