@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,372 @@
1
+ /**
2
+ * Clean Image URLs admin routes: the media host setting, a live check of the
3
+ * media host, and the optional one-click Cloudflare setup (plan, then apply).
4
+ *
5
+ * The Cloudflare API token comes from the request (typed on the page, not
6
+ * stored unless saved), the saved secret, or a Worker secret
7
+ * (IMAGES_API_TOKEN, then CLOUDFLARE_API_TOKEN). It's only sent to
8
+ * api.cloudflare.com, never logged or returned.
9
+ */
10
+ import type { PluginContext } from "emdash";
11
+ import { PluginRouteError, definePluginRoute } from "emdash";
12
+ import { z } from "zod";
13
+
14
+ import { requireFeature } from "../core/features.js";
15
+ import { afterResponse, parseInput, secret, workerEnv } from "../shared.js";
16
+ import { CloudflareApiError, type CloudflareConfig, applySetup, checkMediaHost, planSetup, purgeHost } from "./cloudflare.js";
17
+ import { imageCdn, imageCdnSource, normalizeMediaHost } from "./lib.js";
18
+ import { rememberSiteSetting } from "../core/features.js";
19
+ import { IMAGES_SETTINGS, invalidateMediaHost, refreshMediaHost } from "./settings.js";
20
+ import { type BackfillDeps, type VariantsState, estimateRows, publicState, publishedCount, readVariantsState, startVariantsRun, variantCounts } from "./variants-store.js";
21
+ import { summarizeLibrary } from "./variants-estimate.js";
22
+ import { imageCrops } from "./variants.js";
23
+
24
+ const F = "images";
25
+
26
+ export const QUOTA_NOTE =
27
+ "Cloudflare's Free plan includes 5,000 unique image transformations a month (each new size of each image counts once a month; repeat views are served from the cache). Stored sizes are made once per image (each width and crop, as WebP and AVIF) and then served as plain files, so they don't count again. Beyond the free amount, transformations need a paid Cloudflare Images plan; without one, new sizes stop being made until the next month while existing ones keep working.";
28
+
29
+ export const VARIANTS_FALLBACK_NOTE =
30
+ "New uploads get their sizes right away. Images without stored sizes (existing images until you turn them on, and images over 20 MB, GIFs and SVGs) keep using the media host's /s/ resizing. Crops (thumbnails, avatars) are made the first time a page shows them; pages shown while a new upload's sizes or a crop are being made are cached for minutes instead of days, so they pick them up. Pages keep their normal caching while existing images wait for their sizes; when a run finishes, the page cache is cleared once (and warmed again, with cache warming on) so every page uses them.";
31
+
32
+ const hostInput = z
33
+ .string()
34
+ .max(253)
35
+ .optional()
36
+ .transform((v, zctx) => {
37
+ if (v === undefined) return undefined;
38
+ if (!v.trim()) return "";
39
+ const host = normalizeMediaHost(v);
40
+ if (!host) {
41
+ zctx.addIssue({ code: "custom", message: "The media host must be an https address like https://media.example.com (no path)." });
42
+ return z.NEVER;
43
+ }
44
+ return host;
45
+ });
46
+ const accountInput = z
47
+ .string()
48
+ .trim()
49
+ .max(64)
50
+ .regex(/^([0-9a-f]{32})?$/i, "The account ID is 32 letters and numbers (0-9, a-f).")
51
+ .optional();
52
+ const bucketInput = z
53
+ .string()
54
+ .trim()
55
+ .max(63)
56
+ .regex(/^([a-z0-9][a-z0-9-]{1,61}[a-z0-9])?$/, "Bucket names use lowercase letters, numbers and hyphens (3-63 characters).")
57
+ .optional();
58
+
59
+ const settingsInput = z.object({
60
+ host: hostInput,
61
+ accountId: accountInput,
62
+ bucket: bucketInput,
63
+ token: z.string().trim().max(400).optional(),
64
+ clearToken: z.boolean().optional(),
65
+ });
66
+
67
+ const setupInput = z.object({
68
+ host: hostInput,
69
+ accountId: accountInput,
70
+ bucket: bucketInput,
71
+ token: z.string().trim().max(400).optional(),
72
+ });
73
+
74
+ async function env(): Promise<Record<string, unknown>> {
75
+ try {
76
+ return await workerEnv();
77
+ } catch {
78
+ return {};
79
+ }
80
+ }
81
+
82
+ async function adminSettings(ctx: PluginContext) {
83
+ const [host, accountId, bucket, token] = await Promise.all([
84
+ ctx.settings.get<string>(IMAGES_SETTINGS.host),
85
+ ctx.settings.get<string>(IMAGES_SETTINGS.accountId),
86
+ ctx.settings.get<string>(IMAGES_SETTINGS.bucket),
87
+ ctx.settings.get<string>(IMAGES_SETTINGS.token).catch(() => null),
88
+ ]);
89
+ const e = await env();
90
+ invalidateMediaHost(host ?? null);
91
+ const source = imageCdnSource();
92
+ return {
93
+ host: host ?? "",
94
+ optionHost: source.option ?? "",
95
+ activeHost: source.host ?? "",
96
+ accountId: accountId ?? "",
97
+ bucket: bucket ?? "",
98
+ tokenSet: Boolean(token?.trim()),
99
+ envToken: Boolean(secret(e, "IMAGES_API_TOKEN") || secret(e, "CLOUDFLARE_API_TOKEN")),
100
+ envAccountId: Boolean(secret(e, "CF_ACCOUNT_ID") || secret(e, "CLOUDFLARE_ACCOUNT_ID")),
101
+ siteHost: siteHostname(ctx),
102
+ quotaNote: QUOTA_NOTE,
103
+ };
104
+ }
105
+
106
+ function siteHostname(ctx: PluginContext): string {
107
+ try {
108
+ return ctx.site?.url ? new URL(ctx.site.url).hostname.replace(/^www\./, "") : "";
109
+ } catch {
110
+ return "";
111
+ }
112
+ }
113
+
114
+ /** Credentials for the setup: the request's, then the saved ones, then Worker variables. */
115
+ async function credentials(ctx: PluginContext, input: z.infer<typeof setupInput>): Promise<CloudflareConfig & { bucket: string; host: string }> {
116
+ const e = await env();
117
+ const savedToken = await ctx.settings.get<string>(IMAGES_SETTINGS.token).catch(() => null);
118
+ const token = input.token || savedToken?.trim() || secret(e, "IMAGES_API_TOKEN") || secret(e, "CLOUDFLARE_API_TOKEN");
119
+ const accountId =
120
+ input.accountId || (await ctx.settings.get<string>(IMAGES_SETTINGS.accountId))?.trim() || secret(e, "CF_ACCOUNT_ID") || secret(e, "CLOUDFLARE_ACCOUNT_ID");
121
+ const bucket = input.bucket || (await ctx.settings.get<string>(IMAGES_SETTINGS.bucket))?.trim() || "";
122
+ const host = input.host || imageCdn() || "";
123
+ const missing = [!host && "media host", !accountId && "Cloudflare account ID", !bucket && "R2 bucket name", !token && "Cloudflare API token"].filter(Boolean);
124
+ if (missing.length) throw PluginRouteError.badRequest(`Enter the ${missing.join(", ")} first.`);
125
+ const doFetch = ctx.http ? (ctx.http.fetch.bind(ctx.http) as CloudflareConfig["fetch"]) : undefined;
126
+ return { token: token as string, accountId: accountId as string, bucket, host, fetch: doFetch };
127
+ }
128
+
129
+ /**
130
+ * The media host and the Cloudflare API access for it (the token and account
131
+ * saved on this page, else the IMAGES_API_TOKEN / CLOUDFLARE_API_TOKEN and
132
+ * CF_ACCOUNT_ID Worker secrets). `reason` says what's missing instead.
133
+ */
134
+ export async function mediaApiAccess(
135
+ ctx: PluginContext,
136
+ ): Promise<{ hostname: string; config: CloudflareConfig } | { hostname: string | null; reason: string }> {
137
+ await refreshMediaHost();
138
+ const host = imageCdn();
139
+ if (!host) return { hostname: null, reason: "No media host is set (Clean Image URLs)." };
140
+ const hostname = new URL(host).hostname;
141
+ const e = await env();
142
+ const savedToken = await ctx.settings.get<string>(IMAGES_SETTINGS.token).catch(() => null);
143
+ const token = savedToken?.trim() || secret(e, "IMAGES_API_TOKEN") || secret(e, "CLOUDFLARE_API_TOKEN");
144
+ const accountId = (await ctx.settings.get<string>(IMAGES_SETTINGS.accountId))?.trim() || secret(e, "CF_ACCOUNT_ID") || secret(e, "CLOUDFLARE_ACCOUNT_ID");
145
+ if (!token || !accountId) {
146
+ return { hostname, reason: "Add a Cloudflare API token and account ID on the Clean Image URLs page." };
147
+ }
148
+ const doFetch = ctx.http ? (ctx.http.fetch.bind(ctx.http) as CloudflareConfig["fetch"]) : undefined;
149
+ return { hostname, config: { token, accountId, fetch: doFetch } };
150
+ }
151
+
152
+ /** Clear the media host's images from Cloudflare's zone cache. Says why when it can't, instead of throwing. */
153
+ export async function purgeMediaHost(ctx: PluginContext): Promise<{ purged: boolean; host: string | null; message?: string }> {
154
+ const access = await mediaApiAccess(ctx);
155
+ if (!("config" in access)) {
156
+ return { purged: false, host: access.hostname, message: access.hostname ? `Images on ${access.hostname} weren't cleared. ${access.reason} The token needs Zone → Cache Purge → Purge.` : access.reason };
157
+ }
158
+ try {
159
+ await purgeHost(access.config, access.hostname);
160
+ return { purged: true, host: access.hostname };
161
+ } catch (error) {
162
+ return { purged: false, host: access.hostname, message: `Images on ${access.hostname} weren't cleared. ${error instanceof Error ? error.message : String(error)}` };
163
+ }
164
+ }
165
+
166
+ function apiError(error: unknown): never {
167
+ if (error instanceof CloudflareApiError) throw PluginRouteError.badRequest(error.message);
168
+ throw error;
169
+ }
170
+
171
+ /** A recent image in the media library to check with (SVGs aren't resized). */
172
+ async function sampleFile(database: string): Promise<string | null> {
173
+ const e = await env();
174
+ const db = e[database] as D1Database | undefined;
175
+ if (!db) return null;
176
+ const row = await db
177
+ .prepare(
178
+ "SELECT storage_key FROM media WHERE mime_type LIKE 'image/%' AND mime_type NOT LIKE '%svg%' AND (status IS NULL OR status = 'ready') ORDER BY created_at DESC LIMIT 1",
179
+ )
180
+ .first<{ storage_key: string }>()
181
+ .catch(() => null);
182
+ return row?.storage_key && /^[A-Za-z0-9_.-]+\.[a-z0-9]{2,5}$/i.test(row.storage_key) ? row.storage_key : null;
183
+ }
184
+
185
+ async function runCheck(host: string, database: string) {
186
+ const file = await sampleFile(database);
187
+ if (!file) throw PluginRouteError.badRequest("Upload an image to the media library first: the check fetches one from the media host.");
188
+ const items = await checkMediaHost(host, file);
189
+ return { host, file, ok: items.filter((i) => i.id !== "cached").every((i) => i.ok), items, quotaNote: QUOTA_NOTE };
190
+ }
191
+
192
+ interface VariantsHooks {
193
+ /** Start the backfill's chain of steps (a new run with `force`); it works in the background. */
194
+ start(origin: string, force?: boolean): Promise<VariantsState | null>;
195
+ /** The bindings, or null when something is missing. */
196
+ deps(): Promise<BackfillDeps | null>;
197
+ }
198
+
199
+ /** The stored-sizes status for the admin; `bulk`: sizes for existing images are on. */
200
+ async function variantsStatus(variants: VariantsHooks, bulk: boolean) {
201
+ const deps = await variants.deps();
202
+ if (!deps) {
203
+ return {
204
+ available: false,
205
+ reason: "Stored sizes need a media host, and the MEDIA bucket, IMAGES and DB bindings.",
206
+ bulk,
207
+ crops: imageCrops(),
208
+ fallbackNote: VARIANTS_FALLBACK_NOTE,
209
+ };
210
+ }
211
+ const [counts, { state }] = await Promise.all([variantCounts(deps.db), readVariantsState(deps.db)]);
212
+ return { available: true, bulk, ...counts, state: publicState(state), perHour: state ? imagesPerHour(state) : null, crops: imageCrops(), fallbackNote: VARIANTS_FALLBACK_NOTE };
213
+ }
214
+
215
+ /** Images a running backfill handles an hour, from its start (null when it's not running or just started). */
216
+ export function imagesPerHour(state: VariantsState, now = Date.now()): number | null {
217
+ if (state.phase !== "running") return null;
218
+ const hours = (now - Date.parse(state.startedAt)) / 3_600_000;
219
+ const handled = state.done + state.skipped + state.failed;
220
+ return hours >= 2 / 60 && handled > 0 ? Math.round(handled / hours) : null;
221
+ }
222
+
223
+ /** Origin of a route's request (the chain's steps are posted there). */
224
+ const requestOrigin = (ctx: { request: Request }) => new URL(ctx.request.url).origin;
225
+
226
+ const bulkSetting = async (ctx: PluginContext) => (await ctx.settings.get<boolean>(IMAGES_SETTINGS.bulk)) === true;
227
+
228
+ export function imagesModule(options: { database?: string; variants: VariantsHooks }) {
229
+ const database = options.database ?? "DB";
230
+ const variants = options.variants;
231
+ return {
232
+ routes: {
233
+ "images/settings": {
234
+ permission: "plugins:manage" as const,
235
+ handler: async (ctx: PluginContext) => {
236
+ await requireFeature(ctx, F);
237
+ return adminSettings(ctx);
238
+ },
239
+ },
240
+
241
+ "images/settings/save": definePluginRoute({
242
+ permission: "plugins:manage",
243
+ methods: ["POST"],
244
+ request: { body: "json" },
245
+ handler: async (ctx) => {
246
+ await requireFeature(ctx, F);
247
+ const input = parseInput(settingsInput, ctx.input);
248
+ if (input.host !== undefined) await ctx.settings.set(IMAGES_SETTINGS.host, input.host);
249
+ if (input.accountId !== undefined) await ctx.settings.set(IMAGES_SETTINGS.accountId, input.accountId);
250
+ if (input.bucket !== undefined) await ctx.settings.set(IMAGES_SETTINGS.bucket, input.bucket);
251
+ if (input.clearToken) await ctx.settings.delete(IMAGES_SETTINGS.token);
252
+ else if (input.token) await ctx.settings.set(IMAGES_SETTINGS.token, input.token);
253
+ ctx.log.info("Clean image URLs settings saved", { tokenChanged: Boolean(input.clearToken || input.token) });
254
+ return adminSettings(ctx);
255
+ },
256
+ }),
257
+
258
+ "images/check": definePluginRoute({
259
+ permission: "plugins:manage",
260
+ methods: ["POST"],
261
+ request: { body: "json" },
262
+ handler: async (ctx) => {
263
+ await requireFeature(ctx, F);
264
+ const { host } = parseInput(z.object({ host: hostInput }), ctx.input ?? {});
265
+ await refreshMediaHost();
266
+ const target = host || imageCdn();
267
+ if (!target) throw PluginRouteError.badRequest("Enter a media host to check.");
268
+ return runCheck(target, database);
269
+ },
270
+ }),
271
+
272
+ "images/variants/settings": {
273
+ permission: "plugins:manage" as const,
274
+ handler: async (ctx: PluginContext) => {
275
+ await requireFeature(ctx, F);
276
+ return variantsStatus(variants, await bulkSetting(ctx));
277
+ },
278
+ },
279
+
280
+ /** What stored sizes for the existing library would take: counts for the page's cost estimate (computed there). */
281
+ "images/variants/estimate": {
282
+ permission: "plugins:manage" as const,
283
+ handler: async (ctx: PluginContext) => {
284
+ await requireFeature(ctx, F);
285
+ const deps = await variants.deps();
286
+ if (!deps) throw PluginRouteError.badRequest("Stored sizes need a media host, and the MEDIA bucket, IMAGES and DB bindings.");
287
+ const [rows, pages] = await Promise.all([estimateRows(deps.db), publishedCount(deps.db)]);
288
+ return { ...summarizeLibrary(rows), pages };
289
+ },
290
+ },
291
+
292
+ /** Turn stored sizes for existing images on (after the page showed the estimate) or off. On starts a run. */
293
+ "images/variants/bulk": definePluginRoute({
294
+ permission: "plugins:manage",
295
+ methods: ["POST"],
296
+ request: { body: "json" },
297
+ handler: async (ctx) => {
298
+ await requireFeature(ctx, F);
299
+ const { on } = parseInput(z.object({ on: z.boolean() }), ctx.input ?? {});
300
+ const deps = await variants.deps();
301
+ if (on && !deps) throw PluginRouteError.badRequest("Stored sizes need a media host, and the MEDIA bucket, IMAGES and DB bindings.");
302
+ await ctx.settings.set(IMAGES_SETTINGS.bulk, on);
303
+ rememberSiteSetting(IMAGES_SETTINGS.bulk, on);
304
+ if (on && deps) {
305
+ await startVariantsRun(deps.db, { force: true });
306
+ // The run's steps follow in the background (a chain of requests of their own).
307
+ await afterResponse(variants.start(requestOrigin(ctx)).catch((error) => console.error("coywolf-pack images: couldn't start the backfill", error)));
308
+ }
309
+ ctx.log.info(on ? "Stored sizes for existing images turned on" : "Stored sizes for existing images turned off");
310
+ return variantsStatus(variants, on);
311
+ },
312
+ }),
313
+
314
+ /**
315
+ * "Make missing sizes now" (only with sizes for existing images on): starts the backfill's chain
316
+ * (a new run with `start`), unless one is already working, and reports. The work goes on in the
317
+ * background; the page polls images/variants/settings for progress.
318
+ */
319
+ "images/variants/run": definePluginRoute({
320
+ permission: "plugins:manage",
321
+ methods: ["POST"],
322
+ request: { body: "json" },
323
+ handler: async (ctx) => {
324
+ await requireFeature(ctx, F);
325
+ const deps = await variants.deps();
326
+ if (!deps) throw PluginRouteError.badRequest("Stored sizes need a media host, and the MEDIA bucket, IMAGES and DB bindings.");
327
+ if (!(await bulkSetting(ctx))) throw PluginRouteError.badRequest("Turn on stored sizes for existing images first.");
328
+ const { start } = parseInput(z.object({ start: z.boolean().optional() }), ctx.input ?? {});
329
+ if (start) await startVariantsRun(deps.db, { force: true });
330
+ await afterResponse(variants.start(requestOrigin(ctx)).catch((error) => console.error("coywolf-pack images: couldn't start the backfill", error)));
331
+ return variantsStatus(variants, true);
332
+ },
333
+ }),
334
+
335
+ "images/setup/plan": definePluginRoute({
336
+ permission: "plugins:manage",
337
+ methods: ["POST"],
338
+ request: { body: "json" },
339
+ handler: async (ctx) => {
340
+ await requireFeature(ctx, F);
341
+ const config = await credentials(ctx, parseInput(setupInput, ctx.input ?? {}));
342
+ try {
343
+ const { zone, plan } = await planSetup(config, { host: config.host, bucket: config.bucket });
344
+ return { zone: zone.name, plan: { host: plan.host, steps: plan.steps, done: plan.done }, quotaNote: QUOTA_NOTE };
345
+ } catch (error) {
346
+ apiError(error);
347
+ }
348
+ },
349
+ }),
350
+
351
+ "images/setup/apply": definePluginRoute({
352
+ permission: "plugins:manage",
353
+ methods: ["POST"],
354
+ request: { body: "json" },
355
+ handler: async (ctx) => {
356
+ await requireFeature(ctx, F);
357
+ const config = await credentials(ctx, parseInput(setupInput, ctx.input ?? {}));
358
+ let applied: Awaited<ReturnType<typeof applySetup>>;
359
+ try {
360
+ applied = await applySetup(config, { host: config.host, bucket: config.bucket });
361
+ } catch (error) {
362
+ apiError(error);
363
+ }
364
+ ctx.log.info("Media host setup applied", { zone: applied.zone, steps: applied.results.map((r) => `${r.id}:${r.ok ? "ok" : "failed"}`) });
365
+ const host = normalizeMediaHost(config.host) as string;
366
+ const check = applied.results.every((r) => r.ok) ? await runCheck(host, database).catch(() => null) : null;
367
+ return { zone: applied.zone, results: applied.results, check };
368
+ },
369
+ }),
370
+ },
371
+ };
372
+ }