@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,353 @@
1
+ /**
2
+ * Discovery module: routes and hooks for IndexNow, the news sitemap,
3
+ * llms.txt and per-entry Markdown. Site URLs are answered by the middleware
4
+ * in ./middleware.ts, which calls the public routes here.
5
+ */
6
+ import { type PluginContext, PluginRouteError, after, definePluginRoute } from "emdash";
7
+ import { z } from "zod";
8
+
9
+ import { absoluteUrl, entryUrl } from "../core/content-url.js";
10
+ import { siteInStaging, siteName } from "../core/site.js";
11
+ import { cachedCtxFeatures, ctxFeatures, invalidateFeatures, isOn, readSiteSetting, registerSiteSetting, requireFeature } from "../core/features.js";
12
+ import { parseInput } from "../shared.js";
13
+ import { LLMS_CACHE, NEWS_CACHE, type CachedDocument, type DocKind, buildMarkdown, readDoc, rebuildNow, scheduleRebuild } from "./build.js";
14
+ import { INDEXNOW_ENDPOINTS, IndexNowBatcher, buildPayload, generateKey } from "./indexnow.js";
15
+ import { buildFeed } from "./feed-build.js";
16
+ import { FEED_PATH, FEED_TAG } from "./feed.js";
17
+ import { purgePageCache } from "../pageCache/lib.js";
18
+ import { markdownUrl } from "./markdown.js";
19
+ import {
20
+ INDEXNOW_KEY_SETTING,
21
+ SETTINGS_KEY,
22
+ type DiscoverySettings,
23
+ ensureIndexNowKey,
24
+ loadSettings,
25
+ readIndexNowKey,
26
+ selected,
27
+ settingsSchema,
28
+ } from "./settings.js";
29
+ import { siteOrigin } from "./urls.js";
30
+
31
+ export const FEATURE = {
32
+ main: "discovery",
33
+ indexnow: "discovery.indexnow",
34
+ news: "discovery.newsSitemap",
35
+ llms: "discovery.llms",
36
+ feed: "discovery.feed",
37
+ } as const;
38
+
39
+ export interface DiscoveryOptions {
40
+ /** D1 binding of the site database (term feeds read EmDash's taxonomy tables). Default "DB". */
41
+ database?: string;
42
+ }
43
+
44
+ // ── IndexNow ─────────────────────────────────────────────────────
45
+
46
+ const LOG_KEY = "state:discovery:indexnowLog";
47
+ const LOG_LIMIT = 25;
48
+ const urlKey = (collection: string, id: string) => `state:discovery:url:${collection}:${id}`;
49
+
50
+ export interface PingLogEntry {
51
+ at: string;
52
+ endpoint: string;
53
+ count: number;
54
+ urls: string[];
55
+ status: number;
56
+ ok: boolean;
57
+ error?: string;
58
+ trigger: "content" | "manual";
59
+ }
60
+
61
+ /** Submissions wait this long for more changes, then go out as one request. */
62
+ const BATCH_WINDOW_MS = 3000;
63
+ const batcher = new IndexNowBatcher({ windowMs: BATCH_WINDOW_MS, defer: (task) => after(task) });
64
+
65
+ async function submit(ctx: PluginContext, urls: string[], trigger: PingLogEntry["trigger"]): Promise<PingLogEntry | null> {
66
+ const key = await ensureIndexNowKey(ctx);
67
+ const settings = await loadSettings(ctx);
68
+ const payload = buildPayload(siteOrigin(ctx), key, urls);
69
+ if (!payload) return null;
70
+ const endpoint = INDEXNOW_ENDPOINTS[settings.indexnow.endpoint];
71
+ const entry: PingLogEntry = {
72
+ at: new Date().toISOString(),
73
+ endpoint: settings.indexnow.endpoint,
74
+ count: payload.urlList.length,
75
+ urls: payload.urlList.slice(0, 10),
76
+ status: 0,
77
+ ok: false,
78
+ trigger,
79
+ };
80
+ try {
81
+ if (!ctx.http) throw new Error("network:request capability missing");
82
+ const response = await ctx.http.fetch(endpoint, {
83
+ method: "POST",
84
+ headers: { "Content-Type": "application/json; charset=utf-8" },
85
+ body: JSON.stringify(payload),
86
+ });
87
+ entry.status = response.status;
88
+ entry.ok = response.ok;
89
+ if (!response.ok) entry.error = (await response.text().catch(() => "")).slice(0, 300) || response.statusText;
90
+ } catch (error) {
91
+ entry.error = String(error).slice(0, 300);
92
+ }
93
+ if (entry.ok) ctx.log.info("IndexNow submitted", { count: entry.count, status: entry.status });
94
+ else ctx.log.warn("IndexNow submission failed", { count: entry.count, status: entry.status, error: entry.error });
95
+ const log = (await ctx.kv.get<PingLogEntry[]>(LOG_KEY)) ?? [];
96
+ await ctx.kv.set(LOG_KEY, [entry, ...log].slice(0, LOG_LIMIT));
97
+ return entry;
98
+ }
99
+
100
+ type ChangeKind = "publish" | "update" | "unpublish" | "delete";
101
+
102
+ async function queueIndexNow(ctx: PluginContext, settings: DiscoverySettings, collection: string, id: string, kind: ChangeKind, permanent = false) {
103
+ if (!selected(settings.indexnow.collections, collection)) return;
104
+ const known = await ctx.kv.get<string>(urlKey(collection, id));
105
+ const urls: string[] = [];
106
+
107
+ if (kind === "publish" || kind === "update") {
108
+ const item = await ctx.content?.get(collection, id);
109
+ const path = item && !item.seo?.noIndex ? await entryUrl(ctx, collection, item) : null;
110
+ const url = path ? absoluteUrl(path, siteOrigin(ctx)) : null;
111
+ if (url) urls.push(url);
112
+ // A changed slug: tell engines about the old URL too.
113
+ if (known && known !== url) urls.push(known);
114
+ if (url && url !== known) await ctx.kv.set(urlKey(collection, id), url);
115
+ if (!url && known) await ctx.kv.delete(urlKey(collection, id));
116
+ } else {
117
+ // Unpublished or deleted: the entry has no public URL any more, so use the URL remembered at publish.
118
+ if (known) urls.push(known);
119
+ if (known && (kind === "unpublish" || permanent)) await ctx.kv.delete(urlKey(collection, id));
120
+ }
121
+ if (urls.length) batcher.add(urls, (batch) => submit(ctx, batch, "content").then(() => undefined));
122
+ }
123
+
124
+ async function onContentChange(ctx: PluginContext, collection: string, id: string | undefined, kind: ChangeKind, permanent = false) {
125
+ const features = await ctxFeatures(ctx);
126
+ const llms = isOn(features, FEATURE.llms);
127
+ const news = isOn(features, FEATURE.news);
128
+ const kinds: DocKind[] = [...(llms ? (["llms"] as const) : []), ...(news ? (["news"] as const) : [])];
129
+ if (kinds.length) scheduleRebuild(ctx, kinds);
130
+ if (id && isOn(features, FEATURE.indexnow)) {
131
+ // A site in EmDash's staging mode is hidden from search engines: don't tell them about its pages.
132
+ if (await siteInStaging()) {
133
+ ctx.log.info("IndexNow: skipped, the site is in staging", { collection, id, kind });
134
+ return;
135
+ }
136
+ await queueIndexNow(ctx, await loadSettings(ctx), collection, id, kind, permanent);
137
+ }
138
+ }
139
+
140
+ const idOf = (content: Record<string, unknown>) => (typeof content.id === "string" ? content.id : undefined);
141
+
142
+ // ── Head link to the Markdown source ─────────────────────────────
143
+
144
+ // Read on every page render: it comes with the feature switches' query (no query of its own).
145
+ registerSiteSetting(SETTINGS_KEY);
146
+ /** The last stored value and its parsed settings, so a cache hit doesn't parse again. */
147
+ let settingsMemo: { raw: unknown; settings: DiscoverySettings } | null = null;
148
+ async function memoSettings(ctx: PluginContext): Promise<DiscoverySettings> {
149
+ const read = await readSiteSetting(SETTINGS_KEY).catch(() => null);
150
+ if (!read) return loadSettings(ctx); // D1 unreadable here: through the plugin context.
151
+ if (settingsMemo && settingsMemo.raw === read.value) return settingsMemo.settings;
152
+ const parsed = settingsSchema.safeParse(read.value ?? {});
153
+ settingsMemo = { raw: read.value, settings: parsed.success ? parsed.data : settingsSchema.parse({}) };
154
+ return settingsMemo.settings;
155
+ }
156
+
157
+ const escapeAttr = (s: string) => s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
158
+
159
+ // ── Module ───────────────────────────────────────────────────────
160
+
161
+ export function discoveryModule(options: DiscoveryOptions) {
162
+ const hooks = {
163
+ "content:afterPublish": async (event: { content: Record<string, unknown>; collection: string }, ctx: PluginContext) =>
164
+ onContentChange(ctx, event.collection, idOf(event.content), "publish"),
165
+ "content:afterUnpublish": async (event: { content: Record<string, unknown>; collection: string }, ctx: PluginContext) =>
166
+ onContentChange(ctx, event.collection, idOf(event.content), "unpublish"),
167
+ /** Edits that change the live page directly (collections without draft revisions). */
168
+ "content:afterSave": async (event: { content: Record<string, unknown>; collection: string; isNew: boolean }, ctx: PluginContext) => {
169
+ if (event.content.status !== "published" || event.content.draftRevisionId) return;
170
+ await onContentChange(ctx, event.collection, idOf(event.content), event.isNew ? "publish" : "update");
171
+ },
172
+ "content:afterDelete": async (event: { id: string; collection: string; permanent: boolean }, ctx: PluginContext) =>
173
+ onContentChange(ctx, event.collection, event.id, "delete", event.permanent),
174
+ /**
175
+ * Head links (page:metadata links can't carry a type): the RSS feed on every page, so feed
176
+ * readers and apps find it, and the entry's Markdown source on content pages.
177
+ */
178
+ "page:fragments": async (event: { page: { kind: string; url: string; canonical: string | null; title: string | null; pageTitle?: string | null; content?: { collection: string }; seo?: { robots?: string | null } } }, ctx: PluginContext) => {
179
+ const { page } = event;
180
+ // The same cached read the hook composer made for this render (no query of its own).
181
+ const features = await cachedCtxFeatures(ctx);
182
+ const out: Array<{ kind: "html"; placement: "head"; key: string; html: string }> = [];
183
+ if (isOn(features, FEATURE.feed)) {
184
+ try {
185
+ const href = new URL(FEED_PATH, page.url).href;
186
+ const name = (await siteName(ctx)) || new URL(page.url).host;
187
+ out.push({ kind: "html", placement: "head", key: "discovery-feed", html: `<link rel="alternate" type="application/rss+xml" href="${escapeAttr(href)}" title="${escapeAttr(`${name} RSS feed`)}">` });
188
+ } catch {
189
+ // No usable page URL: no link.
190
+ }
191
+ }
192
+ if (isOn(features, FEATURE.llms) && page.kind === "content" && page.content && !/noindex/i.test(page.seo?.robots ?? "")) {
193
+ const settings = await memoSettings(ctx);
194
+ if (settings.llms.markdown && selected(settings.llms.collections, page.content.collection)) {
195
+ try {
196
+ const href = markdownUrl(new URL(page.canonical || page.url, page.url).href);
197
+ const title = `${page.pageTitle || page.title || "This page"} — Markdown source`;
198
+ out.push({ kind: "html", placement: "head", key: "discovery-markdown", html: `<link rel="alternate" type="text/markdown" href="${escapeAttr(href)}" title="${escapeAttr(title)}">` });
199
+ } catch {
200
+ // No usable page URL: no link.
201
+ }
202
+ }
203
+ }
204
+ return out;
205
+ },
206
+ };
207
+
208
+ const routes = {
209
+ // ── Public (called by the site middleware) ──
210
+ "discovery/public/key": {
211
+ public: true,
212
+ handler: async (ctx: PluginContext) => {
213
+ await requireFeature(ctx, FEATURE.indexnow);
214
+ return { key: await readIndexNowKey(ctx) };
215
+ },
216
+ },
217
+ "discovery/public/news": {
218
+ public: true,
219
+ handler: async (ctx: PluginContext) => {
220
+ await requireFeature(ctx, FEATURE.news);
221
+ return { body: (await readDoc(ctx, "news")).body };
222
+ },
223
+ },
224
+ "discovery/public/feed": {
225
+ public: true,
226
+ handler: async (ctx: PluginContext & { input: unknown }) => {
227
+ await requireFeature(ctx, FEATURE.feed);
228
+ const { path } = parseInput(z.object({ path: z.string().min(1).max(2000).startsWith("/") }), ctx.input);
229
+ const doc = await buildFeed(ctx, await loadSettings(ctx), path, options.database);
230
+ if (!doc) throw PluginRouteError.notFound("No feed at this path.");
231
+ return doc;
232
+ },
233
+ },
234
+ "discovery/public/llms": {
235
+ public: true,
236
+ handler: async (ctx: PluginContext) => {
237
+ await requireFeature(ctx, FEATURE.llms);
238
+ return { body: (await readDoc(ctx, "llms")).body };
239
+ },
240
+ },
241
+ "discovery/public/markdown": {
242
+ public: true,
243
+ handler: async (ctx: PluginContext & { input: unknown }) => {
244
+ await requireFeature(ctx, FEATURE.llms);
245
+ const { path } = parseInput(z.object({ path: z.string().min(1).max(2000).startsWith("/") }), ctx.input);
246
+ const doc = await buildMarkdown(ctx, await loadSettings(ctx), path);
247
+ if (!doc) throw PluginRouteError.notFound("No Markdown source for this path.");
248
+ return doc;
249
+ },
250
+ },
251
+
252
+ // ── Admin ──
253
+ "discovery/status": {
254
+ permission: "plugins:manage" as const,
255
+ handler: async (ctx: PluginContext) => {
256
+ const features = await ctxFeatures(ctx);
257
+ const settings = await loadSettings(ctx);
258
+ const key = isOn(features, FEATURE.indexnow) ? await ensureIndexNowKey(ctx) : await readIndexNowKey(ctx);
259
+ const collections = ((await ctx.schema?.listCollections()) ?? [])
260
+ .filter((c) => c.routable)
261
+ .map((c) => ({ slug: c.slug, label: c.label, hidden: c.hidden, hasSeo: c.hasSeo }));
262
+ const origin = siteOrigin(ctx);
263
+ const doc = async (k: string) => {
264
+ const d = await ctx.kv.get<CachedDocument>(k);
265
+ return d ? { count: d.count, builtAt: d.builtAt } : null;
266
+ };
267
+ return {
268
+ staging: await siteInStaging(),
269
+ features: { indexnow: isOn(features, FEATURE.indexnow), news: isOn(features, FEATURE.news), llms: isOn(features, FEATURE.llms), feed: isOn(features, FEATURE.feed) },
270
+ settings,
271
+ collections,
272
+ key,
273
+ urls: {
274
+ keyFile: key ? `${origin}/${key}.txt` : null,
275
+ news: `${origin}/news-sitemap.xml`,
276
+ llms: `${origin}/llms.txt`,
277
+ feed: `${origin}${FEED_PATH}`,
278
+ },
279
+ log: (await ctx.kv.get<PingLogEntry[]>(LOG_KEY)) ?? [],
280
+ cache: { llms: await doc(LLMS_CACHE), news: await doc(NEWS_CACHE) },
281
+ };
282
+ },
283
+ },
284
+
285
+ "discovery/save": definePluginRoute({
286
+ permission: "plugins:manage",
287
+ methods: ["POST"],
288
+ request: { body: "json" },
289
+ handler: async (ctx) => {
290
+ const settings = parseInput(settingsSchema, ctx.input);
291
+ await ctx.settings.set(SETTINGS_KEY, settings);
292
+ settingsMemo = null;
293
+ // Read with the feature switches: drop that cache too.
294
+ invalidateFeatures();
295
+ // Rebuild in the background; the old copies keep serving until then.
296
+ const features = await ctxFeatures(ctx);
297
+ const kinds: DocKind[] = [...(isOn(features, FEATURE.llms) ? (["llms"] as const) : []), ...(isOn(features, FEATURE.news) ? (["news"] as const) : [])];
298
+ if (kinds.length) scheduleRebuild(ctx, kinds, 0);
299
+ // Feeds in the edge cache were built with the old settings.
300
+ await purgePageCache({ tags: [FEED_TAG] }).catch((error: unknown) => ctx.log.warn("Discovery: clearing cached feeds failed", { error: String(error) }));
301
+ return { settings };
302
+ },
303
+ }),
304
+
305
+ "discovery/regenerate-key": definePluginRoute({
306
+ permission: "plugins:manage",
307
+ methods: ["POST"],
308
+ request: { body: "json" },
309
+ handler: async (ctx) => {
310
+ const key = generateKey();
311
+ await ctx.settings.set(INDEXNOW_KEY_SETTING, key);
312
+ ctx.log.info("IndexNow key regenerated");
313
+ return { key };
314
+ },
315
+ }),
316
+
317
+ /** Submit URLs now (for checking the setup). Defaults to the home page. */
318
+ "discovery/submit": definePluginRoute({
319
+ permission: "plugins:manage",
320
+ methods: ["POST"],
321
+ request: { body: "json" },
322
+ handler: async (ctx) => {
323
+ await requireFeature(ctx, FEATURE.indexnow);
324
+ if (await siteInStaging()) throw PluginRouteError.badRequest("The site is in staging, hidden from search engines, so nothing is sent to IndexNow. Go live under Settings → General → Site Status first.");
325
+ const { urls } = parseInput(z.object({ urls: z.array(z.string().url().max(2000)).max(100).optional() }), ctx.input ?? {});
326
+ await ensureIndexNowKey(ctx);
327
+ const origin = siteOrigin(ctx);
328
+ const list = urls?.length ? urls : [`${origin}/`];
329
+ const foreign = list.filter((u) => new URL(u).host !== new URL(origin).host);
330
+ if (foreign.length) throw PluginRouteError.badRequest(`Only URLs on ${new URL(origin).host} can be submitted.`);
331
+ const entry = await submit(ctx, list, "manual");
332
+ if (!entry) throw PluginRouteError.badRequest("Nothing to submit.");
333
+ return entry;
334
+ },
335
+ }),
336
+
337
+ /** Build llms.txt and the news sitemap again now (replacing the stored copies). */
338
+ "discovery/rebuild": definePluginRoute({
339
+ permission: "plugins:manage",
340
+ methods: ["POST"],
341
+ request: { body: "json" },
342
+ handler: async (ctx) => {
343
+ const features = await ctxFeatures(ctx);
344
+ const out: { llms?: number; news?: number } = {};
345
+ if (isOn(features, FEATURE.llms)) out.llms = (await rebuildNow(ctx, "llms")).count;
346
+ if (isOn(features, FEATURE.news)) out.news = (await rebuildNow(ctx, "news")).count;
347
+ return out;
348
+ },
349
+ }),
350
+ };
351
+
352
+ return { routes, hooks };
353
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Google News sitemap (pure part): the 48-hour selection and the XML.
3
+ * Mirrors Coywolf SEO's news sitemap: articles published in the last
4
+ * 48 hours (the window Google News considers), newest first, at most 1,000
5
+ * (Google's per-sitemap limit), each with publication name and language,
6
+ * publication date, and title.
7
+ */
8
+
9
+ export const NEWS_WINDOW_MS = 48 * 60 * 60 * 1000;
10
+ export const NEWS_LIMIT = 1000;
11
+
12
+ export interface NewsArticle {
13
+ url: string;
14
+ title: string;
15
+ /** Publish date (ISO 8601, or SQLite's "YYYY-MM-DD HH:MM:SS" in UTC). */
16
+ publishedAt: string;
17
+ }
18
+
19
+ export interface NewsPublication {
20
+ name: string;
21
+ language: string;
22
+ }
23
+
24
+ const TZ_SUFFIX = /([zZ]|[+-]\d{2}(:?\d{2})?)$/;
25
+
26
+ /** Parse a stored timestamp, treating offset-less values as UTC (as EmDash does). */
27
+ export function parseDate(value: string | null | undefined): Date | null {
28
+ if (!value) return null;
29
+ let normalized = value.trim();
30
+ if (normalized.includes(" ") && !normalized.includes("T")) normalized = normalized.replace(" ", "T");
31
+ if (!TZ_SUFFIX.test(normalized)) normalized += "Z";
32
+ const time = Date.parse(normalized);
33
+ return Number.isNaN(time) ? null : new Date(time);
34
+ }
35
+
36
+ /** Articles inside the 48-hour window (not in the future), newest first, capped. */
37
+ export function selectNewsArticles(articles: NewsArticle[], now: number = Date.now(), limit = NEWS_LIMIT): NewsArticle[] {
38
+ return articles
39
+ .map((article) => ({ article, time: parseDate(article.publishedAt)?.getTime() ?? Number.NaN }))
40
+ .filter(({ time }) => !Number.isNaN(time) && time > now - NEWS_WINDOW_MS && time <= now)
41
+ .sort((a, b) => b.time - a.time)
42
+ .slice(0, limit)
43
+ .map(({ article }) => article);
44
+ }
45
+
46
+ export function escapeXml(text: string): string {
47
+ return text
48
+ .replace(/&/g, "&amp;")
49
+ .replace(/</g, "&lt;")
50
+ .replace(/>/g, "&gt;")
51
+ .replace(/"/g, "&quot;")
52
+ .replace(/'/g, "&apos;")
53
+ // Characters XML 1.0 forbids outright.
54
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping them is the point.
55
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F￾￿]/g, "");
56
+ }
57
+
58
+ /**
59
+ * The language code Google News expects: ISO 639-1/2 ("en", "fr"), except
60
+ * Chinese, which must be "zh-cn" or "zh-tw".
61
+ */
62
+ export function newsLanguage(locale: string | null | undefined): string {
63
+ const value = (locale ?? "").trim().toLowerCase().replace(/_/g, "-");
64
+ if (!value) return "en";
65
+ if (value.startsWith("zh")) return /-(tw|hk|mo|hant)/.test(value) ? "zh-tw" : "zh-cn";
66
+ const base = value.split("-")[0];
67
+ return /^[a-z]{2,3}$/.test(base) ? base : "en";
68
+ }
69
+
70
+ /** W3C datetime for <news:publication_date>. */
71
+ function w3c(value: string): string {
72
+ return parseDate(value)?.toISOString().replace(/\.\d{3}Z$/, "Z") ?? value;
73
+ }
74
+
75
+ export function buildNewsSitemap(articles: NewsArticle[], publication: NewsPublication): string {
76
+ const name = escapeXml(publication.name);
77
+ const language = escapeXml(newsLanguage(publication.language));
78
+ const lines = [
79
+ '<?xml version="1.0" encoding="UTF-8"?>',
80
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">',
81
+ ];
82
+ for (const article of articles) {
83
+ lines.push(
84
+ "\t<url>",
85
+ `\t\t<loc>${escapeXml(article.url)}</loc>`,
86
+ "\t\t<news:news>",
87
+ "\t\t\t<news:publication>",
88
+ `\t\t\t\t<news:name>${name}</news:name>`,
89
+ `\t\t\t\t<news:language>${language}</news:language>`,
90
+ "\t\t\t</news:publication>",
91
+ `\t\t\t<news:publication_date>${escapeXml(w3c(article.publishedAt))}</news:publication_date>`,
92
+ `\t\t\t<news:title>${escapeXml(article.title)}</news:title>`,
93
+ "\t\t</news:news>",
94
+ "\t</url>",
95
+ );
96
+ }
97
+ lines.push("</urlset>", "");
98
+ return lines.join("\n");
99
+ }
@@ -0,0 +1,58 @@
1
+ import { registerFeatures } from "../core/features.js";
2
+ import type { PackModule } from "../core/module.js";
3
+ import { discoveryMiddleware } from "./middleware.js";
4
+ import { type DiscoveryOptions, FEATURE, discoveryModule } from "./module.js";
5
+
6
+ const FEATURES = [
7
+ {
8
+ id: FEATURE.main,
9
+ label: "Discovery",
10
+ description: "Help search engines, feed readers and AI agents find your content: IndexNow, a Google News sitemap, an RSS feed, and llms.txt with Markdown versions of entries.",
11
+ default: false,
12
+ },
13
+ {
14
+ id: FEATURE.indexnow,
15
+ label: "IndexNow",
16
+ description: "Tell Bing, Yandex, Seznam, Naver and other IndexNow engines when entries are published, updated, unpublished, or deleted.",
17
+ default: false,
18
+ },
19
+ {
20
+ id: FEATURE.news,
21
+ label: "News sitemap",
22
+ description: "A Google News sitemap at /news-sitemap.xml with entries published in the last 48 hours.",
23
+ default: false,
24
+ },
25
+ {
26
+ id: FEATURE.feed,
27
+ label: "RSS feed",
28
+ description: "An RSS feed of the newest entries at /feed/ (and a feed per category), linked from every page so feed readers and apps find it.",
29
+ default: false,
30
+ },
31
+ {
32
+ id: FEATURE.llms,
33
+ label: "llms.txt and Markdown",
34
+ description: "An llms.txt index at /llms.txt and a Markdown version of each entry at its URL + index.html.md.",
35
+ default: false,
36
+ },
37
+ ];
38
+ registerFeatures(FEATURES);
39
+
40
+ export type { DiscoveryOptions } from "./module.js";
41
+
42
+ export function discoveryPack(options: DiscoveryOptions): PackModule {
43
+ const { routes, hooks } = discoveryModule(options);
44
+ return {
45
+ id: "discovery",
46
+ label: "Discovery",
47
+ features: FEATURES,
48
+ routes,
49
+ hooks,
50
+ // page:fragments adds the feed link and the Markdown link, each checked against its own switch.
51
+ hookFeature: { "page:fragments": FEATURE.main },
52
+ adminPages: [{ path: "/discovery", label: "Discovery", icon: "compass" }],
53
+ capabilities: ["content:read", "schema:read", "bylines:read", "network:request", "hooks.page-fragments:register"],
54
+ allowedHosts: ["api.indexnow.org", "www.bing.com"],
55
+ };
56
+ }
57
+
58
+ export { discoveryMiddleware };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Discovery settings: one plugin setting ("discoverySettings") edited on the
3
+ * Discovery admin page, plus the IndexNow key ("discoveryIndexnowKey").
4
+ */
5
+ import { z } from "zod";
6
+
7
+ import type { SettingsReader } from "../shared.js";
8
+ import { FEED_DEFAULT_ITEMS, FEED_MAX_ITEMS } from "./feed.js";
9
+ import { DEFAULT_MAX_ENTRIES } from "./llms.js";
10
+ import { INDEXNOW_ENDPOINTS, KEY_PATTERN, generateKey } from "./indexnow.js";
11
+
12
+ export const SETTINGS_KEY = "discoverySettings";
13
+ export const INDEXNOW_KEY_SETTING = "discoveryIndexnowKey";
14
+
15
+ const endpoints = Object.keys(INDEXNOW_ENDPOINTS) as [keyof typeof INDEXNOW_ENDPOINTS, ...(keyof typeof INDEXNOW_ENDPOINTS)[]];
16
+
17
+ export const settingsSchema = z.object({
18
+ indexnow: z
19
+ .object({
20
+ endpoint: z.enum(endpoints).default("api.indexnow.org"),
21
+ /** Collections whose changes are submitted; empty = every routable collection. */
22
+ collections: z.array(z.string().max(100)).max(200).default([]),
23
+ })
24
+ .default({ endpoint: "api.indexnow.org", collections: [] }),
25
+ news: z
26
+ .object({
27
+ /** Collections listed in the news sitemap (Coywolf SEO's default: posts only). */
28
+ collections: z.array(z.string().max(100)).max(200).default(["posts"]),
29
+ /** <news:name>; empty = the site title. */
30
+ publicationName: z.string().max(200).default(""),
31
+ /** <news:language>; empty = the site locale. */
32
+ language: z.string().max(20).default(""),
33
+ })
34
+ .default({ collections: ["posts"], publicationName: "", language: "" }),
35
+ feed: z
36
+ .object({
37
+ /** Collections in the feed (default: posts). */
38
+ collections: z.array(z.string().max(100)).max(200).default(["posts"]),
39
+ /** Newest entries listed in each feed. */
40
+ items: z.number().int().min(1).max(FEED_MAX_ITEMS).default(FEED_DEFAULT_ITEMS),
41
+ /** Term feeds at <term archive>/feed/ (WordPress's category feeds). */
42
+ termFeeds: z.boolean().default(true),
43
+ })
44
+ .default({ collections: ["posts"], items: FEED_DEFAULT_ITEMS, termFeeds: true }),
45
+ llms: z
46
+ .object({
47
+ /** Blockquote summary; empty = the site tagline. */
48
+ summary: z.string().max(500).default(""),
49
+ /** Detail paragraph under the summary; empty = the default sentence. */
50
+ intro: z.string().max(4000).default(""),
51
+ /** Collections listed in llms.txt; empty = every routable collection. */
52
+ collections: z.array(z.string().max(100)).max(200).default([]),
53
+ maxEntries: z.number().int().min(1).max(5000).default(DEFAULT_MAX_ENTRIES),
54
+ /** Serve `<entry-url>/index.html.md` and link llms.txt to it. */
55
+ markdown: z.boolean().default(true),
56
+ /** Optional content license noted in the Markdown frontmatter. */
57
+ license: z.string().max(200).default(""),
58
+ })
59
+ .default({ summary: "", intro: "", collections: [], maxEntries: DEFAULT_MAX_ENTRIES, markdown: true, license: "" }),
60
+ });
61
+
62
+ export type DiscoverySettings = z.infer<typeof settingsSchema>;
63
+
64
+ export async function loadSettings(ctx: SettingsReader): Promise<DiscoverySettings> {
65
+ const stored = await ctx.settings.get<unknown>(SETTINGS_KEY);
66
+ const parsed = settingsSchema.safeParse(stored ?? {});
67
+ return parsed.success ? parsed.data : settingsSchema.parse({});
68
+ }
69
+
70
+ interface SettingsWriter extends SettingsReader {
71
+ settings: SettingsReader["settings"] & { set(key: string, value: unknown): Promise<void> };
72
+ }
73
+
74
+ /** The IndexNow key, generating and storing one the first time. */
75
+ export async function ensureIndexNowKey(ctx: SettingsWriter): Promise<string> {
76
+ const existing = await ctx.settings.get<string>(INDEXNOW_KEY_SETTING);
77
+ if (typeof existing === "string" && KEY_PATTERN.test(existing)) return existing;
78
+ const key = generateKey();
79
+ await ctx.settings.set(INDEXNOW_KEY_SETTING, key);
80
+ return key;
81
+ }
82
+
83
+ export async function readIndexNowKey(ctx: SettingsReader): Promise<string | null> {
84
+ const key = await ctx.settings.get<string>(INDEXNOW_KEY_SETTING);
85
+ return typeof key === "string" && KEY_PATTERN.test(key) ? key : null;
86
+ }
87
+
88
+ /** Whether a collection is selected, where an empty selection means "all". */
89
+ export function selected(collections: string[], slug: string): boolean {
90
+ return collections.length === 0 || collections.includes(slug);
91
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Public URLs of content entries for discovery (llms.txt, the news sitemap,
3
+ * IndexNow), from the pack-wide resolver in core/content-url.ts: the site's
4
+ * `urls` overrides when configured, otherwise EmDash's own resolution
5
+ * (getPublicUrl, verified once per collection and locale against local
6
+ * url_pattern interpolation so long lists don't cost two queries an entry).
7
+ */
8
+ import type { CollectionSchemaInfo, PluginContentItem, PluginContext } from "emdash";
9
+
10
+ import { absoluteUrl, createEntryUrlResolver } from "../core/content-url.js";
11
+
12
+ export function siteOrigin(ctx: Pick<PluginContext, "site">): string {
13
+ return ctx.site.url.replace(/\/+$/, "");
14
+ }
15
+
16
+ export interface UrlResolver {
17
+ /** Absolute URLs of published entries, keyed by id (null: none). Terms are read in one batch. */
18
+ urls(collection: CollectionSchemaInfo, items: PluginContentItem[]): Promise<Map<string, string | null>>;
19
+ }
20
+
21
+ /** One resolver per build (it remembers collection routes and verdicts). */
22
+ export function createUrlResolver(ctx: PluginContext): UrlResolver {
23
+ const resolver = createEntryUrlResolver(ctx);
24
+ const origin = siteOrigin(ctx);
25
+ return {
26
+ async urls(collection, items) {
27
+ const out = new Map<string, string | null>();
28
+ const usable = items.filter((item) => collection.routable && item.status === "published" && item.slug);
29
+ for (const item of items) out.set(item.id, null);
30
+ const paths = await resolver.urls(collection.slug, usable);
31
+ for (const [id, path] of paths) out.set(id, path ? absoluteUrl(path, origin) : null);
32
+ return out;
33
+ },
34
+ };
35
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ declare module "cloudflare:workers" {
2
+ export const env: Record<string, unknown>;
3
+ export function waitUntil(promise: Promise<unknown>): void;
4
+ }
5
+
6
+ /** Optional peer (src/forms.ts only); the site that uses private form uploads has it installed. */
7
+ declare module "@emdash-cms/plugin-forms" {
8
+ // biome-ignore lint/suspicious/noExplicitAny: EmDash's ResolvedPlugin; wrapFormsPlugin reads it loosely.
9
+ export function createPlugin(options?: Record<string, unknown>): any;
10
+ }