@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,293 @@
1
+ /**
2
+ * "Removed content" (feature "redirects.trashPrompt"): when a published entry
3
+ * is deleted or unpublished, record its former URL as a pending decision in
4
+ * plugin storage. The Redirects admin page lists them with Redirect to…,
5
+ * Return 410 Gone, and Dismiss; the admin also prompts right after a trash or
6
+ * unpublish (src/admin/trash-prompt.tsx). Republishing or restoring the entry
7
+ * clears it and undoes a rule its decision created.
8
+ */
9
+ import type { PluginContext } from "emdash";
10
+ import { PluginRouteError, definePluginRoute } from "emdash";
11
+ import { z } from "zod";
12
+
13
+ import { COLLECTION_SLUG, entryUrl, readCollections } from "../core/content-url.js";
14
+ import { requireFeature } from "../core/features.js";
15
+ import { REDIRECTS_TAG, purgePageCache } from "../pageCache/lib.js";
16
+ import { parseInput, workerEnv } from "../shared.js";
17
+ import { invalidateRedirectCache } from "./middleware.js";
18
+ import {
19
+ type AppliedRule,
20
+ type EntrySnapshot,
21
+ type PendingDecision,
22
+ type RemovalReason,
23
+ appliedStillOurs,
24
+ buildPending,
25
+ idsByCollection,
26
+ pendingId,
27
+ snapshotFromContent,
28
+ shouldDropApplied,
29
+ snapshotFromRow,
30
+ } from "./removed-core.js";
31
+ import { RedirectValidationError, deleteRule, findExactRuleAny, getRule, saveRule } from "./rules.js";
32
+
33
+ export const TRASH_PROMPT_FEATURE = "redirects.trashPrompt";
34
+ const STORE = "redirects_removed";
35
+ /** Rules created from decisions, so restoring or republishing the entry can undo them. */
36
+ const APPLIED = "redirects_applied";
37
+
38
+ export const removedStorage = { [STORE]: { indexes: ["at"] }, [APPLIED]: { indexes: ["at"] } };
39
+
40
+ interface Options {
41
+ database?: string;
42
+ }
43
+
44
+ // biome-ignore lint/suspicious/noExplicitAny: plugin storage collections are untyped here.
45
+ const store = (ctx: PluginContext) => (ctx.storage as Record<string, any>)[STORE] as {
46
+ get(id: string): Promise<PendingDecision | null>;
47
+ put(id: string, data: PendingDecision): Promise<void>;
48
+ delete(id: string): Promise<boolean>;
49
+ query(o: { orderBy?: Record<string, "asc" | "desc">; limit?: number }): Promise<{ items: Array<{ id: string; data: PendingDecision }> }>;
50
+ };
51
+
52
+ // biome-ignore lint/suspicious/noExplicitAny: plugin storage collections are untyped here.
53
+ const applied = (ctx: PluginContext) => (ctx.storage as Record<string, any>)[APPLIED] as {
54
+ get(id: string): Promise<AppliedRule | null>;
55
+ put(id: string, data: AppliedRule): Promise<void>;
56
+ delete(id: string): Promise<boolean>;
57
+ };
58
+
59
+ /** A snapshot plus its former path, resolved while the row and its terms still exist. */
60
+ interface Taken {
61
+ entry: EntrySnapshot;
62
+ urlPattern: string | null;
63
+ url: string | null;
64
+ }
65
+
66
+ /** Snapshots taken in content:beforeDelete, while the row still exists (a permanent delete removes it). */
67
+ const beforeDelete = new Map<string, Taken>();
68
+
69
+ export function removedModule(options: Options) {
70
+ async function db(): Promise<D1Database | undefined> {
71
+ try {
72
+ return (await workerEnv())[options.database ?? "DB"] as D1Database | undefined;
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ }
77
+
78
+ async function snapshot(collection: string, id: string) {
79
+ const d1 = await db();
80
+ if (!d1 || !COLLECTION_SLUG.test(collection)) return null;
81
+ const info = (await readCollections(d1, [collection])).get(collection);
82
+ const row = await d1.prepare(`SELECT * FROM "ec_${collection}" WHERE id = ?`).bind(id).first<Record<string, unknown>>();
83
+ if (!row) return null;
84
+ const entry = snapshotFromRow(row, info?.titleField ?? null);
85
+ return { entry, urlPattern: info?.urlPattern ?? null, url: await formerUrl(d1, collection, entry) };
86
+ }
87
+
88
+ /** The entry's public path via the pack-wide resolver (honors the site's `urls` overrides). */
89
+ async function formerUrl(d1: D1Database, collection: string, entry: EntrySnapshot): Promise<string | null> {
90
+ if (!entry.slug) return null;
91
+ return entryUrl(d1, collection, { id: entry.id, slug: entry.slug, publishedAt: entry.publishedAt, locale: entry.locale }).catch(() => null);
92
+ }
93
+
94
+ async function record(ctx: PluginContext, collection: string, reason: RemovalReason, taken: Taken | null) {
95
+ if (!taken) return;
96
+ const pending = buildPending(collection, taken.entry, { url: taken.url, urlPattern: taken.urlPattern }, reason);
97
+ if (!pending) return;
98
+ const existing = await store(ctx).get(pending.id);
99
+ if (existing) return; // Keep the first record (e.g. trashed, then permanently deleted).
100
+ await store(ctx).put(pending.id, pending);
101
+ ctx.log.info("Removed content recorded", { url: pending.url, reason });
102
+ }
103
+
104
+ /**
105
+ * The entry is back (restored, or republished): drop its pending decision,
106
+ * and delete the rule its decision created while that rule is still ours
107
+ * and, on publish, the entry is back at the same URL. A rule kept because
108
+ * the entry came back at another URL stays remembered, so a later publish
109
+ * back at the old URL still undoes it.
110
+ */
111
+ async function comeBack(ctx: PluginContext, collection: string, content: Record<string, unknown>, reason: "restore" | "publish") {
112
+ const id = pendingId(collection, String(content.id));
113
+ await store(ctx).delete(id);
114
+ const done = await applied(ctx).get(id);
115
+ if (!done) return;
116
+ const d1 = await db();
117
+ if (!d1) return;
118
+ const rule = await getRule(d1, done.ruleId);
119
+ if (!appliedStillOurs(done, rule)) {
120
+ await applied(ctx).delete(id); // Edited, replaced, or deleted since: no longer ours to undo.
121
+ return;
122
+ }
123
+ let currentUrl: string | null = null;
124
+ if (reason === "publish") {
125
+ const info = (await readCollections(d1, [collection])).get(collection);
126
+ currentUrl = await formerUrl(d1, collection, snapshotFromContent(content, info?.titleField ?? null));
127
+ }
128
+ if (!shouldDropApplied(done, rule, currentUrl, reason)) return;
129
+ await applied(ctx).delete(id);
130
+ await deleteRule(d1, done.ruleId);
131
+ invalidateRedirectCache();
132
+ await purgePageCache({ tags: [REDIRECTS_TAG] });
133
+ ctx.log.info("Removed content rule undone", { url: done.url, reason });
134
+ }
135
+
136
+ /**
137
+ * Ids of these pending entries that are live after all (published, not in
138
+ * the trash): a trash that failed after it was recorded, or a decision
139
+ * recorded for an entry that was brought back without the hooks firing.
140
+ * One query per collection; a collection that can't be read counts as
141
+ * not live.
142
+ */
143
+ async function liveIds(d1: D1Database, items: PendingDecision[]): Promise<Set<string>> {
144
+ const live = new Set<string>();
145
+ for (const [collection, ids] of idsByCollection(items)) {
146
+ if (!COLLECTION_SLUG.test(collection)) continue;
147
+ try {
148
+ const { results } = await d1
149
+ .prepare(`SELECT id FROM "ec_${collection}" WHERE status = 'published' AND deleted_at IS NULL AND id IN (${ids.map(() => "?").join(", ")})`)
150
+ .bind(...ids)
151
+ .all<{ id: string }>();
152
+ for (const row of results) live.add(pendingId(collection, String(row.id)));
153
+ } catch {
154
+ // Collection gone or renamed: nothing there is live.
155
+ }
156
+ }
157
+ return live;
158
+ }
159
+
160
+ /** Forget pending decisions whose entries are live again; the rest, in order. */
161
+ async function dropLive(ctx: PluginContext, items: PendingDecision[]): Promise<PendingDecision[]> {
162
+ if (!items.length) return items;
163
+ const d1 = await db();
164
+ if (!d1) return items;
165
+ const live = await liveIds(d1, items);
166
+ if (!live.size) return items;
167
+ for (const id of live) await store(ctx).delete(id);
168
+ ctx.log.info("Removed content forgotten: entries are live", { ids: [...live] });
169
+ return items.filter((item) => !live.has(item.id));
170
+ }
171
+
172
+ const describeRule = (rule: { type: number; target: string; enabled: boolean }) =>
173
+ `${rule.type === 410 ? "410 Gone" : `${rule.type} → ${rule.target}`}${rule.enabled ? "" : ", disabled"}`;
174
+
175
+ const hooks = {
176
+ "content:beforeDelete": async (event: { id: string; collection: string }, ctx: PluginContext) => {
177
+ try {
178
+ const taken = await snapshot(event.collection, event.id);
179
+ if (beforeDelete.size > 500) beforeDelete.clear(); // Deletes that never completed.
180
+ if (taken) beforeDelete.set(pendingId(event.collection, event.id), taken);
181
+ // Record now, before the delete's response, so the admin's prompt finds it at once.
182
+ await record(ctx, event.collection, "deleted", taken);
183
+ } catch {
184
+ // Never block a delete.
185
+ }
186
+ return undefined;
187
+ },
188
+
189
+ "content:afterDelete": async (event: { id: string; collection: string; permanent: boolean }, ctx: PluginContext) => {
190
+ const key = pendingId(event.collection, event.id);
191
+ const taken = beforeDelete.get(key) ?? (await snapshot(event.collection, event.id));
192
+ beforeDelete.delete(key);
193
+ await record(ctx, event.collection, "deleted", taken);
194
+ },
195
+
196
+ "content:afterUnpublish": async (event: { content: Record<string, unknown>; collection: string }, ctx: PluginContext) => {
197
+ const d1 = await db();
198
+ const info = d1 ? (await readCollections(d1, [event.collection])).get(event.collection) : undefined;
199
+ const entry = snapshotFromContent(event.content, info?.titleField ?? null);
200
+ await record(ctx, event.collection, "unpublished", {
201
+ entry,
202
+ urlPattern: info?.urlPattern ?? null,
203
+ url: d1 ? await formerUrl(d1, event.collection, entry) : null,
204
+ });
205
+ },
206
+
207
+ "content:afterPublish": async (event: { content: Record<string, unknown>; collection: string }, ctx: PluginContext) => {
208
+ await comeBack(ctx, event.collection, event.content, "publish");
209
+ },
210
+
211
+ // Restored entries come back as drafts; restoring says the content is coming back, so drop the decision and its rule.
212
+ "content:afterRestore": async (event: { content: Record<string, unknown>; collection: string }, ctx: PluginContext) => {
213
+ await comeBack(ctx, event.collection, event.content, "restore");
214
+ },
215
+ };
216
+
217
+ const routes = {
218
+ "redirects/removed": {
219
+ permission: "plugins:manage" as const,
220
+ handler: async (ctx: PluginContext) => {
221
+ await requireFeature(ctx, TRASH_PROMPT_FEATURE);
222
+ const { items } = await store(ctx).query({ orderBy: { at: "desc" }, limit: 100 });
223
+ return {
224
+ items: await dropLive(
225
+ ctx,
226
+ items.map((i) => i.data),
227
+ ),
228
+ };
229
+ },
230
+ },
231
+
232
+ "redirects/removed/resolve": definePluginRoute({
233
+ permission: "plugins:manage",
234
+ methods: ["POST"],
235
+ request: { body: "json" },
236
+ handler: async (ctx) => {
237
+ await requireFeature(ctx, TRASH_PROMPT_FEATURE);
238
+ const input = parseInput(
239
+ z.object({
240
+ id: z.string().max(300),
241
+ action: z.enum(["redirect", "gone", "dismiss"]),
242
+ target: z.string().max(2000).optional(),
243
+ type: z.number().int().optional(),
244
+ }),
245
+ ctx.input,
246
+ );
247
+ const pending = await store(ctx).get(input.id);
248
+ if (!pending) throw PluginRouteError.notFound("That removed entry is no longer pending.");
249
+ let rule = null;
250
+ if (input.action !== "dismiss") {
251
+ const d1 = await db();
252
+ if (!d1) throw PluginRouteError.badRequest("Redirects: missing database binding.");
253
+ if (!(await dropLive(ctx, [pending])).length) {
254
+ throw PluginRouteError.conflict(`“${pending.title}” is live again at ${pending.url}; there is nothing to redirect.`);
255
+ }
256
+ // Never overwrite a rule someone made for this path (and never adopt it: a restore would delete it).
257
+ const existing = await findExactRuleAny(d1, pending.url);
258
+ if (existing) {
259
+ throw PluginRouteError.conflict(
260
+ `A redirect already exists for ${existing.source} (${describeRule(existing)}). Keep it and dismiss this entry under Redirects → Removed content, or change it under Redirects.`,
261
+ );
262
+ }
263
+ try {
264
+ rule = await saveRule(d1, {
265
+ source: pending.url,
266
+ target: input.action === "gone" ? "" : input.target,
267
+ type: input.action === "gone" ? 410 : (input.type ?? 301),
268
+ note: `Removed: ${pending.title}`.slice(0, 500),
269
+ });
270
+ } catch (error) {
271
+ if (error instanceof RedirectValidationError) throw PluginRouteError.badRequest(error.message);
272
+ throw error;
273
+ }
274
+ invalidateRedirectCache();
275
+ await applied(ctx).put(pending.id, {
276
+ id: pending.id,
277
+ collection: pending.collection,
278
+ entryId: pending.entryId,
279
+ url: pending.url,
280
+ ruleId: rule.id,
281
+ action: input.action,
282
+ at: new Date().toISOString(),
283
+ });
284
+ }
285
+ await store(ctx).delete(pending.id);
286
+ ctx.log.info("Removed content resolved", { url: pending.url, action: input.action });
287
+ return { resolved: pending.id, rule };
288
+ },
289
+ }),
290
+ };
291
+
292
+ return { hooks, routes, storage: removedStorage };
293
+ }
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Redirect rules: storage (a table in the site's D1 database, so backups and
3
+ * rewinds include it), validation, and matching. Shared by the plugin routes
4
+ * and the site middleware.
5
+ *
6
+ * Complements EmDash's built-in redirects, which only accept site-relative
7
+ * destinations and skip any path with a file extension.
8
+ */
9
+
10
+ import { batchedAll } from "../core/d1-batch.js";
11
+
12
+ export const REDIRECT_TYPES = [301, 302, 307, 308, 410] as const;
13
+ export type RedirectType = (typeof REDIRECT_TYPES)[number];
14
+
15
+ export interface RedirectRule {
16
+ id: string;
17
+ source: string;
18
+ target: string;
19
+ type: RedirectType;
20
+ isRegex: boolean;
21
+ enabled: boolean;
22
+ hits: number;
23
+ lastHit: string | null;
24
+ note: string | null;
25
+ createdAt: string;
26
+ updatedAt: string;
27
+ }
28
+
29
+ export interface RedirectInput {
30
+ source: string;
31
+ target?: string;
32
+ type?: number;
33
+ isRegex?: boolean;
34
+ enabled?: boolean;
35
+ note?: string | null;
36
+ }
37
+
38
+ const TABLE = "coywolf_redirects";
39
+
40
+ const SCHEMA = [
41
+ `CREATE TABLE IF NOT EXISTS ${TABLE} (
42
+ id TEXT PRIMARY KEY,
43
+ source TEXT NOT NULL,
44
+ target TEXT NOT NULL DEFAULT '',
45
+ type INTEGER NOT NULL DEFAULT 301,
46
+ is_regex INTEGER NOT NULL DEFAULT 0,
47
+ enabled INTEGER NOT NULL DEFAULT 1,
48
+ hits INTEGER NOT NULL DEFAULT 0,
49
+ last_hit TEXT,
50
+ note TEXT,
51
+ created_at TEXT NOT NULL,
52
+ updated_at TEXT NOT NULL
53
+ )`,
54
+ `CREATE UNIQUE INDEX IF NOT EXISTS ${TABLE}_source ON ${TABLE} (source, is_regex)`,
55
+ // The site middleware reads the (few) pattern rules without scanning the exact ones.
56
+ `CREATE INDEX IF NOT EXISTS ${TABLE}_patterns ON ${TABLE} (source) WHERE is_regex = 1`,
57
+ ];
58
+
59
+ export async function ensureTable(db: D1Database): Promise<void> {
60
+ await db.batch(SCHEMA.map((sql) => db.prepare(sql)));
61
+ }
62
+
63
+ interface Row {
64
+ id: string;
65
+ source: string;
66
+ target: string;
67
+ type: number;
68
+ is_regex: number;
69
+ enabled: number;
70
+ hits: number;
71
+ last_hit: string | null;
72
+ note: string | null;
73
+ created_at: string;
74
+ updated_at: string;
75
+ }
76
+
77
+ const toRule = (r: Row): RedirectRule => ({
78
+ id: r.id,
79
+ source: r.source,
80
+ target: r.target,
81
+ type: r.type as RedirectType,
82
+ isRegex: r.is_regex === 1,
83
+ enabled: r.enabled === 1,
84
+ hits: r.hits,
85
+ lastHit: r.last_hit,
86
+ note: r.note,
87
+ createdAt: r.created_at,
88
+ updatedAt: r.updated_at,
89
+ });
90
+
91
+ /** Exact sources match with or without a trailing slash, so store them without one. */
92
+ export function normalizePath(path: string): string {
93
+ return path.length > 1 ? path.replace(/\/+$/, "") : path;
94
+ }
95
+
96
+ export class RedirectValidationError extends Error {}
97
+
98
+ /** Validate and normalize a rule; throws RedirectValidationError with a readable message. */
99
+ export function validate(input: RedirectInput): Omit<RedirectInput, "type"> & { type: RedirectType; target: string } {
100
+ const source = (input.source ?? "").trim();
101
+ const target = (input.target ?? "").trim();
102
+ const type = (input.type ?? 301) as RedirectType;
103
+ const isRegex = input.isRegex === true;
104
+
105
+ if (!REDIRECT_TYPES.includes(type)) throw new RedirectValidationError(`Type must be one of ${REDIRECT_TYPES.join(", ")}.`);
106
+ if (isRegex) {
107
+ if (!source) throw new RedirectValidationError("Enter a pattern.");
108
+ try {
109
+ new RegExp(source);
110
+ } catch (error) {
111
+ throw new RedirectValidationError(`Invalid pattern: ${(error as Error).message}`);
112
+ }
113
+ } else if (!source.startsWith("/") || source.startsWith("//")) {
114
+ throw new RedirectValidationError("The source must be a path starting with /.");
115
+ }
116
+ if (type !== 410) {
117
+ if (!target) throw new RedirectValidationError("Enter a destination.");
118
+ const sitePath = target.startsWith("/") && !target.startsWith("//");
119
+ if (!sitePath && !/^https?:\/\/[^\s]+$/i.test(target)) {
120
+ throw new RedirectValidationError("The destination must be a path starting with / or an http(s) URL.");
121
+ }
122
+ if (!isRegex && sitePath && normalizePath(target.split("?")[0]) === normalizePath(source)) {
123
+ throw new RedirectValidationError("A redirect can't point to itself.");
124
+ }
125
+ }
126
+ return {
127
+ source: isRegex ? source : normalizePath(source),
128
+ target: type === 410 ? "" : target,
129
+ type,
130
+ isRegex,
131
+ enabled: input.enabled !== false,
132
+ note: input.note?.trim() || null,
133
+ };
134
+ }
135
+
136
+ /** The table is created once per isolate; concurrent first callers share the one batch. */
137
+ let tableReady: Promise<void> | null = null;
138
+
139
+ function ensureTableOnce(db: D1Database): Promise<void> {
140
+ tableReady ??= ensureTable(db).catch((error) => {
141
+ tableReady = null;
142
+ throw error;
143
+ });
144
+ return tableReady;
145
+ }
146
+
147
+ export async function listRules(db: D1Database): Promise<RedirectRule[]> {
148
+ await ensureTableOnce(db);
149
+ const read = () => db.prepare(`SELECT * FROM ${TABLE} ORDER BY is_regex, source`).all<Row>();
150
+ let results: Row[];
151
+ try {
152
+ ({ results } = await read());
153
+ } catch (error) {
154
+ // A restore can drop the table after this isolate created it; recreate and retry once.
155
+ if (!isMissingTable(error)) throw error;
156
+ tableReady = null;
157
+ await ensureTableOnce(db);
158
+ ({ results } = await read());
159
+ }
160
+ return results.map(toRule);
161
+ }
162
+
163
+ const isMissingTable = (error: unknown) => /no such table/i.test(String((error as Error)?.message ?? error));
164
+
165
+ type MatchRow = Pick<Row, "id" | "source" | "target" | "type" | "is_regex">;
166
+
167
+ /**
168
+ * Enabled rules, only the columns matching needs, for the site middleware.
169
+ * Never writes: the table is created by admin writes (saveRule, listRules);
170
+ * until then there are no rules. Read with the pack's other reads of this
171
+ * tick, in one D1 batch.
172
+ */
173
+ async function readMatchRules(db: D1Database, statement: D1PreparedStatement): Promise<RedirectRule[]> {
174
+ let results: MatchRow[];
175
+ try {
176
+ results = await batchedAll<MatchRow>(db, statement);
177
+ } catch (error) {
178
+ if (isMissingTable(error)) return [];
179
+ throw error;
180
+ }
181
+ return results.map((r) => ({
182
+ id: r.id,
183
+ source: r.source,
184
+ target: r.target,
185
+ type: r.type as RedirectType,
186
+ isRegex: r.is_regex === 1,
187
+ enabled: true,
188
+ hits: 0,
189
+ lastHit: null,
190
+ note: null,
191
+ createdAt: "",
192
+ updatedAt: "",
193
+ }));
194
+ }
195
+
196
+ const MATCH_COLUMNS = "id, source, target, type, is_regex";
197
+
198
+ /** The enabled exact rule for a request path, if any: one row through the (source, is_regex) index. */
199
+ export async function findExactRule(db: D1Database, pathname: string): Promise<RedirectRule | null> {
200
+ const statement = db
201
+ .prepare(`SELECT ${MATCH_COLUMNS} FROM ${TABLE} WHERE source = ? AND is_regex = 0 AND enabled = 1`)
202
+ .bind(normalizePath(pathname));
203
+ return (await readMatchRules(db, statement))[0] ?? null;
204
+ }
205
+
206
+ /** The enabled pattern rules, in source order (the order they're tried); read through the patterns index. */
207
+ export function loadPatternRules(db: D1Database): Promise<RedirectRule[]> {
208
+ return readMatchRules(db, db.prepare(`SELECT ${MATCH_COLUMNS} FROM ${TABLE} WHERE is_regex = 1 AND enabled = 1 ORDER BY source`));
209
+ }
210
+
211
+ /** Create or update (by id, or by source when importing). Returns the saved rule. */
212
+ export async function saveRule(db: D1Database, input: RedirectInput & { id?: string }): Promise<RedirectRule> {
213
+ await ensureTable(db);
214
+ const rule = validate(input);
215
+ const now = new Date().toISOString();
216
+ const existing = input.id
217
+ ? await db.prepare(`SELECT id FROM ${TABLE} WHERE id = ?`).bind(input.id).first<{ id: string }>()
218
+ : await db
219
+ .prepare(`SELECT id FROM ${TABLE} WHERE source = ? AND is_regex = ?`)
220
+ .bind(rule.source, rule.isRegex ? 1 : 0)
221
+ .first<{ id: string }>();
222
+ const id = existing?.id ?? input.id ?? crypto.randomUUID();
223
+ if (existing) {
224
+ await db
225
+ .prepare(
226
+ `UPDATE ${TABLE} SET source = ?, target = ?, type = ?, is_regex = ?, enabled = ?, note = ?, updated_at = ? WHERE id = ?`,
227
+ )
228
+ .bind(rule.source, rule.target, rule.type, rule.isRegex ? 1 : 0, rule.enabled ? 1 : 0, rule.note, now, id)
229
+ .run();
230
+ } else {
231
+ await db
232
+ .prepare(
233
+ `INSERT INTO ${TABLE} (id, source, target, type, is_regex, enabled, note, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
234
+ )
235
+ .bind(id, rule.source, rule.target, rule.type, rule.isRegex ? 1 : 0, rule.enabled ? 1 : 0, rule.note, now, now)
236
+ .run();
237
+ }
238
+ const saved = await db.prepare(`SELECT * FROM ${TABLE} WHERE id = ?`).bind(id).first<Row>();
239
+ return toRule(saved!);
240
+ }
241
+
242
+ /** One rule by id, or null (also when the table doesn't exist yet). */
243
+ export async function getRule(db: D1Database, id: string): Promise<RedirectRule | null> {
244
+ try {
245
+ const row = await db.prepare(`SELECT * FROM ${TABLE} WHERE id = ?`).bind(id).first<Row>();
246
+ return row ? toRule(row) : null;
247
+ } catch (error) {
248
+ if (isMissingTable(error)) return null;
249
+ throw error;
250
+ }
251
+ }
252
+
253
+ /** The exact (non-pattern) rule for a path, enabled or not, or null (also when the table doesn't exist yet). */
254
+ export async function findExactRuleAny(db: D1Database, pathname: string): Promise<RedirectRule | null> {
255
+ try {
256
+ const row = await db.prepare(`SELECT * FROM ${TABLE} WHERE source = ? AND is_regex = 0`).bind(normalizePath(pathname)).first<Row>();
257
+ return row ? toRule(row) : null;
258
+ } catch (error) {
259
+ if (isMissingTable(error)) return null;
260
+ throw error;
261
+ }
262
+ }
263
+
264
+ export async function deleteRule(db: D1Database, id: string): Promise<boolean> {
265
+ await ensureTable(db);
266
+ const result = await db.prepare(`DELETE FROM ${TABLE} WHERE id = ?`).bind(id).run();
267
+ return result.meta.changes > 0;
268
+ }
269
+
270
+ // ─── Matching ───────────────────────────────────────────────────────────────
271
+
272
+ export interface CompiledRules {
273
+ exact: Map<string, RedirectRule>;
274
+ patterns: Array<{ rule: RedirectRule; regex: RegExp }>;
275
+ }
276
+
277
+ export function compile(rules: RedirectRule[]): CompiledRules {
278
+ const exact = new Map<string, RedirectRule>();
279
+ const patterns: CompiledRules["patterns"] = [];
280
+ for (const rule of rules) {
281
+ if (!rule.enabled) continue;
282
+ if (rule.isRegex) {
283
+ try {
284
+ patterns.push({ rule, regex: new RegExp(rule.source) });
285
+ } catch {
286
+ // Saved rules are validated; skip anything unparseable rather than fail every request.
287
+ }
288
+ } else {
289
+ exact.set(rule.source, rule);
290
+ }
291
+ }
292
+ return { exact, patterns };
293
+ }
294
+
295
+ const isSitePath = (target: string): boolean => target.startsWith("/") && !target.startsWith("//");
296
+
297
+ /**
298
+ * A capture group must not move a redirect to another site. A site-relative
299
+ * target collapses leading slashes after substitution (in match), so "/$1" with
300
+ * "/evil.com" stays "/evil.com" rather than becoming "//evil.com". An absolute
301
+ * target whose host is written out must keep that host, so "https://example.com$1"
302
+ * can't become "https://example.com.evil.com/".
303
+ */
304
+ function keepsHost(target: string, location: string): boolean {
305
+ // The written-out part of the host: up to the first path, query, fragment, or capture.
306
+ const authority = /^https?:\/\/[^/?#$]*/i.exec(target)?.[0];
307
+ // A host that is (or starts with) a capture, like "https://$1/", is the site owner's choice.
308
+ if (!authority || !authority.slice(authority.indexOf("//") + 2).includes(".")) return true;
309
+ try {
310
+ const fixed = new URL(authority);
311
+ const result = new URL(location);
312
+ return result.protocol === fixed.protocol && result.host === fixed.host;
313
+ } catch {
314
+ return false;
315
+ }
316
+ }
317
+
318
+ export interface Match {
319
+ rule: RedirectRule;
320
+ /** Absolute or site-relative URL; empty for 410. */
321
+ location: string;
322
+ }
323
+
324
+ /**
325
+ * Find the rule for a request path. Exact rules win over patterns; patterns are
326
+ * tried in source order. `$1`…`$9` in a pattern's target are replaced with its
327
+ * capture groups. The request's query string is kept unless the target has one.
328
+ */
329
+ export function match(compiled: CompiledRules, pathname: string, search: string): Match | null {
330
+ let rule = compiled.exact.get(normalizePath(pathname));
331
+ let location = rule?.target ?? "";
332
+ if (!rule) {
333
+ for (const candidate of compiled.patterns) {
334
+ const m = pathname.match(candidate.regex);
335
+ if (!m) continue;
336
+ rule = candidate.rule;
337
+ location = rule.target.replace(/\$(\d)/g, (_, n: string) => m[Number(n)] ?? "");
338
+ break;
339
+ }
340
+ if (rule && rule.type !== 410) {
341
+ if (isSitePath(rule.target)) location = location.replace(/^[/\\]+/, "/");
342
+ else if (!keepsHost(rule.target, location)) return null;
343
+ }
344
+ }
345
+ if (!rule) return null;
346
+ if (rule.type !== 410 && search && !location.includes("?")) location += search;
347
+ return { rule, location };
348
+ }
349
+
350
+ export async function recordHit(db: D1Database, id: string): Promise<void> {
351
+ await db
352
+ .prepare(`UPDATE ${TABLE} SET hits = hits + 1, last_hit = ? WHERE id = ?`)
353
+ .bind(new Date().toISOString(), id)
354
+ .run();
355
+ }