@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,463 @@
1
+ /**
2
+ * Discovery admin page: IndexNow, the news sitemap, the RSS feed, and llms.txt settings
3
+ * and status.
4
+ */
5
+ import { Badge, Banner, Button, Checkbox, Input, InputArea, Loader, Select } from "@cloudflare/kumo";
6
+ import { ArrowClockwise, ArrowSquareOut, Key, PaperPlaneTilt } from "@phosphor-icons/react";
7
+ import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
8
+ import * as React from "react";
9
+
10
+ import { FEATURES_CHANGED, PartSwitch, useParts } from "./feature-switches.js";
11
+ import { SaveBar, isDirty } from "./save-bar.js";
12
+
13
+ const API = "/_emdash/api/plugins/coywolf-pack/discovery";
14
+
15
+ interface Settings {
16
+ indexnow: { endpoint: "api.indexnow.org" | "www.bing.com"; collections: string[] };
17
+ news: { collections: string[]; publicationName: string; language: string };
18
+ feed: { collections: string[]; items: number; termFeeds: boolean };
19
+ llms: { summary: string; intro: string; collections: string[]; maxEntries: number; markdown: boolean; license: string };
20
+ }
21
+
22
+ interface PingLogEntry {
23
+ at: string;
24
+ endpoint: string;
25
+ count: number;
26
+ urls: string[];
27
+ status: number;
28
+ ok: boolean;
29
+ error?: string;
30
+ trigger: "content" | "manual";
31
+ }
32
+
33
+ interface Status {
34
+ /** EmDash's staging mode: the site is hidden from search engines, so IndexNow sends nothing. */
35
+ staging?: boolean;
36
+ features: { indexnow: boolean; news: boolean; llms: boolean; feed: boolean };
37
+ settings: Settings;
38
+ collections: { slug: string; label: string; hidden: boolean; hasSeo: boolean }[];
39
+ key: string | null;
40
+ urls: { keyFile: string | null; news: string; llms: string; feed: string };
41
+ log: PingLogEntry[];
42
+ cache: { llms: { count: number; builtAt: string } | null; news: { count: number; builtAt: string } | null };
43
+ }
44
+
45
+ const errorText = (cause: unknown, fallback: string) => (cause instanceof Error && cause.message ? cause.message : fallback);
46
+ const dateTime = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeStyle: "short" });
47
+
48
+ async function post<T>(path: string, body: unknown): Promise<T> {
49
+ const response = await apiFetch(`${API}/${path}`, {
50
+ method: "POST",
51
+ headers: { "Content-Type": "application/json" },
52
+ body: JSON.stringify(body),
53
+ });
54
+ return parseApiResponse<T>(response, "The request failed");
55
+ }
56
+
57
+ /** A part of Discovery (IndexNow, News sitemap, RSS feed, llms.txt), with its on/off switch next to the title. */
58
+ function Section(props: { title: string; part: string; parts: ReturnType<typeof useParts>; description: string; children: React.ReactNode; actions?: React.ReactNode }) {
59
+ const { parts } = props;
60
+ // Off: only the title, switch and description; its details and settings show once it's on. Until the switches have
61
+ // loaded nothing below shows either (no flash of settings that then disappear), unless they couldn't be loaded.
62
+ const open = parts.parts ? parts.isOn(props.part) : Boolean(parts.error);
63
+ return (
64
+ <section className="rounded-lg border border-kumo-line" aria-labelledby={`discovery-${props.title}`}>
65
+ <div className={`flex flex-wrap items-start justify-between gap-4 p-4${open ? " border-b border-kumo-line" : ""}`}>
66
+ <div className="min-w-0">
67
+ <h2 id={`discovery-${props.title}`} className="flex items-center gap-2 text-base font-semibold">
68
+ {props.title}
69
+ <PartSwitch
70
+ part={parts.parts?.find((p) => p.id === props.part)}
71
+ mainOn={Boolean(parts.main?.enabled)}
72
+ saving={parts.saving}
73
+ onToggle={(id, on) => void parts.toggle(id, on)}
74
+ />
75
+ </h2>
76
+ <p className="mt-1 text-sm text-kumo-subtle">{props.description}</p>
77
+ </div>
78
+ {open && props.actions && <div className="flex flex-wrap gap-2">{props.actions}</div>}
79
+ </div>
80
+ {open && <div className="space-y-4 p-4">{props.children}</div>}
81
+ </section>
82
+ );
83
+ }
84
+
85
+ function ExternalLink(props: { href: string }) {
86
+ return (
87
+ <a className="inline-flex items-center gap-1 break-all text-sm text-kumo-link underline" href={props.href} target="_blank" rel="noreferrer">
88
+ {props.href}
89
+ <ArrowSquareOut aria-hidden="true" className="shrink-0" />
90
+ </a>
91
+ );
92
+ }
93
+
94
+ function CollectionPicker(props: {
95
+ legend: string;
96
+ hint: string;
97
+ collections: Status["collections"];
98
+ value: string[];
99
+ onChange: (value: string[]) => void;
100
+ }) {
101
+ return (
102
+ <fieldset style={{ minWidth: 0 }}>
103
+ <legend className="text-sm font-medium">{props.legend}</legend>
104
+ <p className="mt-0.5 text-sm text-kumo-subtle">{props.hint}</p>
105
+ <div className="mt-2 flex flex-wrap gap-x-6 gap-y-2">
106
+ {props.collections.map((c) => (
107
+ <Checkbox
108
+ key={c.slug}
109
+ label={c.label}
110
+ checked={props.value.includes(c.slug)}
111
+ onCheckedChange={(checked: boolean) =>
112
+ props.onChange(checked ? [...props.value, c.slug] : props.value.filter((s) => s !== c.slug))
113
+ }
114
+ />
115
+ ))}
116
+ </div>
117
+ </fieldset>
118
+ );
119
+ }
120
+
121
+ const when = (iso: string | undefined) => (iso ? dateTime.format(new Date(iso)) : "never");
122
+
123
+ export function DiscoveryPage() {
124
+ const [status, setStatus] = React.useState<Status>();
125
+ const [draft, setDraft] = React.useState<Settings>();
126
+ const [error, setError] = React.useState<string>();
127
+ const [notice, setNotice] = React.useState<string>();
128
+ const [busy, setBusy] = React.useState<string | null>(null);
129
+ const parts = useParts("discovery");
130
+
131
+ const load = React.useCallback(async () => {
132
+ try {
133
+ const data = await parseApiResponse<Status>(await apiFetch(`${API}/status`), "Couldn't load Discovery");
134
+ setStatus(data);
135
+ setDraft(data.settings);
136
+ } catch (cause) {
137
+ setError(errorText(cause, "Couldn't load Discovery"));
138
+ }
139
+ }, []);
140
+ React.useEffect(() => {
141
+ void load();
142
+ }, [load]);
143
+
144
+ // A part switched on this page: its status (key file, stored copies) may have changed. The draft is kept.
145
+ React.useEffect(() => {
146
+ const reload = () =>
147
+ void apiFetch(`${API}/status`)
148
+ .then((response) => parseApiResponse<Status>(response, "Couldn't load Discovery"))
149
+ .then(setStatus)
150
+ .catch(() => undefined);
151
+ window.addEventListener(FEATURES_CHANGED, reload);
152
+ return () => window.removeEventListener(FEATURES_CHANGED, reload);
153
+ }, []);
154
+
155
+ async function run(name: string, task: () => Promise<string>) {
156
+ setBusy(name);
157
+ setError(undefined);
158
+ setNotice(undefined);
159
+ try {
160
+ setNotice(await task());
161
+ await load();
162
+ } catch (cause) {
163
+ setError(errorText(cause, "Something went wrong"));
164
+ } finally {
165
+ setBusy(null);
166
+ }
167
+ }
168
+
169
+ const save = () =>
170
+ run("save", async () => {
171
+ await post("save", draft);
172
+ return "Settings saved. llms.txt and the news sitemap rebuild in the background within a few seconds.";
173
+ });
174
+ const rebuild = () =>
175
+ run("rebuild", async () => {
176
+ const out = await post<{ llms?: number; news?: number }>("rebuild", {});
177
+ const parts = [out.llms !== undefined && `llms.txt lists ${out.llms} entries`, out.news !== undefined && `the news sitemap lists ${out.news}`].filter(Boolean);
178
+ return parts.length ? `Rebuilt: ${parts.join(", ")}.` : "Nothing to rebuild: llms.txt and the news sitemap are off.";
179
+ });
180
+ const submitHome = () =>
181
+ run("submit", async () => {
182
+ const entry = await post<PingLogEntry>("submit", {});
183
+ return entry.ok ? `Submitted the home page to ${entry.endpoint} (HTTP ${entry.status}).` : `The submission failed: ${entry.error ?? `HTTP ${entry.status}`}`;
184
+ });
185
+ const regenerate = () => {
186
+ if (!window.confirm("Create a new IndexNow key? The old key file stops working.")) return;
187
+ void run("key", async () => {
188
+ await post("regenerate-key", {});
189
+ return "New IndexNow key created.";
190
+ });
191
+ };
192
+
193
+ if (!status || !draft) {
194
+ return (
195
+ <div className="space-y-6">
196
+ <Header />
197
+ {error ? (
198
+ <Banner variant="error" role="alert" description={error} />
199
+ ) : (
200
+ <div className="flex justify-center py-12">
201
+ <Loader />
202
+ </div>
203
+ )}
204
+ </div>
205
+ );
206
+ }
207
+
208
+ const set = <K extends keyof Settings>(section: K, patch: Partial<Settings[K]>) =>
209
+ setDraft((d) => (d ? { ...d, [section]: { ...d[section], ...patch } } : d));
210
+ const anyOn = parts.parts ? parts.parts.some((p) => parts.isOn(p.id)) : status.features.indexnow || status.features.news || status.features.feed || status.features.llms;
211
+
212
+ return (
213
+ <div className="space-y-6">
214
+ <Header />
215
+
216
+ {!anyOn && (
217
+ <Banner
218
+ variant="default"
219
+ description="Discovery's parts are all off. Turn on the ones you want with the switch next to each title. You can change their settings first."
220
+ />
221
+ )}
222
+ {status.staging && (
223
+ <Banner
224
+ variant="default"
225
+ description="This site is in staging (Settings → General → Site Status), hidden from search engines, so IndexNow sends nothing until it goes live. The other parts keep working."
226
+ />
227
+ )}
228
+ <div aria-live="polite">
229
+ {error && <Banner variant="error" role="alert" description={error} />}
230
+ {notice && <Banner variant="default" description={notice} />}
231
+ </div>
232
+
233
+ <form
234
+ id="cw-discovery-form"
235
+ className="space-y-6"
236
+ onSubmit={(e) => {
237
+ e.preventDefault();
238
+ void save();
239
+ }}
240
+ >
241
+ <Section
242
+ title="IndexNow"
243
+ part="discovery.indexnow"
244
+ parts={parts}
245
+ description="When an entry is published, updated, unpublished, or deleted, its URL is sent to IndexNow so Bing, Yandex, Seznam, Naver and other participating engines can recrawl it within minutes. Changes made within a few seconds of each other go out together. Entries set to noindex aren't submitted."
246
+ actions={
247
+ <>
248
+ <Button type="button" variant="secondary" icon={<PaperPlaneTilt />} disabled={!(parts.parts ? parts.isOn("discovery.indexnow") : status.features.indexnow) || busy !== null} onClick={() => void submitHome()}>
249
+ {busy === "submit" ? "Submitting…" : "Submit home page"}
250
+ </Button>
251
+ <Button type="button" variant="secondary" icon={<Key />} disabled={busy !== null} onClick={regenerate}>
252
+ New key
253
+ </Button>
254
+ </>
255
+ }
256
+ >
257
+ <div className="text-sm">
258
+ <div className="font-medium">Key file</div>
259
+ {status.urls.keyFile ? (
260
+ <ExternalLink href={status.urls.keyFile} />
261
+ ) : (
262
+ <p className="text-kumo-subtle">A key is created when IndexNow is turned on.</p>
263
+ )}
264
+ </div>
265
+ <Select
266
+ label="Endpoint"
267
+ value={draft.indexnow.endpoint}
268
+ onValueChange={(value: string | null) => set("indexnow", { endpoint: value === "www.bing.com" ? "www.bing.com" : "api.indexnow.org" })}
269
+ items={[
270
+ { value: "api.indexnow.org", label: "api.indexnow.org (shared with all IndexNow engines)" },
271
+ { value: "www.bing.com", label: "www.bing.com" },
272
+ ]}
273
+ />
274
+ <CollectionPicker
275
+ legend="Collections"
276
+ hint="Leave all unchecked to submit every collection with public URLs."
277
+ collections={status.collections}
278
+ value={draft.indexnow.collections}
279
+ onChange={(collections) => set("indexnow", { collections })}
280
+ />
281
+ <div>
282
+ <h3 className="text-sm font-medium">Recent submissions</h3>
283
+ {status.log.length === 0 ? (
284
+ <p className="mt-1 text-sm text-kumo-subtle">None yet.</p>
285
+ ) : (
286
+ <div className="mt-2 overflow-x-auto">
287
+ <table className="w-full text-left text-sm">
288
+ <thead className="text-kumo-subtle">
289
+ <tr>
290
+ <th scope="col" className="py-1 pr-4 font-medium">When</th>
291
+ <th scope="col" className="py-1 pr-4 font-medium">Result</th>
292
+ <th scope="col" className="py-1 pr-4 font-medium">URLs</th>
293
+ </tr>
294
+ </thead>
295
+ <tbody className="divide-y divide-kumo-line">
296
+ {status.log.map((entry) => (
297
+ <tr key={`${entry.at}-${entry.urls[0] ?? ""}`} className="align-top">
298
+ <td className="whitespace-nowrap py-2 pr-4">{when(entry.at)}</td>
299
+ <td className="py-2 pr-4">
300
+ <Badge variant={entry.ok ? "primary" : "destructive"}>{entry.ok ? `OK ${entry.status}` : entry.status ? `Error ${entry.status}` : "Error"}</Badge>
301
+ {entry.error && <div className="mt-1 max-w-xs break-words text-kumo-subtle">{entry.error}</div>}
302
+ </td>
303
+ <td className="py-2 pr-4">
304
+ <div className="break-all">{entry.urls[0]}</div>
305
+ {entry.count > 1 && <div className="text-kumo-subtle">and {entry.count - 1} more</div>}
306
+ </td>
307
+ </tr>
308
+ ))}
309
+ </tbody>
310
+ </table>
311
+ </div>
312
+ )}
313
+ </div>
314
+ </Section>
315
+
316
+ <Section
317
+ title="News sitemap"
318
+ part="discovery.newsSitemap"
319
+ parts={parts}
320
+ description="A Google News sitemap of entries published in the last 48 hours. Submit it in Google Search Console, and optionally add a Sitemap: line for it to your robots.txt (Settings → SEO)."
321
+ >
322
+ <ExternalLink href={status.urls.news} />
323
+ <p className="text-sm text-kumo-subtle">
324
+ {status.cache.news ? `${status.cache.news.count} entries as of ${when(status.cache.news.builtAt)}.` : "Not built yet."}
325
+ </p>
326
+ <CollectionPicker
327
+ legend="Collections"
328
+ hint="Entries from these collections are listed."
329
+ collections={status.collections}
330
+ value={draft.news.collections}
331
+ onChange={(collections) => set("news", { collections })}
332
+ />
333
+ <div className="grid gap-4 sm:grid-cols-2">
334
+ <Input
335
+ label="Publication name"
336
+ description="Must match the name Google News knows you by. Empty uses the site title."
337
+ value={draft.news.publicationName}
338
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => set("news", { publicationName: e.target.value })}
339
+ />
340
+ <Input
341
+ label="Language"
342
+ description="ISO 639 code, such as en or fr. Empty uses the site locale."
343
+ value={draft.news.language}
344
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => set("news", { language: e.target.value })}
345
+ />
346
+ </div>
347
+ </Section>
348
+
349
+ <Section
350
+ title="RSS feed"
351
+ part="discovery.feed"
352
+ parts={parts}
353
+ description="An RSS feed of the newest entries at /feed/, linked from the head of every page so feed readers, podcast and news apps, and bots find it. With category URLs (such as /news/seo/your-post/), each category also has a feed at its address + feed/, including its subcategories. Entries set to noindex aren't listed."
354
+ >
355
+ <ExternalLink href={status.urls.feed} />
356
+ <CollectionPicker
357
+ legend="Collections"
358
+ hint="Entries from these collections are listed, newest first."
359
+ collections={status.collections}
360
+ value={draft.feed.collections}
361
+ onChange={(collections) => set("feed", { collections })}
362
+ />
363
+ <div className="grid gap-4 sm:grid-cols-2">
364
+ <Input
365
+ label="Entries in each feed"
366
+ type="number"
367
+ min={1}
368
+ max={100}
369
+ description="1 to 100."
370
+ value={String(draft.feed.items)}
371
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => set("feed", { items: Math.max(1, Math.min(100, Math.round(Number(e.target.value)) || 1)) })}
372
+ />
373
+ </div>
374
+ <Checkbox
375
+ label="Category feeds"
376
+ checked={draft.feed.termFeeds}
377
+ onCheckedChange={(checked: boolean) => set("feed", { termFeeds: checked })}
378
+ />
379
+ </Section>
380
+
381
+ <Section
382
+ title="llms.txt and Markdown"
383
+ part="discovery.llms"
384
+ parts={parts}
385
+ description="An llms.txt index (llmstxt.org) linking your entries, and a Markdown version of each entry at its URL + index.html.md."
386
+ actions={
387
+ <Button type="button" variant="secondary" icon={<ArrowClockwise />} disabled={busy !== null} onClick={() => void rebuild()}>
388
+ {busy === "rebuild" ? "Rebuilding…" : "Rebuild now"}
389
+ </Button>
390
+ }
391
+ >
392
+ <ExternalLink href={status.urls.llms} />
393
+ <p className="text-sm text-kumo-subtle">
394
+ {status.cache.llms ? `${status.cache.llms.count} entries as of ${when(status.cache.llms.builtAt)}.` : "Not built yet."}
395
+ </p>
396
+ <Input
397
+ label="Summary"
398
+ description="The one-line summary under the site name. Empty uses the site tagline."
399
+ value={draft.llms.summary}
400
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => set("llms", { summary: e.target.value })}
401
+ />
402
+ <InputArea
403
+ label="Introduction"
404
+ description="Optional Markdown shown before the lists, such as what the site covers and which pages matter most."
405
+ rows={4}
406
+ value={draft.llms.intro}
407
+ onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => set("llms", { intro: e.target.value })}
408
+ />
409
+ <CollectionPicker
410
+ legend="Collections"
411
+ hint="Leave all unchecked to list every visible collection with public URLs."
412
+ collections={status.collections}
413
+ value={draft.llms.collections}
414
+ onChange={(collections) => set("llms", { collections })}
415
+ />
416
+ <div className="grid gap-4 sm:grid-cols-2">
417
+ <Input
418
+ label="Maximum entries"
419
+ type="number"
420
+ min={1}
421
+ max={5000}
422
+ description="The first 100 per collection are listed by collection; the rest go under Optional."
423
+ value={String(draft.llms.maxEntries)}
424
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => set("llms", { maxEntries: Math.max(1, Math.min(5000, Number(e.target.value) || 1)) })}
425
+ />
426
+ <Input
427
+ label="Content license (optional)"
428
+ description="Noted in each Markdown file's frontmatter, such as CC BY 4.0."
429
+ value={draft.llms.license}
430
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => set("llms", { license: e.target.value })}
431
+ />
432
+ </div>
433
+ <Checkbox
434
+ label="Serve Markdown versions of entries and link to them from llms.txt"
435
+ checked={draft.llms.markdown}
436
+ onCheckedChange={(checked: boolean) => set("llms", { markdown: checked })}
437
+ />
438
+ </Section>
439
+
440
+ </form>
441
+
442
+ <SaveBar
443
+ form="cw-discovery-form"
444
+ dirty={isDirty(draft, status.settings)}
445
+ saving={busy === "save"}
446
+ canSave={busy === null}
447
+ onDiscard={() => setDraft(status.settings)}
448
+ />
449
+ </div>
450
+ );
451
+ }
452
+
453
+ function Header() {
454
+ return (
455
+ <header className="grid min-w-0 gap-4 border-b border-kumo-line pb-4">
456
+ <h1 className="flex min-h-9 min-w-0 items-center text-2xl font-semibold leading-tight">Discovery</h1>
457
+ <p className="text-sm leading-5 text-pretty text-kumo-subtle">
458
+ Help search engines, feed readers and AI agents find your content: IndexNow pings, a Google News sitemap, an RSS feed, and llms.txt with Markdown versions of
459
+ your entries.
460
+ </p>
461
+ </header>
462
+ );
463
+ }
@@ -0,0 +1,28 @@
1
+ /** Save text the admin built in the browser (an export) as a file. */
2
+
3
+ /** Start a download of `content` as `filename`. CSV gets a byte-order mark so Excel reads it as UTF-8. */
4
+ export function saveFile(content: string | Blob, filename: string, type = "text/plain"): void {
5
+ const blob =
6
+ content instanceof Blob ? content : new Blob(type.startsWith("text/csv") ? ["", content] : [content], { type: `${type};charset=utf-8` });
7
+ const url = URL.createObjectURL(blob);
8
+ const link = document.createElement("a");
9
+ link.href = url;
10
+ link.download = filename;
11
+ link.style.display = "none";
12
+ document.body.appendChild(link);
13
+ link.click();
14
+ link.remove();
15
+ // Some browsers read the blob after click() returns.
16
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
17
+ }
18
+
19
+ /** "2026-10-04", for export file names. */
20
+ export function today(): string {
21
+ return new Date().toISOString().slice(0, 10);
22
+ }
23
+
24
+ /** A host name for file names ("example.com" -> "example-com"); empty when there is none. */
25
+ export function siteSlug(): string {
26
+ return typeof location === "undefined" ? "" : location.hostname.replace(/^www\./, "").replace(/[^a-z0-9]+/gi, "-");
27
+ }
28
+
@@ -0,0 +1,124 @@
1
+ /**
2
+ * A module's parts (its sub-features) switched on or off from the module's
3
+ * own page, so a part can be turned on where it's set up instead of on the
4
+ * Features page. The module's main switch stays on the Features page (turning
5
+ * it off removes the page from the sidebar). Same routes as the Features
6
+ * page: features/list and features/save.
7
+ */
8
+ import { Banner, Switch } from "@cloudflare/kumo";
9
+ import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
10
+ import * as React from "react";
11
+
12
+ const API = "/_emdash/api/plugins/coywolf-pack/features";
13
+
14
+ export interface Part {
15
+ id: string;
16
+ label: string;
17
+ description: string;
18
+ enabled: boolean;
19
+ }
20
+
21
+ interface ModuleState {
22
+ id: string;
23
+ features: Part[];
24
+ }
25
+
26
+ /** Sent after a part is switched, so the page can refresh what depends on it. */
27
+ export const FEATURES_CHANGED = "coywolf-pack:features-changed";
28
+
29
+ const errorText = (cause: unknown, fallback: string) => (cause instanceof Error && cause.message ? cause.message : fallback);
30
+
31
+ /** The module's main switch and its parts, with a toggle that saves at once. */
32
+ export function useParts(moduleId: string) {
33
+ const [main, setMain] = React.useState<Part | null>(null);
34
+ const [parts, setParts] = React.useState<Part[] | null>(null);
35
+ const [saving, setSaving] = React.useState<string | null>(null);
36
+ const [error, setError] = React.useState<string | null>(null);
37
+
38
+ React.useEffect(() => {
39
+ let live = true;
40
+ (async () => {
41
+ try {
42
+ const response = await apiFetch(`${API}/list`);
43
+ const { modules } = await parseApiResponse<{ modules: ModuleState[] }>(response, "Couldn't load the feature switches");
44
+ const [first, ...rest] = modules.find((m) => m.id === moduleId)?.features ?? [];
45
+ if (!live) return;
46
+ setMain(first ?? null);
47
+ setParts(rest);
48
+ } catch (cause) {
49
+ if (live) setError(errorText(cause, "Couldn't load the feature switches"));
50
+ }
51
+ })();
52
+ return () => {
53
+ live = false;
54
+ };
55
+ }, [moduleId]);
56
+
57
+ async function toggle(id: string, enabled: boolean) {
58
+ setSaving(id);
59
+ setError(null);
60
+ try {
61
+ const response = await apiFetch(`${API}/save`, {
62
+ method: "POST",
63
+ headers: { "Content-Type": "application/json" },
64
+ body: JSON.stringify({ features: { [id]: enabled } }),
65
+ });
66
+ const { features } = await parseApiResponse<{ features: Record<string, boolean> }>(response, "Couldn't save");
67
+ setParts((current) => current?.map((p) => ({ ...p, enabled: features[p.id] ?? p.enabled })) ?? null);
68
+ window.dispatchEvent(new CustomEvent(FEATURES_CHANGED, { detail: features }));
69
+ } catch (cause) {
70
+ setError(errorText(cause, "Couldn't save"));
71
+ } finally {
72
+ setSaving(null);
73
+ }
74
+ }
75
+
76
+ const isOn = (id: string) => Boolean(main?.enabled && parts?.find((p) => p.id === id)?.enabled);
77
+ return { main, parts, saving, error, toggle, isOn };
78
+ }
79
+
80
+ /** One part's switch (for a page that shows it next to the part's own settings). */
81
+ export function PartSwitch(props: { part: Part | undefined; mainOn: boolean; saving: string | null; onToggle: (id: string, on: boolean) => void }) {
82
+ const { part } = props;
83
+ if (!part) return null;
84
+ return (
85
+ <Switch className="shrink-0"
86
+ size="sm"
87
+ aria-label={`${part.label}: ${part.enabled && props.mainOn ? "on" : "off"}`}
88
+ checked={part.enabled && props.mainOn}
89
+ disabled={props.saving !== null || !props.mainOn}
90
+ transitioning={props.saving === part.id}
91
+ onCheckedChange={(on) => props.onToggle(part.id, on)}
92
+ />
93
+ );
94
+ }
95
+
96
+ /** A card listing the module's parts with their switches. Nothing when the module has none. */
97
+ export function PartSwitches(props: { module: string }) {
98
+ const { main, parts, saving, error, toggle } = useParts(props.module);
99
+ if (error) return <Banner variant="error" role="alert" description={error} />;
100
+ if (!main || !parts?.length) return null;
101
+ return (
102
+ <section className="rounded-lg border border-kumo-line" aria-labelledby={`cw-parts-${props.module}`}>
103
+ <div className="border-b border-kumo-line p-4">
104
+ <h2 id={`cw-parts-${props.module}`} className="text-base font-semibold">
105
+ Turn parts on or off
106
+ </h2>
107
+ <p className="mt-1 text-sm text-kumo-subtle">
108
+ {main.enabled ? "Changes apply right away. The same switches are on the Features page." : `${main.label} is off: turn it on under Plugins → Coywolf Pack → Features first.`}
109
+ </p>
110
+ </div>
111
+ <ul className="divide-y divide-kumo-line">
112
+ {parts.map((part) => (
113
+ <li key={part.id} className="flex items-start justify-between gap-4 px-4 py-3">
114
+ <div className="min-w-0">
115
+ <h3 className="text-sm font-medium">{part.label}</h3>
116
+ <p className="mt-0.5 text-sm text-kumo-subtle">{part.description}</p>
117
+ </div>
118
+ <PartSwitch part={part} mainOn={main.enabled} saving={saving} onToggle={(id, on) => void toggle(id, on)} />
119
+ </li>
120
+ ))}
121
+ </ul>
122
+ </section>
123
+ );
124
+ }