@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,287 @@
1
+ /**
2
+ * Coywolf Pack site middleware: everything the pack serves outside EmDash's
3
+ * plugin API (redirects, and module files such as sitemaps or downloads).
4
+ * Add it first in the site's src/middleware.ts:
5
+ *
6
+ * ```ts
7
+ * import { sequence } from "astro:middleware";
8
+ * import { coywolfPack } from "@coywolf/emdash/middleware";
9
+ * export const onRequest = sequence(coywolfPack(), yourMiddleware);
10
+ * ```
11
+ *
12
+ * Each module's handler runs only while its feature is on.
13
+ *
14
+ * A GET or HEAD answered after one of the pack's reads failed (it then fails
15
+ * closed: features at their defaults, settings unset, rules absent) is kept out of the edge
16
+ * cache, so a page rendered without its schema, reviews, videos or redirects
17
+ * isn't served for days. See src/core/read-failures.ts and settlePage.
18
+ */
19
+ import type { MiddlewareHandler } from "astro";
20
+
21
+ import { invalidateFeatures, knownFeatures, registerSiteOption, rememberSiteOption, siteFeatures, siteOption, siteSetting, isOn } from "./core/features.js";
22
+ import { claimSettle, recordSettingsChange } from "./core/generation.js";
23
+ import { invalidateRobotsCache } from "./robots/middleware.js";
24
+ import { lastReadFailure, noteReadFailure, readFailures } from "./core/read-failures.js";
25
+ import { injectAdminEnhancements } from "./core/settings-enhance.js";
26
+ import type { PackMiddleware } from "./core/module.js";
27
+ import { MIDDLEWARE } from "./modules.js";
28
+ import { ALWAYS, LIFETIME_DEFAULTS, LIFETIME_SETTINGS } from "./pageCache/pack.js";
29
+ import { ROBOTS_EDGE_MAX_AGE } from "./robots/pack.js";
30
+ import { type PurgeScope, ROBOTS_TAG, applyPageLifetime, keepAtEdge, isRedirectSave, purgePageCache, purgeScope, purgesAfter, redirectPagesScope, settlePage, watchInvalidation } from "./pageCache/lib.js";
31
+ import { STOPGAP_HEADER, WARMER_AGENT, WARM_DAILY_SETTING, WARM_SETTING, WARM_STATE_OPTION, scheduleRewarm, startWarm, warmMayHaveWork, warmStep } from "./pageCache/warm.js";
32
+ import { prefetchRedirects } from "./redirects/middleware.js";
33
+ import { notePackMiddleware } from "./search/live-serve.js";
34
+ import { withMediaPreconnect } from "./images/lib.js";
35
+ import { pendingMediaRenders, renderedPendingMedia } from "./images/pending.js";
36
+ import { refreshMediaHost } from "./images/settings.js";
37
+ import { variantsAfterMediaWrite } from "./images/pack.js";
38
+
39
+ // The warming progress row is read with the feature switches, so requests can skip an idle warmer without a query.
40
+ registerSiteOption(WARM_STATE_OPTION);
41
+
42
+ /**
43
+ * False when the progress row as last read with the switches says there's
44
+ * nothing to warm (the run is done or failed, and no rewarm or daily refresh is due), so
45
+ * the request skips its warming step and that step's read. A run started (or
46
+ * rewarm scheduled) in another isolate is seen on this isolate's next read of
47
+ * the switches (FEATURES_TTL_MS at most); one started here is remembered at
48
+ * once. True when unknown.
49
+ */
50
+ function warmingMayHaveWork(daily: boolean): boolean {
51
+ return warmMayHaveWork(siteOption(WARM_STATE_OPTION), Date.now(), daily);
52
+ }
53
+
54
+ /** The daily refresh is on unless turned off (read with the switches, so no extra query). */
55
+ async function warmDaily(database?: string): Promise<boolean> {
56
+ return (await siteSetting<boolean>(WARM_DAILY_SETTING, database)) !== false;
57
+ }
58
+
59
+ /** Start warming, and remember the new run in this isolate's copy of the progress row. */
60
+ async function startWarming(db: D1Database, reason: string): Promise<void> {
61
+ const state = await startWarm(db, reason);
62
+ rememberSiteOption(WARM_STATE_OPTION, JSON.stringify(state));
63
+ }
64
+
65
+ /** Per isolate: when this isolate last started a warming step (one at a time, at most every few seconds). */
66
+ let warmingUntil = 0;
67
+ const WARM_EVERY_MS = 5_000;
68
+ const WARM_BUDGET_MS = 20_000;
69
+
70
+ export interface CoywolfPackMiddlewareOptions {
71
+ /** D1 binding of the site database. Default "DB". */
72
+ database?: string;
73
+ }
74
+
75
+ const PASS_THROUGH = /^\/(_astro|_image)\//;
76
+ /** EmDash's site settings API (POST saves General, SEO, Social…). */
77
+ const EMDASH_SETTINGS_API = "/_emdash/api/settings";
78
+ /** Largest redirect save body read for its sources (redirects/import takes up to 2 MiB). */
79
+ const REDIRECT_BODY_MAX = 2 * 1024 * 1024 + 64 * 1024;
80
+ /** EmDash admin pages (not its API): the pack adds its admin enhancements (step-by-step guides on the plugin Settings page). */
81
+ const ADMIN_PAGE = /^\/_emdash\/admin(\/|$)/;
82
+ /**
83
+ * EmDash's admin manifest (the sidebar). Read with fresh switches: another isolate's
84
+ * cached read could otherwise keep a module that was just switched on (or off) out of
85
+ * (or in) the sidebar for up to FEATURES_TTL_MS.
86
+ */
87
+ const ADMIN_MANIFEST = "/_emdash/api/manifest";
88
+
89
+ export function coywolfPack(options: CoywolfPackMiddlewareOptions = {}, handlers: PackMiddleware[] = MIDDLEWARE): MiddlewareHandler {
90
+ return async (context, next) => {
91
+ if (PASS_THROUGH.test(context.url.pathname)) return next();
92
+ if (ADMIN_PAGE.test(context.url.pathname) && context.request.method === "GET") return injectAdminEnhancements(await next());
93
+ let workers: { env: Record<string, unknown>; waitUntil?: (p: Promise<unknown>) => void };
94
+ try {
95
+ workers = (await import("cloudflare:workers")) as unknown as typeof workers;
96
+ } catch {
97
+ return next(); // Not running on Cloudflare.
98
+ }
99
+ // Pages rendered in this isolate may now load the live search client as a file (it's served below).
100
+ notePackMiddleware();
101
+ // Reads that fail from here until the response is rendered keep it out of the edge cache.
102
+ const failuresBefore = readFailures();
103
+ const readFailed = () => readFailures() !== failuresBefore;
104
+ const routeCache = (context as unknown as { cache?: Parameters<typeof settlePage>[0] }).cache;
105
+ const env = workers.env;
106
+ const waitUntil = (p: Promise<unknown>) => (workers.waitUntil ? workers.waitUntil(p) : void p);
107
+ // This request's redirect lookup goes to D1 in the same batch as the switches (when they aren't cached).
108
+ // (Skipped when the switches this isolate last read have Redirects off.)
109
+ const known = knownFeatures();
110
+ if (!known || isOn(known, "redirects")) prefetchRedirects(context.url, env, { database: options.database });
111
+ const features = await siteFeatures(options.database, context.url.pathname === ADMIN_MANIFEST);
112
+ const db = env[options.database ?? "DB"] as D1Database | undefined;
113
+ /** Clear cached pages, then (for a full purge, when warming is on) start warming them again. */
114
+ const purge = (scope: PurgeScope) =>
115
+ purgePageCache(scope).then(async (purged) => {
116
+ if (purged && "purgeEverything" in scope && db && (await siteSetting<boolean>(WARM_SETTING, options.database))) await startWarming(db, "settings");
117
+ });
118
+ // Page cache: a successful pack admin write (settings, redirects, imports…) can change any page.
119
+ if (purgesAfter(context.request.method, context.url.pathname)) {
120
+ // A redirect save also clears the pages cached at its sources; read its body before the route does.
121
+ // Only a body the route would accept (it checks permissions after this): anything else gets the tag purge alone.
122
+ const length = Number(context.request.headers.get("content-length"));
123
+ const redirectBody =
124
+ isRedirectSave(context.url.pathname) && length > 0 && length <= REDIRECT_BODY_MAX
125
+ ? context.request.clone().json().catch(() => null)
126
+ : null;
127
+ const response = await next();
128
+ if (response.status < 400) {
129
+ const pages = redirectPagesScope(await redirectBody);
130
+ const scope = pages && "purgeEverything" in pages ? pages : purgeScope(context.url.pathname);
131
+ if (pages && !("purgeEverything" in pages)) waitUntil(purge(pages).catch((error) => console.error("coywolf-pack: could not clear pages at redirect sources", error)));
132
+ // This isolate's settings caches go now; other isolates drop theirs when they see the new generation.
133
+ invalidateFeatures();
134
+ const recorded = db
135
+ ? recordSettingsChange(db, scope).catch((error) => console.error("coywolf-pack: could not record the settings change", error))
136
+ : Promise.resolve();
137
+ if (scope) waitUntil(recorded.then(() => purge(scope)));
138
+ else waitUntil(recorded);
139
+ }
140
+ return response;
141
+ }
142
+ // Content writes: EmDash clears the pages tagged with the collection (home page, archives, many
143
+ // posts). With warming on, a run starts a minute after the last such write (scheduleRewarm).
144
+ const method = context.request.method;
145
+ const invalidated =
146
+ method !== "GET" && method !== "HEAD" && context.url.pathname.startsWith("/_emdash/api/")
147
+ ? watchInvalidation((context as unknown as { cache?: Parameters<typeof watchInvalidation>[0] }).cache)
148
+ : null;
149
+ // Only SEO saves can change it (not General, Social…), so other settings saves don't touch the settings generation.
150
+ // A staging change (EmDash's Site Status: Go live, or back to staging) changes every page's robots tag.
151
+ const settingsLength = Number(context.request.headers.get("content-length"));
152
+ const settingsKeys: { seo: boolean; staging: boolean } =
153
+ method === "POST" && context.url.pathname === EMDASH_SETTINGS_API && settingsLength > 0 && settingsLength <= 64 * 1024
154
+ ? await context.request
155
+ .clone()
156
+ .json()
157
+ .then((body: unknown) => (body && typeof body === "object" ? { seo: "seo" in body, staging: "staging" in body } : { seo: false, staging: false }))
158
+ .catch(() => ({ seo: false, staging: false }))
159
+ : { seo: false, staging: false };
160
+ const seoSave = settingsKeys.seo;
161
+ const afterWrite = (response: Response): Response => {
162
+ // EmDash's SEO settings include "Disallow AI training", whose lines the pack's robots.txt carries: clear it,
163
+ // here now and (settling purge) again once every isolate's robots.txt cache has expired.
164
+ if (seoSave && response.status < 400 && isOn(features, "robots")) {
165
+ invalidateRobotsCache();
166
+ const scope: PurgeScope = { pathPrefixes: ["/robots.txt"] };
167
+ waitUntil(
168
+ (db ? recordSettingsChange(db, scope) : Promise.resolve())
169
+ .then(() => purge(scope))
170
+ .catch((error) => console.error("coywolf-pack: could not clear robots.txt after EmDash's settings changed", error)),
171
+ );
172
+ }
173
+ // Going live (or back to staging) changes every page's robots tag: EmDash renders noindex, nofollow on every page
174
+ // while the site is in staging, and only clears pages tagged with its settings. Clear every cached page (which
175
+ // restarts warming), and again once every isolate has the new settings (settling purge).
176
+ if (settingsKeys.staging && response.status < 400) {
177
+ const scope: PurgeScope = { purgeEverything: true };
178
+ waitUntil(
179
+ (db ? recordSettingsChange(db, scope) : Promise.resolve())
180
+ .then(() => purge(scope))
181
+ .catch((error) => console.error("coywolf-pack: could not clear pages after the site's staging status changed", error)),
182
+ );
183
+ }
184
+ // Stored image sizes follow media deletes, replaced files and WordPress media imports (new uploads: the media:afterUpload hook).
185
+ if (method !== "GET" && method !== "HEAD" && response.status < 400) {
186
+ const work = variantsAfterMediaWrite(method, context.url.pathname, isOn(features, "images"), context.url.origin);
187
+ if (work) waitUntil(work.catch((error) => console.error("coywolf-pack images: stored sizes after a media change failed", error)));
188
+ }
189
+ if (invalidated?.() && response.status < 400 && db) {
190
+ waitUntil(
191
+ (async () => {
192
+ if (!(await siteSetting<boolean>(WARM_SETTING, options.database))) return;
193
+ const state = await scheduleRewarm(db);
194
+ if (state) rememberSiteOption(WARM_STATE_OPTION, JSON.stringify(state));
195
+ })().catch((error) => console.error("coywolf-pack: could not schedule cache warming", error)),
196
+ );
197
+ }
198
+ return response;
199
+ };
200
+ // Settings saved in another isolate reach every isolate within the switches' lifetime; pages
201
+ // rendered meanwhile may have used the old ones, so the first request after that purges once more.
202
+ if (db) {
203
+ waitUntil(
204
+ claimSettle(db)
205
+ .then((scope) => (scope ? purge(scope) : undefined))
206
+ .catch((error) => console.error("coywolf-pack: settling purge failed", error)),
207
+ );
208
+ }
209
+ // Page cache lifetimes from Plugins → Performance, on routes the site made cacheable.
210
+ if (context.request.method === "GET") {
211
+ const [maxAgeDays, refreshDays] = await Promise.all([
212
+ siteSetting<number>(LIFETIME_SETTINGS.maxAgeDays, options.database),
213
+ siteSetting<number>(LIFETIME_SETTINGS.refreshDays, options.database),
214
+ ]);
215
+ applyPageLifetime(
216
+ (context as unknown as { cache?: Parameters<typeof applyPageLifetime>[0] }).cache,
217
+ maxAgeDays ?? LIFETIME_DEFAULTS.maxAgeDays,
218
+ refreshDays ?? LIFETIME_DEFAULTS.refreshDays,
219
+ );
220
+ }
221
+ // Cache warming rides on real page traffic (see src/pageCache/warm.ts): after this
222
+ // response, claim a small batch of cold pages and visit them in the background.
223
+ if (
224
+ context.request.method === "GET" &&
225
+ !context.url.pathname.startsWith("/_emdash/") &&
226
+ context.request.headers.get("user-agent") !== WARMER_AGENT &&
227
+ Date.now() > warmingUntil &&
228
+ (await siteSetting<boolean>(WARM_SETTING, options.database)) &&
229
+ warmingMayHaveWork(await warmDaily(options.database))
230
+ ) {
231
+ const self = env.SELF as { fetch(request: Request): Promise<Response> } | undefined;
232
+ if (db && self) {
233
+ warmingUntil = Date.now() + WARM_BUDGET_MS + WARM_EVERY_MS;
234
+ const origin = (context.site ?? context.url).origin;
235
+ waitUntil(
236
+ warmStep(db, self, origin, { budgetMs: WARM_BUDGET_MS, batchSize: 4, daily: await warmDaily(options.database) })
237
+ .catch((error) => console.error("coywolf-pack: cache warming failed", error))
238
+ .finally(() => {
239
+ warmingUntil = Date.now() + WARM_EVERY_MS;
240
+ }),
241
+ );
242
+ }
243
+ }
244
+ for (const handler of handlers) {
245
+ if (handler.feature !== ALWAYS && !isOn(features, handler.feature)) continue;
246
+ try {
247
+ const response = await handler.handle(context, env, waitUntil);
248
+ if (response) {
249
+ // The pack's own responses (robots.txt, files…) set their own Cache-Control.
250
+ // Without this, a site route rule (e.g. on a catch-all page route) would add
251
+ // its edge lifetime to them too. Redirects set their own edge caching.
252
+ if (!handler.ownsCache || readFailed()) routeCache?.set?.(false);
253
+ return response;
254
+ }
255
+ } catch (error) {
256
+ console.error(`coywolf-pack: ${handler.module} middleware failed`, error);
257
+ // The request goes on to the site instead (a page, or EmDash's own robots.txt): not into the edge cache.
258
+ noteReadFailure(`${handler.module} middleware`);
259
+ }
260
+ }
261
+ // The last word on edge caching, once the response has rendered (settlePage): not cached after a
262
+ // failed read; and a page that showed a video's Stream poster or an image's /s/ size while its
263
+ // stored copy is made (src/images/pending.ts) is cached for minutes, not days, so the next render
264
+ // uses the copy. The cache warmer's render of such a page isn't cached; it's told so
265
+ // (STOPGAP_HEADER) and visits again.
266
+ if (method === "GET" || method === "HEAD") {
267
+ const before = pendingMediaRenders();
268
+ const response = await next();
269
+ // EmDash's own robots.txt (Robots.txt Rules off): kept at the edge for a day, as the pack's is.
270
+ if (context.url.pathname === "/robots.txt" && response.status === 200 && !readFailed()) keepAtEdge(context, ROBOTS_EDGE_MAX_AGE, [ROBOTS_TAG]);
271
+ // The media host (read with the feature switches: no extra query) for the page's preconnect header.
272
+ await refreshMediaHost().catch(() => undefined);
273
+ const settled = await settlePage(routeCache, response, {
274
+ failed: readFailed,
275
+ stopgap: method === "GET" && isOn(features, "images") ? () => renderedPendingMedia(context.locals, before) : undefined,
276
+ markHeader: context.request.headers.get("user-agent") === WARMER_AGENT ? STOPGAP_HEADER : undefined,
277
+ label: `${method} ${context.url.pathname}`,
278
+ reason: lastReadFailure,
279
+ });
280
+ return context.url.pathname.startsWith("/_emdash/") ? settled : withMediaPreconnect(settled);
281
+ }
282
+ return afterWrite(await next());
283
+ };
284
+ }
285
+
286
+ /** Earlier name, kept so existing sites keep working. Serves all pack middleware, not just redirects. */
287
+ export const coywolfRedirects = coywolfPack;
package/src/modules.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The module list. Order matters: hooks and middleware run in this order.
3
+ * To add a module: import its factory and middleware, then add one entry to
4
+ * MODULES (and MIDDLEWARE if it serves site URLs).
5
+ */
6
+ import { aiPack } from "./ai/pack.js";
7
+ import { backupsDownloadMiddleware, backupsPack } from "./backups/pack.js";
8
+ import { codeBlocksPack } from "./codeBlocks/pack.js";
9
+ import { customBlocksPack } from "./customBlocks/pack.js";
10
+ import type { PackMiddleware, PackModule } from "./core/module.js";
11
+ import { headingsPack } from "./headings/pack.js";
12
+ import { imagesMiddleware, imagesPack } from "./images/pack.js";
13
+ import { pageCacheMiddleware, strayRobotsMiddleware, pageCachePack } from "./pageCache/pack.js";
14
+ import { breadcrumbsPack } from "./breadcrumbs/pack.js";
15
+ import { filesMiddleware, filesPack } from "./files/pack.js";
16
+ import { formUploadsPack } from "./formUploads/pack.js";
17
+ import { discoveryMiddleware, discoveryPack } from "./discovery/pack.js";
18
+ import { linksPack } from "./links/pack.js";
19
+ import { redirectsMiddleware, redirectsPack } from "./redirects/pack.js";
20
+ import { schemaPack } from "./schema/pack.js";
21
+ import { searchLiveMiddleware, searchPack, searchRateLimitMiddleware } from "./search/pack.js";
22
+ import { robotsMiddleware, robotsPack } from "./robots/pack.js";
23
+ import { reviewsPack } from "./reviews/pack.js";
24
+ import type { CoywolfOptions } from "./options.js";
25
+ import { videosCaptionsMiddleware, videosPack, videosSitemapMiddleware } from "./videos/pack.js";
26
+ import { wpImportPack } from "./wpImport/pack.js";
27
+
28
+ type Factory = (options: CoywolfOptions) => PackModule | null;
29
+
30
+ export const MODULES: Factory[] = [
31
+ (o) => (o.backups ? backupsPack(o.backups) : null),
32
+ (o) => (o.redirects === false ? null : redirectsPack(o.redirects ?? {})),
33
+ // Before Headings & TOC: imported heading ids must be in place when anchors are stamped.
34
+ (o) => (o.wpImport === false ? null : wpImportPack()),
35
+ (o) => (o.headings === false ? null : headingsPack(o.headings ?? {})),
36
+ (o) => (o.breadcrumbs === false ? null : breadcrumbsPack(o.breadcrumbs ?? {})),
37
+ (o) => (o.codeBlocks === false ? null : codeBlocksPack(o.codeBlocks ?? {})),
38
+ (o) => (o.schema === false ? null : schemaPack(o.schema ?? {})),
39
+ (o) => (o.files === false ? null : filesPack(o.files ?? {})),
40
+ (o) => (o.formUploads === false ? null : formUploadsPack()),
41
+ (o) => (o.search === false ? null : searchPack(o.search ?? {})),
42
+ (o) => (o.ai === false ? null : aiPack(o.ai ?? {})),
43
+ (o) => (o.discovery === false ? null : discoveryPack(o.discovery ?? {})),
44
+ (o) => (o.links === false ? null : linksPack()),
45
+ (o) => (o.videos === false ? null : videosPack(o.videos ?? {})),
46
+ (o) => (o.robots === false ? null : robotsPack(o.robots ?? {})),
47
+ (o) => (o.reviews === false ? null : reviewsPack(o.reviews ?? {})),
48
+ (o) => (o.customBlocks === false || o.contentBlocks === false ? null : customBlocksPack()),
49
+ (o) => (o.images === false ? null : imagesPack(o.images ?? {})),
50
+ (o) => (o.pageCache === false ? null : pageCachePack()),
51
+ ];
52
+
53
+ export const MIDDLEWARE: PackMiddleware[] = [pageCacheMiddleware, strayRobotsMiddleware, imagesMiddleware, backupsDownloadMiddleware, redirectsMiddleware, robotsMiddleware, filesMiddleware, searchLiveMiddleware, searchRateLimitMiddleware, ...discoveryMiddleware, videosSitemapMiddleware, videosCaptionsMiddleware];
package/src/options.ts ADDED
@@ -0,0 +1,66 @@
1
+ import type { AiOptions } from "./ai/store.js";
2
+ import type { BackupsOptions } from "./backups/module.js";
3
+ import type { HeadingsOptions } from "./headings/pack.js";
4
+ import type { BreadcrumbsOptions } from "./breadcrumbs/pack.js";
5
+ import type { CodeBlocksOptions } from "./codeBlocks/pack.js";
6
+ import type { FilesOptions } from "./files/module.js";
7
+ import type { DiscoveryOptions } from "./discovery/module.js";
8
+ import type { LinksOptions } from "./links/module.js";
9
+ import type { RedirectsOptions } from "./redirects/module.js";
10
+ import type { SchemaOptions } from "./schema/module.js";
11
+ import type { SearchOptions } from "./search/module.js";
12
+ import type { VideosOptions } from "./videos/module.js";
13
+ import type { RobotsOptions } from "./robots/module.js";
14
+ import type { ReviewsOptions } from "./reviews/pack.js";
15
+ import type { TrailingSlash } from "./core/content-url.js";
16
+
17
+ /**
18
+ * Modules to include in the build. Backups needs its bindings, so it's
19
+ * included only when configured; every other module is included unless set
20
+ * to false. Included modules are then turned on or off on the Coywolf Pack page.
21
+ */
22
+ export interface CoywolfOptions {
23
+ backups?: BackupsOptions | false;
24
+ redirects?: RedirectsOptions | false;
25
+ headings?: HeadingsOptions | false;
26
+ breadcrumbs?: BreadcrumbsOptions | false;
27
+ codeBlocks?: CodeBlocksOptions | false;
28
+ schema?: SchemaOptions | false;
29
+ files?: FilesOptions | false;
30
+ /** Private form uploads (no options here; the Forms plugin is wrapped with privateFormUploads()). `false` leaves the module out. */
31
+ formUploads?: Record<string, never> | false;
32
+ search?: SearchOptions | false;
33
+ ai?: AiOptions | false;
34
+ discovery?: DiscoveryOptions | false;
35
+ links?: LinksOptions | false;
36
+ videos?: VideosOptions | false;
37
+ robots?: RobotsOptions | false;
38
+ reviews?: ReviewsOptions | false;
39
+ /** Custom Blocks: Note, Details, Affiliate disclosure, Quote, Testimonial and Podcast links blocks (no options yet). `false` leaves the module out. */
40
+ customBlocks?: Record<string, never> | false;
41
+ /** Clean image URLs (/media/<id>-<w>x<h>.<format>): media bucket and Images binding names. */
42
+ images?: import("./images/pack.js").ImagesOptions | false;
43
+ /** Page cache upkeep for Cloudflare Workers Cache (no options yet). `false` leaves the module out. */
44
+ pageCache?: Record<string, never> | false;
45
+ /** @deprecated Until 0.12.0 the Custom Blocks module was called Content Blocks; `contentBlocks: false` still leaves it out. */
46
+ contentBlocks?: Record<string, never> | false;
47
+ /** WordPress import tools (no options yet). `false` leaves the module out. */
48
+ wpImport?: Record<string, never> | false;
49
+ /**
50
+ * Entry URL patterns for collections the theme routes differently from
51
+ * EmDash's url_pattern, e.g. `{ posts: "/{term:category|uncategorized}/{slug}/" }`
52
+ * or, with parent categories in the path, `{ posts: "/{termpath:category|uncategorized}/{slug}/" }`.
53
+ * Used by every module that builds entry URLs (see docs/content-urls.md).
54
+ */
55
+ urls?: Record<string, string>;
56
+ /** Trailing-slash policy for entry URLs. Default: EmDash's (Astro's `trailingSlash`). */
57
+ trailingSlash?: TrailingSlash;
58
+ /**
59
+ * Parent terms for `{termpath:<taxonomy>}` URLs, taxonomy → term slug →
60
+ * parent slug, e.g. `{ category: { seo: "news" } }`. Used only for terms
61
+ * that have no parent in EmDash.
62
+ */
63
+ termParents?: Record<string, Record<string, string>>;
64
+ /** Parent pages for `{pagepath}` URLs, slug → parent slug, e.g. `{ "coywolf-seo": "apps" }`. EmDash entries have no parent of their own. */
65
+ pageParents?: Record<string, string>;
66
+ }
@@ -0,0 +1,224 @@
1
+ // Unit tests for page cache purge rules.
2
+ // Run: node --test src/pageCache/lib.test.mjs (Node 22.6+ strips the types from lib.ts)
3
+ import assert from "node:assert/strict";
4
+ import { test } from "node:test";
5
+
6
+ const { purgesAfter, watchInvalidation } = await import("./lib.ts");
7
+ const api = "/_emdash/api/plugins/coywolf-pack/";
8
+
9
+ test("admin writes to pack routes purge the page cache", () => {
10
+ for (const route of ["schema/site/save", "redirects/import", "features/save", "robots/save", "videos/webhook", "videos/update", "backups/run"]) {
11
+ assert.equal(purgesAfter("POST", api + route), true, route);
12
+ }
13
+ });
14
+
15
+ test("public pack routes, reads, and other plugins don't", () => {
16
+ for (const route of ["search/live", "search/index", "discovery/public/llms", "videos/like", "videos/play", "videos/caption", "videos/embed", "videos/sitemap"]) {
17
+ assert.equal(purgesAfter("POST", api + route), false, route);
18
+ }
19
+ assert.equal(purgesAfter("GET", api + "schema/config"), false);
20
+ assert.equal(purgesAfter("POST", "/_emdash/api/plugins/emdash-forms/submit"), false);
21
+ assert.equal(purgesAfter("POST", "/_emdash/api/content/posts"), false, "EmDash purges its own content by tag");
22
+ });
23
+
24
+ const { applyPageLifetime } = await import("./lib.ts");
25
+
26
+ function routeCache(maxAge) {
27
+ return {
28
+ enabled: true,
29
+ options: maxAge === undefined ? {} : { maxAge },
30
+ sets: [],
31
+ set(o) {
32
+ this.sets.push(o);
33
+ },
34
+ };
35
+ }
36
+
37
+ test("page lifetimes apply only to routes the site made cacheable", () => {
38
+ const page = routeCache(3600);
39
+ assert.equal(applyPageLifetime(page, 7, 1), true);
40
+ assert.deepEqual(page.sets, [{ maxAge: 604800, swr: 86400 }]);
41
+
42
+ const uncached = routeCache(undefined);
43
+ assert.equal(applyPageLifetime(uncached, 7, 1), false, "never turns caching on");
44
+ assert.deepEqual(uncached.sets, []);
45
+ assert.equal(applyPageLifetime(undefined, 7, 1), false);
46
+ assert.equal(applyPageLifetime({ ...routeCache(60), enabled: false }, 7, 1), false);
47
+ });
48
+
49
+ const { settlePage, STOPGAP_LIFETIME } = await import("./lib.ts");
50
+
51
+ const html = (body = "<p>hi</p>", init = {}) => new Response(body, { headers: { "content-type": "text/html; charset=utf-8" }, ...init });
52
+ /** settlePage with no failed read: the stopgap checks alone. */
53
+ const shortenStopgapPage = (cache, response, stopgap, markHeader) => settlePage(cache, response, { failed: () => false, stopgap, markHeader });
54
+
55
+ test("a page rendered with a stopgap poster is cached for minutes, decided after its body has rendered", async () => {
56
+ const page = routeCache(604800);
57
+ let rendered = false;
58
+ // The flag is set while the body streams (a component deep in the page), not before next() returns.
59
+ const stream = new ReadableStream({
60
+ async pull(controller) {
61
+ await new Promise((r) => setTimeout(r, 0));
62
+ rendered = true;
63
+ controller.enqueue(new TextEncoder().encode("<video>"));
64
+ controller.close();
65
+ },
66
+ });
67
+ const out = await shortenStopgapPage(page, html(stream, { headers: { "content-type": "text/html", "x-keep": "1" } }), () => rendered);
68
+ assert.deepEqual(page.sets, [{ maxAge: STOPGAP_LIFETIME.maxAge, swr: STOPGAP_LIFETIME.swr }]);
69
+ assert.equal(await out.text(), "<video>");
70
+ assert.equal(out.headers.get("x-keep"), "1");
71
+ assert.equal(out.status, 200);
72
+ });
73
+
74
+ test("pages without a stopgap, uncached routes and non-HTML responses keep their lifetime", async () => {
75
+ const page = routeCache(604800);
76
+ assert.equal(await (await shortenStopgapPage(page, html(), () => false)).text(), "<p>hi</p>");
77
+ assert.deepEqual(page.sets, []);
78
+
79
+ const untouched = html();
80
+ assert.equal(await shortenStopgapPage(routeCache(undefined), untouched, () => assert.fail("asked")), untouched);
81
+ assert.equal(await shortenStopgapPage(undefined, untouched, () => assert.fail("asked")), untouched);
82
+
83
+ const json = new Response("{}", { headers: { "content-type": "application/json" } });
84
+ assert.equal(await shortenStopgapPage(routeCache(60), json, () => assert.fail("asked")), json);
85
+ const missing = html("gone", { status: 404 });
86
+ assert.equal(await shortenStopgapPage(routeCache(60), missing, () => assert.fail("asked")), missing);
87
+
88
+ const short = routeCache(120);
89
+ await shortenStopgapPage(short, html(), () => true);
90
+ assert.deepEqual(short.sets, [{ maxAge: 120, swr: STOPGAP_LIFETIME.swr }], "never lengthens a shorter lifetime");
91
+ });
92
+
93
+ test("the cache warmer's stopgap render isn't cached and is marked for a revisit; visitors' renders aren't marked", async () => {
94
+ const warmer = routeCache(604800);
95
+ const marked = await shortenStopgapPage(warmer, html("<video>", { headers: { "content-type": "text/html", "x-keep": "1" } }), () => true, "X-Coywolf-Stopgap");
96
+ assert.deepEqual(warmer.sets, [false], "not cached, so the mark never reaches visitors and the revisit renders afresh");
97
+ assert.equal(marked.headers.get("x-coywolf-stopgap"), "1");
98
+ assert.equal(marked.headers.get("x-keep"), "1");
99
+ assert.equal(await marked.text(), "<video>");
100
+
101
+ const visitor = routeCache(604800);
102
+ const plain = await shortenStopgapPage(visitor, html(), () => true);
103
+ assert.equal(plain.headers.get("x-coywolf-stopgap"), null);
104
+ assert.deepEqual(visitor.sets, [{ maxAge: STOPGAP_LIFETIME.maxAge, swr: STOPGAP_LIFETIME.swr }]);
105
+
106
+ const fine = routeCache(604800);
107
+ const clean = await shortenStopgapPage(fine, html(), () => false, "X-Coywolf-Stopgap");
108
+ assert.equal(clean.headers.get("x-coywolf-stopgap"), null, "the warmer's normal renders aren't marked");
109
+ assert.deepEqual(fine.sets, []);
110
+ });
111
+
112
+ test("watchInvalidation notices EmDash clearing pages by tag, and only a successful clear", async () => {
113
+ const calls = [];
114
+ const cache = {
115
+ async invalidate(options) {
116
+ calls.push(options);
117
+ if (options.tags.includes("boom")) throw new Error("purge failed");
118
+ },
119
+ };
120
+ const invalidated = watchInvalidation(cache);
121
+ assert.equal(invalidated(), false, "a draft save that clears nothing");
122
+ await assert.rejects(cache.invalidate({ tags: ["boom"] }));
123
+ assert.equal(invalidated(), false);
124
+ await cache.invalidate({ tags: ["posts", "01ABC"] });
125
+ assert.equal(invalidated(), true);
126
+ assert.deepEqual(calls.at(-1), { tags: ["posts", "01ABC"] }, "the route's call still reaches the cache");
127
+ assert.equal(watchInvalidation(undefined)(), false, "no route cache");
128
+ assert.equal(watchInvalidation({})(), false);
129
+ });
130
+
131
+ // ── After a failed read (src/core/read-failures.ts) ─────────────
132
+
133
+ // Astro's own route cache (what context.cache is in production), so set(false) and a later
134
+ // set() behave as they really do: a later set() turns caching back on.
135
+ const { AstroCache, applyCacheHeaders, isCacheActive } = await import(new URL("../../node_modules/astro/dist/core/cache/runtime/cache.js", import.meta.url).href);
136
+ const PROVIDER = { setHeaders: (options) => new Headers({ "Cloudflare-CDN-Cache-Control": options.maxAge === undefined ? "public" : `public, max-age=${options.maxAge}` }) };
137
+ function astroCache(maxAge = 604800) {
138
+ const cache = new AstroCache(PROVIDER);
139
+ cache.set({ maxAge, swr: 86400 });
140
+ return cache;
141
+ }
142
+ /** The CDN header Astro would send for this cache state. */
143
+ const cdnHeader = (cache) => {
144
+ const response = new Response(null);
145
+ applyCacheHeaders(cache, response, new Request("https://example.com/"));
146
+ return response.headers.get("Cloudflare-CDN-Cache-Control");
147
+ };
148
+
149
+ test("Astro's cache: a set() after set(false) turns caching back on (why settlePage decides last)", () => {
150
+ const cache = astroCache();
151
+ cache.set(false);
152
+ assert.equal(isCacheActive(cache), false);
153
+ cache.set({ tags: ["emdash:menu:primary"] });
154
+ assert.equal(isCacheActive(cache), true);
155
+ assert.equal(cdnHeader(cache), "public");
156
+ });
157
+
158
+ test("a page whose render saw a failed read isn't cached, decided after its body (a component's late cache.set can't undo it)", async () => {
159
+ const cache = astroCache();
160
+ let failed = false;
161
+ // The layout renders while the body streams: a settings read fails, then the menu adds its cache tag.
162
+ const stream = new ReadableStream({
163
+ async pull(controller) {
164
+ await new Promise((r) => setTimeout(r, 0));
165
+ failed = true;
166
+ cache.set({ tags: ["emdash:menu:primary"] });
167
+ controller.enqueue(new TextEncoder().encode("<main></main>"));
168
+ controller.close();
169
+ },
170
+ });
171
+ const out = await settlePage(cache, html(stream, { headers: { "content-type": "text/html", "x-keep": "1" } }), { failed: () => failed, label: "GET /a/" });
172
+ assert.equal(isCacheActive(cache), false);
173
+ assert.equal(cdnHeader(cache), null, "no Cloudflare-CDN-Cache-Control: the adapter sends no-store");
174
+ assert.equal(await out.text(), "<main></main>", "the visitor still gets the page");
175
+ assert.equal(out.headers.get("x-keep"), "1");
176
+ assert.equal(out.status, 200);
177
+ });
178
+
179
+ test("a failed read wins over a stopgap; the cache warmer is told to come back", async () => {
180
+ const visitor = astroCache();
181
+ await settlePage(visitor, html(), { failed: () => true, stopgap: () => true });
182
+ assert.equal(isCacheActive(visitor), false, "not cached for minutes either");
183
+
184
+ const warmer = astroCache();
185
+ const marked = await settlePage(warmer, html(), { failed: () => true, markHeader: "X-Coywolf-Stopgap" });
186
+ assert.equal(isCacheActive(warmer), false);
187
+ assert.equal(marked.headers.get("x-coywolf-stopgap"), "1", "visited again later (src/pageCache/warm.ts)");
188
+ });
189
+
190
+ test("other responses after a failed read: left out of the cache without reading their body", async () => {
191
+ for (const response of [
192
+ new Response("User-agent: *\nAllow: /\n", { headers: { "content-type": "text/plain" } }), // EmDash's robots.txt
193
+ new Response("{}", { headers: { "content-type": "application/json" } }),
194
+ html("<p>later</p>", { status: 503 }),
195
+ new Response(null, { status: 200, headers: { "content-type": "text/html" } }), // HEAD
196
+ ]) {
197
+ const cache = astroCache();
198
+ const out = await settlePage(cache, response, { failed: () => true });
199
+ assert.equal(out, response, "returned as is, still streaming");
200
+ assert.equal(response.bodyUsed, false);
201
+ assert.equal(isCacheActive(cache), false, response.headers.get("content-type"));
202
+ }
203
+ // A route that only had cache tags (no lifetime) is still left out.
204
+ const tagged = new AstroCache(PROVIDER);
205
+ tagged.set({ tags: ["posts"] });
206
+ await settlePage(tagged, html(), { failed: () => true });
207
+ assert.equal(isCacheActive(tagged), false);
208
+ });
209
+
210
+ test("no failed read: a cacheable page keeps its lifetime, and an uncached route is never turned on", async () => {
211
+ const cache = astroCache();
212
+ const out = await settlePage(cache, html(), { failed: () => false });
213
+ assert.equal(await out.text(), "<p>hi</p>");
214
+ assert.equal(cdnHeader(cache), "public, max-age=604800");
215
+
216
+ const off = astroCache();
217
+ off.set(false); // e.g. a 404 the site turned off
218
+ await settlePage(off, html("gone", { status: 404 }), { failed: () => false });
219
+ assert.equal(isCacheActive(off), false);
220
+
221
+ const untouched = html();
222
+ assert.equal(await settlePage(undefined, untouched, { failed: () => assert.fail("asked") }), untouched, "no route cache (dev)");
223
+ assert.equal(await settlePage({ enabled: false, set: () => assert.fail("set") }, untouched, { failed: () => assert.fail("asked") }), untouched);
224
+ });