@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,244 @@
1
+ /**
2
+ * Combine Coywolf Pack modules into one EmDash plugin definition.
3
+ */
4
+ import type { PluginContext } from "emdash";
5
+
6
+ import { type FeatureMap, cachedCtxFeatures, ctxFeatures, isOn } from "./features.js";
7
+ import type { PackModule, TaskDef } from "./module.js";
8
+ import { noteReadFailure } from "./read-failures.js";
9
+ import { requestStore } from "./request-memo.js";
10
+ import { afterResponse } from "../shared.js";
11
+
12
+ /**
13
+ * Capability EmDash requires before it registers each hook
14
+ * (emdash/src/plugins/hooks.ts HOOK_REQUIRED_CAPABILITY). A hook without its
15
+ * capability is silently skipped, so the pack declares these automatically.
16
+ */
17
+ const HOOK_CAPABILITY: Record<string, string> = {
18
+ "email:beforeSend": "hooks.email-events:register",
19
+ "email:afterSend": "hooks.email-events:register",
20
+ "email:deliver": "hooks.email-transport:register",
21
+ "content:beforeSave": "content:write",
22
+ "content:afterSave": "content:read",
23
+ "content:beforeDelete": "content:read",
24
+ "content:afterDelete": "content:read",
25
+ "content:beforePublish": "hooks.content-policy:register",
26
+ "content:beforeSchedule": "hooks.content-policy:register",
27
+ "content:beforeUnpublish": "hooks.content-policy:register",
28
+ "content:afterPublish": "content:read",
29
+ "content:afterUnpublish": "content:read",
30
+ "content:afterRestore": "content:read",
31
+ "content:afterSchedule": "content:read",
32
+ "content:afterUnschedule": "content:read",
33
+ "media:beforeUpload": "media:write",
34
+ "media:afterUpload": "media:read",
35
+ "comment:beforeCreate": "users:read",
36
+ "comment:moderate": "users:read",
37
+ "comment:afterCreate": "users:read",
38
+ "comment:afterModerate": "users:read",
39
+ "byline:afterSave": "bylines:read",
40
+ "byline:afterDelete": "bylines:read",
41
+ "page:fragments": "hooks.page-fragments:register",
42
+ };
43
+
44
+ /** Capabilities the modules' hooks need. */
45
+ export function hookCapabilities(modules: PackModule[]): string[] {
46
+ const caps = new Set<string>();
47
+ for (const module of modules) {
48
+ for (const name of Object.keys(module.hooks ?? {})) {
49
+ const cap = HOOK_CAPABILITY[name];
50
+ if (cap) caps.add(cap);
51
+ }
52
+ }
53
+ return [...caps];
54
+ }
55
+
56
+ /** Hooks whose results are collected from every module. */
57
+ const COLLECTING = new Set(["page:metadata", "page:fragments"]);
58
+
59
+ /**
60
+ * EmDash gives a hook 5 seconds (define-plugin.ts), then renders the page
61
+ * without its part and only logs it. A page hook still running just before
62
+ * that (D1 slow, not failing) has its page kept out of the edge cache, like
63
+ * one whose read failed (src/core/read-failures.ts).
64
+ */
65
+ const PAGE_HOOK_DEADLINE_MS = 4_500;
66
+ function noteIfLate(run: Handler): Handler {
67
+ return async (event, ctx) => {
68
+ const late = setTimeout(() => noteReadFailure("a page hook near EmDash's timeout"), PAGE_HOOK_DEADLINE_MS);
69
+ try {
70
+ return await run(event, ctx);
71
+ } finally {
72
+ clearTimeout(late);
73
+ }
74
+ };
75
+ }
76
+ /**
77
+ * Hooks EmDash runs without waiting for them. media:afterUpload is fired and
78
+ * forgotten right before the upload's response goes out (emdash-runtime
79
+ * handleMediaCreate), so the composed hook hands the whole job (the feature
80
+ * check, then each participant) to waitUntil at once, before anything async:
81
+ * awaiting anything first (the other hooks start with a settings read) let the
82
+ * request finish, which dropped the work without a trace. Participants are
83
+ * gated by hookFeature like everywhere else, inside the job.
84
+ */
85
+ const BACKGROUND = new Set(["media:afterUpload"]);
86
+
87
+ /** EmDash's deferred-task tracker for the current request (its request context, found through the pack's request memo). */
88
+ function deferredTasks(): { track<T>(promise: Promise<T>): Promise<T> } | undefined {
89
+ const store = requestStore() as { deferredTasks?: { track?: <T>(promise: Promise<T>) => Promise<T> } } | undefined;
90
+ return typeof store?.deferredTasks?.track === "function" ? (store.deferredTasks as { track<T>(promise: Promise<T>): Promise<T> }) : undefined;
91
+ }
92
+ /** Scheduled jobs can take far longer than EmDash's 5-second hook default (Workers cron allows 15 minutes). */
93
+ const CRON_TIMEOUT_MS = 14 * 60_000;
94
+ /** plugin:activate schedules every task (one D1 write each); a boot-time failure would disable the plugin. */
95
+ const ACTIVATE_TIMEOUT_MS = 60_000;
96
+
97
+ const mainFeature = (module: PackModule) => module.features[0]?.id ?? module.id;
98
+
99
+ function hookFeature(module: PackModule, hook: string): string {
100
+ return module.hookFeature?.[hook as keyof typeof module.hookFeature] ?? mainFeature(module);
101
+ }
102
+
103
+ // biome-ignore lint/suspicious/noExplicitAny: events differ per hook.
104
+ type Handler = (event: any, ctx: PluginContext) => Promise<unknown>;
105
+
106
+ export function composeHooks(modules: PackModule[], extra: { tasks: TaskDef[] }) {
107
+ const names = new Set<string>();
108
+ for (const module of modules) for (const name of Object.keys(module.hooks ?? {})) names.add(name);
109
+
110
+ const hooks: Record<string, Handler | { handler: Handler; timeout?: number; priority?: number }> = {};
111
+
112
+ for (const name of names) {
113
+ if (name === "cron" || name === "plugin:activate") continue; // Composed below.
114
+ const participants = modules.filter((m) => m.hooks?.[name as keyof NonNullable<PackModule["hooks"]>]);
115
+ if (BACKGROUND.has(name)) {
116
+ // Priority 0 (EmDash's default is 100, lower runs first): the pipeline runs plugins' handlers one
117
+ // after another, so this registration comes before another plugin's handler can keep EmDash waiting.
118
+ hooks[name] = { priority: 0, handler: (event, ctx) => {
119
+ const job = (async () => {
120
+ try {
121
+ const features = await cachedCtxFeatures(ctx);
122
+ for (const module of participants) {
123
+ if (!isOn(features, hookFeature(module, name))) continue;
124
+ try {
125
+ await module.hooks?.[name as "media:afterUpload"]?.(event, ctx);
126
+ } catch (error) {
127
+ console.error(`coywolf-pack: ${module.id}: ${name} failed`, error);
128
+ }
129
+ }
130
+ } catch (error) {
131
+ console.error(`coywolf-pack: ${name} failed`, error);
132
+ }
133
+ })();
134
+ // Synchronous: nothing has been awaited yet. Tracked as one of EmDash's deferred tasks too (as its
135
+ // own after() does), so a request-scoped database with a close() stays open until the job is done.
136
+ afterResponse(deferredTasks()?.track(job) ?? job);
137
+ return Promise.resolve();
138
+ } };
139
+ continue;
140
+ }
141
+ const run: Handler = async (event, ctx) => {
142
+ const features = await cachedCtxFeatures(ctx);
143
+ if (COLLECTING.has(name)) {
144
+ const out: unknown[] = [];
145
+ for (const module of participants) {
146
+ if (!isOn(features, hookFeature(module, name))) continue;
147
+ try {
148
+ const result = await module.hooks?.[name as "page:metadata"]?.(event, ctx);
149
+ if (Array.isArray(result)) out.push(...result);
150
+ else if (result) out.push(result);
151
+ } catch (error) {
152
+ ctx.log.error(`${module.id}: ${name} failed`, { error: String(error) });
153
+ // The page goes out without this module's part (its schema, its video player…): not into the edge cache.
154
+ noteReadFailure(`${module.id}: ${name}`);
155
+ }
156
+ }
157
+ return out;
158
+ }
159
+ if (name === "content:beforeSave") {
160
+ let content = event.content as Record<string, unknown>;
161
+ let changed = false;
162
+ for (const module of participants) {
163
+ if (!isOn(features, hookFeature(module, name))) continue;
164
+ const result = (await module.hooks?.["content:beforeSave"]?.({ ...event, content }, ctx)) as Record<string, unknown> | undefined;
165
+ if (result) {
166
+ content = result;
167
+ changed = true;
168
+ }
169
+ }
170
+ return changed ? content : undefined;
171
+ }
172
+ for (const module of participants) {
173
+ if (!isOn(features, hookFeature(module, name))) continue;
174
+ try {
175
+ await module.hooks?.[name as "content:afterSave"]?.(event, ctx);
176
+ } catch (error) {
177
+ ctx.log.error(`${module.id}: ${name} failed`, { error: String(error) });
178
+ }
179
+ }
180
+ return undefined;
181
+ };
182
+ hooks[name] = COLLECTING.has(name) ? noteIfLate(run) : run;
183
+ }
184
+
185
+ const tasks = [...extra.tasks, ...modules.flatMap((m) => (m.tasks ?? []).map((t) => ({ ...t, feature: t.feature ?? mainFeature(m) })))];
186
+
187
+ // EmDash 1.2.0 runs install → activate once at boot for a config plugin with no _plugin_state row, and a
188
+ // hook that fails (its 5-second default timeout included) disables the plugin until an admin re-enables
189
+ // it. Scheduling a dozen tasks is a dozen D1 writes: give it room.
190
+ hooks["plugin:activate"] = {
191
+ timeout: ACTIVATE_TIMEOUT_MS,
192
+ // Never throws: EmDash leaves a plugin whose activation fails turned off, and a brief D1 error here
193
+ // shouldn't switch off the whole pack. Each step is logged; tasks are scheduled again whenever the
194
+ // Features page loads (features/list), and module steps run again on the next activation.
195
+ handler: async (event, ctx) => {
196
+ try {
197
+ await ensureTasks(ctx, tasks);
198
+ } catch (error) {
199
+ ctx.log.error("plugin:activate: scheduling tasks failed; they're scheduled again when the Features page opens", { error: String(error) });
200
+ }
201
+ for (const module of modules) {
202
+ try {
203
+ await module.hooks?.["plugin:activate"]?.(event, ctx);
204
+ } catch (error) {
205
+ ctx.log.error(`${module.id}: plugin:activate failed`, { error: String(error) });
206
+ }
207
+ }
208
+ },
209
+ };
210
+
211
+ hooks.cron = {
212
+ timeout: CRON_TIMEOUT_MS,
213
+ handler: async (event: { name: string }, ctx) => {
214
+ const task = tasks.find((t) => t.name === event.name);
215
+ if (!task) return;
216
+ const features: FeatureMap = await ctxFeatures(ctx);
217
+ if (task.feature && !isOn(features, task.feature)) return;
218
+ await task.handler(ctx);
219
+ },
220
+ };
221
+
222
+ return { hooks, tasks };
223
+ }
224
+
225
+ /**
226
+ * Config-registered native plugins don't get plugin:activate at boot, so
227
+ * routes call this too. schedule() is an upsert.
228
+ */
229
+ export async function ensureTasks(
230
+ ctx: { cron?: { schedule(name: string, opts: { schedule: string }): Promise<void> }; log?: { error(msg: string, data?: unknown): void } },
231
+ tasks: TaskDef[],
232
+ ) {
233
+ // One bad task must not break the routes that call this (e.g. the Features page).
234
+ for (const task of tasks) {
235
+ try {
236
+ await ctx.cron?.schedule(task.name, { schedule: task.schedule });
237
+ } catch (error) {
238
+ ctx.log?.error(`coywolf-pack: could not schedule task ${task.name}`, { error: String(error) });
239
+ }
240
+ }
241
+ }
242
+
243
+ /** EmDash's task-name rule (emdash/src/plugins/cron.ts validateTaskName). */
244
+ export const TASK_NAME = /^[a-zA-Z][a-zA-Z0-9_-]*$/;