nnw-theme 2.0.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,6 +35,7 @@ version, name it: `npx nnw-theme@2.0.0 check`.
35
35
  | `check` | Validate and package, then render and test every case in WebKit |
36
36
  | `screenshot` | Check one case and save its image; `--promote` makes it the marketplace card |
37
37
  | `package` | Validate and build `dist/<Name>.nnwtheme.zip` |
38
+ | `fixture list` / `fixture add <name>…` | Add ready-made fixtures modeled on WordPress, Ghost, Substack, and Medium markup |
38
39
  | `capture` | Explain how to capture a real article from NetNewsWire as a fixture |
39
40
  | `bump` | Increase the `Info.plist` `Version` before a release |
40
41
  | `marketplace enable` | Add the marketplace discovery topic on GitHub |
@@ -0,0 +1,118 @@
1
+ # nnw-theme catalog fixture: ghost (catalog version 1)
2
+ # Markup: Ghost Koenig editor cards, as sent in Ghost RSS content:encoded, captured 2026-09.
3
+ # Text and links are synthetic; the structure, classes and attributes are real.
4
+ # Exercises: a bare unsized lead image, wide/full image cards, gallery rows, bookmark,
5
+ # callout, button, call-to-action, toggle, video and embed cards, loading="lazy" images.
6
+ # Refresh: npx nnw-theme@2 fixture add ghost --force
7
+
8
+ byline = 'Rowan Ellery, Sam Okafor'
9
+ date_long = 'September 28, 2026'
10
+ date_medium = 'Sep 28, 2026'
11
+ date_short = '9/28/26'
12
+ dateline_style = 'articleDateline'
13
+ datetime_long = 'September 28, 2026 at 9:00:00 AM'
14
+ datetime_medium = 'Sep 28, 2026 at 9:00 AM'
15
+ datetime_short = '9/28/26, 9:00 AM'
16
+ feed_link = 'https://example.org/'
17
+ feed_link_title = 'The Workbench Journal'
18
+ preferred_link = 'https://example.org/notes-from-the-workbench/'
19
+ time_long = '9:00:00 AM PDT'
20
+ time_medium = '9:00:00 AM'
21
+ time_short = '9:00 AM'
22
+ title = 'Notes from the workbench: a season of small repairs'
23
+ body = '''
24
+ <img src="https://example.org/content/images/2026/09/workbench-lead.jpg" alt="Notes from the workbench: a season of small repairs"><p>Ghost’s RSS feed opens with the post’s feature image as a bare <code>img</code>, unsized and unwrapped, directly before the first paragraph. Everything after it is written by Ghost’s editor, which marks each rich block as a <code>kg-card</code>.</p>
25
+ <p>An image card with a caption:</p>
26
+ <figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://example.org/content/images/2026/09/bench-vise.jpg" class="kg-image" alt="A bench vise holding a strip of wood" loading="lazy" width="492" height="316"><figcaption><span style="white-space: pre-wrap;">A narrow image keeps its own size; the caption runs wider than the picture.</span></figcaption></figure>
27
+ <p>A wide image card, meant to break out of the text column:</p>
28
+ <figure class="kg-card kg-image-card kg-width-wide kg-card-hascaption"><img src="https://example.org/content/images/2026/09/shop-floor.jpg" class="kg-image" alt="The shop floor seen from the doorway" loading="lazy" width="2000" height="1125" srcset="https://example.org/content/images/size/w600/2026/09/shop-floor.jpg 600w, https://example.org/content/images/size/w1000/2026/09/shop-floor.jpg 1000w, https://example.org/content/images/size/w1600/2026/09/shop-floor.jpg 1600w, https://example.org/content/images/2026/09/shop-floor.jpg 2000w" sizes="(min-width: 1200px) 1200px"><figcaption><span style="white-space: pre-wrap;">The shop floor after the spring clean-out.</span></figcaption></figure>
29
+ <p>A full-width image card, meant to span the whole window:</p>
30
+ <figure class="kg-card kg-image-card kg-width-full"><img src="https://example.org/content/images/2026/09/lumber-rack.jpg" class="kg-image" alt="A rack of sorted lumber" loading="lazy" width="2400" height="900" srcset="https://example.org/content/images/size/w600/2026/09/lumber-rack.jpg 600w, https://example.org/content/images/size/w1000/2026/09/lumber-rack.jpg 1000w, https://example.org/content/images/size/w1600/2026/09/lumber-rack.jpg 1600w, https://example.org/content/images/size/w2400/2026/09/lumber-rack.jpg 2400w"></figure>
31
+ <p>A YouTube embed. Ghost’s oEmbed markup sizes every one at 200×113:</p>
32
+ <figure class="kg-card kg-embed-card"><iframe width="200" height="113" src="https://example.org/embed/sharpening-a-plane-iron?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen title="Sharpening a plane iron, start to finish"></iframe></figure>
33
+ <p>Another embed, sized 560×316:</p>
34
+ <figure class="kg-card kg-embed-card"><iframe src="https://example.org/talks/the-case-for-hand-tools" width="560" height="316" frameborder="0" scrolling="no" webkitallowfullscreen mozallowfullscreen allowfullscreen></iframe></figure>
35
+ <p>A bookmark card, a link preview; the feed drops its thumbnail and icon:</p>
36
+ <figure class="kg-card kg-bookmark-card"><a class="kg-bookmark-container" href="https://example.org/a-guide-to-winter-finishes/"><div class="kg-bookmark-content"><div class="kg-bookmark-title">A Guide to Winter Finishes &#x2022; The Workbench Journal</div><small><div class="kg-bookmark-description">Oils, waxes, and varnishes behave differently when the shop is cold. Here is what we use from November to March, and why each one earns its place.</div></small></div></a></figure>
37
+ <p>Text between two bookmark cards.</p>
38
+ <figure class="kg-card kg-bookmark-card"><a class="kg-bookmark-container" href="https://example.org/the-long-bench/"><div class="kg-bookmark-content"><div class="kg-bookmark-title">The Long Bench</div><small><div class="kg-bookmark-description">Twelve feet of maple and three years of arguments about where it should go.</div></small></div></a></figure>
39
+ <p>A toggle card:</p>
40
+ <div class="kg-card kg-toggle-card" data-kg-toggle-state="close">
41
+ <div class="kg-toggle-heading">
42
+ <h4 class="kg-toggle-heading-text"><span style="white-space: pre-wrap;">What we bought this season (~&#x24;2,100)</span></h4>
43
+ <button class="kg-toggle-card-icon" aria-label="Expand toggle to read content">
44
+ <svg id="Regular" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
45
+ <path class="cls-1" d="M23.25,7.311,12.53,18.03a.749.749,0,0,1-1.06,0L.75,7.311"/>
46
+ </svg>
47
+ </button>
48
+ </div>
49
+ <div class="kg-toggle-content"><ul><li value="1"><a href="https://example.org/tools/block-plane" target="_blank" rel="noopener noreferrer"><span style="white-space: pre-wrap;">Low-angle block plane</span></a><span style="white-space: pre-wrap;"> &#x2014; &#x24;180</span></li><li value="2"><a href="https://example.org/tools/marking-gauge" target="_blank" rel="noopener noreferrer"><span style="white-space: pre-wrap;">Wheel marking gauge</span></a><span style="white-space: pre-wrap;"> &#x2014; &#x24;45</span></li><li value="3"><a href="https://example.org/tools/dust-collector" target="_blank" rel="noopener noreferrer"><span style="white-space: pre-wrap;">Dust collector with a cyclone separator</span></a><span style="white-space: pre-wrap;"> &#x2014; &#x24;1,875</span></li></ul></div>
50
+ </div>
51
+ <p>A callout card:</p>
52
+ <div class="kg-card kg-callout-card kg-callout-card-grey"><div class="kg-callout-emoji">&#x1f4a1;</div><div class="kg-callout-text"><b><strong style="white-space: pre-wrap;">Have a repair story you would like us to feature? Send a few photos and a paragraph about what went wrong and how you fixed it; we read every one.</strong></b></div></div>
53
+ <p>A minimal call-to-action card:</p>
54
+ <div class="kg-card kg-cta-card kg-cta-bg-grey kg-cta-minimal " data-layout="minimal">
55
+
56
+ <div class="kg-cta-content">
57
+
58
+
59
+ <div class="kg-cta-content-inner">
60
+
61
+ <div class="kg-cta-text">
62
+ <p><span style="white-space: pre-wrap;">&#x1f4e2; The workshop schedule for next month is up. Sessions are small, so sign up early if you want a bench.</span></p>
63
+ </div>
64
+
65
+
66
+ </div>
67
+
68
+ </div>
69
+ </div>
70
+ <p>A sponsored call-to-action card with an unsized image and a button:</p>
71
+ <div class="kg-card kg-cta-card kg-cta-bg-yellow kg-cta-minimal kg-cta-has-img kg-cta-centered" data-layout="minimal">
72
+
73
+ <div class="kg-cta-sponsor-label-wrapper">
74
+ <div class="kg-cta-sponsor-label">
75
+ <b><strong style="white-space: pre-wrap;">from our friends at the Lumber Co-op</strong></b>
76
+ </div>
77
+ </div>
78
+
79
+ <div class="kg-cta-content">
80
+
81
+ <div class="kg-cta-image-container">
82
+ <a href="https://example.org/co-op?ref=workbench"><img src="https://example.org/content/images/2026/01/co-op-logo.webp" alt="CTA Image" data-image-dimensions="240x240"></a>
83
+ </div>
84
+
85
+
86
+ <div class="kg-cta-content-inner">
87
+
88
+ <div class="kg-cta-text">
89
+ <p><span style="white-space: pre-wrap;">Looking for well-dried hardwood? </span><a href="https://example.org/co-op?ref=workbench" class="cta-link-color"><b><strong style="white-space: pre-wrap;">The Lumber Co-op</strong></b></a><span style="white-space: pre-wrap;"> is giving readers </span><b><strong style="white-space: pre-wrap;">10 percent</strong></b><span style="white-space: pre-wrap;"> off their first order this fall. </span></p>
90
+ </div>
91
+
92
+
93
+ <a href="https://example.org/co-op?ref=workbench" class="kg-cta-button " style="background-color: #000000; color: #FFFFFF;">
94
+ BROWSE THE YARD
95
+ </a>
96
+
97
+ </div>
98
+
99
+ </div>
100
+ </div>
101
+ <p>A button card:</p>
102
+ <div class="kg-card kg-button-card kg-align-left"><a href="https://example.org/podcast/episode-12?t=2748" class="kg-btn kg-btn-accent">Shop Talk, Episode #12, 45:47</a></div>
103
+ <p>A gallery card with two rows of lazy images:</p>
104
+ <figure class="kg-card kg-gallery-card kg-width-wide"><div class="kg-gallery-container"><div class="kg-gallery-row"><div class="kg-gallery-image"><img src="https://example.org/content/images/2026/09/joint-1.png" width="934" height="1098" loading="lazy" alt="A dovetail joint" srcset="https://example.org/content/images/size/w600/2026/09/joint-1.png 600w, https://example.org/content/images/2026/09/joint-1.png 934w" sizes="(min-width: 720px) 720px"></div><div class="kg-gallery-image"><img src="https://example.org/content/images/2026/09/joint-2.png" width="1212" height="1050" loading="lazy" alt="A mortise and tenon" srcset="https://example.org/content/images/size/w600/2026/09/joint-2.png 600w, https://example.org/content/images/size/w1000/2026/09/joint-2.png 1000w, https://example.org/content/images/2026/09/joint-2.png 1212w" sizes="(min-width: 720px) 720px"></div></div><div class="kg-gallery-row"><div class="kg-gallery-image"><img src="https://example.org/content/images/2026/09/joint-3.png" width="1310" height="1208" loading="lazy" alt="A bridle joint" srcset="https://example.org/content/images/size/w600/2026/09/joint-3.png 600w, https://example.org/content/images/size/w1000/2026/09/joint-3.png 1000w, https://example.org/content/images/2026/09/joint-3.png 1310w" sizes="(min-width: 720px) 720px"></div><div class="kg-gallery-image"><img src="https://example.org/content/images/2026/09/joint-4.png" width="1084" height="1122" loading="lazy" alt="A half-lap joint" srcset="https://example.org/content/images/size/w600/2026/09/joint-4.png 600w, https://example.org/content/images/size/w1000/2026/09/joint-4.png 1000w, https://example.org/content/images/2026/09/joint-4.png 1084w" sizes="(min-width: 720px) 720px"></div><div class="kg-gallery-image"><img src="https://example.org/content/images/2026/09/joint-5.png" width="1200" height="800" loading="lazy" alt="A finger joint" srcset="https://example.org/content/images/size/w600/2026/09/joint-5.png 600w, https://example.org/content/images/size/w1000/2026/09/joint-5.png 1000w, https://example.org/content/images/2026/09/joint-5.png 1200w" sizes="(min-width: 720px) 720px"></div></div></div></figure>
105
+ <p>A portrait video card:</p>
106
+ <figure class="kg-card kg-video-card kg-width-regular" data-kg-thumbnail="https://example.org/content/media/2026/09/glue-up_thumb.jpg" data-kg-custom-thumbnail>
107
+ <div class="kg-video-container">
108
+ <video src="https://example.org/content/media/2026/09/glue-up.mp4" poster="https://example.org/content/media/2026/09/glue-up_thumb.jpg" width="1080" height="1920" playsinline preload="metadata" controls></video>
109
+
110
+
111
+ </div>
112
+
113
+ </figure>
114
+ <p>A social embed, without its loader script:</p>
115
+ <figure class="kg-card kg-embed-card"><blockquote class="twitter-tweet"><p lang="en" dir="ltr">finished the long bench today. twelve feet, zero regrets &#x1fab5; <a href="https://example.org/s/bench-photo">example.org/s/bench-photo</a></p>&#x2014; Workbench Journal (@workbench) <a href="https://example.org/workbench/status/2100349174529265967">September 16, 2026</a></blockquote></figure>
116
+ <p>The alternative blockquote style:</p>
117
+ <blockquote class="kg-blockquote-alt">&#x201c;Measure twice, cut once, and then measure again out of habit.&#x201d; &#x2014; <a style="font-weight:300;color: var(--color-secondary-text);" href="https://example.org/sayings/" rel="noopener noreferrer" target="_blank">Shop saying</a></blockquote>
118
+ <p>That is the whole season. Next month: the finishing room.</p>'''
@@ -0,0 +1,26 @@
1
+ # nnw-theme catalog fixture: medium (catalog version 1)
2
+ # Markup: Medium posts, as sent in Medium RSS content:encoded, captured 2026-09.
3
+ # Text and links are synthetic; the structure, classes and attributes are real.
4
+ # Exercises: classless markup; figures whose images have no width or height (they
5
+ # render as 1600x900 placeholders, so a theme must scale them down), captions with
6
+ # emphasis, <pre> blocks written with <br> and long lines, an embed iframe, the
7
+ # 1x1 tracking pixel, and the "originally published" footer after an <hr>.
8
+ # Refresh: npx nnw-theme@2 fixture add medium --force
9
+
10
+ byline = 'Field Notes Engineering Team'
11
+ date_long = 'September 18, 2026'
12
+ date_medium = 'Sep 18, 2026'
13
+ date_short = '9/18/26'
14
+ dateline_style = 'articleDateline'
15
+ datetime_long = 'September 18, 2026 at 11:31:07 AM'
16
+ datetime_medium = 'Sep 18, 2026 at 11:31 AM'
17
+ datetime_short = '9/18/26, 11:31 AM'
18
+ feed_link = 'https://example.org/field-notes?source=rss'
19
+ feed_link_title = 'Field Notes - Medium'
20
+ preferred_link = 'https://example.org/field-notes/caching-the-slow-path-5e1a0c9b7d2f?source=rss'
21
+ time_long = '11:31:07 AM PDT'
22
+ time_medium = '11:31:07 AM'
23
+ time_short = '11:31 AM'
24
+ title = 'Caching the Slow Path: What We Learned Moving Our Search Index Closer to Readers'
25
+ body = '''
26
+ <p><em>By </em><a href="https://example.org/@avery.lind"><em>Avery Lind</em></a><em> and </em><a href="https://example.org/@tomas.reyes"><em>Tomás Reyes</em></a></p><h3>Introduction</h3><p>Most of our search queries are fast. A small share are not, and those few slow ones account for nearly all of the complaints. This post describes how we found them, why caching them was harder than it looked, and what the change did to latency for readers far from our data centers.</p><figure><img alt="" src="https://example.org/max/1024/1*search-latency-overview.png" /><figcaption>Query latency by percentile before the change. The long tail on the right is the slow path.</figcaption></figure><h3>Finding the slow path</h3><p>We started by sampling every query that took longer than a second and grouping them by shape. Three shapes covered 90 percent of the slow queries, and all three shared one expensive step: expanding a reader’s saved filters into a full query.</p><blockquote><em>If a query is slow because of the reader, not the content, the cache key has to be the reader.</em></blockquote><p>That observation shaped everything that followed. Here is the filter definition that expanded into the largest queries:</p><pre>{<br> "filter": "saved",<br> "owner": "reader:7f3a",<br> "expand": ["topics", "authors", "publications", "followed_tags", "muted_tags", "language_preferences"],<br> "limit": 50<br>}</pre><h4><strong>Why a plain cache did not work</strong></h4><p>A reader’s filters change rarely, but the content they match changes constantly. Caching the results would have served stale lists; caching only the expansion kept results fresh and removed the expensive step.</p><figure><img alt="" src="https://example.org/max/248/1*cache-key-formula.png" /><figcaption><em>The cache key: a hash of the reader’s filter set and its version, so any edit invalidates it.</em></figcaption></figure><ol><li><strong>Hash the filter set.</strong> Readers with identical filters share an entry.</li><li><strong>Version on edit.</strong> Any change to a filter bumps its version and misses the cache.</li><li><strong>Expire slowly.</strong> Entries live for a day, which is longer than most filters go unused.</li></ol><p>The rollout was gradual. We enabled the cache for one region at a time and compared latency against the regions still waiting:</p><figure><img alt="" src="https://example.org/max/1024/1*latency-by-region.jpeg" /><figcaption>Median and 99th-percentile latency by region, two weeks after rollout. Regions farthest from our data centers improved the most.</figcaption></figure><p>We also recorded a short walkthrough of the dashboards we used during the rollout:</p><figure><iframe src="https://example.org/widgets/media.html?src=https%3A%2F%2Fexample.org%2Fembed%2Fsearch-rollout&amp;display_name=Video&amp;type=text%2Fhtml&amp;schema=video" width="854" height="480" frameborder="0" scrolling="no"><a href="https://example.org/media/3b9f0e2c7d1a">https://example.org/media/3b9f0e2c7d1a</a></iframe></figure><h3>What we would do differently</h3><ul><li>Measure the slow path per region from the start; the global numbers hid the worst of it.</li><li>Build the invalidation first. Every bug we shipped was a stale entry.</li></ul><pre>curl -s "https://example.org/api/search?filter=saved&amp;owner=reader:7f3a&amp;expand=topics,authors,publications,followed_tags,muted_tags,language_preferences&amp;limit=50" | jq '.results | length'</pre><h3>Acknowledgements</h3><p>Thanks to <a href="https://example.org/@jun.park"><em>Jun Park</em></a> for the latency dashboards and <a href="https://example.org/@lena.koch"><em>Lena Koch</em></a> for reviewing the invalidation design.</p><img src="https://example.org/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5e1a0c9b7d2f" width="1" height="1" alt=""><hr><p><a href="https://example.org/field-notes/caching-the-slow-path-5e1a0c9b7d2f">Caching the Slow Path: What We Learned Moving Our Search Index Closer to Readers</a> was originally published in <a href="https://example.org/field-notes">Field Notes</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>'''
@@ -0,0 +1,33 @@
1
+ # nnw-theme catalog fixture: substack (catalog version 1)
2
+ # Markup: Substack posts, as sent in Substack RSS content:encoded, captured 2026-09.
3
+ # Text and links are synthetic; the structure, classes and attributes are real.
4
+ # Exercises: captioned-image-container wrapping <picture> with <source> srcsets and
5
+ # image2 data attributes, wide and narrow images, inline SVG buttons over images,
6
+ # footnote-anchor / footnote-content footnotes, subscribe and button widgets,
7
+ # pullquote, callout, embedded post, iframe embeds, and a long publication name.
8
+ # NetNewsWire's own scripts turn the footnote anchors into popovers, and check tests
9
+ # each one.
10
+ # Refresh: npx nnw-theme@2 fixture add substack --force
11
+
12
+ byline = 'Morgan Halloway'
13
+ date_long = 'September 24, 2026'
14
+ date_medium = 'Sep 24, 2026'
15
+ date_short = '9/24/26'
16
+ dateline_style = 'articleDateline'
17
+ datetime_long = 'September 24, 2026 at 6:02:41 AM'
18
+ datetime_medium = 'Sep 24, 2026 at 6:02 AM'
19
+ datetime_short = '9/24/26, 6:02 AM'
20
+ feed_link = 'https://example.org/'
21
+ feed_link_title = 'The Commonplace Dispatch: Notes on Cities, Transit, and the Stubborn Economics of Everyday Life'
22
+ preferred_link = 'https://example.org/p/why-the-bus-is-late-and-what-it-costs'
23
+ time_long = '6:02:41 AM PDT'
24
+ time_medium = '6:02:41 AM'
25
+ time_short = '6:02 AM'
26
+ title = 'Why the bus is late, what it quietly costs everyone who rides it, and the three fixes that actually work'
27
+ body = '''
28
+ <div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://example.org/image/fetch/w_1456/bus-stop.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://example.org/image/fetch/w_424,f_webp/bus-stop.jpeg 424w, https://example.org/image/fetch/w_848,f_webp/bus-stop.jpeg 848w, https://example.org/image/fetch/w_1272,f_webp/bus-stop.jpeg 1272w, https://example.org/image/fetch/w_1456,f_webp/bus-stop.jpeg 1456w" sizes="100vw"><img src="https://example.org/image/fetch/w_1456,f_auto/bus-stop.jpeg" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://example.org/public/images/bus-stop_2048x1366.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:402119,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/jpeg&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://example.org/image/fetch/w_424,f_auto/bus-stop.jpeg 424w, https://example.org/image/fetch/w_848,f_auto/bus-stop.jpeg 848w, https://example.org/image/fetch/w_1272,f_auto/bus-stop.jpeg 1272w, https://example.org/image/fetch/w_1456,f_auto/bus-stop.jpeg 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">Riders waiting at a stop on a route that is scheduled every ten minutes and arrives, on average, every seventeen. Photo by the author.</figcaption></figure></div><p><span>Every transit agency publishes a schedule, and every rider learns how far to trust it.</span><a class="footnote-anchor" data-component-name="FootnoteAnchorToDOM" id="footnote-anchor-1" href="#footnote-1" target="_self">1</a><span> The gap between the two is where most of the cost of a late bus hides: not in the minutes themselves, but in the slack people build into their days to absorb them.</span></p><p>This week I want to walk through where that slack comes from, how to put a number on it, and which fixes cities have tried that measurably shrink it.<a class="footnote-anchor" data-component-name="FootnoteAnchorToDOM" id="footnote-anchor-2" href="#footnote-2" target="_self">2</a></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://example.org/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">The Commonplace Dispatch is reader-supported. To receive new posts and support the work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><h3>Where the minutes go</h3><p>A bus that runs late at the start of its route rarely catches up. Each stop adds a little dwell time, and a crowded bus dwells longer, so the delay compounds as the trip goes on. The chart below shows one route’s average delay by stop over a month of weekday mornings.</p><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://example.org/image/fetch/w_1456/delay-by-stop.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://example.org/image/fetch/w_424,f_webp/delay-by-stop.png 424w, https://example.org/image/fetch/w_848,f_webp/delay-by-stop.png 848w, https://example.org/image/fetch/w_1272,f_webp/delay-by-stop.png 1272w, https://example.org/image/fetch/w_1456,f_webp/delay-by-stop.png 1456w" sizes="100vw"><img src="https://example.org/image/fetch/w_1456,f_auto/delay-by-stop.png" width="1456" height="49" data-attrs="{&quot;src&quot;:&quot;https://example.org/public/images/delay-by-stop_1456x49.png&quot;,&quot;height&quot;:49,&quot;width&quot;:1456,&quot;type&quot;:&quot;image/png&quot;,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" title="" srcset="https://example.org/image/fetch/w_424,f_auto/delay-by-stop.png 424w, https://example.org/image/fetch/w_848,f_auto/delay-by-stop.png 848w, https://example.org/image/fetch/w_1272,f_auto/delay-by-stop.png 1272w, https://example.org/image/fetch/w_1456,f_auto/delay-by-stop.png 1456w" sizes="100vw" loading="lazy"></picture><div></div></div></a></figure></div><p>The pattern is the same on nearly every route we looked at: a small delay at the first few stops, then a steady climb.</p><div class="pullquote"><p>The cost of a late bus is not the minutes it loses. It is the minutes everyone adds to their day in case it does.</p></div><p>A narrow image stays at its own size rather than stretching to the column:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://example.org/image/fetch/w_616/route-map.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://example.org/image/fetch/w_424,f_webp/route-map.jpeg 424w, https://example.org/image/fetch/w_848,f_webp/route-map.jpeg 848w" sizes="100vw"><img src="https://example.org/image/fetch/w_616,f_auto/route-map.jpeg" width="616" height="452" data-attrs="{&quot;src&quot;:&quot;https://example.org/public/images/route-map_616x452.jpeg&quot;,&quot;height&quot;:452,&quot;width&quot;:616,&quot;type&quot;:&quot;image/jpeg&quot;,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://example.org/image/fetch/w_424,f_auto/route-map.jpeg 424w, https://example.org/image/fetch/w_848,f_auto/route-map.jpeg 848w" sizes="100vw" loading="lazy"></picture><div></div></div></a><figcaption class="image-caption">The route, with the three stops where most of the delay starts.</figcaption></figure></div><h3>Three fixes that work</h3><ol><li><p><strong>Board at every door.</strong> Letting riders tap on at the back cuts dwell time at busy stops by a third.</p></li><li><p><strong>Paint the lane.</strong> A bus lane only helps if it is kept clear, but even a painted one changes where drivers stop.</p></li><li><p><strong>Hold the signal.</strong> Giving a late bus a few extra seconds of green light keeps small delays from compounding.<a class="footnote-anchor" data-component-name="FootnoteAnchorToDOM" id="footnote-anchor-3" href="#footnote-3" target="_self">3</a></p></li></ol><div class="callout-block" data-callout="true"><h4><strong>&#128338; How we measured</strong></h4><p>We compared scheduled and actual arrival times for every weekday morning trip in August, using the agency’s published real-time feed. <em><strong>Updated September 24, 2026</strong></em></p></div><p>I wrote about the politics of bus lanes last spring, and the argument still holds:</p><div class="embedded-post-wrap" data-attrs="{&quot;id&quot;:171234567,&quot;url&quot;:&quot;https://example.org/p/who-owns-the-curb&quot;,&quot;publication_name&quot;:&quot;The Commonplace Dispatch&quot;}" data-component-name="EmbeddedPostToDOM"><a class="embedded-post" native="true" href="https://example.org/p/who-owns-the-curb"><div class="embedded-post-header"><img class="embedded-post-publication-logo" src="https://example.org/image/fetch/w_56,h_56/logo.png"><span class="embedded-post-publication-name">The Commonplace Dispatch</span></div><div class="embedded-post-title-wrapper"><div class="embedded-post-title">Who owns the curb? </div></div><div class="embedded-post-body">Every city street is a negotiation between people who want to park, people who want to load, and people who want to get through. The curb is where that negotiation gets loud&#8230;</div><div class="embedded-post-cta-wrapper"><span class="embedded-post-cta">Read more</span></div><div class="embedded-post-meta">5 months ago &#183; 212 likes &#183; 48 comments &#183; Morgan Halloway</div></a></div><p>An interactive chart, embedded as an iframe:</p><div id="datawrapper-iframe" class="datawrapper-wrap outer" data-attrs="{&quot;url&quot;:&quot;https://example.org/charts/dw-delay/1/&quot;}" data-component-name="DatawrapperToDOM"><iframe id="iframe-datawrapper" class="datawrapper-iframe" src="https://example.org/charts/dw-delay/1/" width="730" height="471" frameborder="0" scrolling="no"></iframe></div><div class="twitter-embed" data-attrs="{&quot;url&quot;:&quot;https://example.org/transit/status/1971234567&quot;}" data-component-name="Twitter2ToDOM"></div><blockquote><p><span>A schedule is a promise the agency makes to itself. A frequency is a promise it makes to riders.</span></p></blockquote><p><span>That line from a planner I interviewed has stuck with me. If you ride a route that runs often enough, the schedule stops mattering, and so does most of the slack.</span></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://example.org/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://example.org/subscribe?"><span>Subscribe now</span></a></p><hr><p>Thanks for reading. Next week: why transfers feel twice as long as they are.</p><div class="footnote" data-component-name="FootnoteToDOM"><a id="footnote-1" href="#footnote-anchor-1" class="footnote-number" contenteditable="false" target="_self">1</a><div class="footnote-content"><p>Most riders, when asked, give a number within a minute or two of the route’s real average delay.</p></div></div><div class="footnote" data-component-name="FootnoteToDOM"><a id="footnote-2" href="#footnote-anchor-2" class="footnote-number" contenteditable="false" target="_self">2</a><div class="footnote-content"><p>The measurements come from a month of weekday mornings; evenings are <a href="https://example.org/p/the-evening-problem">a different story</a>.</p></div></div><div class="footnote" data-component-name="FootnoteToDOM"><a id="footnote-3" href="#footnote-anchor-3" class="footnote-number" contenteditable="false" target="_self">3</a><div class="footnote-content"><p>Signal priority works best on routes with long gaps between intersections. On a dense grid, the gains shrink.</p></div></div>'''
29
+
30
+ [expect.footnotes.notes]
31
+ "1" = "Most riders, when asked, give a number within a minute or two of the route’s real average delay."
32
+ "2" = "The measurements come from a month of weekday mornings; evenings are a different story."
33
+ "3" = "Signal priority works best on routes with long gaps between intersections. On a dense grid, the gains shrink."
@@ -0,0 +1,84 @@
1
+ # nnw-theme catalog fixture: wordpress (catalog version 1)
2
+ # Markup: WordPress 6 block and classic editor output, as sent in WordPress RSS
3
+ # content:encoded, captured 2026-08 and 2026-09.
4
+ # Text and links are synthetic; the structure, classes and attributes are real.
5
+ # Exercises: wp-block-image with an inline pixel width wider than the image,
6
+ # alignwide / alignfull, wp-element-caption, a gallery, pullquote, table, and embed
7
+ # figures; classic wp-caption with an inline width, alignleft / alignright floats, and
8
+ # an inline-styled float beside a paragraph; a <picture> ad; footnotes from WordPress
9
+ # core, Jetpack and Modern Footnotes; the "appeared first on" feed footer.
10
+ # NetNewsWire's own scripts turn only the Jetpack markers into popovers; a theme script
11
+ # can handle the others, and check then tests each popover.
12
+ # Refresh: npx nnw-theme@2 fixture add wordpress --force
13
+
14
+ byline = 'Priya Castellanos'
15
+ date_long = 'September 22, 2026'
16
+ date_medium = 'Sep 22, 2026'
17
+ date_short = '9/22/26'
18
+ dateline_style = 'articleDateline'
19
+ datetime_long = 'September 22, 2026 at 6:45:00 AM'
20
+ datetime_medium = 'Sep 22, 2026 at 6:45 AM'
21
+ datetime_short = '9/22/26, 6:45 AM'
22
+ feed_link = 'https://example.org/'
23
+ feed_link_title = 'Gulf Coast Weather Desk'
24
+ preferred_link = 'https://example.org/2026/09/22/one-more-hot-week-then-a-front/'
25
+ time_long = '6:45:00 AM CDT'
26
+ time_medium = '6:45:00 AM'
27
+ time_short = '6:45 AM'
28
+ title = 'One more hot week, then a front with some modest hope behind it'
29
+ body = '''
30
+ <p>Good morning. We are in the last stretch of the hot season, and the week ahead looks like it knows it. Expect highs in the middle 90s through Thursday before a weak front offers some relief this weekend.<sup data-fn="5f2c1a7e-3b4d-4e8f-9a60-1c2d3e4f5a6b" class="fn"><a href="#5f2c1a7e-3b4d-4e8f-9a60-1c2d3e4f5a6b" id="5f2c1a7e-3b4d-4e8f-9a60-1c2d3e4f5a6b-link">1</a></sup></p>
31
+
32
+ <figure class="wp-block-image size-full is-resized"><img fetchpriority="high" decoding="async" width="830" height="342" src="https://example.org/wp-content/uploads/2026/09/forecast-week.png" alt="" class="wp-image-59086" style="width:869px;height:auto" srcset="https://example.org/wp-content/uploads/2026/09/forecast-week.png 830w, https://example.org/wp-content/uploads/2026/09/forecast-week-300x124.png 300w, https://example.org/wp-content/uploads/2026/09/forecast-week-767x316.png 767w" sizes="(max-width: 830px) 100vw, 830px" /><figcaption class="wp-element-caption">The middle of this week will be hot and mostly dry. (Forecast model)</figcaption></figure>
33
+
34
+ <h2 class="wp-block-heading">Monday</h2>
35
+
36
+ <p>Another hot and mostly sunny day, with highs of 94 to 97 degrees. Rain chances are near zero, and winds will be light out of the southwest.<sup id="fnref-59090-1"><a href="#fn-59090-1" class="jetpack-footnote" title="Southwest winds keep the sea breeze from reaching inland neighborhoods.">2</a></sup></p>
37
+
38
+ <figure class="wp-block-image alignwide size-large"><img decoding="async" width="1024" height="576" src="https://example.org/wp-content/uploads/2026/09/radar-1024x576.png" alt="Radar loop over the coast" class="wp-image-59085" srcset="https://example.org/wp-content/uploads/2026/09/radar-1024x576.png 1024w, https://example.org/wp-content/uploads/2026/09/radar-300x169.png 300w, https://example.org/wp-content/uploads/2026/09/radar-1536x864.png 1536w, https://example.org/wp-content/uploads/2026/09/radar.png 1920w" sizes="(max-width: 1024px) 100vw, 1024px" /><figcaption class="wp-element-caption">A wide image: the radar is quiet, for now.</figcaption></figure>
39
+
40
+ <figure class="wp-block-image alignfull size-full"><img decoding="async" width="2560" height="720" src="https://example.org/wp-content/uploads/2026/09/skyline-scaled.jpg" alt="" class="wp-image-59084" srcset="https://example.org/wp-content/uploads/2026/09/skyline-scaled.jpg 2560w, https://example.org/wp-content/uploads/2026/09/skyline-300x84.jpg 300w, https://example.org/wp-content/uploads/2026/09/skyline-1024x288.jpg 1024w" sizes="(max-width: 2560px) 100vw, 2560px" /></figure>
41
+
42
+ <h2 class="wp-block-heading">Tuesday through Thursday</h2>
43
+
44
+ <p>More of the same. The overnight lows are the real story: they will barely drop below 80 near the coast, which gives the air conditioning no break.<sup class="modern-footnotes-footnote " data-mfn="3" data-mfn-post-scope="000000000000059090_3"><a href="javascript:void(0)" role="button" aria-pressed="false" aria-describedby="mfn-content-000000000000059090_3-3">3</a></sup><span id="mfn-content-000000000000059090_3-3" role="tooltip" class="modern-footnotes-footnote__note" tabindex="0" data-mfn="3">Lows in the 80s are about ten degrees above the long-term normal for late September.</span></p>
45
+
46
+ <figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Day</th><th>High</th><th>Low</th><th>Rain chance</th></tr></thead><tbody><tr><td>Tuesday</td><td>96°</td><td>80°</td><td>0%</td></tr><tr><td>Wednesday</td><td>97°</td><td>81°</td><td>10%</td></tr><tr><td>Thursday</td><td>95°</td><td>79°</td><td>20%</td></tr></tbody></table><figcaption class="wp-element-caption">The week ahead, at the airport station.</figcaption></figure>
47
+
48
+ <figure class="wp-block-pullquote"><blockquote><p>The front will not be dramatic. It will simply be the first one of the season that makes it all the way here.</p><cite>Forecast discussion, Monday morning</cite></blockquote></figure>
49
+
50
+ <figure class="wp-block-gallery has-nested-images columns-default is-cropped wp-block-gallery-1 is-layout-flex wp-block-gallery-is-layout-flex">
51
+ <figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="768" data-id="59081" src="https://example.org/wp-content/uploads/2026/09/clouds-1-1024x768.jpg" alt="" class="wp-image-59081" srcset="https://example.org/wp-content/uploads/2026/09/clouds-1-1024x768.jpg 1024w, https://example.org/wp-content/uploads/2026/09/clouds-1-300x225.jpg 300w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>
52
+
53
+ <figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="768" height="1024" data-id="59082" src="https://example.org/wp-content/uploads/2026/09/clouds-2-768x1024.jpg" alt="" class="wp-image-59082" srcset="https://example.org/wp-content/uploads/2026/09/clouds-2-768x1024.jpg 768w, https://example.org/wp-content/uploads/2026/09/clouds-2-225x300.jpg 225w" sizes="auto, (max-width: 768px) 100vw, 768px" /></figure>
54
+
55
+ <figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="683" data-id="59083" src="https://example.org/wp-content/uploads/2026/09/clouds-3-1024x683.jpg" alt="" class="wp-image-59083" srcset="https://example.org/wp-content/uploads/2026/09/clouds-3-1024x683.jpg 1024w, https://example.org/wp-content/uploads/2026/09/clouds-3-300x200.jpg 300w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>
56
+ <figcaption class="blocks-gallery-caption wp-element-caption">Reader photos of last week’s afternoon clouds.</figcaption></figure>
57
+
58
+ <figure class="wp-block-embed is-type-video is-provider-youtube wp-block-embed-youtube wp-embed-aspect-16-9 wp-has-aspect-ratio"><div class="wp-block-embed__wrapper">
59
+ <iframe loading="lazy" title="How a cold front forms" width="640" height="360" src="https://example.org/embed/cold-front?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
60
+ </div></figure>
61
+
62
+ <h2 class="wp-block-heading">The weekend</h2>
63
+
64
+ <div id="attachment_59070" style="width: 1034px" class="wp-caption aligncenter"><img aria-describedby="caption-attachment-59070" decoding="async" loading="lazy" class="size-large wp-image-59070" src="https://example.org/wp-content/uploads/2026/09/rain-chances-1024x696.png" alt="" width="1024" height="696" srcset="https://example.org/wp-content/uploads/2026/09/rain-chances-1024x696.png 1024w, https://example.org/wp-content/uploads/2026/09/rain-chances-300x204.png 300w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /><p id="caption-attachment-59070" class="wp-caption-text">A classic-editor caption: its inline width is 10 pixels wider than the image.</p></div>
65
+
66
+ <p><img loading="lazy" decoding="async" class="alignright size-medium wp-image-59071" src="https://example.org/wp-content/uploads/2026/09/thermometer-300x200.jpg" alt="" width="300" height="200" />The front should arrive Saturday evening and bring rain chances up to about 30 percent. Behind it, lows fall into the upper 60s for the first time since May. This paragraph wraps beside an image floated right by the classic editor, and it goes on long enough to run past the bottom of the image on a wide window.</p>
67
+
68
+ <p><img loading="lazy" decoding="async" class="alignleft size-thumbnail wp-image-59072" src="https://example.org/wp-content/uploads/2026/09/umbrella-150x150.jpg" alt="" width="150" height="150" />A shorter paragraph beside a left-floated thumbnail.</p>
69
+
70
+ <figure style="float:left; margin: 0 1.5em 1em 0;"><img width="96" height="96" src="https://example.org/wp-content/uploads/2026/03/station-icon-128x128.png" class="type:primaryImage wp-post-image" alt="Station icon" decoding="async" /></figure><p>A feed that floats a small icon in an inline-styled figure, followed by a real paragraph, which must wrap under the icon rather than run down beside it as a narrow column. After the front, the forecast settles into dry, mild days with highs near 85 and clear nights. It is the kind of weather this region waits all summer for, and it should last at least through the middle of next week.</p>
71
+
72
+ <p style="margin-top: 3em"><a href="https://example.org/sponsor/"><picture><source srcset="https://example.org/wp-content/uploads/2026/05/sponsor-640x200.png" media="(max-width: 600px)" type="image/png"><img src="https://example.org/wp-content/uploads/2026/05/sponsor-1456x180.png" srcset="https://example.org/wp-content/uploads/2026/05/sponsor-1456x180.png 1456w, https://example.org/wp-content/uploads/2026/05/sponsor-1456x180-640x79.png 640w" alt="Sponsored by a local generator installer"></picture></a></p>
73
+
74
+ <ol class="wp-block-footnotes"><li id="5f2c1a7e-3b4d-4e8f-9a60-1c2d3e4f5a6b">The front is the first of the season to reach the coast; earlier ones stalled well inland. <a href="#5f2c1a7e-3b4d-4e8f-9a60-1c2d3e4f5a6b-link" aria-label="Jump to footnote reference 1">↩︎</a></li></ol>
75
+
76
+ <div class="footnotes">
77
+ <hr />
78
+ <ol>
79
+ <li id="fn-59090-1">
80
+ Southwest winds keep the sea breeze from reaching inland neighborhoods.&#160;<a href="#fnref-59090-1">&#8617;</a>
81
+ </li>
82
+ </ol>
83
+ </div>
84
+ <p>The post <a href="https://example.org/2026/09/22/one-more-hot-week-then-a-front/">One more hot week, then a front with some modest hope behind it</a> appeared first on <a href="https://example.org">Gulf Coast Weather Desk</a>.</p>'''
@@ -1,7 +1,8 @@
1
1
  // Footnote checks, evaluated in each rendered page after its screenshot. Returns a
2
2
  // list of failure messages. NetNewsWire's newsfoot.js opens a popover for any
3
3
  // a.footnote whose hash names an element; main.js adds that class to local
4
- // sup > a[href*='#fn'] links, and a theme script may add it to other formats.
4
+ // sup > a[href*='#fn'] links and Substack's a[id^='footnote-anchor-'] links, and a
5
+ // theme script may add it to other formats.
5
6
  //
6
7
  // expect is the fixture's [expect.footnotes] table: notes maps each marker's text to
7
8
  // its note text, plain_links lists selectors for links that are not footnotes, and
@@ -31,7 +32,15 @@ async ({notes = null, plain_links = [], keep_with_word = false} = {}) => {
31
32
  if (document.querySelectorAll(`[id="${CSS.escape(id)}"]`).length > 1) {
32
33
  fail(`footnote ${label(marker)}: more than one element has id ${id}`);
33
34
  }
34
- const note = text(target);
35
+ // newsfoot shows a Substack note from the elements after its backlink
36
+ // <a id="footnote-N">N</a>, and any other note from the target itself.
37
+ let note = text(target);
38
+ if (marker.matches("a[id^='footnote-anchor-']")) {
39
+ let content = "";
40
+ for (let node = target.nextElementSibling; node && !node.matches("a[id^='footnote-']");
41
+ node = node.nextElementSibling) content += node.textContent;
42
+ note = content.replace(/\s+/g, " ").trim();
43
+ }
35
44
  if (note) noteFor.set(marker, note);
36
45
  else fail(`footnote ${label(marker)}: note is empty`);
37
46
  }
@@ -10,6 +10,31 @@ checked on macOS and iPhone in light and dark. Names used by check scenarios
10
10
  (`large-text`, `article-javascript-off`) are reserved. Use `'''literal'''` strings for
11
11
  HTML bodies so they need no escaping.
12
12
 
13
+ ## Platform fixtures
14
+
15
+ Much of a theme's hardest content comes from the software that published the article:
16
+ Ghost cards, WordPress captions with inline pixel widths, Substack's image and footnote
17
+ wrappers. `nnw-theme` ships ready-made fixtures modeled on that markup:
18
+
19
+ ```sh
20
+ npx nnw-theme@2 fixture list # names, summaries, and which you have
21
+ npx nnw-theme@2 fixture add ghost substack # copy into fixtures/ghost.toml, ...
22
+ npx nnw-theme@2 fixture add wordpress --force # replace a copy with the current one
23
+ ```
24
+
25
+ Each is copied into `fixtures/` and is from then on an ordinary fixture of the
26
+ repository, checked on macOS and iPhone in light and dark: four more renders per
27
+ fixture in every `check`, sixteen for all four. Their structure, classes, and attributes
28
+ come from real feeds; their text is synthetic and their links point at `example.org`.
29
+ The copy never changes on its own when `nnw-theme` updates. A header comment records
30
+ its catalog version, and `fixture list` says when a newer one is available; take it
31
+ with `fixture add NAME --force`, which replaces any edits to the file.
32
+
33
+ NetNewsWire turns Substack footnotes into popovers on its own. Most WordPress
34
+ footnotes stay plain links until a theme script turns them into `a.footnote` markers.
35
+
36
+ ## Your own articles
37
+
13
38
  Prefer real articles for content a theme must handle. `npx nnw-theme@2 capture`
14
39
  prints the steps for saving the selected article from a NetNewsWire debug build with
15
40
  the `nnwdump` lldb command; it needs Xcode and a NetNewsWire clone, so the person
@@ -18,8 +43,8 @@ same-size placeholder and a network feed icon for a generated one; do not inline
18
43
  fetch the originals.
19
44
 
20
45
  Footnote markers are the links NetNewsWire's `newsfoot.js` handles: `a.footnote`.
21
- Its `main.js` adds that class to local `sup > a[href*='#fn']` links; a theme script
22
- may add it to other formats. `check` requires every visible marker to resolve to a
46
+ Its `main.js` adds that class to local `sup > a[href*='#fn']` links and to Substack's
47
+ `a[id^='footnote-anchor-']` links; a theme script may add it to other formats. `check` requires every visible marker to resolve to a
23
48
  non-empty note with a unique id, stay legible on its background, match the other
24
49
  markers' height, font size, and baseline offset, and open a popover with its note
25
50
  without resizing. A fixture may declare exact expectations after its other keys:
@@ -19,7 +19,8 @@ specific reading needs. Prefer changing the CSS variables and rules in
19
19
  `stylesheet.css`. Edit `template.html` only when the requested structure or behavior
20
20
  needs it. For format constraints and macro behavior, read the `theme-format` topic.
21
21
  To preview content the built-in articles lack, such as a real feed's markup or
22
- footnotes, add a fixture as the `fixtures` topic describes.
22
+ footnotes, add a fixture as the `fixtures` topic describes; `npx nnw-theme@2 fixture
23
+ list` offers ready-made ones modeled on WordPress, Ghost, Substack, and Medium.
23
24
 
24
25
  Iterate with this loop:
25
26
 
@@ -5,7 +5,7 @@ import { checkPages } from "../browser.js";
5
5
  import { interactive, openInBrowser, promptConfirm } from "../interactive.js";
6
6
  import { version } from "../main.js";
7
7
  import { buildArchive } from "../package.js";
8
- import { findRoot, findTheme, fixturePath, footnoteExpectations, readFixture, ThemeError, themeStem, } from "../project.js";
8
+ import { findRoot, findTheme, fixturePath, footnoteExpectations, readFixture, repositoryFixtures, ThemeError, themeStem, } from "../project.js";
9
9
  import { checkTargets, extraFixtures, renderSite, writeGallery, } from "../render.js";
10
10
  import { staleStubs } from "../stubs.js";
11
11
  import { printWarnings } from "./package.js";
@@ -17,6 +17,13 @@ export function expectations(root, targets) {
17
17
  }
18
18
  return result;
19
19
  }
20
+ /** A one-line pointer to the platform fixtures, for a theme with no fixtures of its own. */
21
+ export function fixtureHint(root) {
22
+ if (repositoryFixtures(root).length)
23
+ return undefined;
24
+ return ("Hint: this theme has no fixtures of its own. `npx nnw-theme@2 fixture list` " +
25
+ "shows ready-made ones modeled on WordPress, Ghost, Substack, and Medium markup.");
26
+ }
20
27
  async function offerToOpen(index, choice) {
21
28
  const open = choice ?? (interactive() && (await promptConfirm("Open the preview?", true)));
22
29
  if (open)
@@ -58,6 +65,9 @@ export default async function check({ values }) {
58
65
  writeFileSync(report, `PASS\n${targets.length} WebKit renders checked\nPackage: ${packageName}\n${footer}`, "utf8");
59
66
  console.log(`PASS: ${targets.length} WebKit renders and ${packageName}`);
60
67
  }
68
+ const hint = fixtureHint(root);
69
+ if (hint)
70
+ console.log(hint);
61
71
  const index = join(site, "index.html");
62
72
  console.log(`Preview: ${index}`);
63
73
  await offerToOpen(index, values.open);
@@ -0,0 +1,82 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CATALOG_FIXTURES } from "../commands.js";
4
+ import { catalogPath, findRoot, ThemeError } from "../project.js";
5
+ const VERSION_RE = /^# nnw-theme catalog fixture: \S+ \(catalog version (\d+)\)$/m;
6
+ /** The catalog version a fixture file's header records, if it has one. */
7
+ export function catalogVersion(text) {
8
+ const match = VERSION_RE.exec(text);
9
+ return match ? Number(match[1]) : undefined;
10
+ }
11
+ /** How a repository's copy of a catalog fixture compares with the catalog's. */
12
+ export function catalogStatus(root, name) {
13
+ const own = join(root, "fixtures", `${name}.toml`);
14
+ if (!existsSync(own))
15
+ return "";
16
+ const ours = readFileSync(own, "utf8");
17
+ const theirs = readFileSync(catalogPath(name), "utf8");
18
+ if (ours === theirs)
19
+ return "added";
20
+ const [have, latest] = [catalogVersion(ours), catalogVersion(theirs)];
21
+ if (have !== undefined && latest !== undefined && have < latest) {
22
+ return `added (version ${have}; version ${latest} available)`;
23
+ }
24
+ return "added (differs)";
25
+ }
26
+ /** Copy catalog fixtures into fixtures/, keeping existing files unless forced. */
27
+ export function addCatalogFixtures(root, names, force = false) {
28
+ const result = { added: [], current: [], skipped: [] };
29
+ mkdirSync(join(root, "fixtures"), { recursive: true });
30
+ for (const name of new Set(names)) {
31
+ const source = catalogPath(name);
32
+ const destination = join(root, "fixtures", `${name}.toml`);
33
+ if (existsSync(destination)) {
34
+ if (readFileSync(destination, "utf8") === readFileSync(source, "utf8")) {
35
+ result.current.push(name);
36
+ continue;
37
+ }
38
+ if (!force) {
39
+ result.skipped.push(name);
40
+ continue;
41
+ }
42
+ }
43
+ copyFileSync(source, destination);
44
+ result.added.push(name);
45
+ }
46
+ return result;
47
+ }
48
+ export function list() {
49
+ let root;
50
+ try {
51
+ root = findRoot();
52
+ }
53
+ catch (error) {
54
+ if (!(error instanceof ThemeError))
55
+ throw error;
56
+ }
57
+ const names = Object.keys(CATALOG_FIXTURES);
58
+ const width = Math.max(...names.map((name) => name.length)) + 2;
59
+ const summaryWidth = Math.max(...Object.values(CATALOG_FIXTURES).map((text) => text.length)) + 2;
60
+ for (const [name, summary] of Object.entries(CATALOG_FIXTURES)) {
61
+ const status = root ? catalogStatus(root, name) : "";
62
+ console.log(`${name.padEnd(width)}${status ? summary.padEnd(summaryWidth) + status : summary}`);
63
+ }
64
+ console.log("\nAdd one with `npx nnw-theme@2 fixture add NAME`. Each adds four renders to every check.");
65
+ }
66
+ export function add({ values, positionals }) {
67
+ const root = findRoot();
68
+ const { added, current, skipped } = addCatalogFixtures(root, positionals, values.force === true);
69
+ for (const name of added)
70
+ console.log(`Added fixtures/${name}.toml.`);
71
+ for (const name of current)
72
+ console.log(`fixtures/${name}.toml is already current.`);
73
+ if (added.length) {
74
+ const renders = added.length * 4;
75
+ console.log(`Preview with \`npx nnw-theme@2 render ${added.join(" ")}\`, then run ` +
76
+ `\`npx nnw-theme@2 check\` (${renders} more renders).`);
77
+ }
78
+ if (skipped.length) {
79
+ throw new ThemeError(`kept existing ${skipped.map((name) => `fixtures/${name}.toml`).join(", ")}; ` +
80
+ "pass --force to replace with the catalog version");
81
+ }
82
+ }
@@ -2,10 +2,12 @@ import { spawnSync } from "node:child_process";
2
2
  import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join, relative } from "node:path";
4
4
  import { setupWebkit } from "../browser.js";
5
+ import { CATALOG_FIXTURE_NAMES } from "../commands.js";
5
6
  import { interactive, promptConfirm, promptText } from "../interactive.js";
6
7
  import { findRoot, findTheme, IDENTITY_END, IDENTITY_START, PLACEHOLDER_MARKER, readPlist, ThemeError, writePlist, } from "../project.js";
7
8
  import { pyRepr } from "../pyformat.js";
8
9
  import { urlsplit } from "../urlparse.js";
10
+ import { addCatalogFixtures } from "./fixture.js";
9
11
  import { marketplaceEnable } from "./marketplace.js";
10
12
  function slug(value) {
11
13
  return value
@@ -153,6 +155,24 @@ export default async function init({ values }) {
153
155
  }
154
156
  if (marketplace === "yes")
155
157
  marketplaceEnable();
158
+ let platformFixtures = flag("platform-fixtures");
159
+ if (platformFixtures === undefined && interactive()) {
160
+ const renders = CATALOG_FIXTURE_NAMES.length * 4;
161
+ platformFixtures = (await promptConfirm(`Add platform fixtures (${CATALOG_FIXTURE_NAMES.join(", ")})? They catch layout ` +
162
+ "bugs from real publishing platforms' markup, but add " +
163
+ `${renders} renders to every check, so check and CI take longer.`, false))
164
+ ? "yes"
165
+ : "no";
166
+ if (platformFixtures === "no") {
167
+ console.log("Add any of them later: `npx nnw-theme@2 fixture list`.");
168
+ }
169
+ }
170
+ if (platformFixtures === "yes") {
171
+ const { added } = addCatalogFixtures(root, CATALOG_FIXTURE_NAMES);
172
+ if (added.length) {
173
+ console.log(`Added ${added.map((name) => `fixtures/${name}.toml`).join(", ")}.`);
174
+ }
175
+ }
156
176
  let install = values["install-browser"];
157
177
  if (install === undefined && prompting && interactive()) {
158
178
  install = await promptConfirm("Set up WebKit for previews now?");
package/dist/commands.js CHANGED
@@ -12,6 +12,17 @@ export const GUIDE_TOPICS = [
12
12
  export const SHELLS = ["fish", "zsh", "bash"];
13
13
  /** Fixtures that ship in the package; also in project.ts, which reads them. */
14
14
  export const BUILT_IN_FIXTURE_NAMES = ["article", "kitchen-sink"];
15
+ /**
16
+ * Platform fixtures a theme can copy into fixtures/ with `fixture add`, each with the
17
+ * summary `fixture list` prints. They live in assets/catalog/ and never run unless added.
18
+ */
19
+ export const CATALOG_FIXTURES = {
20
+ ghost: "Ghost cards, galleries, and lazy images",
21
+ medium: "Medium figures, unsized images, and footer",
22
+ substack: "Substack images, widgets, and footnotes",
23
+ wordpress: "WordPress block and classic editor markup",
24
+ };
25
+ export const CATALOG_FIXTURE_NAMES = Object.keys(CATALOG_FIXTURES);
15
26
  const allowRemoteMedia = {
16
27
  name: "allow-remote-media",
17
28
  type: "boolean",
@@ -22,7 +33,8 @@ export const COMMANDS = [
22
33
  name: "init",
23
34
  summary: "personalize a fresh copy of the template (run once)",
24
35
  description: "Name the theme, choose its permanent identifier, and optionally join the " +
25
- "marketplace and install WebKit. Runs once, while .nnw-theme-uninitialized exists.",
36
+ "marketplace, add the platform fixtures, and install WebKit. Runs once, while " +
37
+ ".nnw-theme-uninitialized exists.",
26
38
  options: [
27
39
  { name: "name", type: "string" },
28
40
  { name: "creator", type: "string" },
@@ -31,6 +43,12 @@ export const COMMANDS = [
31
43
  { name: "confirm-identifier", type: "string" },
32
44
  { name: "github-user", type: "string" },
33
45
  { name: "marketplace", type: "string", choices: ["yes", "no"] },
46
+ {
47
+ name: "platform-fixtures",
48
+ type: "string",
49
+ choices: ["yes", "no"],
50
+ help: "add every platform fixture (more renders in each check)",
51
+ },
34
52
  { name: "install-browser", type: "boolean", negatable: true },
35
53
  ],
36
54
  },
@@ -111,6 +129,42 @@ export const COMMANDS = [
111
129
  summary: "validate the theme and build its release ZIP (no WebKit)",
112
130
  options: [{ name: "output-dir", type: "string", default: "dist" }, allowRemoteMedia],
113
131
  },
132
+ {
133
+ name: "fixture",
134
+ summary: "add fixtures modeled on WordPress, Ghost, Substack, and Medium",
135
+ description: "Copy ready-made fixtures with markup modeled on what major publishing platforms " +
136
+ "send into fixtures/, where preview and check pick them up. Each one adds four " +
137
+ "renders to every check.",
138
+ options: [],
139
+ subcommands: [
140
+ {
141
+ name: "list",
142
+ summary: "list the platform fixtures and which ones this theme has",
143
+ options: [],
144
+ },
145
+ {
146
+ name: "add",
147
+ summary: "copy platform fixtures into fixtures/",
148
+ description: "Copy each named platform fixture to fixtures/NAME.toml. An existing file is " +
149
+ "kept unless --force is given. Each added fixture is checked on Mac and iPhone " +
150
+ "in light and dark: four more renders per check.",
151
+ options: [
152
+ {
153
+ name: "force",
154
+ type: "boolean",
155
+ help: "replace existing copies with the current version",
156
+ },
157
+ ],
158
+ positionals: {
159
+ name: "names",
160
+ help: `platform fixtures to add: ${CATALOG_FIXTURE_NAMES.join(", ")}`,
161
+ variadic: true,
162
+ choices: CATALOG_FIXTURE_NAMES,
163
+ required: true,
164
+ },
165
+ },
166
+ ],
167
+ },
114
168
  {
115
169
  name: "capture",
116
170
  summary: "explain how to capture a real article from NetNewsWire as a fixture",
@@ -47,59 +47,88 @@ export function fish() {
47
47
  `complete -c ${PROGRAM} -n __fish_use_subcommand -l version -d 'Print the version'`,
48
48
  ];
49
49
  for (const command of visible()) {
50
- const when = `'__fish_seen_subcommand_from ${command.name}'`;
51
50
  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}`);
51
+ const seen = `__fish_seen_subcommand_from ${command.name}`;
52
+ if (!command.subcommands) {
53
+ fishCommand(lines, command, `'${seen}'`);
54
+ continue;
64
55
  }
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
- }
56
+ const names = command.subcommands.map((sub) => sub.name).join(" ");
57
+ for (const sub of command.subcommands) {
58
+ lines.push(`complete -c ${PROGRAM} -n '${seen}; and not __fish_seen_subcommand_from ${names}' ` +
59
+ `-a ${sub.name} -d ${fishQuote(sub.summary)}`);
60
+ fishCommand(lines, sub, `'${seen}; and __fish_seen_subcommand_from ${sub.name}'`);
75
61
  }
76
62
  }
77
63
  return `${lines.join("\n")}\n`;
78
64
  }
65
+ /** A leaf command's options and positional words, completed while `when` holds. */
66
+ function fishCommand(lines, command, when) {
67
+ for (const option of command.options) {
68
+ const description = option.help ? ` -d ${fishQuote(option.help)}` : "";
69
+ let values = "";
70
+ if (option.completeFixtures)
71
+ values = " -x -a '(__nnw_theme_fixtures)'";
72
+ else if (option.choices)
73
+ values = ` -x -a ${fishQuote(option.choices.join(" "))}`;
74
+ else if (option.type === "string")
75
+ values = option.name.endsWith("dir") ? " -r -F" : " -x";
76
+ lines.push(`complete -c ${PROGRAM} -n ${when} -l ${option.name}${values}${description}`);
77
+ if (option.negatable)
78
+ lines.push(`complete -c ${PROGRAM} -n ${when} -l no-${option.name}`);
79
+ }
80
+ const words = positionalWords(command);
81
+ if (words === null)
82
+ lines.push(`complete -c ${PROGRAM} -n ${when} -a '(__nnw_theme_fixtures)'`);
83
+ else if (words)
84
+ lines.push(`complete -c ${PROGRAM} -n ${when} -a ${fishQuote(words.join(" "))}`);
85
+ }
79
86
  // bash ---------------------------------------------------------------------------
87
+ /** The case body completing a leaf command's options and positional words. */
88
+ function bashCommand(command, indent) {
89
+ const valueCases = [];
90
+ for (const option of command.options) {
91
+ if (option.type !== "string")
92
+ continue;
93
+ let reply = "COMPREPLY=()";
94
+ if (option.completeFixtures)
95
+ reply = 'COMPREPLY=($(compgen -W "$(_nnw_theme_fixtures)" -- "$cur"))';
96
+ else if (option.choices)
97
+ reply = `COMPREPLY=($(compgen -W ${single(option.choices.join(" "))} -- "$cur"))`;
98
+ else if (option.name.endsWith("dir"))
99
+ reply = 'COMPREPLY=($(compgen -d -- "$cur"))';
100
+ valueCases.push(`${indent} --${option.name}) ${reply}; return ;;`);
101
+ }
102
+ const words = positionalWords(command);
103
+ let positional = "COMPREPLY=()";
104
+ if (words === null)
105
+ positional = 'COMPREPLY=($(compgen -W "$(_nnw_theme_fixtures)" -- "$cur"))';
106
+ else if (words)
107
+ positional = `COMPREPLY=($(compgen -W ${single(words.join(" "))} -- "$cur"))`;
108
+ return [
109
+ ...(valueCases.length ? [`${indent}case "$prev" in`, ...valueCases, `${indent}esac`] : []),
110
+ `${indent}if [[ "$cur" == -* ]]; then`,
111
+ `${indent} COMPREPLY=($(compgen -W ${single(allFlags(command).join(" "))} -- "$cur"))`,
112
+ `${indent}else`,
113
+ `${indent} ${positional}`,
114
+ `${indent}fi`,
115
+ ];
116
+ }
80
117
  export function bash() {
81
118
  const cases = [];
82
119
  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 ;;`);
120
+ cases.push(` ${command.name})`);
121
+ if (command.subcommands) {
122
+ const names = command.subcommands.map((sub) => sub.name).join(" ");
123
+ cases.push(" if (( COMP_CWORD == 2 )); then", ` COMPREPLY=($(compgen -W ${single(`${names} --help`)} -- "$cur"))`, " return", " fi", ` case "\${COMP_WORDS[2]}" in`);
124
+ for (const sub of command.subcommands) {
125
+ cases.push(` ${sub.name})`, ...bashCommand(sub, " "), " ;;");
126
+ }
127
+ cases.push(" esac");
95
128
  }
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", " ;;");
129
+ else
130
+ cases.push(...bashCommand(command, " "));
131
+ cases.push(" ;;");
103
132
  }
104
133
  return `# nnw-theme completion for bash. Install: source <(npx --yes nnw-theme@2 completion bash)
105
134
  if ! type -P ${PROGRAM} >/dev/null 2>&1; then
@@ -151,17 +180,28 @@ function zshOption(option) {
151
180
  specs.push(`'--no-${option.name}[${help}]'`);
152
181
  return specs;
153
182
  }
183
+ /** An _arguments call completing a leaf command's options and positional words. */
184
+ function zshArguments(command) {
185
+ const specs = [...command.options.flatMap(zshOption), "'(- *)'{-h,--help}'[show help]'"];
186
+ const words = positionalWords(command);
187
+ if (words === null)
188
+ specs.push("'*:fixture:_nnw_theme_fixtures'");
189
+ else if (words) {
190
+ const position = command.positionals?.variadic ? "*" : "1";
191
+ specs.push(`'${position}:${command.positionals?.name ?? "command"}:(${words.join(" ")})'`);
192
+ }
193
+ return `_arguments -s ${specs.join(" ")}`;
194
+ }
154
195
  export function zsh() {
155
196
  const described = visible().map((command) => ` '${command.name}:${zshEscape(command.summary)}'`);
156
197
  const cases = [];
157
198
  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(" ")} ;;`);
199
+ if (!command.subcommands) {
200
+ cases.push(` ${command.name}) ${zshArguments(command)} ;;`);
201
+ continue;
202
+ }
203
+ const described = command.subcommands.map((sub) => `'${sub.name}:${zshEscape(sub.summary)}'`);
204
+ cases.push(` ${command.name})`, " if (( CURRENT == 2 )); then", ` local -a subcommands=(${described.join(" ")})`, ` _describe -t commands 'nnw-theme ${command.name} command' subcommands`, " return", " fi", " local subcommand=$words[2]", " shift words", " (( CURRENT-- ))", " case $subcommand in", ...command.subcommands.map((sub) => ` ${sub.name}) ${zshArguments(sub)} ;;`), " esac", " ;;");
165
205
  }
166
206
  return `# nnw-theme completion for zsh. Install: source <(npx --yes nnw-theme@2 completion zsh)
167
207
  if (( ! $+commands[${PROGRAM}] )); then
package/dist/main.js CHANGED
@@ -14,6 +14,8 @@ const HANDLERS = {
14
14
  capture: () => import("./commands/capture.js"),
15
15
  bump: () => import("./commands/bump.js"),
16
16
  "marketplace enable": () => import("./commands/marketplace.js"),
17
+ "fixture list": async () => ({ default: (await import("./commands/fixture.js")).list }),
18
+ "fixture add": async () => ({ default: (await import("./commands/fixture.js")).add }),
17
19
  guide: () => import("./commands/guide.js"),
18
20
  completion: () => import("./commands/completion.js"),
19
21
  update: () => import("./commands/update.js"),
@@ -91,8 +93,12 @@ export function commandHelp(command, path) {
91
93
  if (command.subcommands)
92
94
  usage.push(`{${command.subcommands.map((sub) => sub.name).join(",")}}`);
93
95
  if (command.positionals) {
96
+ const { variadic, required } = command.positionals;
94
97
  const name = command.positionals.name.toUpperCase();
95
- usage.push(command.positionals.variadic ? `[${name} ...]` : `[${name}]`);
98
+ if (required)
99
+ usage.push(variadic ? `${name} [${name} ...]` : name);
100
+ else
101
+ usage.push(variadic ? `[${name} ...]` : `[${name}]`);
96
102
  }
97
103
  const sections = [
98
104
  wrap(usage.join(" "), 0),
@@ -158,6 +164,9 @@ function parseCommand(command, argv) {
158
164
  if (!spec && positionals.length) {
159
165
  throw new UsageError(`unrecognized arguments: ${positionals.join(" ")}`);
160
166
  }
167
+ if (spec?.required && !positionals.length) {
168
+ throw new UsageError(`the following arguments are required: ${spec.name}`);
169
+ }
161
170
  if (spec && !spec.variadic && positionals.length > 1) {
162
171
  throw new UsageError(`unrecognized arguments: ${positionals.slice(1).join(" ")}`);
163
172
  }
package/dist/project.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { parse as parseToml } from "smol-toml";
5
- import { BUILT_IN_FIXTURE_NAMES } from "./commands.js";
5
+ import { BUILT_IN_FIXTURE_NAMES, CATALOG_FIXTURE_NAMES } from "./commands.js";
6
6
  import { buildPlist, parsePlist } from "./plist.js";
7
7
  export const REQUIRED_THEME_FILES = ["Info.plist", "template.html", "stylesheet.css"];
8
8
  export const PLACEHOLDER_MARKER = ".nnw-theme-uninitialized";
@@ -136,6 +136,13 @@ export function repositoryFixtures(root) {
136
136
  .map((name) => name.slice(0, -".toml".length))
137
137
  .sort();
138
138
  }
139
+ /** A platform fixture in the package's catalog, which `fixture add` copies from. */
140
+ export function catalogPath(name) {
141
+ if (!CATALOG_FIXTURE_NAMES.includes(name)) {
142
+ throw new ThemeError(`unknown platform fixture: ${name} (choose from ${CATALOG_FIXTURE_NAMES.join(", ")})`);
143
+ }
144
+ return packagePath("assets", "catalog", `${name}.toml`);
145
+ }
139
146
  /** Where a fixture lives: the repository's copy, else the package's built-in one. */
140
147
  export function fixturePath(root, name) {
141
148
  const own = join(root, "fixtures", `${name}.toml`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nnw-theme",
3
- "version": "2.0.3",
3
+ "version": "2.1.0",
4
4
  "description": "Create, preview, check, and publish a NetNewsWire theme.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Marquard",