@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,169 @@
1
+ /**
2
+ * Serves Coywolf redirects (through the pack middleware in src/middleware.ts).
3
+ * Each request reads its exact rule, if any, from D1: one row through the
4
+ * (source, is_regex) index. Pattern rules (few) are kept in memory per
5
+ * Worker isolate, compiled in source order, and read again after a redirect
6
+ * edit (in this isolate at once; in others when they see the new settings
7
+ * generation, see src/core/generation.ts), or after `cacheSeconds`. Reading
8
+ * never writes: the table is created by the first admin write. Hits are
9
+ * counted after the response is sent.
10
+ *
11
+ * With Astro's cacheCloudflare() provider, GET and HEAD redirects are kept in
12
+ * the edge cache (Workers Cache) for 30 days, tagged REDIRECTS_TAG; any
13
+ * redirect edit purges that tag (src/pageCache/lib.ts purgeScope). Repeats
14
+ * served from the cache don't reach the Worker, so they aren't counted as hits.
15
+ */
16
+ import type { MiddlewareHandler } from "astro";
17
+
18
+ import { isCurrent, settingsEpoch } from "../core/features.js";
19
+ import { noteReadFailure } from "../core/read-failures.js";
20
+ import { REDIRECTS_TAG } from "../pageCache/lib.js";
21
+ import { type CompiledRules, compile, findExactRule, loadPatternRules, match, recordHit } from "./rules.js";
22
+
23
+ export interface CoywolfRedirectsOptions {
24
+ /** D1 binding of the site database. Default "DB". */
25
+ database?: string;
26
+ /** Longest time to keep pattern rules per isolate, in seconds. Default 600 (edits reload them sooner). */
27
+ cacheSeconds?: number;
28
+ }
29
+
30
+ type Patterns = CompiledRules["patterns"];
31
+
32
+ let cache: { patterns: Patterns; at: number; epoch: number } | null = null;
33
+ /** A load in progress: concurrent requests of a cold isolate share it. */
34
+ let loading: Promise<Patterns> | null = null;
35
+
36
+ /** Drop the in-memory pattern rules so the next request reloads them (called after admin edits). */
37
+ export function invalidateRedirectCache(): void {
38
+ cache = null;
39
+ loading = null;
40
+ }
41
+
42
+ function patterns(db: D1Database, ttl: number): Promise<Patterns> {
43
+ if (cache && isCurrent(cache, ttl)) return Promise.resolve(cache.patterns);
44
+ if (!loading) {
45
+ const epoch = settingsEpoch();
46
+ const load = loadPatternRules(db)
47
+ .then((list) => {
48
+ const compiled = compile(list).patterns;
49
+ if (loading === load && epoch === settingsEpoch()) cache = { patterns: compiled, at: Date.now(), epoch };
50
+ return compiled;
51
+ })
52
+ .finally(() => {
53
+ if (loading === load) loading = null;
54
+ });
55
+ loading = load;
56
+ }
57
+ return loading;
58
+ }
59
+
60
+ /** The rules that can match a request (its exact rule and the patterns), read once per request URL. */
61
+ const lookups = new WeakMap<URL, Promise<CompiledRules>>();
62
+
63
+ function rulesFor(url: URL, db: D1Database, ttl: number): Promise<CompiledRules> {
64
+ let found = lookups.get(url);
65
+ if (!found) {
66
+ // Issued in the same tick, so both reads go to D1 in one batch.
67
+ found = Promise.all([findExactRule(db, url.pathname), patterns(db, ttl)]).then(([exact, list]) => ({
68
+ exact: new Map(exact ? [[exact.source, exact]] : []),
69
+ patterns: list,
70
+ }));
71
+ lookups.set(url, found);
72
+ }
73
+ return found;
74
+ }
75
+
76
+ const SKIP = /^\/(_emdash|_astro|_image)\//;
77
+ const ttlOf = (options: CoywolfRedirectsOptions) => (options.cacheSeconds ?? 600) * 1000;
78
+
79
+ /**
80
+ * Start this request's lookup without waiting: the pack middleware calls it
81
+ * before reading the feature switches, so when those aren't cached all the
82
+ * reads go to D1 in one batch. Errors surface (logged) in serveRedirect.
83
+ */
84
+ export function prefetchRedirects(url: URL, env: Record<string, unknown>, options: CoywolfRedirectsOptions = {}): void {
85
+ const db = env[options.database ?? "DB"] as D1Database | undefined;
86
+ if (!db || SKIP.test(url.pathname)) return;
87
+ rulesFor(url, db, ttlOf(options)).catch(() => undefined);
88
+ }
89
+
90
+ /**
91
+ * Answer a request from the redirect rules, or return undefined to pass it on.
92
+ * `edge`: the response will be kept in the edge cache (see serveCachedRedirect).
93
+ */
94
+ export async function serveRedirect(
95
+ url: URL,
96
+ env: Record<string, unknown>,
97
+ waitUntil: (p: Promise<unknown>) => void,
98
+ options: CoywolfRedirectsOptions = {},
99
+ edge = false,
100
+ ): Promise<Response | undefined> {
101
+ const { pathname, search } = url;
102
+ if (SKIP.test(pathname)) return undefined;
103
+ const db = env[options.database ?? "DB"] as D1Database | undefined;
104
+ if (!db) return undefined;
105
+
106
+ let compiled: CompiledRules;
107
+ try {
108
+ compiled = await rulesFor(url, db, ttlOf(options));
109
+ } catch (error) {
110
+ console.error("coywolf redirects: could not load rules", error);
111
+ // The page this URL renders instead of its redirect mustn't be edge-cached.
112
+ noteReadFailure("redirect rules");
113
+ return undefined;
114
+ }
115
+
116
+ const found = match(compiled, pathname, search);
117
+ if (!found) return undefined;
118
+
119
+ waitUntil(recordHit(db, found.rule.id).catch(() => undefined));
120
+
121
+ // Browsers may keep a redirect for an hour. Unless it goes in the edge cache
122
+ // (whose lifetime Astro sets in Cloudflare-CDN-Cache-Control), "private" keeps
123
+ // Workers Cache from storing it untagged, where an edit couldn't clear it.
124
+ const caching = { "Cache-Control": edge ? "max-age=3600" : "private, max-age=3600" };
125
+ if (found.rule.type === 410) return new Response("Gone", { status: 410, headers: caching });
126
+ let location = found.location;
127
+ if (location.startsWith("/")) {
128
+ // match() already collapses leading slashes from capture groups; this is a
129
+ // second check that a site-relative destination never resolves off-site.
130
+ const resolved = new URL(location, url);
131
+ if (resolved.origin !== url.origin) return undefined;
132
+ // The edge cache is shared by every hostname the Worker answers (it's keyed by
133
+ // path and query), so a cached redirect stays on whichever host was asked.
134
+ location = edge ? resolved.pathname + resolved.search + resolved.hash : resolved.href;
135
+ }
136
+ return new Response(null, { status: found.rule.type, headers: { Location: location, ...caching } });
137
+ }
138
+
139
+ /** Astro's per-request cache controls (route caching), as far as redirects use them. */
140
+ interface RouteCache {
141
+ enabled?: boolean;
142
+ set(options: { maxAge?: number; tags?: string[] } | false): void;
143
+ }
144
+
145
+ /** How long the edge cache keeps a redirect: until an edit purges it, or 30 days. */
146
+ export const REDIRECT_EDGE_MAX_AGE = 30 * 86400;
147
+
148
+ /**
149
+ * The pack middleware's redirect handler: serveRedirect, and for GET and HEAD
150
+ * with Astro route caching on, the edge lifetime and tag (replacing any the
151
+ * site's route rules gave the request). Astro sends them as
152
+ * Cloudflare-CDN-Cache-Control and Cache-Tag, which Cloudflare strips before
153
+ * the response reaches the browser.
154
+ */
155
+ export async function serveCachedRedirect(
156
+ context: Parameters<MiddlewareHandler>[0],
157
+ env: Record<string, unknown>,
158
+ waitUntil: (p: Promise<unknown>) => void,
159
+ ): Promise<Response | undefined> {
160
+ const cache = (context as unknown as { cache?: RouteCache }).cache;
161
+ const method = context.request.method;
162
+ const edge = Boolean(cache?.enabled) && (method === "GET" || method === "HEAD");
163
+ const response = await serveRedirect(context.url, env, waitUntil, {}, edge);
164
+ if (response && edge) {
165
+ cache!.set(false);
166
+ cache!.set({ maxAge: REDIRECT_EDGE_MAX_AGE, tags: [REDIRECTS_TAG] });
167
+ }
168
+ return response;
169
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Redirects module: the admin side of Coywolf redirects (rules are served by
3
+ * the middleware in ./middleware.ts).
4
+ */
5
+ import { PluginRouteError, definePluginRoute } from "emdash";
6
+ import { z } from "zod";
7
+
8
+ import { parseInput, workerEnv } from "../shared.js";
9
+ import { invalidateRedirectCache } from "./middleware.js";
10
+ import {
11
+ REDIRECT_TYPES,
12
+ RedirectValidationError,
13
+ compile,
14
+ deleteRule,
15
+ listRules,
16
+ match,
17
+ saveRule,
18
+ validate,
19
+ } from "./rules.js";
20
+
21
+ export interface RedirectsOptions {
22
+ /** D1 binding of the site database. Default "DB". */
23
+ database?: string;
24
+ }
25
+
26
+ const ruleInput = z.object({
27
+ id: z.string().optional(),
28
+ source: z.string().max(2000),
29
+ target: z.string().max(2000).optional(),
30
+ type: z.number().int().optional(),
31
+ isRegex: z.boolean().optional(),
32
+ enabled: z.boolean().optional(),
33
+ note: z.string().max(500).nullish(),
34
+ });
35
+
36
+ export function redirectsModule(options: RedirectsOptions) {
37
+ async function db() {
38
+ const env = await workerEnv();
39
+ const database = env[options.database ?? "DB"] as D1Database | undefined;
40
+ if (!database) throw PluginRouteError.badRequest("Redirects: missing database binding.");
41
+ return database;
42
+ }
43
+
44
+ /** Turn rule validation errors into readable 400s. */
45
+ async function guarded<T>(fn: () => Promise<T>): Promise<T> {
46
+ try {
47
+ return await fn();
48
+ } catch (error) {
49
+ if (error instanceof RedirectValidationError) throw PluginRouteError.badRequest(error.message);
50
+ throw error;
51
+ }
52
+ }
53
+
54
+ const routes = {
55
+ "redirects/list": {
56
+ permission: "plugins:manage" as const,
57
+ handler: async () => ({ items: await listRules(await db()), types: REDIRECT_TYPES }),
58
+ },
59
+
60
+ "redirects/save": definePluginRoute({
61
+ permission: "plugins:manage",
62
+ methods: ["POST"],
63
+ request: { body: "json" },
64
+ handler: async (ctx) => {
65
+ const input = parseInput(ruleInput, ctx.input);
66
+ const rule = await guarded(() => db().then((d) => saveRule(d, input)));
67
+ invalidateRedirectCache();
68
+ ctx.log.info("Redirect saved", { source: rule.source, target: rule.target });
69
+ return rule;
70
+ },
71
+ }),
72
+
73
+ "redirects/delete": definePluginRoute({
74
+ permission: "plugins:manage",
75
+ methods: ["POST"],
76
+ request: { body: "json" },
77
+ handler: async (ctx) => {
78
+ const { id } = parseInput(z.object({ id: z.string() }), ctx.input);
79
+ const deleted = await deleteRule(await db(), id);
80
+ invalidateRedirectCache();
81
+ return { deleted };
82
+ },
83
+ }),
84
+
85
+ /** Add or update many rules (matched by source). Every rule is validated before any is saved. */
86
+ "redirects/import": definePluginRoute({
87
+ permission: "plugins:manage",
88
+ methods: ["POST"],
89
+ request: { body: "json", maxBytes: 2 * 1024 * 1024 },
90
+ handler: async (ctx) => {
91
+ const { rules } = parseInput(z.object({ rules: z.array(ruleInput.omit({ id: true })).max(5000) }), ctx.input);
92
+ const errors: string[] = [];
93
+ rules.forEach((rule, i) => {
94
+ try {
95
+ validate(rule);
96
+ } catch (error) {
97
+ errors.push(`Row ${i + 1} (${rule.source}): ${(error as Error).message}`);
98
+ }
99
+ });
100
+ if (errors.length) throw PluginRouteError.badRequest(errors.slice(0, 10).join("\n"));
101
+ const database = await db();
102
+ for (const rule of rules) await saveRule(database, rule);
103
+ invalidateRedirectCache();
104
+ ctx.log.info("Redirects imported", { count: rules.length });
105
+ return { imported: rules.length };
106
+ },
107
+ }),
108
+
109
+ /** Which rule (if any) a URL would hit. */
110
+ "redirects/test": definePluginRoute({
111
+ permission: "plugins:manage",
112
+ methods: ["POST"],
113
+ request: { body: "json" },
114
+ handler: async (ctx) => {
115
+ const { url } = parseInput(z.object({ url: z.string().min(1).max(2000) }), ctx.input);
116
+ const parsed = new URL(url, "https://example.invalid");
117
+ const found = match(compile(await listRules(await db())), parsed.pathname, parsed.search);
118
+ return found ? { matched: true, rule: found.rule, location: found.location } : { matched: false };
119
+ },
120
+ }),
121
+ };
122
+
123
+ return { routes };
124
+ }
@@ -0,0 +1,89 @@
1
+ import type { PluginContext } from "emdash";
2
+
3
+ import { cachedCtxFeatures, isOn, registerFeatures } from "../core/features.js";
4
+ import type { PackMiddleware, PackModule } from "../core/module.js";
5
+ import { serveCachedRedirect } from "./middleware.js";
6
+ import { type RedirectsOptions, redirectsModule } from "./module.js";
7
+ import { TRASH_PROMPT_FEATURE, removedModule } from "./removed.js";
8
+ import { SLUG_CHANGES_FEATURE, slugChangesModule } from "./slug-changes.js";
9
+
10
+ const FEATURES = [
11
+ {
12
+ id: "redirects",
13
+ label: "Redirects",
14
+ description: "External and file-path redirects, regular expressions, hit counts, and import.",
15
+ default: true,
16
+ },
17
+ {
18
+ id: TRASH_PROMPT_FEATURE,
19
+ label: "Removed content",
20
+ description:
21
+ "When a published entry is trashed or unpublished, ask what its old URL should do: redirect it or return 410 Gone. Undecided URLs stay listed on the Redirects page.",
22
+ default: false,
23
+ },
24
+ {
25
+ id: SLUG_CHANGES_FEATURE,
26
+ label: "Slug changes",
27
+ description:
28
+ "When a published entry's URL changes (a new slug, category or parent page), add a 301 from the old URL to the new one. EmDash's own slug-change redirect uses the collection's URL pattern, which can't express category or parent paths.",
29
+ default: true,
30
+ },
31
+ ];
32
+ registerFeatures(FEATURES);
33
+
34
+ // biome-ignore lint/suspicious/noExplicitAny: hook events differ per hook.
35
+ type Hook = (event: any, ctx: PluginContext) => Promise<unknown>;
36
+
37
+ /**
38
+ * One handler per hook from the features that share it, each run only while its
39
+ * feature is on (the module's own gate is the main Redirects switch).
40
+ */
41
+ function mergeHooks(parts: Array<{ feature: string; hooks: Record<string, Hook> }>): Record<string, Hook> {
42
+ const names = new Set(parts.flatMap((p) => Object.keys(p.hooks)));
43
+ return Object.fromEntries(
44
+ [...names].map((name) => [
45
+ name,
46
+ async (event: unknown, ctx: PluginContext) => {
47
+ const features = await cachedCtxFeatures(ctx);
48
+ let result: unknown;
49
+ for (const part of parts) {
50
+ const run = part.hooks[name];
51
+ if (!run || !isOn(features, part.feature)) continue;
52
+ // Each feature on its own, as compose.ts isolates modules: one failing doesn't skip the other.
53
+ try {
54
+ const value = await run(event, ctx);
55
+ if (value !== undefined) result = value;
56
+ } catch (error) {
57
+ ctx.log.warn(`Redirects: ${part.feature} ${name} failed`, { error: String(error) });
58
+ }
59
+ }
60
+ return result;
61
+ },
62
+ ]),
63
+ );
64
+ }
65
+
66
+ export function redirectsPack(options: RedirectsOptions): PackModule {
67
+ const removed = removedModule(options);
68
+ const slugs = slugChangesModule(options);
69
+ return {
70
+ id: "redirects",
71
+ label: "Redirects",
72
+ features: FEATURES,
73
+ routes: { ...redirectsModule(options).routes, ...removed.routes },
74
+ adminPages: [{ path: "/redirects", label: "Redirects", icon: "arrow-bend-up-right" }],
75
+ hooks: mergeHooks([
76
+ { feature: TRASH_PROMPT_FEATURE, hooks: removed.hooks },
77
+ { feature: SLUG_CHANGES_FEATURE, hooks: slugs.hooks },
78
+ ]),
79
+ storage: { ...removed.storage, ...slugs.storage },
80
+ capabilities: ["content:read"],
81
+ };
82
+ }
83
+
84
+ export const redirectsMiddleware: PackMiddleware = {
85
+ module: "redirects",
86
+ feature: "redirects",
87
+ ownsCache: true,
88
+ handle: serveCachedRedirect,
89
+ };
@@ -0,0 +1,151 @@
1
+ /**
2
+ * "Removed content" decisions (feature "redirects.trashPrompt"): the pure
3
+ * parts, unit tested in Node. When a published entry is deleted or
4
+ * unpublished, its former URL is recorded so an editor can redirect it,
5
+ * mark it 410 Gone, or dismiss it.
6
+ */
7
+ import { type CollectionInfo, interpolateUrlPattern } from "../core/content-url.js";
8
+
9
+ export type RemovalReason = "deleted" | "unpublished";
10
+
11
+ export interface PendingDecision {
12
+ /** Storage id: "<collection>:<entry id>". */
13
+ id: string;
14
+ collection: string;
15
+ entryId: string;
16
+ /** Former public path (the site's `urls` override, or the collection's URL pattern). */
17
+ url: string;
18
+ title: string;
19
+ reason: RemovalReason;
20
+ locale: string | null;
21
+ /** ISO time of the removal. */
22
+ at: string;
23
+ }
24
+
25
+ /** The entry fields a decision needs, from a D1 row or a hook's content record. */
26
+ export interface EntrySnapshot {
27
+ id: string;
28
+ slug: string | null;
29
+ status: string | null;
30
+ publishedAt: string | null;
31
+ locale: string | null;
32
+ title: string | null;
33
+ }
34
+
35
+ export const pendingId = (collection: string, entryId: string) => `${collection}:${entryId}`;
36
+
37
+ const str = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
38
+
39
+ /** Read a snapshot from an ec_* row (snake_case columns, title in the collection's title field). */
40
+ export function snapshotFromRow(row: Record<string, unknown>, titleField: string | null): EntrySnapshot {
41
+ return {
42
+ id: String(row.id),
43
+ slug: str(row.slug),
44
+ status: str(row.status),
45
+ publishedAt: str(row.published_at),
46
+ locale: str(row.locale),
47
+ title: str(titleField ? row[titleField] : null) ?? str(row.title),
48
+ };
49
+ }
50
+
51
+ /** Read a snapshot from a content hook's record (EmDash ContentItem: camelCase, fields under data). */
52
+ export function snapshotFromContent(content: Record<string, unknown>, titleField: string | null): EntrySnapshot {
53
+ const data = (content.data ?? {}) as Record<string, unknown>;
54
+ return {
55
+ id: String(content.id),
56
+ slug: str(content.slug),
57
+ status: str(content.status),
58
+ publishedAt: str(content.publishedAt),
59
+ locale: str(content.locale),
60
+ title: str(titleField ? data[titleField] : null) ?? str(data.title) ?? str(content.title),
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Build the decision for a removed entry, or null when there's nothing to
66
+ * decide: the entry never had a public URL (no slug, never published), or,
67
+ * for deletes, it wasn't live when it was deleted.
68
+ */
69
+ export function buildPending(
70
+ collection: string,
71
+ entry: EntrySnapshot,
72
+ /** The former path when already resolved (core/content-url.ts entryUrl), else the collection's URL pattern. */
73
+ info: { url?: string | null; urlPattern?: CollectionInfo["urlPattern"] } | undefined,
74
+ reason: RemovalReason,
75
+ now: Date = new Date(),
76
+ ): PendingDecision | null {
77
+ if (!entry.slug) return null;
78
+ if (reason === "deleted" && entry.status !== "published") return null;
79
+ if (reason === "unpublished" && !entry.publishedAt) return null;
80
+ const url =
81
+ info?.url ||
82
+ interpolateUrlPattern({
83
+ pattern: info?.urlPattern ?? null,
84
+ collection,
85
+ slug: entry.slug,
86
+ id: entry.id,
87
+ date: entry.publishedAt,
88
+ });
89
+ return {
90
+ id: pendingId(collection, entry.id),
91
+ collection,
92
+ entryId: entry.id,
93
+ url,
94
+ title: entry.title ?? entry.slug,
95
+ reason,
96
+ locale: entry.locale,
97
+ at: now.toISOString(),
98
+ };
99
+ }
100
+
101
+ /**
102
+ * A redirect or 410 rule created from a removal decision, remembered so that
103
+ * restoring or republishing the entry can take the rule away again.
104
+ */
105
+ export interface AppliedRule {
106
+ /** Same id as the decision: "<collection>:<entry id>". */
107
+ id: string;
108
+ collection: string;
109
+ entryId: string;
110
+ /** The former path the rule was created for. */
111
+ url: string;
112
+ ruleId: string;
113
+ action: "redirect" | "gone";
114
+ /** ISO time the rule was created. */
115
+ at: string;
116
+ }
117
+
118
+ const samePath = (a: string, b: string) => (a.length > 1 ? a.replace(/\/+$/, "") : a) === (b.length > 1 ? b.replace(/\/+$/, "") : b);
119
+
120
+ /** Whether the rule is still the one a removal decision made: same source, our "Removed:" note. Edited, replaced, or deleted since: false. */
121
+ export function appliedStillOurs(applied: Pick<AppliedRule, "url">, rule: { source: string; note: string | null } | null): boolean {
122
+ return rule !== null && samePath(rule.source, applied.url) && (rule.note?.startsWith("Removed:") ?? false);
123
+ }
124
+
125
+ /**
126
+ * Whether restoring or republishing an entry should delete the rule its
127
+ * removal created: only while the rule is still the one we made
128
+ * (appliedStillOurs), and, on publish, only when the entry is back at the
129
+ * same URL (a new slug means the old URL still needs the rule).
130
+ */
131
+ export function shouldDropApplied(
132
+ applied: Pick<AppliedRule, "url">,
133
+ rule: { source: string; note: string | null } | null,
134
+ currentUrl: string | null,
135
+ reason: "restore" | "publish",
136
+ ): boolean {
137
+ if (!appliedStillOurs(applied, rule)) return false;
138
+ if (reason === "publish") return currentUrl !== null && samePath(currentUrl, applied.url);
139
+ return true;
140
+ }
141
+
142
+ /** Group pending decisions' entry ids by collection, for one "is it live?" query per collection. */
143
+ export function idsByCollection(items: Array<Pick<PendingDecision, "collection" | "entryId">>): Map<string, string[]> {
144
+ const out = new Map<string, string[]>();
145
+ for (const item of items) {
146
+ const ids = out.get(item.collection) ?? [];
147
+ if (!ids.includes(item.entryId)) ids.push(item.entryId);
148
+ out.set(item.collection, ids);
149
+ }
150
+ return out;
151
+ }