@coywolf/emdash 0.0.0-stage → 0.35.13

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 (261) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -2
  3. package/package.json +70 -5
  4. package/src/admin/admin-css.ts +57 -0
  5. package/src/admin/ai.tsx +915 -0
  6. package/src/admin/backups.tsx +613 -0
  7. package/src/admin/breadcrumbs.tsx +542 -0
  8. package/src/admin/codeBlocks.tsx +204 -0
  9. package/src/admin/customBlocks.tsx +321 -0
  10. package/src/admin/dirty.ts +20 -0
  11. package/src/admin/discovery.tsx +463 -0
  12. package/src/admin/download.ts +28 -0
  13. package/src/admin/feature-switches.tsx +124 -0
  14. package/src/admin/features.tsx +142 -0
  15. package/src/admin/files.tsx +791 -0
  16. package/src/admin/formUploads.tsx +445 -0
  17. package/src/admin/guides.tsx +18 -0
  18. package/src/admin/headings.tsx +262 -0
  19. package/src/admin/images.tsx +825 -0
  20. package/src/admin/index.tsx +62 -0
  21. package/src/admin/links.tsx +980 -0
  22. package/src/admin/performance.tsx +330 -0
  23. package/src/admin/preview.tsx +63 -0
  24. package/src/admin/redirects-removed.tsx +266 -0
  25. package/src/admin/redirects.tsx +470 -0
  26. package/src/admin/reviews.tsx +232 -0
  27. package/src/admin/robots-bots.tsx +647 -0
  28. package/src/admin/robots-shared.tsx +109 -0
  29. package/src/admin/robots-wizard.tsx +748 -0
  30. package/src/admin/robots.tsx +911 -0
  31. package/src/admin/save-bar.tsx +134 -0
  32. package/src/admin/schema.tsx +1187 -0
  33. package/src/admin/search.tsx +355 -0
  34. package/src/admin/settings-ui.tsx +87 -0
  35. package/src/admin/trash-prompt-core.ts +135 -0
  36. package/src/admin/trash-prompt.tsx +156 -0
  37. package/src/admin/usage.tsx +301 -0
  38. package/src/admin/videos-display.tsx +248 -0
  39. package/src/admin/videos.tsx +1202 -0
  40. package/src/admin/wpImport.tsx +1516 -0
  41. package/src/ai/entities.ts +59 -0
  42. package/src/ai/export.ts +61 -0
  43. package/src/ai/logic.test.ts +187 -0
  44. package/src/ai/logic.ts +606 -0
  45. package/src/ai/pack.ts +121 -0
  46. package/src/ai/prompts.ts +87 -0
  47. package/src/ai/providers.ts +276 -0
  48. package/src/ai/queue.test.ts +97 -0
  49. package/src/ai/routes.ts +359 -0
  50. package/src/ai/store.ts +301 -0
  51. package/src/ai/wikidata.test.ts +184 -0
  52. package/src/ai/wikidata.ts +87 -0
  53. package/src/ai/worker.ts +530 -0
  54. package/src/astro/astro-modules.d.ts +6 -0
  55. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  56. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  57. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  58. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  59. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  60. package/src/astro/customBlocks/Details.astro +8 -0
  61. package/src/astro/customBlocks/Disclosure.astro +8 -0
  62. package/src/astro/customBlocks/Note.astro +8 -0
  63. package/src/astro/customBlocks/Podcast.astro +8 -0
  64. package/src/astro/customBlocks/Quote.astro +8 -0
  65. package/src/astro/customBlocks/Testimonial.astro +8 -0
  66. package/src/astro/files/FileCard.astro +103 -0
  67. package/src/astro/headings/HeadingBlock.astro +129 -0
  68. package/src/astro/headings/TableOfContents.astro +45 -0
  69. package/src/astro/headings/request.ts +19 -0
  70. package/src/astro/images/ResponsiveImage.astro +60 -0
  71. package/src/astro/index.ts +84 -0
  72. package/src/astro/reviews/CoywolfReview.astro +27 -0
  73. package/src/astro/reviews/Review.astro +49 -0
  74. package/src/astro/search/SearchBox.astro +61 -0
  75. package/src/astro/search/SearchBoxForm.astro +509 -0
  76. package/src/astro/tables/CaptionedTable.astro +72 -0
  77. package/src/astro/videos/CoywolfVideo.astro +448 -0
  78. package/src/backups/download.ts +112 -0
  79. package/src/backups/dump.test.mjs +287 -0
  80. package/src/backups/dump.ts +288 -0
  81. package/src/backups/module.ts +276 -0
  82. package/src/backups/pack.ts +33 -0
  83. package/src/backups/restore.test.mjs +103 -0
  84. package/src/backups/restore.ts +209 -0
  85. package/src/backups/store.ts +409 -0
  86. package/src/breadcrumbs/pack.ts +113 -0
  87. package/src/breadcrumbs/settings.ts +102 -0
  88. package/src/breadcrumbs/stamp.ts +40 -0
  89. package/src/breadcrumbs/trail.ts +123 -0
  90. package/src/codeBlocks/contrast.ts +179 -0
  91. package/src/codeBlocks/hljs.d.ts +5 -0
  92. package/src/codeBlocks/pack.ts +110 -0
  93. package/src/codeBlocks/render.test.ts +152 -0
  94. package/src/codeBlocks/render.ts +304 -0
  95. package/src/codeBlocks/settings.ts +18 -0
  96. package/src/codeBlocks/themes.generated.ts +304 -0
  97. package/src/codeBlocks/themes.ts +114 -0
  98. package/src/core/compose.ts +244 -0
  99. package/src/core/content-url.ts +888 -0
  100. package/src/core/csv.ts +119 -0
  101. package/src/core/d1-batch.ts +65 -0
  102. package/src/core/entry-docs.ts +175 -0
  103. package/src/core/features-module.ts +48 -0
  104. package/src/core/features.ts +392 -0
  105. package/src/core/generation.ts +88 -0
  106. package/src/core/guide-content.ts +166 -0
  107. package/src/core/module.ts +104 -0
  108. package/src/core/read-failures.ts +57 -0
  109. package/src/core/request-memo.ts +71 -0
  110. package/src/core/secrets.ts +83 -0
  111. package/src/core/settings-enhance.ts +74 -0
  112. package/src/core/site.ts +36 -0
  113. package/src/core/storage.ts +27 -0
  114. package/src/customBlocks/pack.ts +260 -0
  115. package/src/customBlocks/render.ts +424 -0
  116. package/src/customBlocks/rich.ts +313 -0
  117. package/src/customBlocks/settings.ts +46 -0
  118. package/src/discovery/build.ts +252 -0
  119. package/src/discovery/feed-build.ts +217 -0
  120. package/src/discovery/feed.ts +172 -0
  121. package/src/discovery/indexnow.ts +114 -0
  122. package/src/discovery/llms.ts +79 -0
  123. package/src/discovery/markdown.ts +294 -0
  124. package/src/discovery/memory-cache.ts +97 -0
  125. package/src/discovery/middleware.ts +186 -0
  126. package/src/discovery/module.ts +353 -0
  127. package/src/discovery/news.ts +99 -0
  128. package/src/discovery/pack.ts +58 -0
  129. package/src/discovery/settings.ts +91 -0
  130. package/src/discovery/urls.ts +35 -0
  131. package/src/env.d.ts +10 -0
  132. package/src/files/card.ts +231 -0
  133. package/src/files/counts.test.ts +114 -0
  134. package/src/files/counts.ts +60 -0
  135. package/src/files/export.ts +44 -0
  136. package/src/files/format.test.ts +93 -0
  137. package/src/files/format.ts +163 -0
  138. package/src/files/module.ts +596 -0
  139. package/src/files/pack.ts +92 -0
  140. package/src/files/r2-rules.test.ts +30 -0
  141. package/src/files/r2-rules.ts +41 -0
  142. package/src/files/range.ts +50 -0
  143. package/src/files/s3.ts +114 -0
  144. package/src/files/serve.ts +141 -0
  145. package/src/files/sigv4.test.ts +64 -0
  146. package/src/files/sigv4.ts +160 -0
  147. package/src/files/site.ts +137 -0
  148. package/src/files/walker.ts +58 -0
  149. package/src/formUploads/lib.ts +266 -0
  150. package/src/formUploads/pack.ts +55 -0
  151. package/src/formUploads/wrap.ts +592 -0
  152. package/src/forms.ts +16 -0
  153. package/src/headings/headings.test.ts +159 -0
  154. package/src/headings/pack.ts +154 -0
  155. package/src/headings/render.ts +139 -0
  156. package/src/headings/settings.ts +117 -0
  157. package/src/headings/slug.ts +68 -0
  158. package/src/headings/stamp.ts +142 -0
  159. package/src/headings/test-hooks.mjs +14 -0
  160. package/src/headings/toc.ts +48 -0
  161. package/src/images/cloudflare.ts +329 -0
  162. package/src/images/lib.ts +221 -0
  163. package/src/images/module.ts +372 -0
  164. package/src/images/pack.ts +804 -0
  165. package/src/images/pending.ts +37 -0
  166. package/src/images/settings.ts +48 -0
  167. package/src/images/setup.ts +192 -0
  168. package/src/images/variants-estimate.ts +162 -0
  169. package/src/images/variants-store.ts +475 -0
  170. package/src/images/variants.ts +311 -0
  171. package/src/index.ts +143 -0
  172. package/src/links/check.ts +101 -0
  173. package/src/links/classify.ts +270 -0
  174. package/src/links/export.ts +59 -0
  175. package/src/links/module.ts +652 -0
  176. package/src/links/pack.ts +43 -0
  177. package/src/links/pt.ts +325 -0
  178. package/src/links/store.ts +704 -0
  179. package/src/middleware.ts +287 -0
  180. package/src/modules.ts +53 -0
  181. package/src/options.ts +66 -0
  182. package/src/pageCache/lib.test.mjs +224 -0
  183. package/src/pageCache/lib.ts +274 -0
  184. package/src/pageCache/pack.ts +240 -0
  185. package/src/pageCache/usage-core.ts +152 -0
  186. package/src/pageCache/usage.ts +262 -0
  187. package/src/pageCache/warm.test.mjs +670 -0
  188. package/src/pageCache/warm.ts +722 -0
  189. package/src/redirects/middleware.ts +169 -0
  190. package/src/redirects/module.ts +124 -0
  191. package/src/redirects/pack.ts +89 -0
  192. package/src/redirects/removed-core.ts +151 -0
  193. package/src/redirects/removed.ts +293 -0
  194. package/src/redirects/rules.ts +355 -0
  195. package/src/redirects/slug-changes.ts +207 -0
  196. package/src/redirects/slug-core.test.ts +50 -0
  197. package/src/redirects/slug-core.ts +49 -0
  198. package/src/redirects/transfer.ts +92 -0
  199. package/src/reviews/lib.ts +624 -0
  200. package/src/reviews/pack.ts +135 -0
  201. package/src/reviews/schema.ts +128 -0
  202. package/src/reviews/settings.ts +26 -0
  203. package/src/robots/bots.ts +321 -0
  204. package/src/robots/data/bots.json +696 -0
  205. package/src/robots/data/verified.json +582 -0
  206. package/src/robots/directory.ts +26 -0
  207. package/src/robots/explain.ts +191 -0
  208. package/src/robots/guided.ts +300 -0
  209. package/src/robots/importer.ts +263 -0
  210. package/src/robots/middleware.ts +151 -0
  211. package/src/robots/module.ts +446 -0
  212. package/src/robots/pack.ts +69 -0
  213. package/src/robots/radar.ts +129 -0
  214. package/src/robots/rep.test.ts +335 -0
  215. package/src/robots/rep.ts +362 -0
  216. package/src/robots/rules.test.ts +251 -0
  217. package/src/robots/rules.ts +883 -0
  218. package/src/robots/transfer.ts +45 -0
  219. package/src/robots/validate.ts +524 -0
  220. package/src/schema/catalog.ts +146 -0
  221. package/src/schema/graph.test.mjs +522 -0
  222. package/src/schema/graph.ts +791 -0
  223. package/src/schema/module.ts +1138 -0
  224. package/src/schema/og-image.ts +100 -0
  225. package/src/schema/pack.ts +58 -0
  226. package/src/search/engine.ts +578 -0
  227. package/src/search/fallback.ts +80 -0
  228. package/src/search/live-cache.ts +116 -0
  229. package/src/search/live-client.ts +770 -0
  230. package/src/search/live-serve.ts +282 -0
  231. package/src/search/module.ts +261 -0
  232. package/src/search/pack.ts +57 -0
  233. package/src/search/query.ts +132 -0
  234. package/src/search/ratelimit-core.ts +118 -0
  235. package/src/search/ratelimit.ts +79 -0
  236. package/src/search/snippet.ts +135 -0
  237. package/src/search/title-index.ts +139 -0
  238. package/src/search/title-match.ts +192 -0
  239. package/src/shared.ts +71 -0
  240. package/src/version.ts +2 -0
  241. package/src/videos/date.ts +40 -0
  242. package/src/videos/engagement.ts +75 -0
  243. package/src/videos/lib.ts +679 -0
  244. package/src/videos/module.ts +1160 -0
  245. package/src/videos/pack.ts +187 -0
  246. package/src/videos/pause.ts +42 -0
  247. package/src/videos/poster.ts +324 -0
  248. package/src/videos/render.ts +336 -0
  249. package/src/videos/store.ts +551 -0
  250. package/src/videos/stream.ts +134 -0
  251. package/src/wpImport/convert.ts +702 -0
  252. package/src/wpImport/guests.ts +329 -0
  253. package/src/wpImport/gutenberg.ts +157 -0
  254. package/src/wpImport/markers.ts +66 -0
  255. package/src/wpImport/pack.ts +451 -0
  256. package/src/wpImport/parents.ts +202 -0
  257. package/src/wpImport/prepare.ts +592 -0
  258. package/src/wpImport/redirects.ts +358 -0
  259. package/src/wpImport/sources.ts +67 -0
  260. package/src/wpImport/stream.ts +160 -0
  261. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Headings & TOC settings: site defaults for heading anchors and the Table
3
+ * of Contents block. Blocks can override most of them. (Breadcrumbs have
4
+ * their own page, Breadcrumb Nav.)
5
+ */
6
+ import { Banner, Checkbox, Input, Loader, Select } from "@cloudflare/kumo";
7
+ import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
8
+ import * as React from "react";
9
+
10
+ import { SAMPLE_TOC_HEADINGS, TOC_CSS, renderTocHtml } from "../headings/render.js";
11
+ import { buildTocTree, countToc } from "../headings/toc.js";
12
+ import { PreviewSection } from "./preview.js";
13
+ import { SaveBar, isDirty } from "./save-bar.js";
14
+ import { PartSwitches } from "./feature-switches.js";
15
+
16
+ const API = "/_emdash/api/plugins/coywolf-pack/headings";
17
+
18
+ interface Settings {
19
+ prefix: string;
20
+ copyLink: boolean;
21
+ scrollOffset: number;
22
+ scrollUnit: "px" | "rem";
23
+ toc: {
24
+ title: string;
25
+ levels: number[];
26
+ listStyle: "none" | "bulleted" | "numbered";
27
+ display: "open" | "collapsible" | "collapsed";
28
+ minHeadings: number;
29
+ smoothScroll: boolean;
30
+ showTitle: boolean;
31
+ };
32
+ }
33
+
34
+ const errorText = (cause: unknown, fallback: string) => (cause instanceof Error && cause.message ? cause.message : fallback);
35
+
36
+ /** The TOC block with these defaults, on a sample article's headings. */
37
+ function TocPreview({ toc }: { toc: Settings["toc"] }) {
38
+ const tree = buildTocTree(SAMPLE_TOC_HEADINGS, toc.levels);
39
+ const count = countToc(tree);
40
+ const hidden = count < toc.minHeadings;
41
+ const html = hidden
42
+ ? ""
43
+ : renderTocHtml(tree, { title: toc.title.trim() || "Table of contents", showTitle: toc.showTitle, listStyle: toc.listStyle, display: toc.display, smooth: false, titleId: "cw-toc-preview" });
44
+ return (
45
+ <PreviewSection
46
+ id="cw-toc-preview-title"
47
+ title="Table of Contents preview"
48
+ css={TOC_CSS}
49
+ html={html}
50
+ empty={
51
+ count === 0
52
+ ? "Pick at least one heading level to list."
53
+ : `Hidden: the sample lists ${count} heading${count === 1 ? "" : "s"}, fewer than the minimum of ${toc.minHeadings}.`
54
+ }
55
+ note="A sample article's headings with these defaults. On the site the table uses your theme's fonts and link colors."
56
+ />
57
+ );
58
+ }
59
+
60
+ function Section(props: { title: string; description: string; children: React.ReactNode }) {
61
+ const id = `cw-headings-${props.title.toLowerCase().replace(/[^a-z]+/g, "-")}`;
62
+ return (
63
+ <section className="rounded-lg border border-kumo-line" aria-labelledby={id}>
64
+ <div className="border-b border-kumo-line p-4">
65
+ <h2 id={id} className="text-base font-semibold">
66
+ {props.title}
67
+ </h2>
68
+ <p className="mt-1 text-sm text-kumo-subtle">{props.description}</p>
69
+ </div>
70
+ <div className="space-y-4 p-4">{props.children}</div>
71
+ </section>
72
+ );
73
+ }
74
+
75
+ export function HeadingsPage() {
76
+ const [settings, setSettings] = React.useState<Settings | null>(null);
77
+ const [saved, setSaved] = React.useState<Settings | null>(null);
78
+ const [error, setError] = React.useState<string | null>(null);
79
+ const [notice, setNotice] = React.useState<string | null>(null);
80
+ const [saving, setSaving] = React.useState(false);
81
+
82
+ React.useEffect(() => {
83
+ void (async () => {
84
+ try {
85
+ const response = await apiFetch(`${API}/settings`);
86
+ const loaded = (await parseApiResponse<{ settings: Settings }>(response, "Couldn't load settings")).settings;
87
+ setSettings(loaded);
88
+ setSaved(loaded);
89
+ } catch (cause) {
90
+ setError(errorText(cause, "Couldn't load settings"));
91
+ }
92
+ })();
93
+ }, []);
94
+
95
+ const set = (patch: Partial<Settings>) => setSettings((s) => (s ? { ...s, ...patch } : s));
96
+ const setToc = (patch: Partial<Settings["toc"]>) => setSettings((s) => (s ? { ...s, toc: { ...s.toc, ...patch } } : s));
97
+
98
+ const dirty = isDirty(settings, saved);
99
+
100
+ async function save() {
101
+ if (!settings) return;
102
+ setSaving(true);
103
+ setError(null);
104
+ setNotice(null);
105
+ try {
106
+ const response = await apiFetch(`${API}/settings/save`, {
107
+ method: "POST",
108
+ headers: { "Content-Type": "application/json" },
109
+ body: JSON.stringify({ settings }),
110
+ });
111
+ const next = (await parseApiResponse<{ settings: Settings }>(response, "Couldn't save")).settings;
112
+ setSettings(next);
113
+ setSaved(next);
114
+ setNotice("Settings saved. Pages pick them up within a minute.");
115
+ } catch (cause) {
116
+ setError(errorText(cause, "Couldn't save"));
117
+ } finally {
118
+ setSaving(false);
119
+ }
120
+ }
121
+
122
+ return (
123
+ <div className="space-y-6">
124
+ <header className="grid min-w-0 gap-4 border-b border-kumo-line pb-4">
125
+ <h1 className="flex min-h-9 min-w-0 items-center text-2xl font-semibold leading-tight">Headings &amp; TOC</h1>
126
+ <p className="text-sm leading-5 text-pretty text-kumo-subtle">
127
+ Defaults for heading anchors and the Table of Contents block. Turn each one on under Plugins → Coywolf Pack. Anchors
128
+ are saved with the content, so they stay the same when a heading is reworded.
129
+ </p>
130
+ </header>
131
+
132
+ <PartSwitches module="headings" />
133
+
134
+ <div aria-live="polite">{notice && <Banner variant="default" role="status" title={notice} />}</div>
135
+ {error && <Banner variant="error" role="alert" description={error} />}
136
+
137
+ {!settings && !error && (
138
+ <div className="flex justify-center py-12">
139
+ <Loader />
140
+ </div>
141
+ )}
142
+
143
+ {settings && (
144
+ <form
145
+ id="cw-headings-form"
146
+ className="space-y-6"
147
+ onSubmit={(e) => {
148
+ e.preventDefault();
149
+ void save();
150
+ }}
151
+ >
152
+ <Section title="Heading anchors" description="Ids added to H2–H6 headings so readers can link to a section.">
153
+ <Input
154
+ label="Id prefix"
155
+ description='Generated ids look like "jump-pricing". Changing the prefix affects new headings only; existing anchors stay put.'
156
+ value={settings.prefix}
157
+ pattern="[A-Za-z][A-Za-z0-9_\-]{0,19}|"
158
+ maxLength={20}
159
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ prefix: e.target.value })}
160
+ />
161
+ <Checkbox
162
+ label='Show a "copy link to section" button when hovering or focusing a heading'
163
+ checked={settings.copyLink}
164
+ onCheckedChange={(checked: boolean) => set({ copyLink: checked })}
165
+ />
166
+ <div className="flex flex-wrap items-end gap-3">
167
+ <Input
168
+ label="Scroll offset"
169
+ description="Space kept above a heading you jump to, for sticky headers. 0 for none."
170
+ type="number"
171
+ min={0}
172
+ max={500}
173
+ value={String(settings.scrollOffset)}
174
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ scrollOffset: Number(e.target.value) || 0 })}
175
+ />
176
+ <Select
177
+ label="Unit"
178
+ value={settings.scrollUnit}
179
+ onValueChange={(value: string | null) => set({ scrollUnit: value === "rem" ? "rem" : "px" })}
180
+ items={[
181
+ { value: "px", label: "px" },
182
+ { value: "rem", label: "rem" },
183
+ ]}
184
+ />
185
+ </div>
186
+ </Section>
187
+
188
+ <Section title="Table of Contents" description="Defaults for Table of Contents blocks. Each block can override the title (or hide it), levels, list style, and display. Uncheck H3–H6 to list only H2 headings.">
189
+ <Input
190
+ label="Title"
191
+ value={settings.toc.title}
192
+ maxLength={100}
193
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setToc({ title: e.target.value })}
194
+ />
195
+ <Checkbox
196
+ label="Show the title (a collapsible table always shows it, since it's the toggle)"
197
+ checked={settings.toc.showTitle}
198
+ onCheckedChange={(checked: boolean) => setToc({ showTitle: checked })}
199
+ />
200
+ <fieldset style={{ minWidth: 0 }}>
201
+ <legend className="mb-2 text-sm font-medium">Heading levels</legend>
202
+ <div className="flex flex-wrap gap-4">
203
+ {[2, 3, 4, 5, 6].map((level) => (
204
+ <Checkbox
205
+ key={level}
206
+ label={`H${level}`}
207
+ checked={settings.toc.levels.includes(level)}
208
+ onCheckedChange={(checked: boolean) =>
209
+ setToc({
210
+ levels: checked
211
+ ? [...settings.toc.levels, level].sort((a, b) => a - b)
212
+ : settings.toc.levels.filter((l) => l !== level),
213
+ })
214
+ }
215
+ />
216
+ ))}
217
+ </div>
218
+ </fieldset>
219
+ <Select
220
+ label="List style"
221
+ value={settings.toc.listStyle}
222
+ onValueChange={(value: string | null) => setToc({ listStyle: (value ?? "none") as Settings["toc"]["listStyle"] })}
223
+ items={[
224
+ { value: "none", label: "Plain" },
225
+ { value: "bulleted", label: "Bulleted" },
226
+ { value: "numbered", label: "Numbered (1, 1.1, 1.1.1)" },
227
+ ]}
228
+ />
229
+ <Select
230
+ label="Display"
231
+ value={settings.toc.display}
232
+ onValueChange={(value: string | null) => setToc({ display: (value ?? "open") as Settings["toc"]["display"] })}
233
+ items={[
234
+ { value: "open", label: "Always open" },
235
+ { value: "collapsible", label: "Collapsible, open" },
236
+ { value: "collapsed", label: "Collapsible, collapsed" },
237
+ ]}
238
+ />
239
+ <Input
240
+ label="Minimum headings"
241
+ description="Hide the table when it would list fewer headings than this."
242
+ type="number"
243
+ min={1}
244
+ max={10}
245
+ value={String(settings.toc.minHeadings)}
246
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setToc({ minHeadings: Number(e.target.value) || 1 })}
247
+ />
248
+ <Checkbox
249
+ label="Smooth scrolling (skipped for visitors who prefer reduced motion)"
250
+ checked={settings.toc.smoothScroll}
251
+ onCheckedChange={(checked: boolean) => setToc({ smoothScroll: checked })}
252
+ />
253
+ <TocPreview toc={settings.toc} />
254
+ </Section>
255
+
256
+ </form>
257
+ )}
258
+
259
+ <SaveBar form="cw-headings-form" dirty={dirty} saving={saving} onDiscard={() => setSettings(saved)} />
260
+ </div>
261
+ );
262
+ }