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
package/dist/render.js ADDED
@@ -0,0 +1,516 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { decodeHTML } from "entities";
5
+ import { renderingInputs } from "./netnewswire.js";
6
+ import { fixturePath, readFixture, repositoryFixtures, ThemeError, themeStem, } from "./project.js";
7
+ import { formatG, pyStr } from "./pyformat.js";
8
+ const MACRO_RE = /\[\[([a-zA-Z0-9_-]+)\]\]/g;
9
+ const SCRIPT_RE = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
10
+ // A whole <img> or <source> tag; quoted attribute values may contain ">".
11
+ const MEDIA_TAG_RE = /<(img|source)\b((?:[^>"']|"[^"]*"|'[^']*')*)>/gi;
12
+ const ATTRIBUTE_RE = /([^\s"'=<>/]+)(?:\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>`]+))?/g;
13
+ const TEMPLATE_KEYS = [
14
+ "title",
15
+ "preferred_link",
16
+ "external_link_label",
17
+ "external_link_stripped",
18
+ "external_link",
19
+ "feed_link_title",
20
+ "feed_link",
21
+ "byline",
22
+ "avatar_src",
23
+ "dateline_style",
24
+ "datetime_long",
25
+ "datetime_medium",
26
+ "datetime_short",
27
+ "date_long",
28
+ "date_medium",
29
+ "date_short",
30
+ "time_long",
31
+ "time_medium",
32
+ "time_short",
33
+ "text_size_class",
34
+ "body",
35
+ ];
36
+ // macOS scales through text_size_class, iOS through the font-size macro. largeText
37
+ // is the macOS default: an unset preference falls back to ArticleTextSize.large.
38
+ export const DEFAULT_TEXT_SIZE_CLASS = "largeText";
39
+ export const LARGE_TEXT_SIZE_CLASS = "xxLargeText";
40
+ export const DEFAULT_DYNAMIC_TYPE_SIZE = 17.0;
41
+ export const LARGE_DYNAMIC_TYPE_SIZE = 23.0;
42
+ const WEBKIT_SHIM = `
43
+ window.webkit = window.webkit || {messageHandlers: new Proxy({}, {
44
+ get: function() { return {postMessage: function() {}}; }
45
+ })};
46
+ `;
47
+ // The iOS app injects the feed icon's localized accessibility label for main_ios.js
48
+ // (WebViewConfiguration.feedInfoLabelScript); templates with #nnwImageIcon need it.
49
+ const IOS_LABEL_SHIM = `
50
+ const nnwGetFeedInfoLabel = "Get Feed Info";
51
+ `;
52
+ export const PLATFORM_NAMES = {
53
+ mac: "Mac",
54
+ iphone: "iPhone",
55
+ ipad: "iPad",
56
+ };
57
+ const PLATFORMS = Object.keys(PLATFORM_NAMES);
58
+ export const APPEARANCES = ["light", "dark"];
59
+ const VIEWPORTS = {
60
+ mac: [1280, 800],
61
+ iphone: [393, 852],
62
+ ipad: [834, 1112],
63
+ };
64
+ // The template's own fixtures get the full matrix and the stress cases. Any other
65
+ // fixture a theme adds is an extra: checked on Mac and iPhone in both appearances.
66
+ export const FIXTURE_NAMES = {
67
+ article: "Article",
68
+ "kitchen-sink": "Kitchen sink",
69
+ };
70
+ const EXTRA_PLATFORMS = ["mac", "iphone"];
71
+ // Gallery sections, in order: [scenario, heading, what the scenario covers].
72
+ const SCENARIOS = [
73
+ [
74
+ "article",
75
+ "Everyday reading",
76
+ "The article fixture: a typical short post with a headline, byline, standfirst, " +
77
+ "prose, and a pull quote.",
78
+ ],
79
+ [
80
+ "kitchen-sink",
81
+ "Stress test",
82
+ "The kitchen-sink fixture: a very long headline, byline, and feed name; inline " +
83
+ "formatting; nested lists; an unbroken identifier that exposes horizontal " +
84
+ "overflow; a quotation, code block, table, figure, and footnote.",
85
+ ],
86
+ [
87
+ "large-text",
88
+ "Large text",
89
+ "The kitchen-sink fixture at a large reading size: the xxLargeText size class on " +
90
+ "Mac and 23 pt Dynamic Type on iPhone.",
91
+ ],
92
+ [
93
+ "article-javascript-off",
94
+ "Article JavaScript off",
95
+ "The article fixture with the theme's own scripts removed, as when a reader turns " +
96
+ "off NetNewsWire's Article JavaScript setting. It must still read well.",
97
+ ],
98
+ ];
99
+ export const RESERVED_FIXTURE_NAMES = SCENARIOS.map(([scenario]) => scenario);
100
+ const CHECKS = [
101
+ "article content renders",
102
+ "no unresolved [[macros]]",
103
+ "no horizontal overflow",
104
+ "no broken images",
105
+ "no external requests",
106
+ "no JavaScript errors",
107
+ ];
108
+ export class RenderTarget {
109
+ fixture;
110
+ platform;
111
+ appearance;
112
+ width;
113
+ height;
114
+ largeText;
115
+ themeScripts;
116
+ constructor(fixture, platform, appearance, { largeText = false, themeScripts = true } = {}) {
117
+ this.fixture = fixture;
118
+ this.platform = platform;
119
+ this.appearance = appearance;
120
+ [this.width, this.height] = VIEWPORTS[platform];
121
+ this.largeText = largeText;
122
+ this.themeScripts = themeScripts;
123
+ }
124
+ get ios() {
125
+ return this.platform === "iphone" || this.platform === "ipad";
126
+ }
127
+ get label() {
128
+ const parts = [
129
+ FIXTURE_NAMES[this.fixture] ?? this.fixture,
130
+ PLATFORM_NAMES[this.platform],
131
+ this.appearance,
132
+ ];
133
+ if (this.largeText)
134
+ parts.push("large text");
135
+ if (!this.themeScripts)
136
+ parts.push("Article JavaScript off");
137
+ return parts.join(" · ");
138
+ }
139
+ get scenario() {
140
+ if (this.largeText)
141
+ return "large-text";
142
+ if (!this.themeScripts)
143
+ return "article-javascript-off";
144
+ return this.fixture;
145
+ }
146
+ get slug() {
147
+ const parts = [this.fixture, this.platform, this.appearance];
148
+ if (this.largeText)
149
+ parts.push("large-text");
150
+ if (!this.themeScripts)
151
+ parts.push("no-article-js");
152
+ return parts.join("-");
153
+ }
154
+ }
155
+ /** Fixture names beyond the template's own, in gallery order. */
156
+ export function extraFixtures(root) {
157
+ const extras = repositoryFixtures(root).filter((name) => !(name in FIXTURE_NAMES));
158
+ const reserved = RESERVED_FIXTURE_NAMES.filter((name) => extras.includes(name)).sort();
159
+ if (reserved.length) {
160
+ throw new ThemeError(`fixture name(s) reserved for a check scenario: ${reserved.join(", ")}; rename the file`);
161
+ }
162
+ return extras;
163
+ }
164
+ export function normalTargets(extras = []) {
165
+ const targets = [];
166
+ for (const fixture of Object.keys(FIXTURE_NAMES)) {
167
+ for (const platform of PLATFORMS) {
168
+ for (const appearance of APPEARANCES) {
169
+ targets.push(new RenderTarget(fixture, platform, appearance));
170
+ }
171
+ }
172
+ }
173
+ for (const fixture of extras) {
174
+ for (const platform of EXTRA_PLATFORMS) {
175
+ for (const appearance of APPEARANCES) {
176
+ targets.push(new RenderTarget(fixture, platform, appearance));
177
+ }
178
+ }
179
+ }
180
+ return targets;
181
+ }
182
+ export function checkTargets(extras = []) {
183
+ return [
184
+ ...normalTargets(extras),
185
+ new RenderTarget("kitchen-sink", "mac", "light", { largeText: true }),
186
+ new RenderTarget("kitchen-sink", "iphone", "light", { largeText: true }),
187
+ new RenderTarget("article", "mac", "light", { themeScripts: false }),
188
+ new RenderTarget("article", "iphone", "light", { themeScripts: false }),
189
+ ];
190
+ }
191
+ /** Perform NetNewsWire's single-pass, non-recursive macro substitution. */
192
+ export function substitute(template, mapping) {
193
+ return template.replace(MACRO_RE, (match, name) => Object.hasOwn(mapping, name) ? pyStr(mapping[name]) : match);
194
+ }
195
+ /** Python's html.escape(value), which also escapes quotes. */
196
+ export function escapeHTML(value) {
197
+ return value
198
+ .replaceAll("&", "&amp;")
199
+ .replaceAll("<", "&lt;")
200
+ .replaceAll(">", "&gt;")
201
+ .replaceAll('"', "&quot;")
202
+ .replaceAll("'", "&#x27;");
203
+ }
204
+ function base64(text) {
205
+ return Buffer.from(text, "utf8").toString("base64");
206
+ }
207
+ function avatarDataURI(title) {
208
+ const words = title.trim().split(/\s+/).filter(Boolean);
209
+ const initials = words.length
210
+ ? words
211
+ .slice(0, 3)
212
+ .map((word) => Array.from(word)[0])
213
+ .join("")
214
+ .toUpperCase()
215
+ : "?";
216
+ const byte = Number.parseInt(createHash("sha256").update(title, "utf8").digest("hex").slice(0, 2), 16);
217
+ const hue = ((byte / 255) * 360).toFixed(0);
218
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96">' +
219
+ `<rect width="96" height="96" rx="18" fill="hsl(${hue} 42% 38%)"/>` +
220
+ '<text x="48" y="50" text-anchor="middle" dominant-baseline="central" ' +
221
+ 'font-family="system-ui" font-size="32" font-weight="700" fill="white">' +
222
+ `${escapeHTML(initials)}</text></svg>`;
223
+ return `data:image/svg+xml;base64,${base64(svg)}`;
224
+ }
225
+ /** Whether a URL would load over the network, including relative to the base URL. */
226
+ function fetches(url) {
227
+ const value = url.trim().toLowerCase();
228
+ return !!value && !value.startsWith("data:") && !value.startsWith("#");
229
+ }
230
+ function placeholder(width, height) {
231
+ const size = [width, height].map((value) => value && /^[0-9]+$/.test(value) ? Number.parseInt(value, 10) : 0);
232
+ const [widthPx, heightPx] = size.every(Boolean) ? size : [1600, 900];
233
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${widthPx}" height="${heightPx}">` +
234
+ '<rect width="100%" height="100%" fill="#8a8f98" fill-opacity=".35"/></svg>';
235
+ return `data:image/svg+xml;base64,${base64(svg)}`;
236
+ }
237
+ /**
238
+ * Swap a fixture's network images for same-size placeholders.
239
+ *
240
+ * Previews never touch the network, so a captured article's images would otherwise
241
+ * render broken. Each <img> keeps its attributes and width/height (16:9 when it has
242
+ * none), so layout matches; a <source> that would fetch is dropped for its <img>.
243
+ */
244
+ export function offlineMedia(body) {
245
+ return body.replace(MEDIA_TAG_RE, (whole, tagName, rest) => {
246
+ const tag = tagName.toLowerCase();
247
+ const attributes = [...rest.matchAll(ATTRIBUTE_RE)].map((match) => [
248
+ match[1] ?? "",
249
+ match[2] ?? "",
250
+ ]);
251
+ const values = new Map(attributes.map(([name, value]) => [
252
+ name.toLowerCase(),
253
+ decodeHTML(value.replace(/^["']+|["']+$/g, "")),
254
+ ]));
255
+ const srcset = (values.get("srcset") ?? "").split(",").some(fetches);
256
+ if (!(srcset || fetches(values.get("src") ?? "")))
257
+ return whole;
258
+ if (tag === "source")
259
+ return "";
260
+ const kept = attributes
261
+ .filter(([name]) => !["src", "srcset", "sizes"].includes(name.toLowerCase()))
262
+ .map(([name, value]) => (value ? ` ${name}=${value}` : ` ${name}`))
263
+ .join("");
264
+ return `<${tagName}${kept} src="${placeholder(values.get("width"), values.get("height"))}">`;
265
+ });
266
+ }
267
+ function fixtureMapping(fixture, target) {
268
+ const mapping = {};
269
+ for (const key of TEMPLATE_KEYS)
270
+ mapping[key] = pyStr(fixture[key] ?? "");
271
+ if (!mapping.dateline_style) {
272
+ mapping.dateline_style = mapping.title ? "articleDateline" : "articleDatelineTitle";
273
+ }
274
+ mapping.body = offlineMedia(mapping.body ?? "");
275
+ if (!mapping.avatar_src || fetches(mapping.avatar_src)) {
276
+ mapping.avatar_src = avatarDataURI(mapping.feed_link_title ?? "");
277
+ }
278
+ if (target.ios) {
279
+ // NetNewsWire leaves this macro unresolved on iOS; empty renders the same.
280
+ mapping.text_size_class = "";
281
+ }
282
+ else {
283
+ const fallback = target.largeText ? LARGE_TEXT_SIZE_CLASS : DEFAULT_TEXT_SIZE_CLASS;
284
+ mapping.text_size_class = pyStr(fixture.text_size_class || fallback);
285
+ }
286
+ return mapping;
287
+ }
288
+ function read(path) {
289
+ return readFileSync(path, "utf8");
290
+ }
291
+ function injectScripts(page, inputs, platform) {
292
+ const ios = platform === "iphone" || platform === "ipad";
293
+ const scripts = [WEBKIT_SHIM];
294
+ if (ios)
295
+ scripts.push(IOS_LABEL_SHIM);
296
+ scripts.push(read(join(inputs, "Shared", "main.js")), read(ios ? join(inputs, "iOS", "main_ios.js") : join(inputs, "Mac", "main_mac.js")), read(join(inputs, "Shared", "newsfoot.js")));
297
+ const bundle = scripts.map((script) => `<script>\n${script}\n</script>`).join("\n");
298
+ return page.replace("</body>", () => `${bundle}\n</body>`);
299
+ }
300
+ function isFile(path) {
301
+ try {
302
+ return statSync(path).isFile();
303
+ }
304
+ catch {
305
+ return false;
306
+ }
307
+ }
308
+ export function renderPage(theme, fixture, target, snapshot = renderingInputs().path) {
309
+ const pagePath = join(snapshot, target.ios ? "iOS" : "Mac", "page.html");
310
+ const required = [
311
+ pagePath,
312
+ join(snapshot, "Shared", "core.css"),
313
+ join(snapshot, "Shared", "main.js"),
314
+ ];
315
+ if (!required.every(isFile)) {
316
+ throw new ThemeError("bundled NetNewsWire rendering inputs are incomplete");
317
+ }
318
+ const skeleton = read(pagePath).replace(SCRIPT_RE, "");
319
+ const stylesheet = read(join(theme, "stylesheet.css"));
320
+ // styleSubstitutions() is empty on macOS, so the macro must stay literal there.
321
+ const styleSubstitutions = {};
322
+ if (target.ios) {
323
+ const size = target.largeText ? LARGE_DYNAMIC_TYPE_SIZE : DEFAULT_DYNAMIC_TYPE_SIZE;
324
+ styleSubstitutions["font-size"] = formatG(Number(fixture.font_size ?? size));
325
+ }
326
+ const style = substitute(`${read(join(snapshot, "Shared", "core.css"))}\n${stylesheet}`, styleSubstitutions);
327
+ const template = read(join(theme, "template.html"));
328
+ let body = substitute(template, fixtureMapping(fixture, target));
329
+ if (!target.themeScripts)
330
+ body = body.replace(SCRIPT_RE, "");
331
+ let page = substitute(skeleton, {
332
+ title: fixture.title ?? "Theme preview",
333
+ style,
334
+ body,
335
+ baseURL: fixture.preferred_link || fixture.feed_link || "",
336
+ windowScrollY: "0",
337
+ });
338
+ page = injectScripts(page, snapshot, target.platform);
339
+ const csp = "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; " +
340
+ "img-src data:; media-src data:; connect-src 'none'; frame-src 'none'; " +
341
+ "object-src 'none'; base-uri http: https:; form-action 'none'";
342
+ const head = '<meta charset="utf-8">\n' +
343
+ '<meta name="color-scheme" content="light dark">\n' +
344
+ `<meta http-equiv="Content-Security-Policy" content="${csp}">`;
345
+ return page.replace(/(<head[^>]*>)/i, (match) => `${match}\n${head}`);
346
+ }
347
+ export function renderSite(root, theme, targets, snapshot = renderingInputs().path) {
348
+ const site = join(root, "build", "preview");
349
+ rmSync(site, { recursive: true, force: true });
350
+ const pages = join(site, "pages");
351
+ const views = join(site, "views");
352
+ for (const directory of [pages, views, join(site, "screenshots")]) {
353
+ mkdirSync(directory, { recursive: true });
354
+ }
355
+ const name = themeStem(theme);
356
+ for (const target of targets) {
357
+ const fixture = readFixture(fixturePath(root, target.fixture));
358
+ writeFileSync(join(pages, `${target.slug}.html`), renderPage(theme, fixture, target, snapshot), "utf8");
359
+ const label = escapeHTML(target.label);
360
+ const viewer = `<!doctype html>
361
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
362
+ <title>${label} · ${escapeHTML(name)} preview</title><style>
363
+ html,body,iframe { border: 0; height: 100%; margin: 0; width: 100%; }
364
+ </style></head><body><iframe src="../pages/${target.slug}.html" sandbox="allow-scripts"
365
+ style="color-scheme: ${target.appearance}"
366
+ title="Sandboxed ${label} theme preview"></iframe></body></html>`;
367
+ writeFileSync(join(views, `${target.slug}.html`), viewer, "utf8");
368
+ }
369
+ writeGallery(site, name, targets);
370
+ return site;
371
+ }
372
+ function renderCase(target, failures) {
373
+ const slug = escapeHTML(target.slug);
374
+ const label = escapeHTML(target.label);
375
+ const shot = `screenshots/${slug}.png`;
376
+ let status = "";
377
+ if (failures !== null) {
378
+ status = failures.length
379
+ ? '<span class="badge fail">✗ Failed</span>'
380
+ : '<span class="badge pass">✓ Passed</span>';
381
+ }
382
+ const reasons = (failures ?? []).map((reason) => `<li>${escapeHTML(reason)}</li>`).join("");
383
+ // The live page renders at the real viewport size and is scaled into the thumbnail;
384
+ // the checked screenshot covers it once it exists.
385
+ return (`<figure class="case${failures?.length ? " failed" : ""}" id="${slug}">` +
386
+ `<a class="thumb" href="views/${slug}.html" ` +
387
+ `style="aspect-ratio: ${target.width} / ${target.height}">` +
388
+ `<iframe loading="lazy" src="pages/${slug}.html" sandbox="allow-scripts" ` +
389
+ `tabindex="-1" width="${target.width}" height="${target.height}" ` +
390
+ `style="color-scheme: ${target.appearance}" ` +
391
+ `title="Sandboxed ${label} theme preview"></iframe>` +
392
+ `<img src="${shot}" alt="Checked screenshot: ${label}" ` +
393
+ 'onload="this.previousElementSibling.remove()" onerror="this.remove()"></a>' +
394
+ `<figcaption>${status}` +
395
+ `<a href="views/${slug}.html">Full size</a>` +
396
+ `${failures !== null ? ` · <a href=${shot}>Screenshot</a>` : ""}` +
397
+ `${reasons ? `<ul class=reasons>${reasons}</ul>` : ""}</figcaption></figure>`);
398
+ }
399
+ function title(word) {
400
+ return word.charAt(0).toUpperCase() + word.slice(1);
401
+ }
402
+ function section(heading, description, targets, results) {
403
+ const platforms = PLATFORMS.filter((name) => targets.some((t) => t.platform === name));
404
+ const appearances = APPEARANCES.filter((name) => targets.some((t) => t.appearance === name));
405
+ const header = appearances.map((name) => `<div class="column">${title(name)}</div>`).join("");
406
+ const byCell = new Map(targets.map((t) => [`${t.platform}/${t.appearance}`, t]));
407
+ const rows = platforms.map((platform) => {
408
+ const cells = appearances.map((appearance) => {
409
+ const target = byCell.get(`${platform}/${appearance}`);
410
+ if (!target)
411
+ return "<div></div>";
412
+ return renderCase(target, results === null ? null : (results[target.slug] ?? []));
413
+ });
414
+ return `<div class="row-label">${PLATFORM_NAMES[platform]}</div>${cells.join("")}`;
415
+ });
416
+ return (`<section><h2>${escapeHTML(heading)}</h2><p>${escapeHTML(description)}</p>` +
417
+ `<div class="matrix" style="--columns: ${appearances.length}">` +
418
+ `<div></div>${header}${rows.join("")}</div></section>`);
419
+ }
420
+ /** Write index.html; results maps each checked slug to its failures. */
421
+ export function writeGallery(site, themeName, targets, results = null) {
422
+ const known = new Set(RESERVED_FIXTURE_NAMES);
423
+ const scenarios = new Map(SCENARIOS.map(([scenario, heading, text]) => [scenario, [heading, text]]));
424
+ for (const target of targets) {
425
+ if (!known.has(target.scenario) && !scenarios.has(target.scenario)) {
426
+ scenarios.set(target.scenario, [target.fixture, `Your fixtures/${target.fixture}.toml.`]);
427
+ }
428
+ }
429
+ const sections = [];
430
+ for (const [scenario, [heading = "", text = ""]] of scenarios) {
431
+ const members = targets.filter((target) => target.scenario === scenario);
432
+ if (members.length)
433
+ sections.push(section(heading, text, members, results));
434
+ }
435
+ let summary;
436
+ let failedList = "";
437
+ if (results === null) {
438
+ summary =
439
+ `<p class=summary>${targets.length} cases, not checked yet. Run ` +
440
+ "<code>npx nnw-theme@1 check</code> to verify them in WebKit.</p>";
441
+ }
442
+ else {
443
+ const failed = targets.filter((target) => results[target.slug]?.length);
444
+ summary =
445
+ `<p class="summary ${failed.length ? "fail" : "pass"}">${targets.length} cases · ` +
446
+ `${targets.length - failed.length} passed · ${failed.length} failed</p>`;
447
+ if (failed.length) {
448
+ failedList =
449
+ "<section class=failures><h2>Failures</h2><ul>" +
450
+ failed
451
+ .map((t) => `<li><a href="#${escapeHTML(t.slug)}">${escapeHTML(t.label)}</a>: ` +
452
+ `${escapeHTML((results[t.slug] ?? []).join("; "))}</li>`)
453
+ .join("") +
454
+ "</ul></section>";
455
+ }
456
+ }
457
+ const checks = CHECKS.map((check) => `<li>${escapeHTML(check)}</li>`).join("");
458
+ const name = escapeHTML(themeName);
459
+ const gallery = `<!doctype html>
460
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
461
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E">
462
+ <title>${name} preview</title><style>
463
+ :root { color-scheme: light dark; font-family: system-ui; --pass: #1a7f37; --fail: #cf222e;
464
+ --line: color-mix(in srgb, CanvasText 18%, Canvas);
465
+ --wash: color-mix(in srgb, CanvasText 6%, Canvas); }
466
+ @media (prefers-color-scheme: dark) { :root { --pass: #3fb950; --fail: #f85149; } }
467
+ body { background: Canvas; color: CanvasText; line-height: 1.45; margin: 0 auto;
468
+ max-width: 72rem; padding: 1.5rem 1rem 4rem; }
469
+ .notice { background: var(--wash); border-radius: .4rem; font-size: .9rem;
470
+ margin: 0 0 1.5rem; padding: .6rem .9rem; }
471
+ h1 { margin: 0 0 .3rem; } h2 { margin: 0 0 .25rem; }
472
+ .summary { font-size: 1.1rem; font-weight: 600; margin: 0 0 .5rem; }
473
+ .summary.pass { color: var(--pass); } .summary.fail { color: var(--fail); }
474
+ details { margin-bottom: 1rem; } summary { cursor: pointer; }
475
+ section { border-top: 1px solid var(--line); padding: 1.5rem 0 .5rem; }
476
+ section > p { margin: 0 0 1rem; max-width: 46rem; }
477
+ .failures li { margin-bottom: .3rem; } .failures a { color: var(--fail); }
478
+ .matrix { align-items: start; display: grid; gap: 1rem 1.25rem;
479
+ grid-template-columns: 4rem repeat(var(--columns), minmax(0, 1fr)); }
480
+ .column { font-weight: 600; }
481
+ .row-label { font-weight: 600; padding-top: .3rem; }
482
+ .case { margin: 0; }
483
+ .thumb { background: var(--wash); border: 1px solid var(--line); border-radius: .5rem;
484
+ display: block; height: 20rem; max-width: 100%; overflow: hidden; position: relative;
485
+ width: auto; }
486
+ .case.failed .thumb { border: 2px solid var(--fail); }
487
+ .thumb iframe { border: 0; left: 0; pointer-events: none; position: absolute; top: 0;
488
+ transform-origin: 0 0; }
489
+ .thumb img { display: block; height: 100%; object-fit: cover; object-position: top;
490
+ width: 100%; }
491
+ figcaption { font-size: .85rem; margin-top: .4rem; }
492
+ .badge { font-weight: 600; margin-right: .5rem; }
493
+ .badge.pass { color: var(--pass); } .badge.fail { color: var(--fail); }
494
+ .reasons { color: var(--fail); margin: .3rem 0 0; padding-left: 1.1rem; }
495
+ @media (max-width: 40rem) {
496
+ .matrix { grid-template-columns: repeat(var(--columns), minmax(0, 1fr)); }
497
+ .matrix > div:first-child { display: none; }
498
+ .row-label { grid-column: 1 / -1; padding: 0; }
499
+ .thumb { height: auto; width: 100%; }
500
+ }
501
+ </style></head><body>
502
+ <p class="notice">Development preview. Install themes only from a release.</p>
503
+ <h1>${name} preview</h1>
504
+ ${summary}
505
+ <details><summary>What every case checks</summary><ul>${checks}</ul></details>
506
+ ${failedList}${sections.join("")}
507
+ <script>
508
+ // Scale each live page from its real viewport width down to the thumbnail.
509
+ const fit = frame => { frame.style.transform =
510
+ \`scale(\${frame.parentElement.clientWidth / frame.width})\`; };
511
+ const observer = new ResizeObserver(entries =>
512
+ entries.forEach(entry => entry.target.querySelectorAll("iframe").forEach(fit)));
513
+ document.querySelectorAll(".thumb").forEach(thumb => observer.observe(thumb));
514
+ </script></body></html>`;
515
+ writeFileSync(join(site, "index.html"), gallery, "utf8");
516
+ }
package/dist/stubs.js ADDED
@@ -0,0 +1,49 @@
1
+ // The small files every theme repository keeps. Each starts with a marker naming its
2
+ // stub and version; check compares only the marker, so authors can add notes below.
3
+ import { readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ export const TEMPLATE_BLOB = "https://github.com/dave-atx/netnewswire-theme-template/blob/main";
6
+ export const STUBS = [
7
+ { path: "AGENTS.md", name: "agents", version: 1 },
8
+ { path: ".agents/skills/creating-nnw-themes/SKILL.md", name: "skill", version: 1 },
9
+ { path: ".github/workflows/check.yml", name: "check", version: 1 },
10
+ { path: ".github/workflows/pages.yml", name: "pages", version: 1 },
11
+ { path: ".github/workflows/release.yml", name: "release", version: 1 },
12
+ { path: ".github/workflows/screenshot.yml", name: "screenshot", version: 1 },
13
+ ];
14
+ const MARKER = /nnw-theme-stub:\s*([a-z-]+)\s+v(\d+)/;
15
+ /** The marker in a stub file: its first line, or the first after YAML front matter. */
16
+ export function readMarker(text) {
17
+ let lines = text.split("\n");
18
+ if (lines[0]?.trim() === "---") {
19
+ const end = lines.indexOf("---", 1);
20
+ if (end > 0)
21
+ lines = lines.slice(end + 1);
22
+ }
23
+ const match = MARKER.exec(lines[0] ?? "");
24
+ return match ? { name: match[1] ?? "", version: Number(match[2]) } : null;
25
+ }
26
+ /** A warning for each stub that is missing, unmarked, or older than this package's. */
27
+ export function staleStubs(root) {
28
+ const warnings = [];
29
+ for (const stub of STUBS) {
30
+ const url = `${TEMPLATE_BLOB}/${stub.path}`;
31
+ let text;
32
+ try {
33
+ text = readFileSync(join(root, stub.path), "utf8");
34
+ }
35
+ catch {
36
+ warnings.push(`${stub.path} is missing; add the current stub from ${url}`);
37
+ continue;
38
+ }
39
+ const marker = readMarker(text);
40
+ if (!marker || marker.name !== stub.name) {
41
+ warnings.push(`${stub.path} has no nnw-theme-stub marker; replace it with ${url}`);
42
+ }
43
+ else if (marker.version < stub.version) {
44
+ warnings.push(`${stub.path} is stub ${stub.name} v${marker.version}; v${stub.version} is current. ` +
45
+ `Replace it with ${url}`);
46
+ }
47
+ }
48
+ return warnings;
49
+ }
@@ -0,0 +1,32 @@
1
+ // The parts of Python's urllib.parse.urlsplit the checks rely on, so a URL is judged
2
+ // remote, absolute, or local exactly as the Python tool judged it.
3
+ const SCHEME_CHARS = /^[A-Za-z][A-Za-z0-9+.-]*$/;
4
+ export function urlsplit(input) {
5
+ let url = input.replace(/^[\0-\x20]+/, "").replace(/[\t\r\n]/g, "");
6
+ let scheme = "";
7
+ const colon = url.indexOf(":");
8
+ if (colon > 0 && SCHEME_CHARS.test(url.slice(0, colon))) {
9
+ scheme = url.slice(0, colon).toLowerCase();
10
+ url = url.slice(colon + 1);
11
+ }
12
+ let netloc = "";
13
+ if (url.startsWith("//")) {
14
+ const rest = url.slice(2);
15
+ const end = rest.search(/[/?#]/);
16
+ netloc = end < 0 ? rest : rest.slice(0, end);
17
+ }
18
+ return { scheme, netloc, hostname: hostname(netloc) };
19
+ }
20
+ function hostname(netloc) {
21
+ const host = netloc.slice(netloc.lastIndexOf("@") + 1);
22
+ let name;
23
+ if (host.startsWith("[")) {
24
+ const end = host.indexOf("]");
25
+ name = end < 0 ? host.slice(1) : host.slice(1, end);
26
+ }
27
+ else {
28
+ const colon = host.indexOf(":");
29
+ name = colon < 0 ? host : host.slice(0, colon);
30
+ }
31
+ return name ? name.toLowerCase() : null;
32
+ }