@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,294 @@
1
+ /**
2
+ * Portable Text → Markdown, for the per-page Markdown sources served at
3
+ * `<entry-url>/index.html.md` (the llmstxt.org convention, as in Coywolf SEO).
4
+ *
5
+ * Pure and dependency-free so it can be unit-tested with `node --test`.
6
+ * Handles the core block types EmDash's editor writes: text blocks
7
+ * (paragraphs, headings h1–h6, blockquotes, bullet and numbered lists at any
8
+ * depth, with strong/em/code/strike-through and link marks), images, code,
9
+ * horizontal breaks, and tables. Anything else (embeds, buttons, custom
10
+ * plugin blocks) is skipped rather than guessed at.
11
+ */
12
+
13
+ // biome-ignore lint/suspicious/noExplicitAny: Portable Text is untyped JSON.
14
+ type Node = Record<string, any>;
15
+
16
+ export interface MarkdownOptions {
17
+ /** Turn a site-relative href or media URL into an absolute one. Default: unchanged. */
18
+ absolute?: (url: string) => string;
19
+ }
20
+
21
+ export interface Frontmatter {
22
+ [key: string]: string | string[] | undefined;
23
+ }
24
+
25
+ const HEADING = /^h([1-6])$/;
26
+ const DANGEROUS_SCHEME = /^\s*(javascript|vbscript|data):/i;
27
+
28
+ const isRecord = (value: unknown): value is Node => typeof value === "object" && value !== null && !Array.isArray(value);
29
+
30
+ /** Escape characters that would otherwise turn plain text into Markdown syntax. */
31
+ export function escapeText(text: string): string {
32
+ return text.replace(/([\\`*_[\]<>])/g, "\\$1");
33
+ }
34
+
35
+ /**
36
+ * Escape a line's leading characters that would start a block: headings,
37
+ * quotes, list markers, code fences (``` or ~~~), and thematic breaks or
38
+ * setext underlines (runs of 3+ "-", "*", "_" or "=", optionally spaced).
39
+ */
40
+ export function escapeLineStart(line: string): string {
41
+ // Thematic break / setext underline: the whole line is one repeated marker.
42
+ if (/^\s*([-*_=])(\s*\1){2,}\s*$/.test(line)) return line.replace(/^(\s*)(\S)/, "$1\\$2");
43
+ // Fenced code: ``` is already escaped by escapeText; ~~~ is not.
44
+ if (/^\s*~{3,}/.test(line)) return line.replace(/^(\s*)~/, "$1\\~");
45
+ return line.replace(/^(\s*)([#>+-]|\d+[.)])(?=\s|$)/, (_m, space: string, marker: string) => `${space}${marker.replace(/([#>+\-.)])/, "\\$1")}`);
46
+ }
47
+
48
+ function safeHref(href: unknown, options: MarkdownOptions): string | null {
49
+ if (typeof href !== "string" || !href.trim() || DANGEROUS_SCHEME.test(href)) return null;
50
+ const url = options.absolute ? options.absolute(href.trim()) : href.trim();
51
+ return url.replace(/[()\s]/g, (c) => encodeURIComponent(c));
52
+ }
53
+
54
+ /** Inline content of a text block (or table cell): spans with marks and link annotations. */
55
+ export function renderSpans(children: unknown, markDefs: unknown, options: MarkdownOptions = {}): string {
56
+ if (!Array.isArray(children)) return "";
57
+ const defs = new Map<string, Node>();
58
+ if (Array.isArray(markDefs)) for (const def of markDefs) if (isRecord(def) && typeof def._key === "string") defs.set(def._key, def);
59
+
60
+ let out = "";
61
+ for (const child of children) {
62
+ if (!isRecord(child)) continue;
63
+ if (child._type !== "span") {
64
+ // Inline objects (e.g. a hard break) — keep a break, skip the rest.
65
+ if (child._type === "break") out += " \n";
66
+ continue;
67
+ }
68
+ const raw = typeof child.text === "string" ? child.text : "";
69
+ if (!raw) continue;
70
+ const marks: string[] = Array.isArray(child.marks) ? child.marks.filter((m: unknown): m is string => typeof m === "string") : [];
71
+
72
+ const isCode = marks.includes("code");
73
+ let text = isCode ? codeSpan(raw) : raw.split("\n").map(escapeText).join(" \n");
74
+ if (!isCode && text.trim()) {
75
+ // Keep surrounding whitespace outside the emphasis markers so `** bold**` never happens.
76
+ const lead = text.match(/^\s*/)?.[0] ?? "";
77
+ const trail = text.match(/\s*$/)?.[0] ?? "";
78
+ let core = text.slice(lead.length, text.length - trail.length);
79
+ if (marks.includes("strike-through")) core = `~~${core}~~`;
80
+ if (marks.includes("em")) core = `_${core}_`;
81
+ if (marks.includes("strong")) core = `**${core}**`;
82
+ text = lead + core + trail;
83
+ }
84
+ for (const mark of marks) {
85
+ const def = defs.get(mark);
86
+ if (def && (def._type === "link" || typeof def.href === "string")) {
87
+ const href = safeHref(def.href, options);
88
+ if (href) text = `[${text}](${href})`;
89
+ }
90
+ }
91
+ out += text;
92
+ }
93
+ return out;
94
+ }
95
+
96
+ function codeSpan(text: string): string {
97
+ const runs = text.match(/`+/g) ?? [];
98
+ const fence = "`".repeat(Math.max(0, ...runs.map((r) => r.length)) + 1);
99
+ const pad = text.startsWith("`") || text.endsWith("`") ? " " : "";
100
+ return `${fence}${pad}${text}${pad}${fence}`;
101
+ }
102
+
103
+ function renderCode(block: Node): string | null {
104
+ const code = typeof block.code === "string" ? block.code : "";
105
+ if (!code) return null;
106
+ const runs = code.match(/`{3,}/g) ?? [];
107
+ const fence = "`".repeat(Math.max(3, ...runs.map((r) => r.length + 1)));
108
+ const language = typeof block.language === "string" ? block.language.replace(/[^\w+#.-]/g, "") : "";
109
+ const filename = typeof block.filename === "string" && block.filename.trim() ? `${escapeText(block.filename.trim())}\n\n` : "";
110
+ return `${filename}${fence}${language}\n${code.replace(/\n+$/, "")}\n${fence}`;
111
+ }
112
+
113
+ /** The same URL resolution EmDash's image renderer uses for local media. */
114
+ function imageUrl(block: Node): string | null {
115
+ const asset = isRecord(block.asset) ? block.asset : {};
116
+ const url = typeof asset.url === "string" && asset.url ? asset.url : typeof asset.src === "string" && asset.src ? asset.src : null;
117
+ if (url) return url;
118
+ const key = isRecord(asset.meta) && typeof asset.meta.storageKey === "string" ? asset.meta.storageKey : null;
119
+ return key ? `/_emdash/api/media/file/${key.split("/").map(encodeURIComponent).join("/")}` : null;
120
+ }
121
+
122
+ function renderImage(block: Node, options: MarkdownOptions): string | null {
123
+ const src = safeHref(imageUrl(block), options);
124
+ if (!src) return null;
125
+ const asset = isRecord(block.asset) ? block.asset : {};
126
+ const altRaw = typeof block.alt === "string" ? block.alt : typeof asset.alt === "string" ? asset.alt : "";
127
+ const alt = altRaw.replace(/[\r\n]+/g, " ").replace(/([\\[\]])/g, "\\$1");
128
+ let out = `![${alt}](${src})`;
129
+ const link = typeof block.link === "string" ? block.link : isRecord(block.link) ? block.link.href : null;
130
+ const href = safeHref(link, options);
131
+ if (href) out = `[${out}](${href})`;
132
+ const caption = typeof block.caption === "string" ? block.caption.trim() : "";
133
+ if (caption) out += `\n\n_${escapeText(caption.replace(/\s+/g, " "))}_`;
134
+ return out;
135
+ }
136
+
137
+ function cellText(cell: Node, tableDefs: unknown, options: MarkdownOptions): string {
138
+ const defs = [...(Array.isArray(tableDefs) ? tableDefs : []), ...(Array.isArray(cell.markDefs) ? cell.markDefs : [])];
139
+ const content = Array.isArray(cell.content) ? cell.content : Array.isArray(cell.children) ? cell.children : [];
140
+ return renderSpans(content, defs, options).replace(/ {2}\n|\n/g, "<br>").replace(/\|/g, "\\|").trim();
141
+ }
142
+
143
+ function renderTable(block: Node, options: MarkdownOptions): string | null {
144
+ const rows: Node[] = Array.isArray(block.rows) ? block.rows.filter(isRecord) : [];
145
+ const grid: string[][] = [];
146
+ for (const row of rows) {
147
+ const cells: Node[] = Array.isArray(row.cells) ? row.cells.filter(isRecord) : [];
148
+ const line: string[] = [];
149
+ for (const cell of cells) {
150
+ line.push(cellText(cell, block.markDefs, options));
151
+ const span = typeof cell.colspan === "number" && cell.colspan > 1 ? Math.min(cell.colspan, 100) : 1;
152
+ for (let i = 1; i < span; i++) line.push("");
153
+ }
154
+ grid.push(line);
155
+ }
156
+ if (!grid.length) return null;
157
+ const width = Math.max(...grid.map((r) => r.length));
158
+ if (!width) return null;
159
+ const pad = (r: string[]) => [...r, ...Array(width - r.length).fill("")];
160
+ const line = (r: string[]) => `| ${pad(r).join(" | ")} |`;
161
+ // GFM tables need a header row: use the first row (the editor's header row when it has one).
162
+ const [head, ...body] = grid;
163
+ return [line(head), `| ${Array(width).fill("---").join(" | ")} |`, ...body.map(line)].join("\n");
164
+ }
165
+
166
+ interface ListState {
167
+ /** Numbering counters per nesting level for numbered lists. */
168
+ counters: number[];
169
+ }
170
+
171
+ function renderListItem(block: Node, state: ListState, options: MarkdownOptions): string {
172
+ const level = typeof block.level === "number" && block.level > 0 ? Math.min(block.level, 10) : 1;
173
+ state.counters.length = level;
174
+ const numbered = block.listItem === "number";
175
+ let marker = "-";
176
+ if (numbered) {
177
+ state.counters[level - 1] = (state.counters[level - 1] ?? 0) + 1;
178
+ marker = `${state.counters[level - 1]}.`;
179
+ } else {
180
+ state.counters[level - 1] = 0;
181
+ }
182
+ const indent = " ".repeat(level - 1);
183
+ const text = renderSpans(block.children, block.markDefs, options);
184
+ const continuation = `${indent}${" ".repeat(marker.length + 1)}`;
185
+ return `${indent}${marker} ${text.split("\n").join(`\n${continuation}`)}`;
186
+ }
187
+
188
+ function renderTextBlock(block: Node, options: MarkdownOptions): string | null {
189
+ const text = renderSpans(block.children, block.markDefs, options);
190
+ if (!text.trim()) return null;
191
+ const style = typeof block.style === "string" ? block.style : "normal";
192
+ const heading = HEADING.exec(style);
193
+ if (heading) return `${"#".repeat(Number(heading[1]))} ${text.replace(/ {2}\n/g, " ").trim()}`;
194
+ if (style === "blockquote") return text.split("\n").map((l) => `> ${l}`).join("\n");
195
+ return text.split("\n").map(escapeLineStart).join("\n");
196
+ }
197
+
198
+ /**
199
+ * Serialize a Portable Text array to Markdown. Blocks are separated by a
200
+ * blank line; consecutive list items form one list.
201
+ */
202
+ export function portableTextToMarkdown(value: unknown, options: MarkdownOptions = {}): string {
203
+ const blocks: unknown[] = typeof value === "string" ? safeParse(value) : Array.isArray(value) ? value : [];
204
+ const parts: string[] = [];
205
+ let list: string[] | null = null;
206
+ const listState: ListState = { counters: [] };
207
+
208
+ const closeList = () => {
209
+ if (list?.length) parts.push(list.join("\n"));
210
+ list = null;
211
+ listState.counters = [];
212
+ };
213
+
214
+ for (const block of blocks) {
215
+ if (!isRecord(block)) continue;
216
+ if (block._type === "block" && typeof block.listItem === "string") {
217
+ list ??= [];
218
+ list.push(renderListItem(block, listState, options));
219
+ continue;
220
+ }
221
+ closeList();
222
+ let rendered: string | null = null;
223
+ switch (block._type) {
224
+ case "block":
225
+ rendered = renderTextBlock(block, options);
226
+ break;
227
+ case "image":
228
+ rendered = renderImage(block, options);
229
+ break;
230
+ case "code":
231
+ rendered = renderCode(block);
232
+ break;
233
+ case "break":
234
+ rendered = "---";
235
+ break;
236
+ case "table":
237
+ rendered = renderTable(block, options);
238
+ break;
239
+ default:
240
+ rendered = null; // Unknown block types are skipped.
241
+ }
242
+ if (rendered) parts.push(rendered);
243
+ }
244
+ closeList();
245
+ return parts.join("\n\n").trim();
246
+ }
247
+
248
+ function safeParse(text: string): unknown[] {
249
+ try {
250
+ const parsed = JSON.parse(text);
251
+ return Array.isArray(parsed) ? parsed : [];
252
+ } catch {
253
+ return [];
254
+ }
255
+ }
256
+
257
+ /** YAML frontmatter with double-quoted scalars (matches Coywolf SEO's .md output). */
258
+ export function frontmatter(fields: Frontmatter): string {
259
+ const yaml = (value: string) => `"${value.replace(/\r\n|\r|\n/g, " ").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
260
+ const lines = ["---"];
261
+ for (const [key, value] of Object.entries(fields)) {
262
+ if (Array.isArray(value)) {
263
+ if (!value.length) continue;
264
+ lines.push(`${key}:`);
265
+ for (const item of value) lines.push(` - ${yaml(item)}`);
266
+ } else if (typeof value === "string" && value.trim()) {
267
+ lines.push(`${key}: ${yaml(value)}`);
268
+ }
269
+ }
270
+ lines.push("---");
271
+ return `${lines.join("\n")}\n`;
272
+ }
273
+
274
+ /** Rough token count: ~4 characters per token (the same heuristic Coywolf SEO uses for X-Markdown-Tokens). */
275
+ export function estimateTokens(text: string): number {
276
+ return Math.max(1, Math.ceil([...text].length / 4));
277
+ }
278
+
279
+ /** `<page-url>/index.html.md`, the llmstxt.org convention Coywolf SEO uses. */
280
+ export function markdownUrl(pageUrl: string): string {
281
+ const url = new URL(pageUrl);
282
+ url.search = "";
283
+ url.hash = "";
284
+ if (!url.pathname.endsWith("/")) url.pathname += "/";
285
+ url.pathname += "index.html.md";
286
+ return url.href;
287
+ }
288
+
289
+ /** The page path an `.../index.html.md` request is for, or null when the path isn't one. */
290
+ export function pagePathFromMarkdownPath(pathname: string): string | null {
291
+ if (!pathname.endsWith("/index.html.md")) return null;
292
+ const page = pathname.slice(0, -"index.html.md".length);
293
+ return page || "/";
294
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * A small per-isolate cache for middleware results, bounded by size rather
3
+ * than entry count: total bytes are capped, large values aren't kept, and
4
+ * misses (null results, e.g. a .md request for a random path) get their own
5
+ * small LRU so they can't crowd out real documents.
6
+ */
7
+
8
+ export interface MemoryCacheOptions {
9
+ ttlMs: number;
10
+ /** Total size of cached values. */
11
+ maxBytes: number;
12
+ /** Values larger than this aren't cached. */
13
+ maxEntryBytes: number;
14
+ /** How many miss (null) results to remember. */
15
+ maxMisses: number;
16
+ now?: () => number;
17
+ }
18
+
19
+ interface Slot {
20
+ value: unknown;
21
+ bytes: number;
22
+ expires: number;
23
+ }
24
+
25
+ /** Approximate size: UTF-16 string length × 2 of the JSON form. */
26
+ export function sizeOf(value: unknown): number {
27
+ try {
28
+ return (JSON.stringify(value) ?? "").length * 2;
29
+ } catch {
30
+ return Number.POSITIVE_INFINITY;
31
+ }
32
+ }
33
+
34
+ export class MemoryCache {
35
+ private readonly hits = new Map<string, Slot>();
36
+ private readonly misses = new Map<string, number>();
37
+ private bytes = 0;
38
+ private readonly options: MemoryCacheOptions;
39
+ private readonly now: () => number;
40
+
41
+ constructor(options: MemoryCacheOptions) {
42
+ this.options = options;
43
+ this.now = options.now ?? Date.now;
44
+ }
45
+
46
+ get totalBytes(): number {
47
+ return this.bytes;
48
+ }
49
+
50
+ get size(): { hits: number; misses: number } {
51
+ return { hits: this.hits.size, misses: this.misses.size };
52
+ }
53
+
54
+ /** `undefined` = not cached; `null` = a remembered miss. */
55
+ get(key: string): unknown | null | undefined {
56
+ const now = this.now();
57
+ const slot = this.hits.get(key);
58
+ if (slot) {
59
+ if (slot.expires > now) {
60
+ // Refresh LRU position.
61
+ this.hits.delete(key);
62
+ this.hits.set(key, slot);
63
+ return slot.value;
64
+ }
65
+ this.remove(key);
66
+ }
67
+ const miss = this.misses.get(key);
68
+ if (miss !== undefined) {
69
+ if (miss > now) return null;
70
+ this.misses.delete(key);
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ set(key: string, value: unknown): void {
76
+ this.remove(key);
77
+ this.misses.delete(key);
78
+ const expires = this.now() + this.options.ttlMs;
79
+ if (value === null || value === undefined) {
80
+ while (this.misses.size >= this.options.maxMisses) this.misses.delete(this.misses.keys().next().value as string);
81
+ if (this.options.maxMisses > 0) this.misses.set(key, expires);
82
+ return;
83
+ }
84
+ const bytes = sizeOf(value);
85
+ if (bytes > this.options.maxEntryBytes || bytes > this.options.maxBytes) return;
86
+ while (this.bytes + bytes > this.options.maxBytes && this.hits.size) this.remove(this.hits.keys().next().value as string);
87
+ this.hits.set(key, { value, bytes, expires });
88
+ this.bytes += bytes;
89
+ }
90
+
91
+ private remove(key: string): void {
92
+ const slot = this.hits.get(key);
93
+ if (!slot) return;
94
+ this.hits.delete(key);
95
+ this.bytes -= slot.bytes;
96
+ }
97
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Site URLs for the Discovery module, served through the pack middleware:
3
+ *
4
+ * - `/<key>.txt` IndexNow key file
5
+ * - `/news-sitemap.xml` Google News sitemap (also `/coywolf-news-sitemap.xml`, Coywolf SEO's name)
6
+ * - `/feed/`, `<term archive>/feed/` RSS feeds
7
+ * - `/llms.txt` llms.txt
8
+ * - `<entry-url>/index.html.md` Markdown source of an entry
9
+ *
10
+ * The work happens in the module's public plugin routes (so it runs in the
11
+ * plugin context, with EmDash's URL resolution and the plugin's KV cache);
12
+ * this file dispatches to them through EmDash's public route handler and
13
+ * keeps results briefly in isolate memory.
14
+ *
15
+ * There is deliberately no `Accept: text/markdown` negotiation on entry
16
+ * URLs: Cloudflare's cache key ignores Accept, so a single such request
17
+ * could put Markdown into the cached HTML page.
18
+ */
19
+ import type { MiddlewareHandler } from "astro";
20
+
21
+ import { PLUGIN_ID } from "../core/features.js";
22
+ import { noteReadFailure, routeFailed } from "../core/read-failures.js";
23
+ import type { PackMiddleware } from "../core/module.js";
24
+ import { FEED_EDGE_MAX_AGE, parseFeedPath } from "./feed.js";
25
+ import { keyFromPath } from "./indexnow.js";
26
+ import { pagePathFromMarkdownPath } from "./markdown.js";
27
+ import { MemoryCache } from "./memory-cache.js";
28
+ import { FEATURE } from "./module.js";
29
+
30
+ type Context = Parameters<MiddlewareHandler>[0];
31
+
32
+ interface PublicRouteResult {
33
+ success: boolean;
34
+ data?: unknown;
35
+ /** HTTP status of the route's answer (500 for an error the route didn't expect; unset when the plugin isn't enabled). */
36
+ status?: number;
37
+ error?: { code?: string };
38
+ }
39
+ type PublicRouteHandler = (pluginId: string, method: string, path: string, request: Request) => Promise<PublicRouteResult>;
40
+
41
+ /** About 2 MB of documents per isolate; bodies over 256 KB and all but 32 misses aren't kept. */
42
+ const memory = new MemoryCache({ ttlMs: 60_000, maxBytes: 2 * 1024 * 1024, maxEntryBytes: 256 * 1024, maxMisses: 32 });
43
+
44
+ /** Call one of the module's public routes. Null when it isn't available or answers with an error (e.g. 404; a server error isn't kept). */
45
+ async function callRoute<T>(context: Context, route: string, query: Record<string, string> = {}, keep: "all" | "misses" = "all"): Promise<T | null> {
46
+ const cacheKey = `${route}?${new URLSearchParams(query)}`;
47
+ const hit = memory.get(cacheKey);
48
+ if (hit !== undefined && (keep === "all" || hit === null)) return hit as T | null;
49
+
50
+ const locals = context.locals as { emdash?: { handlePublicPluginApiRoute?: PublicRouteHandler } };
51
+ const dispatch = locals.emdash?.handlePublicPluginApiRoute;
52
+ if (typeof dispatch !== "function") return null;
53
+ const url = new URL(`/_emdash/api/plugins/${PLUGIN_ID}/${route}`, context.url);
54
+ for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v);
55
+ const result = await dispatch(PLUGIN_ID, "GET", route, new Request(url, { method: "GET" }));
56
+ const value = result.success ? ((result.data ?? null) as T | null) : null;
57
+ // A server error (a read that failed) isn't kept as the answer: the next request asks again.
58
+ if (routeFailed(result)) {
59
+ noteReadFailure("discovery route");
60
+ return null;
61
+ }
62
+
63
+ if (keep === "all" || value === null) memory.set(cacheKey, value);
64
+ return value;
65
+ }
66
+
67
+ const isRead = (context: Context) => context.request.method === "GET" || context.request.method === "HEAD";
68
+
69
+ function respond(context: Context, body: string, headers: Record<string, string>): Response {
70
+ return new Response(context.request.method === "HEAD" ? null : body, { status: 200, headers });
71
+ }
72
+
73
+ // ── IndexNow key file ────────────────────────────────────────────
74
+
75
+ export const indexNowKeyMiddleware: PackMiddleware = {
76
+ module: "discovery",
77
+ feature: FEATURE.indexnow,
78
+ handle: async (context) => {
79
+ if (!isRead(context)) return undefined;
80
+ const requested = keyFromPath(context.url.pathname);
81
+ if (!requested) return undefined;
82
+ const result = await callRoute<{ key: string | null }>(context, "discovery/public/key");
83
+ if (!result?.key || result.key !== requested) return undefined;
84
+ return respond(context, result.key, {
85
+ "Content-Type": "text/plain; charset=utf-8",
86
+ "Cache-Control": "no-cache",
87
+ "X-Robots-Tag": "noindex",
88
+ });
89
+ },
90
+ };
91
+
92
+ // ── News sitemap ─────────────────────────────────────────────────
93
+
94
+ const NEWS_PATHS = new Set(["/news-sitemap.xml", "/coywolf-news-sitemap.xml"]);
95
+
96
+ export const newsSitemapMiddleware: PackMiddleware = {
97
+ module: "discovery",
98
+ feature: FEATURE.news,
99
+ handle: async (context) => {
100
+ if (!isRead(context) || !NEWS_PATHS.has(context.url.pathname)) return undefined;
101
+ const result = await callRoute<{ body: string }>(context, "discovery/public/news");
102
+ if (!result?.body) return undefined;
103
+ return respond(context, result.body, {
104
+ "Content-Type": "application/xml; charset=utf-8",
105
+ "Cache-Control": "public, max-age=300",
106
+ // A sitemap never sends a robots noindex header (test/sitemap-headers.test.mjs).
107
+ });
108
+ },
109
+ };
110
+
111
+ // ── RSS feeds ────────────────────────────────────────────────────
112
+
113
+ interface RouteCache {
114
+ enabled?: boolean;
115
+ set(options: { maxAge?: number; swr?: number; tags?: string[] } | false): void;
116
+ }
117
+
118
+ export const feedMiddleware: PackMiddleware = {
119
+ module: "discovery",
120
+ feature: FEATURE.feed,
121
+ // Feeds are kept in the edge cache (Workers Cache), tagged so a publish, edit or term change clears them.
122
+ ownsCache: true,
123
+ handle: async (context) => {
124
+ if (!isRead(context) || !parseFeedPath(context.url.pathname)) return undefined;
125
+ // No feed at this path (not a term, or the feed's off): the site answers, as it would without the pack.
126
+ // Only misses are kept in memory: a feed goes to the edge cache, and a copy from memory could put the feed
127
+ // from before a publish back there for an hour, right after EmDash cleared it.
128
+ const doc = await callRoute<{ body: string; tags?: string[] }>(context, "discovery/public/feed", { path: context.url.pathname }, "misses");
129
+ if (!doc?.body) return undefined;
130
+ const cache = (context as unknown as { cache?: RouteCache }).cache;
131
+ if (cache?.enabled) {
132
+ // Replace any lifetime the site's route rules or Plugins → Performance gave the request; swr 0 so a reader
133
+ // polling once an hour isn't handed the copy its last poll left.
134
+ cache.set(false);
135
+ cache.set({ maxAge: FEED_EDGE_MAX_AGE, swr: 0, tags: doc.tags ?? [] });
136
+ }
137
+ return respond(context, doc.body, {
138
+ "Content-Type": "application/rss+xml; charset=utf-8",
139
+ "Cache-Control": "public, max-age=900",
140
+ "X-Content-Type-Options": "nosniff",
141
+ });
142
+ },
143
+ };
144
+
145
+ // ── llms.txt and Markdown sources ────────────────────────────────
146
+
147
+ interface MarkdownDoc {
148
+ body: string;
149
+ tokens: number;
150
+ markdownUrl: string;
151
+ }
152
+
153
+ export const llmsMiddleware: PackMiddleware = {
154
+ module: "discovery",
155
+ feature: FEATURE.llms,
156
+ handle: async (context) => {
157
+ if (!isRead(context)) return undefined;
158
+ const { pathname } = context.url;
159
+
160
+ if (pathname === "/llms.txt") {
161
+ const result = await callRoute<{ body: string }>(context, "discovery/public/llms");
162
+ if (!result?.body) return undefined;
163
+ return respond(context, result.body, {
164
+ "Content-Type": "text/plain; charset=utf-8",
165
+ "Cache-Control": "public, max-age=3600",
166
+ "X-Content-Type-Options": "nosniff",
167
+ });
168
+ }
169
+
170
+ const pagePath = pagePathFromMarkdownPath(pathname);
171
+ if (pagePath) {
172
+ const doc = await callRoute<MarkdownDoc>(context, "discovery/public/markdown", { path: pagePath });
173
+ if (!doc?.body) return undefined;
174
+ return respond(context, doc.body, {
175
+ "Content-Type": "text/markdown; charset=utf-8",
176
+ "Cache-Control": "public, max-age=3600",
177
+ "X-Markdown-Tokens": String(doc.tokens),
178
+ "X-Content-Type-Options": "nosniff",
179
+ });
180
+ }
181
+
182
+ return undefined;
183
+ },
184
+ };
185
+
186
+ export const discoveryMiddleware: PackMiddleware[] = [indexNowKeyMiddleware, newsSitemapMiddleware, feedMiddleware, llmsMiddleware];