@coywolf/emdash 0.0.0-stage → 0.35.14

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 (267) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -2
  3. package/docs/README.md +45 -0
  4. package/docs/ai.md +39 -0
  5. package/docs/backups.md +88 -0
  6. package/docs/breadcrumbs.md +59 -0
  7. package/docs/code-blocks.md +21 -0
  8. package/docs/content-urls.md +41 -0
  9. package/docs/custom-blocks.md +27 -0
  10. package/docs/discovery.md +32 -0
  11. package/docs/files.md +44 -0
  12. package/docs/form-uploads.md +59 -0
  13. package/docs/headings.md +37 -0
  14. package/docs/images.md +141 -0
  15. package/docs/internals.md +13 -0
  16. package/docs/links.md +34 -0
  17. package/docs/performance.md +72 -0
  18. package/docs/redirects.md +84 -0
  19. package/docs/releasing.md +21 -0
  20. package/docs/reviews.md +89 -0
  21. package/docs/robots.md +81 -0
  22. package/docs/schema.md +79 -0
  23. package/docs/search.md +114 -0
  24. package/docs/videos.md +48 -0
  25. package/docs/wordpress-import.md +198 -0
  26. package/package.json +79 -5
  27. package/src/admin/admin-css.ts +57 -0
  28. package/src/admin/ai.tsx +915 -0
  29. package/src/admin/backups.tsx +613 -0
  30. package/src/admin/breadcrumbs.tsx +542 -0
  31. package/src/admin/codeBlocks.tsx +204 -0
  32. package/src/admin/customBlocks.tsx +321 -0
  33. package/src/admin/dirty.ts +20 -0
  34. package/src/admin/discovery.tsx +463 -0
  35. package/src/admin/download.ts +28 -0
  36. package/src/admin/feature-switches.tsx +124 -0
  37. package/src/admin/features.tsx +142 -0
  38. package/src/admin/files.tsx +791 -0
  39. package/src/admin/formUploads.tsx +445 -0
  40. package/src/admin/guides.tsx +18 -0
  41. package/src/admin/headings.tsx +262 -0
  42. package/src/admin/images.tsx +825 -0
  43. package/src/admin/index.tsx +62 -0
  44. package/src/admin/links.tsx +980 -0
  45. package/src/admin/performance.tsx +330 -0
  46. package/src/admin/preview.tsx +63 -0
  47. package/src/admin/redirects-removed.tsx +266 -0
  48. package/src/admin/redirects.tsx +470 -0
  49. package/src/admin/reviews.tsx +232 -0
  50. package/src/admin/robots-bots.tsx +647 -0
  51. package/src/admin/robots-shared.tsx +109 -0
  52. package/src/admin/robots-wizard.tsx +748 -0
  53. package/src/admin/robots.tsx +911 -0
  54. package/src/admin/save-bar.tsx +134 -0
  55. package/src/admin/schema.tsx +1187 -0
  56. package/src/admin/search.tsx +355 -0
  57. package/src/admin/settings-ui.tsx +87 -0
  58. package/src/admin/trash-prompt-core.ts +135 -0
  59. package/src/admin/trash-prompt.tsx +156 -0
  60. package/src/admin/usage.tsx +301 -0
  61. package/src/admin/videos-display.tsx +248 -0
  62. package/src/admin/videos.tsx +1202 -0
  63. package/src/admin/wpImport.tsx +1516 -0
  64. package/src/ai/entities.ts +59 -0
  65. package/src/ai/export.ts +61 -0
  66. package/src/ai/logic.ts +606 -0
  67. package/src/ai/pack.ts +121 -0
  68. package/src/ai/prompts.ts +87 -0
  69. package/src/ai/providers.ts +276 -0
  70. package/src/ai/routes.ts +359 -0
  71. package/src/ai/store.ts +301 -0
  72. package/src/ai/wikidata.ts +87 -0
  73. package/src/ai/worker.ts +530 -0
  74. package/src/astro/astro-modules.d.ts +6 -0
  75. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  76. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  77. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  78. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  79. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  80. package/src/astro/customBlocks/Details.astro +8 -0
  81. package/src/astro/customBlocks/Disclosure.astro +8 -0
  82. package/src/astro/customBlocks/Note.astro +8 -0
  83. package/src/astro/customBlocks/Podcast.astro +8 -0
  84. package/src/astro/customBlocks/Quote.astro +8 -0
  85. package/src/astro/customBlocks/Testimonial.astro +8 -0
  86. package/src/astro/files/FileCard.astro +103 -0
  87. package/src/astro/headings/HeadingBlock.astro +129 -0
  88. package/src/astro/headings/TableOfContents.astro +45 -0
  89. package/src/astro/headings/request.ts +19 -0
  90. package/src/astro/images/ResponsiveImage.astro +60 -0
  91. package/src/astro/index.ts +84 -0
  92. package/src/astro/reviews/CoywolfReview.astro +27 -0
  93. package/src/astro/reviews/Review.astro +49 -0
  94. package/src/astro/search/SearchBox.astro +61 -0
  95. package/src/astro/search/SearchBoxForm.astro +509 -0
  96. package/src/astro/tables/CaptionedTable.astro +72 -0
  97. package/src/astro/videos/CoywolfVideo.astro +448 -0
  98. package/src/backups/download.ts +112 -0
  99. package/src/backups/dump.ts +288 -0
  100. package/src/backups/module.ts +276 -0
  101. package/src/backups/pack.ts +33 -0
  102. package/src/backups/restore.ts +209 -0
  103. package/src/backups/store.ts +409 -0
  104. package/src/breadcrumbs/pack.ts +113 -0
  105. package/src/breadcrumbs/settings.ts +102 -0
  106. package/src/breadcrumbs/stamp.ts +40 -0
  107. package/src/breadcrumbs/trail.ts +123 -0
  108. package/src/codeBlocks/contrast.ts +179 -0
  109. package/src/codeBlocks/hljs.d.ts +5 -0
  110. package/src/codeBlocks/pack.ts +110 -0
  111. package/src/codeBlocks/render.ts +304 -0
  112. package/src/codeBlocks/settings.ts +18 -0
  113. package/src/codeBlocks/themes.generated.ts +304 -0
  114. package/src/codeBlocks/themes.ts +114 -0
  115. package/src/core/compose.ts +244 -0
  116. package/src/core/content-url.ts +888 -0
  117. package/src/core/csv.ts +119 -0
  118. package/src/core/d1-batch.ts +65 -0
  119. package/src/core/entry-docs.ts +175 -0
  120. package/src/core/features-module.ts +48 -0
  121. package/src/core/features.ts +392 -0
  122. package/src/core/generation.ts +88 -0
  123. package/src/core/guide-content.ts +166 -0
  124. package/src/core/module.ts +104 -0
  125. package/src/core/read-failures.ts +57 -0
  126. package/src/core/request-memo.ts +71 -0
  127. package/src/core/secrets.ts +83 -0
  128. package/src/core/settings-enhance.ts +74 -0
  129. package/src/core/site.ts +36 -0
  130. package/src/core/storage.ts +27 -0
  131. package/src/customBlocks/pack.ts +260 -0
  132. package/src/customBlocks/render.ts +424 -0
  133. package/src/customBlocks/rich.ts +313 -0
  134. package/src/customBlocks/settings.ts +46 -0
  135. package/src/discovery/build.ts +252 -0
  136. package/src/discovery/feed-build.ts +217 -0
  137. package/src/discovery/feed.ts +172 -0
  138. package/src/discovery/indexnow.ts +114 -0
  139. package/src/discovery/llms.ts +79 -0
  140. package/src/discovery/markdown.ts +294 -0
  141. package/src/discovery/memory-cache.ts +97 -0
  142. package/src/discovery/middleware.ts +186 -0
  143. package/src/discovery/module.ts +353 -0
  144. package/src/discovery/news.ts +99 -0
  145. package/src/discovery/pack.ts +58 -0
  146. package/src/discovery/settings.ts +91 -0
  147. package/src/discovery/urls.ts +35 -0
  148. package/src/env.d.ts +10 -0
  149. package/src/files/card.ts +231 -0
  150. package/src/files/counts.ts +60 -0
  151. package/src/files/export.ts +44 -0
  152. package/src/files/format.ts +163 -0
  153. package/src/files/module.ts +596 -0
  154. package/src/files/pack.ts +92 -0
  155. package/src/files/r2-rules.ts +41 -0
  156. package/src/files/range.ts +50 -0
  157. package/src/files/s3.ts +114 -0
  158. package/src/files/serve.ts +141 -0
  159. package/src/files/sigv4.ts +160 -0
  160. package/src/files/site.ts +137 -0
  161. package/src/files/walker.ts +58 -0
  162. package/src/formUploads/lib.ts +266 -0
  163. package/src/formUploads/pack.ts +55 -0
  164. package/src/formUploads/wrap.ts +592 -0
  165. package/src/forms.ts +16 -0
  166. package/src/headings/pack.ts +154 -0
  167. package/src/headings/render.ts +139 -0
  168. package/src/headings/settings.ts +117 -0
  169. package/src/headings/slug.ts +68 -0
  170. package/src/headings/stamp.ts +142 -0
  171. package/src/headings/test-hooks.mjs +14 -0
  172. package/src/headings/toc.ts +48 -0
  173. package/src/images/cloudflare.ts +329 -0
  174. package/src/images/lib.ts +221 -0
  175. package/src/images/module.ts +372 -0
  176. package/src/images/pack.ts +804 -0
  177. package/src/images/pending.ts +37 -0
  178. package/src/images/settings.ts +48 -0
  179. package/src/images/setup.ts +192 -0
  180. package/src/images/variants-estimate.ts +162 -0
  181. package/src/images/variants-store.ts +475 -0
  182. package/src/images/variants.ts +311 -0
  183. package/src/index.ts +143 -0
  184. package/src/links/check.ts +101 -0
  185. package/src/links/classify.ts +270 -0
  186. package/src/links/export.ts +59 -0
  187. package/src/links/module.ts +652 -0
  188. package/src/links/pack.ts +43 -0
  189. package/src/links/pt.ts +325 -0
  190. package/src/links/store.ts +704 -0
  191. package/src/middleware.ts +287 -0
  192. package/src/modules.ts +53 -0
  193. package/src/options.ts +66 -0
  194. package/src/pageCache/lib.ts +274 -0
  195. package/src/pageCache/pack.ts +240 -0
  196. package/src/pageCache/usage-core.ts +152 -0
  197. package/src/pageCache/usage.ts +262 -0
  198. package/src/pageCache/warm.ts +722 -0
  199. package/src/redirects/middleware.ts +169 -0
  200. package/src/redirects/module.ts +124 -0
  201. package/src/redirects/pack.ts +89 -0
  202. package/src/redirects/removed-core.ts +151 -0
  203. package/src/redirects/removed.ts +293 -0
  204. package/src/redirects/rules.ts +355 -0
  205. package/src/redirects/slug-changes.ts +207 -0
  206. package/src/redirects/slug-core.ts +49 -0
  207. package/src/redirects/transfer.ts +92 -0
  208. package/src/reviews/lib.ts +624 -0
  209. package/src/reviews/pack.ts +135 -0
  210. package/src/reviews/schema.ts +128 -0
  211. package/src/reviews/settings.ts +26 -0
  212. package/src/robots/bots.ts +321 -0
  213. package/src/robots/data/bots.json +696 -0
  214. package/src/robots/data/verified.json +582 -0
  215. package/src/robots/directory.ts +26 -0
  216. package/src/robots/explain.ts +191 -0
  217. package/src/robots/guided.ts +300 -0
  218. package/src/robots/importer.ts +263 -0
  219. package/src/robots/middleware.ts +151 -0
  220. package/src/robots/module.ts +446 -0
  221. package/src/robots/pack.ts +69 -0
  222. package/src/robots/radar.ts +129 -0
  223. package/src/robots/rep.ts +362 -0
  224. package/src/robots/rules.ts +883 -0
  225. package/src/robots/transfer.ts +45 -0
  226. package/src/robots/validate.ts +524 -0
  227. package/src/schema/catalog.ts +146 -0
  228. package/src/schema/graph.ts +791 -0
  229. package/src/schema/module.ts +1138 -0
  230. package/src/schema/og-image.ts +100 -0
  231. package/src/schema/pack.ts +58 -0
  232. package/src/search/engine.ts +578 -0
  233. package/src/search/fallback.ts +80 -0
  234. package/src/search/live-cache.ts +116 -0
  235. package/src/search/live-client.ts +770 -0
  236. package/src/search/live-serve.ts +282 -0
  237. package/src/search/module.ts +261 -0
  238. package/src/search/pack.ts +57 -0
  239. package/src/search/query.ts +132 -0
  240. package/src/search/ratelimit-core.ts +118 -0
  241. package/src/search/ratelimit.ts +79 -0
  242. package/src/search/snippet.ts +135 -0
  243. package/src/search/title-index.ts +139 -0
  244. package/src/search/title-match.ts +192 -0
  245. package/src/shared.ts +71 -0
  246. package/src/version.ts +2 -0
  247. package/src/videos/date.ts +40 -0
  248. package/src/videos/engagement.ts +75 -0
  249. package/src/videos/lib.ts +679 -0
  250. package/src/videos/module.ts +1160 -0
  251. package/src/videos/pack.ts +187 -0
  252. package/src/videos/pause.ts +42 -0
  253. package/src/videos/poster.ts +324 -0
  254. package/src/videos/render.ts +336 -0
  255. package/src/videos/store.ts +551 -0
  256. package/src/videos/stream.ts +134 -0
  257. package/src/wpImport/convert.ts +702 -0
  258. package/src/wpImport/guests.ts +329 -0
  259. package/src/wpImport/gutenberg.ts +157 -0
  260. package/src/wpImport/markers.ts +66 -0
  261. package/src/wpImport/pack.ts +451 -0
  262. package/src/wpImport/parents.ts +202 -0
  263. package/src/wpImport/prepare.ts +592 -0
  264. package/src/wpImport/redirects.ts +358 -0
  265. package/src/wpImport/sources.ts +67 -0
  266. package/src/wpImport/stream.ts +160 -0
  267. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Feature switches. Every Coywolf Pack feature can be turned off on the
3
+ * Features admin page; the choices are one plugin setting ("features", an
4
+ * object of id → boolean). Hooks and routes read it through the plugin
5
+ * context; middleware and Astro block renderers, which run outside it, read
6
+ * the same option row from D1 with a per-isolate cache (five minutes).
7
+ *
8
+ * That one query also reads every registered site setting and a few pack
9
+ * option rows (the settings generation, the deployed version), so the
10
+ * settings a page render needs cost no query of their own. A save through
11
+ * the pack's admin routes clears this isolate's cache at once and records a
12
+ * new settings generation; other isolates see it on their next read and drop
13
+ * every cache keyed on settingsEpoch() (see src/core/generation.ts).
14
+ */
15
+ import type { FeatureDef } from "./module.js";
16
+ import { workerEnv } from "../shared.js";
17
+ import { batchedAll } from "./d1-batch.js";
18
+ import { noteReadFailure } from "./read-failures.js";
19
+
20
+ export const PLUGIN_ID = "coywolf-pack";
21
+ export const FEATURES_SETTING = "features";
22
+ const OPTION_NAME = `plugin:${PLUGIN_ID}:settings:${FEATURES_SETTING}`;
23
+
24
+ export type FeatureMap = Record<string, boolean>;
25
+
26
+ /** Every feature the pack knows about. Modules register theirs at import time. */
27
+ const catalog = new Map<string, FeatureDef>();
28
+
29
+ export function registerFeatures(defs: FeatureDef[]): void {
30
+ for (const def of defs) catalog.set(def.id, def);
31
+ }
32
+
33
+ export function featureCatalog(): FeatureDef[] {
34
+ return [...catalog.values()];
35
+ }
36
+
37
+ const parentOf = (id: string) => (id.includes(".") ? id.slice(0, id.indexOf(".")) : null);
38
+
39
+ /**
40
+ * The stored choice of a feature's legacy ids (FeatureDef.replaces): the
41
+ * first one present in the map wins. A legacy sub-feature ("a.b") counted
42
+ * only while its parent was on, so its parent's state (stored, else the
43
+ * parent's default) is applied too. Undefined when none is stored.
44
+ */
45
+ export function legacyChoice(stored: FeatureMap | null | undefined, def: FeatureDef): boolean | undefined {
46
+ if (!stored || !def.replaces) return undefined;
47
+ for (const legacy of def.replaces) {
48
+ if (typeof stored[legacy] !== "boolean") continue;
49
+ const parent = parentOf(legacy);
50
+ // A parent that's no longer in the catalog (and wasn't stored) doesn't hold the choice back.
51
+ const parentOn = !parent || (stored[parent] ?? (catalog.has(parent) ? (catalog.get(parent)?.default ?? false) : true));
52
+ return stored[legacy] && parentOn;
53
+ }
54
+ return undefined;
55
+ }
56
+
57
+ /**
58
+ * Stored choices over catalog defaults. A feature with no stored choice
59
+ * takes its legacy ids' choice (FeatureDef.replaces), then its default. A
60
+ * sub-feature ("a.b") is on only when its parent ("a") is on too.
61
+ */
62
+ export function resolveFeatures(stored: FeatureMap | null | undefined): FeatureMap {
63
+ const out: FeatureMap = {};
64
+ for (const def of catalog.values()) out[def.id] = stored?.[def.id] ?? legacyChoice(stored, def) ?? def.default ?? false;
65
+ for (const id of Object.keys(out)) {
66
+ const parent = parentOf(id);
67
+ if (parent && parent in out && !out[parent]) out[id] = false;
68
+ }
69
+ return out;
70
+ }
71
+
72
+ export function isOn(features: FeatureMap, id: string): boolean {
73
+ return features[id] ?? false;
74
+ }
75
+
76
+ // ── Plugin context (hooks, routes) ───────────────────────────────
77
+
78
+ interface SettingsCtx {
79
+ settings: { get<T>(key: string): Promise<T | null | undefined> };
80
+ }
81
+
82
+ export async function ctxFeatures(ctx: SettingsCtx): Promise<FeatureMap> {
83
+ let stored: FeatureMap | null | undefined;
84
+ try {
85
+ stored = await ctx.settings.get<FeatureMap>(FEATURES_SETTING);
86
+ } catch (error) {
87
+ // A page hook that can't read the switches adds nothing to the page: keep that page out of the edge cache.
88
+ noteReadFailure("feature switches (plugin context)");
89
+ throw error;
90
+ }
91
+ return remember(resolveFeatures(stored));
92
+ }
93
+
94
+ /** The switches most recently read in this isolate (null before the first read). */
95
+ let lastKnown: FeatureMap | null = null;
96
+ function remember(features: FeatureMap): FeatureMap {
97
+ lastKnown = features;
98
+ return features;
99
+ }
100
+
101
+ /**
102
+ * Synchronous view of the switches, for EmDash's admin manifest (sidebar
103
+ * pages and dashboard widgets), which reads the plugin's page list on every
104
+ * admin request. The pack middleware reads the switches before EmDash's
105
+ * routes run, so this is current; before any read it returns null.
106
+ */
107
+ export function knownFeatures(): FeatureMap | null {
108
+ return lastKnown;
109
+ }
110
+
111
+ /**
112
+ * Like ctxFeatures, but shares the per-isolate cache with siteFeatures so
113
+ * hooks that run on every page view (page:metadata, page:fragments) don't
114
+ * add a settings read to each render.
115
+ */
116
+ export async function cachedCtxFeatures(ctx: SettingsCtx): Promise<FeatureMap> {
117
+ if (cached && Date.now() - cached.at < TTL_MS) return cached.features;
118
+ const started = generation;
119
+ const features = await ctxFeatures(ctx);
120
+ // Same guard as queryOptions: a save in this isolate while this read ran (invalidateFeatures) makes it stale.
121
+ if (generation === started) cached = { features, at: Date.now() };
122
+ return features;
123
+ }
124
+
125
+ /** For public routes: like requireFeature, but from the per-isolate cache (no settings read on most requests). */
126
+ export async function requireCachedFeature(ctx: SettingsCtx, id: string): Promise<void> {
127
+ if (!isOn(await cachedCtxFeatures(ctx), id)) {
128
+ const { PluginRouteError } = await import("emdash");
129
+ throw PluginRouteError.notFound("This feature is turned off (Plugins → Coywolf Pack).");
130
+ }
131
+ }
132
+
133
+ // ── Outside the plugin context (middleware, Astro components) ────
134
+
135
+ /** How long this isolate trusts its read of the switches, settings and generation. */
136
+ export const FEATURES_TTL_MS = 5 * 60_000;
137
+ const TTL_MS = FEATURES_TTL_MS;
138
+ /**
139
+ * `settings` is set when the read came from D1 (siteFeatures), which also
140
+ * reads the registered settings; `keys` are the settings that read asked for;
141
+ * `options` are the raw values of the registered option rows (registerSiteOption).
142
+ */
143
+ let cached: { features: FeatureMap; at: number; settings?: Map<string, unknown>; keys?: Set<string>; options?: Map<string, string> } | null = null;
144
+ /** A D1 read in progress: concurrent cache misses in this isolate share it. */
145
+ let reading: Promise<SiteOptions> | null = null;
146
+ /** Bumped by invalidateFeatures, so a read that started before a save isn't cached. */
147
+ let generation = 0;
148
+
149
+ /**
150
+ * Bumped whenever settings may have changed: a save in this isolate
151
+ * (invalidateFeatures) or a new settings generation seen in D1 (a save in
152
+ * another isolate). Per-isolate caches of settings-derived data store the
153
+ * epoch they were read at and are dropped when it moves (isCurrent).
154
+ */
155
+ let epoch = 0;
156
+ export function settingsEpoch(): number {
157
+ return epoch;
158
+ }
159
+
160
+ /** A per-isolate cache entry is usable while the settings epoch hasn't moved and it's younger than `maxAgeMs`. */
161
+ export function isCurrent(entry: { epoch: number; at: number } | null | undefined, maxAgeMs: number): boolean {
162
+ return Boolean(entry && entry.epoch === epoch && Date.now() - entry.at < maxAgeMs);
163
+ }
164
+
165
+ /**
166
+ * Settings read on most page renders, registered up front so the first read
167
+ * of an isolate already includes them (a key registered after that read costs
168
+ * a second query). Modules still call registerSiteSetting for their own keys.
169
+ */
170
+ const PRELOADED_SETTINGS = [
171
+ // Content blocks, Headings, Breadcrumbs (+ its legacy key), Code blocks, Reviews.
172
+ "contentBlocks",
173
+ "customBlocksPodcast",
174
+ "headings",
175
+ "breadcrumbs",
176
+ "codeBlocksTheme",
177
+ "reviewsStyle",
178
+ // Clean image URLs, Page cache, Search, Videos posters.
179
+ "imagesMediaHost",
180
+ "pageCacheMaxAgeDays",
181
+ "pageCacheRefreshDays",
182
+ "pageCacheWarm",
183
+ "searchVersion",
184
+ "videosMirroredPosters",
185
+ // Coywolf Files.
186
+ "filesBase",
187
+ "filesPublicBaseUrl",
188
+ "filesScheme",
189
+ "filesAccent",
190
+ // Schema & Social (src/schema/module.ts schemaSettingsSchema).
191
+ "schemaSearchUrl",
192
+ "schemaAuthorUrlPattern",
193
+ "schemaBreadcrumbHome",
194
+ "schemaRobotsMaxImage",
195
+ "schemaRobotsMaxSnippet",
196
+ "schemaRobotsMaxVideo",
197
+ "schemaRobotsNofollow",
198
+ "schemaOgLocale",
199
+ // Videos player settings (src/videos/store.ts publicConfig).
200
+ "videosCustomerSubdomain",
201
+ "videosAccentColor",
202
+ "videosBackgroundColor",
203
+ "videosLightEmbed",
204
+ "videosDisplay",
205
+ // Discovery.
206
+ "discoverySettings",
207
+ ];
208
+
209
+ /**
210
+ * Other plugin settings read in the same query as the switches, sharing
211
+ * their per-isolate cache, so middleware, page hooks and Astro renderers that
212
+ * need them on every request add no query (e.g. the search content version,
213
+ * the code block theme, the review style).
214
+ */
215
+ const siteSettingKeys = new Set<string>(PRELOADED_SETTINGS);
216
+ export function registerSiteSetting(key: string): void {
217
+ siteSettingKeys.add(key);
218
+ }
219
+
220
+ /** Whole option rows (not plugin settings) read in the same query, raw (see siteOption). */
221
+ const siteOptionNames = new Set<string>();
222
+ export function registerSiteOption(name: string): void {
223
+ siteOptionNames.add(name);
224
+ }
225
+
226
+ const settingOption = (key: string) => `plugin:${PLUGIN_ID}:settings:${key}`;
227
+
228
+ /** Forget cached switches and registered settings (call after saving them). */
229
+ export function invalidateFeatures(): void {
230
+ cached = null;
231
+ reading = null;
232
+ generation++;
233
+ epoch++;
234
+ }
235
+
236
+ /** The option row last read for the settings generation, to notice saves made in other isolates. */
237
+ let lastGeneration: string | null | undefined;
238
+ /** Name of the settings-generation row (written by src/core/generation.ts). */
239
+ export const GENERATION_OPTION = `plugin:${PLUGIN_ID}:state:settingsGeneration`;
240
+ registerSiteOption(GENERATION_OPTION);
241
+
242
+ /**
243
+ * Read the switches straight from D1. Fails closed (all off) if the database
244
+ * can't be read, and notes the failure (src/core/read-failures.ts), so the
245
+ * page rendered with them isn't edge-cached.
246
+ */
247
+ export async function siteFeatures(database = "DB", fresh = false): Promise<FeatureMap> {
248
+ if (!fresh && cached && Date.now() - cached.at < TTL_MS) return cached.features;
249
+ return (await readSiteOptions(database)).features;
250
+ }
251
+
252
+ /** A D1 binding name, or the binding itself (for callers that already have it). */
253
+ type Database = string | D1Database;
254
+
255
+ interface SiteOptions {
256
+ features: FeatureMap;
257
+ settings: Map<string, unknown>;
258
+ /** The registered settings this read asked for. */
259
+ keys: Set<string>;
260
+ /** False when D1 couldn't be read (features are then all off and settings empty; each caller notes it, see readSiteOptions). */
261
+ ok: boolean;
262
+ }
263
+
264
+ function readSiteOptions(database: Database): Promise<SiteOptions> {
265
+ if (!reading) {
266
+ const read = queryOptions(database).finally(() => {
267
+ if (reading === read) reading = null;
268
+ });
269
+ reading = read;
270
+ }
271
+ // Noted for each caller handed a failed read (concurrent requests share one), not once per query.
272
+ return reading.then((read) => {
273
+ if (!read.ok) noteReadFailure("feature switches and settings");
274
+ return read;
275
+ });
276
+ }
277
+
278
+ async function queryOptions(database: Database): Promise<SiteOptions> {
279
+ let stored: FeatureMap | null = null;
280
+ const settings = new Map<string, unknown>();
281
+ const options = new Map<string, string>();
282
+ const keys = new Set(siteSettingKeys);
283
+ const optionNames = new Set(siteOptionNames);
284
+ const started = generation;
285
+ try {
286
+ const db = typeof database === "string" ? ((await workerEnv())[database] as D1Database | undefined) : database;
287
+ const names = [OPTION_NAME, ...[...keys].map(settingOption), ...optionNames];
288
+ // With the pack's other reads of this tick (the request's redirect lookup): one D1 batch.
289
+ const rows = db
290
+ ? await batchedAll<{ name: string; value: string }>(db, db.prepare(`SELECT name, value FROM options WHERE name IN (${names.map(() => "?").join(",")})`).bind(...names))
291
+ : [];
292
+ for (const row of rows) {
293
+ if (!row.value) continue;
294
+ if (optionNames.has(row.name)) options.set(row.name, row.value);
295
+ else if (row.name === OPTION_NAME) stored = JSON.parse(row.value) as FeatureMap;
296
+ else {
297
+ try {
298
+ settings.set(row.name.slice(settingOption("").length), JSON.parse(row.value));
299
+ } catch {
300
+ // An unreadable setting reads as unset.
301
+ }
302
+ }
303
+ }
304
+ } catch (error) {
305
+ console.error("coywolf-pack: could not read feature switches", error);
306
+ return { features: resolveFeatures({}), settings, keys, ok: false };
307
+ }
308
+ const features = remember(resolveFeatures(stored));
309
+ // A save in another isolate since the last read: drop this isolate's settings-derived caches.
310
+ const seen = options.get(GENERATION_OPTION) ?? null;
311
+ if (lastGeneration !== undefined && seen !== lastGeneration && savedGeneration(seen) !== savedGeneration(lastGeneration)) epoch++;
312
+ lastGeneration = seen;
313
+ // A save in this isolate while the query ran (invalidateFeatures) makes this read stale: don't cache it.
314
+ if (generation === started) cached = { features, at: Date.now(), settings, keys, options };
315
+ return { features, settings, keys, ok: true };
316
+ }
317
+
318
+ /** The generation id in a settings-generation row ("settled" changes don't count as a new save). */
319
+ function savedGeneration(raw: string | null | undefined): string | null {
320
+ if (!raw) return null;
321
+ try {
322
+ const value = JSON.parse(raw) as { id?: unknown };
323
+ return typeof value?.id === "string" ? value.id : raw;
324
+ } catch {
325
+ return raw;
326
+ }
327
+ }
328
+
329
+ /**
330
+ * A registered option row (registerSiteOption) from the last read of this
331
+ * isolate, raw. Undefined when there's no current read with it (call
332
+ * siteFeatures first); null when the row doesn't exist.
333
+ */
334
+ export function siteOption(name: string): string | null | undefined {
335
+ if (!cached?.options || Date.now() - cached.at >= TTL_MS) return undefined;
336
+ return cached.options.get(name) ?? null;
337
+ }
338
+
339
+ /** Update a registered option row in this isolate's cache after writing it. */
340
+ export function rememberSiteOption(name: string, raw: string | null): void {
341
+ if (!cached?.options) return;
342
+ if (raw === null) cached.options.delete(name);
343
+ else cached.options.set(name, raw);
344
+ if (name === GENERATION_OPTION) lastGeneration = raw;
345
+ }
346
+
347
+ /**
348
+ * The registered settings from the per-isolate cache, or a fresh read when
349
+ * the cache is old or was read before `key` was registered (a module imported
350
+ * after the first read of this isolate). Null when D1 can't be read (a noted
351
+ * read failure, see src/core/read-failures.ts).
352
+ */
353
+ async function siteSettings(key: string, database: Database): Promise<Map<string, unknown> | null> {
354
+ registerSiteSetting(key);
355
+ if (cached?.settings && cached.keys?.has(key) && Date.now() - cached.at < TTL_MS) return cached.settings;
356
+ let read = await readSiteOptions(database);
357
+ // A read that was already in flight may have been started before `key` was registered.
358
+ if (read.ok && !read.keys.has(key)) read = await readSiteOptions(database);
359
+ return read.ok ? read.settings : null;
360
+ }
361
+
362
+ /** A setting registered with registerSiteSetting, from the switches' per-isolate cache (null when unset). */
363
+ export async function siteSetting<T>(key: string, database = "DB"): Promise<T | null> {
364
+ return (((await siteSettings(key, database))?.get(key) as T | undefined) ?? null);
365
+ }
366
+
367
+ /**
368
+ * Like siteSetting, but tells an unset setting (`{ value: null }`) from one
369
+ * that couldn't be read (null), for callers that keep their last value or
370
+ * skip caching a default when D1 is unavailable.
371
+ */
372
+ export async function readSiteSetting<T = unknown>(key: string, database: Database = "DB"): Promise<{ value: T | null } | null> {
373
+ const settings = await siteSettings(key, database);
374
+ return settings ? { value: (settings.get(key) as T | undefined) ?? null } : null;
375
+ }
376
+
377
+ /** Update a registered setting in this isolate's cache after writing it (other isolates see it within FEATURES_TTL_MS). */
378
+ export function rememberSiteSetting(key: string, value: unknown): void {
379
+ cached?.settings?.set(key, value);
380
+ }
381
+
382
+ export async function siteFeatureOn(id: string, database?: string): Promise<boolean> {
383
+ return isOn(await siteFeatures(database), id);
384
+ }
385
+
386
+ /** For routes: answer 404 when the feature is off, so a disabled module looks absent. */
387
+ export async function requireFeature(ctx: SettingsCtx, id: string): Promise<void> {
388
+ if (!isOn(await ctxFeatures(ctx), id)) {
389
+ const { PluginRouteError } = await import("emdash");
390
+ throw PluginRouteError.notFound("This feature is turned off (Plugins → Coywolf Pack).");
391
+ }
392
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The settings generation: one option row the pack middleware rewrites after
3
+ * every successful pack admin write (settings, redirects, schema, videos…).
4
+ * Every isolate reads it in the feature switches' query (src/core/features.ts),
5
+ * so a save made in one isolate reaches the others on their next read
6
+ * (FEATURES_TTL_MS at most) without a query of its own, and they drop every
7
+ * cache keyed on settingsEpoch().
8
+ *
9
+ * Until then another isolate may still render a page with the old settings,
10
+ * and the page cache would keep that page for days. So a save that cleared
11
+ * the page cache asks for a second, "settling" purge once every isolate must
12
+ * have seen it: the first request after that time, in any isolate, claims it
13
+ * (a compare-and-set on the row, so only one does) and purges again.
14
+ */
15
+ import type { PurgeScope } from "../pageCache/lib.js";
16
+ import { FEATURES_TTL_MS, GENERATION_OPTION, rememberSiteOption, siteOption } from "./features.js";
17
+
18
+ /** Margin after the switches' lifetime before the settling purge, for reads that were in flight at save time. */
19
+ const SETTLE_MARGIN_MS = 15_000;
20
+
21
+ export interface GenerationRow {
22
+ /** New on every save. */
23
+ id: string;
24
+ /** When the save happened (ms since the epoch). */
25
+ at: number;
26
+ /** The purge to repeat once every isolate has the new settings; absent when nothing needs it (or it's done). */
27
+ settle?: PurgeScope;
28
+ }
29
+
30
+ export function parseGeneration(raw: string | null | undefined): GenerationRow | null {
31
+ if (!raw) return null;
32
+ try {
33
+ const row = JSON.parse(raw) as GenerationRow;
34
+ return row && typeof row.id === "string" && typeof row.at === "number" ? row : null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /** Both purges in one: every path prefix (or tag) of both, or everything when either clears everything or they're of different kinds. */
41
+ export function mergeScopes(a: PurgeScope | null | undefined, b: PurgeScope | null | undefined): PurgeScope | null {
42
+ if (!a || !b) return a ?? b ?? null;
43
+ if ("pathPrefixes" in a && "pathPrefixes" in b) return { pathPrefixes: [...new Set([...a.pathPrefixes, ...b.pathPrefixes])] };
44
+ if ("tags" in a && "tags" in b) return { tags: [...new Set([...a.tags, ...b.tags])] };
45
+ return { purgeEverything: true };
46
+ }
47
+
48
+ /**
49
+ * Record a save: a new generation, and the purge to settle later when the
50
+ * save purged the page cache. A settling purge still pending from an earlier
51
+ * save is kept (merged), and its clock restarts: isolates may have read the
52
+ * earlier generation just before this save.
53
+ */
54
+ export async function recordSettingsChange(db: D1Database, settle: PurgeScope | null, now = Date.now()): Promise<GenerationRow> {
55
+ const current = await db.prepare("SELECT value FROM options WHERE name = ?").bind(GENERATION_OPTION).first<{ value: string }>();
56
+ const pending = mergeScopes(parseGeneration(current?.value)?.settle, settle);
57
+ const row: GenerationRow = { id: crypto.randomUUID(), at: now, ...(pending ? { settle: pending } : {}) };
58
+ const raw = JSON.stringify(row);
59
+ await db
60
+ .prepare("INSERT INTO options (name, value) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET value = excluded.value")
61
+ .bind(GENERATION_OPTION, raw)
62
+ .run();
63
+ rememberSiteOption(GENERATION_OPTION, raw);
64
+ return row;
65
+ }
66
+
67
+ /** True when the last read generation has a settling purge whose time has come. */
68
+ export function settleDue(row: GenerationRow | null, now = Date.now()): row is GenerationRow & { settle: PurgeScope } {
69
+ return Boolean(row?.settle && now - row.at > FEATURES_TTL_MS + SETTLE_MARGIN_MS);
70
+ }
71
+
72
+ /**
73
+ * Claim the settling purge of the generation this isolate last read: returns
74
+ * its scope when this call won it (the caller purges), null otherwise. Reads
75
+ * nothing: the row comes from the switches' cached query; the claim is one
76
+ * conditional write, and only when a purge is due.
77
+ */
78
+ export async function claimSettle(db: D1Database, now = Date.now()): Promise<PurgeScope | null> {
79
+ const raw = siteOption(GENERATION_OPTION);
80
+ const row = parseGeneration(raw);
81
+ if (!raw || !settleDue(row, now)) return null;
82
+ const { settle, ...rest } = row;
83
+ const next = JSON.stringify(rest);
84
+ // Only one isolate claims it, and a newer save (another row) isn't touched.
85
+ rememberSiteOption(GENERATION_OPTION, next);
86
+ const result = await db.prepare("UPDATE options SET value = ? WHERE name = ? AND value = ?").bind(next, GENERATION_OPTION, raw).run();
87
+ return (result.meta?.changes ?? 0) > 0 ? settle : null;
88
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Step-by-step guides for the API keys and tokens some features need, as
3
+ * static HTML strings (trusted: written here, never from user data). Shown in
4
+ * a closed <details> next to each credential field on module pages
5
+ * (src/admin/guides.tsx) and on EmDash's generic plugin Settings page, where
6
+ * the admin enhancement script (src/core/settings-enhance.ts) swaps each
7
+ * field's plain-text description for the same guide.
8
+ *
9
+ * Kept free of imports so tests can load it directly.
10
+ */
11
+
12
+ export type GuideId = "anthropic" | "openai" | "gemini" | "workers-ai" | "r2" | "stream" | "radar" | "media-host" | "images-token";
13
+
14
+ export interface Guide {
15
+ summary: string;
16
+ /** Each step's HTML. */
17
+ steps: string[];
18
+ /** Optional note under the steps (HTML). */
19
+ note?: string;
20
+ }
21
+
22
+ const link = (href: string, text: string) => `<a href="${href}" target="_blank" rel="noreferrer" class="text-kumo-link underline">${text}</a>`;
23
+ const code = (text: string) => `<code class="rounded bg-kumo-tint px-1 text-xs">${text}</code>`;
24
+ const b = (text: string) => `<strong class="font-medium">${text}</strong>`;
25
+
26
+ /** Creating a Cloudflare API token: the steps every token guide shares. */
27
+ const cloudflareTokenSteps = (permission: string, zone = false) => [
28
+ `Sign in to the ${link("https://dash.cloudflare.com/profile/api-tokens", "Cloudflare dashboard → My Profile → API Tokens")}.`,
29
+ `Select ${b("Create Token")}, then ${b("Create Custom Token → Get started")}.`,
30
+ "Name it after this site so you'll recognize it later.",
31
+ `Under Permissions, choose ${permission}.`,
32
+ `Under Account Resources, choose ${b("Include → your account")}.`,
33
+ ...(zone ? [`Under Zone Resources, choose ${b("Include → Specific zone → your site's domain")}.`] : []),
34
+ `Select ${b("Continue to summary")}, then ${b("Create Token")}.`,
35
+ "Copy the token right away. Cloudflare shows it only once.",
36
+ ];
37
+
38
+ export const GUIDES: Record<GuideId, Guide> = {
39
+ anthropic: {
40
+ summary: "How to get an Anthropic API key",
41
+ steps: [
42
+ `Sign in to the ${link("https://platform.claude.com/settings/keys", "Claude Console")} (create an account if you don't have one).`,
43
+ `Add a payment method or credits under ${b("Billing")}. Keys don't work without them.`,
44
+ `Go to ${b("API Keys")} and select ${b("Create Key")}. Name it after this site.`,
45
+ "Copy the key (it starts with sk-ant-). It's shown only once.",
46
+ "Paste it into the API key field and save.",
47
+ ],
48
+ note: "Each analyzed entry or image is one model call, billed by Anthropic. The daily call limit caps what you spend.",
49
+ },
50
+ openai: {
51
+ summary: "How to get an OpenAI API key",
52
+ steps: [
53
+ `Sign in to the ${link("https://platform.openai.com/api-keys", "OpenAI Platform → API keys")}.`,
54
+ `Add a payment method or credits under ${b("Settings → Billing")}. Keys don't work without them.`,
55
+ `Select ${b("Create new secret key")}, name it after this site, and keep the default permissions.`,
56
+ "Copy the key (it starts with sk-). It's shown only once.",
57
+ "Paste it into the API key field and save.",
58
+ ],
59
+ note: "Each analyzed entry or image is one model call, billed by OpenAI. The daily call limit caps what you spend.",
60
+ },
61
+ gemini: {
62
+ summary: "How to get a Google Gemini API key",
63
+ steps: [
64
+ `Sign in to ${link("https://aistudio.google.com/apikey", "Google AI Studio → API keys")} with a Google account.`,
65
+ `Select ${b("Create API key")} and pick (or create) a Google Cloud project.`,
66
+ "Copy the key.",
67
+ "Paste it into the API key field and save.",
68
+ ],
69
+ note: "The free tier has low rate limits. For steady use, turn on billing for the project in Google AI Studio.",
70
+ },
71
+ "workers-ai": {
72
+ summary: "How to connect Workers AI (no key needed)",
73
+ steps: [
74
+ `Open your site's ${code("wrangler.jsonc")}.`,
75
+ `Add ${code('"ai": { "binding": "AI" }')} at the top level.`,
76
+ "Deploy the site. This page then shows the binding as connected.",
77
+ ],
78
+ note: "Workers AI is billed to your Cloudflare account, with a daily free allowance.",
79
+ },
80
+ r2: {
81
+ summary: "How to get the R2 account ID and keys",
82
+ steps: [
83
+ `In the ${link("https://dash.cloudflare.com/?to=/:account/r2/overview", "Cloudflare dashboard → R2")}, copy your ${b("Account ID")} (shown on the R2 overview page) into R2 account ID on the Files page → Settings.`,
84
+ `Under Buckets, note the name of the bucket bound as ${code("MEDIA")} in your ${code("wrangler.jsonc")}. Enter it as the R2 bucket name.`,
85
+ `Select ${b("Manage API tokens")} (on the R2 overview page), then ${b("Create Account API token")}.`,
86
+ `Choose the ${b("Object Read &amp; Write")} permission and ${b("Apply to specific buckets only")} → that bucket.`,
87
+ `Select ${b("Create API Token")}. Copy the ${b("Access Key ID")} and ${b("Secret Access Key")}. The secret is shown only once.`,
88
+ "Paste them into their fields and save. The secret is stored encrypted.",
89
+ ],
90
+ },
91
+ stream: {
92
+ summary: "How to get the Stream account ID and API token",
93
+ steps: [
94
+ `In the ${link("https://dash.cloudflare.com/?to=/:account/stream", "Cloudflare dashboard → Stream")}, copy your ${b("Account ID")} (in the right-hand column, or from the address bar after dash.cloudflare.com/) into Cloudflare account ID on the Videos page → Settings.`,
95
+ ...cloudflareTokenSteps(b("Account → Stream → Edit")),
96
+ "Paste the token into Stream API token and save. It's stored encrypted.",
97
+ ],
98
+ note: `Or set them as Worker variables instead: ${code("CF_ACCOUNT_ID")}, and the token with ${code("npx wrangler secret put CF_STREAM_TOKEN")}.`,
99
+ },
100
+ radar: {
101
+ summary: "How to get a Cloudflare Radar API token",
102
+ steps: [...cloudflareTokenSteps(b("Account → Radar → Read")), "Paste the token into Cloudflare Radar API token and save. It's stored encrypted."],
103
+ note: `Radar's API is free. Or set the token as a Worker secret instead: ${code("npx wrangler secret put RADAR_API_TOKEN")}.`,
104
+ },
105
+ "media-host": {
106
+ summary: "How to set up a media host by hand in the Cloudflare dashboard",
107
+ steps: [
108
+ `Find the R2 bucket bound as ${code("MEDIA")} in your site's ${code("wrangler.jsonc")} (its ${code("bucket_name")}). The media host serves that bucket, at a subdomain of the site's own domain, such as ${code("media.example.com")}.`,
109
+ `Turn on Image Transformations: ${link("https://dash.cloudflare.com/?to=/:account/images/transformations", "Cloudflare dashboard → Images → Transformations")}, find the site's domain and select ${b("Enable for zone")}. Leave ${b("Resize images from any origin")} off: the media host is on the same domain.`,
110
+ `Connect the domain to the bucket: ${link("https://dash.cloudflare.com/?to=/:account/r2/overview", "R2")} → the media bucket → ${b("Settings → Custom Domains → Add")}. Enter ${code("media.example.com")}, set the minimum TLS version to 1.2, and confirm. Cloudflare adds the DNS record; the certificate takes a few minutes.`,
111
+ `Add the first URL rewrite rule: the site's domain → ${b("Rules → Overview → Create rule → URL Rewrite Rule")}. Name it ${code("media.example.com: /s/&lt;W&gt;x&lt;H&gt;/&lt;file&gt; → cropped resize (Coywolf Pack clean image URLs)")}. Choose ${b("Custom filter expression → Edit expression")} and enter ${code('(http.host eq "media.example.com" and http.request.uri.path wildcard "/s/*x*/*")')}. Under Path, choose ${b("Rewrite to… → Dynamic")} and enter ${code('wildcard_replace(http.request.uri.path, "/s/*x*/*", "/cdn-cgi/image/width=${1},height=${2},fit=cover,format=auto,quality=85/${3}")')}. Leave Query as is and deploy.`,
112
+ `Add the second rule, named ${code("media.example.com: /s/&lt;W&gt;/&lt;file&gt; → resize to width (Coywolf Pack clean image URLs)")}, with the expression ${code('(http.host eq "media.example.com" and http.request.uri.path wildcard "/s/*/*" and not http.request.uri.path wildcard "/s/*x*/*")')} and the dynamic path ${code('wildcard_replace(http.request.uri.path, "/s/*/*", "/cdn-cgi/image/width=${1},format=auto,quality=85/${2}")')}. Deploy it.`,
113
+ `Turn on Smart Tiered Cache (free): the site's domain → ${b("Caching → Tiered Cache")} → ${b("Smart Tiered Caching")}. A data center that hasn't cached an image yet then asks a nearby Cloudflare cache instead of going back to R2.`,
114
+ `Use your own host name in place of ${code("media.example.com")} everywhere (keep the names exactly as shown otherwise: Set up media host recognizes its rules by name).`,
115
+ `Enter the host as the Media host on this page and select ${b("Check")}. When every check passes, save. Or set it in ${code("astro.config.mjs")}: ${code('coywolfPlugin({ images: { cdn: "https://media.example.com" } })')}.`,
116
+ ],
117
+ note: "Cloudflare's Free plan includes 5,000 unique image transformations a month (each new size of each image counts once a month). Beyond that, transformations need a paid Cloudflare Images plan.",
118
+ },
119
+ "images-token": {
120
+ summary: "How to get a Cloudflare API token for Set up media host",
121
+ steps: [
122
+ `In the ${link("https://dash.cloudflare.com/", "Cloudflare dashboard")}, open the site's domain and copy the ${b("Account ID")} from the right-hand column (API section) into Cloudflare account ID.`,
123
+ ...cloudflareTokenSteps(
124
+ `these six: ${b("Zone → Zone → Read")}, ${b("Zone → Zone Settings → Edit")}, ${b("Zone → Transform Rules → Edit")}, ${b("Zone → DNS → Edit")}, ${b("Account → Workers R2 Storage → Edit")} and ${b("Account → Account Rulesets → Read")}`,
125
+ true,
126
+ ),
127
+ "Paste the token into Cloudflare API token. Save it to keep it (stored encrypted), or leave it unsaved to use it just this once.",
128
+ ],
129
+ note: `The token is used only for the setup, and only with api.cloudflare.com. You can delete it in Cloudflare afterward. Or set it as a Worker secret instead: ${code("npx wrangler secret put IMAGES_API_TOKEN")}.`,
130
+ },
131
+ };
132
+
133
+ /**
134
+ * What the generic Settings page shows for each secret once enhanced: a short
135
+ * plain description, then the guides (closed). Keys match SECRET_SETTINGS.
136
+ */
137
+ export const SETTINGS_PAGE_GUIDES: Record<string, { intro: string; guides: GuideId[] }> = {
138
+ aiApiKey: {
139
+ intro: "For Anthropic, OpenAI or Gemini (Workers AI needs no key). The provider and model settings are on the AI Enrichment page.",
140
+ guides: ["anthropic", "openai", "gemini"],
141
+ },
142
+ filesR2SecretAccessKey: {
143
+ intro: "For large uploads. The account ID, access key ID and bucket name go on the Files page → Settings.",
144
+ guides: ["r2"],
145
+ },
146
+ videosApiToken: {
147
+ intro: "Or set the CF_STREAM_TOKEN Worker secret. The account ID goes on the Videos page → Settings.",
148
+ guides: ["stream"],
149
+ },
150
+ imagesApiToken: {
151
+ intro: "Optional. Only for Set up media host on the Clean Image URLs page. Or set the IMAGES_API_TOKEN Worker secret.",
152
+ guides: ["images-token"],
153
+ },
154
+ robotsRadarToken: {
155
+ intro: "Optional. Keeps the crawler list current from Cloudflare Radar each week. Or set the RADAR_API_TOKEN Worker secret.",
156
+ guides: ["radar"],
157
+ },
158
+ };
159
+
160
+ /** A guide as a closed <details> (HTML), styled with the admin's own classes. */
161
+ export function guideHtml(guide: Guide): string {
162
+ const steps = guide.steps.map((s) => `<li>${s}</li>`).join("");
163
+ return `<details class="rounded-md border border-kumo-line text-sm"><summary class="cursor-pointer px-3 py-2 font-medium" style="display:list-item">${guide.summary}</summary><div class="space-y-2 border-t border-kumo-line px-3 py-3"><ol class="space-y-1.5" style="list-style:decimal;padding-inline-start:1.25rem">${steps}</ol>${
164
+ guide.note ? `<p class="text-xs text-kumo-subtle">${guide.note}</p>` : ""
165
+ }</div></details>`;
166
+ }