@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,313 @@
1
+ /**
2
+ * Rich text for the Custom Blocks' text fields. EmDash block fields are
3
+ * plain text inputs, so a field holds simple markup that's turned into safe
4
+ * HTML here:
5
+ *
6
+ * - A blank line starts a new paragraph; a single line break is a <br>.
7
+ * - `[link text](https://…)` is a link and `**text**` is bold.
8
+ * - A small set of HTML tags is kept (links, bold, italics, code, <abbr>,
9
+ * <q>, lists, paragraphs, headings, images, …), so content imported from
10
+ * WordPress keeps its exact markup. Their attributes are limited to safe ones
11
+ * (href, title, rel, cite, src, alt, …), URLs to http(s), mailto, tel and
12
+ * site-relative ones, and inline styles to text styling (color, font size,
13
+ * weight, …) with plain values.
14
+ * - Any other tag is dropped (its text stays; <script> and <style> bodies go),
15
+ * stray "<" and "&" are escaped, and tags left open are closed, so a field
16
+ * can't break the page around it.
17
+ *
18
+ * Pure, no imports: runs in the Worker, the admin and tests.
19
+ */
20
+
21
+ type Attrs = Record<string, string>;
22
+
23
+ interface TagRule {
24
+ /** Allowed attributes (others are dropped). */
25
+ attrs?: string[];
26
+ /** Block-level: ends the current paragraph, not allowed in inline fields. */
27
+ block?: boolean;
28
+ void?: boolean;
29
+ }
30
+
31
+ const RULES: Record<string, TagRule> = {
32
+ a: { attrs: ["href", "title", "rel", "hreflang"] },
33
+ abbr: { attrs: ["title"] },
34
+ b: {},
35
+ strong: {},
36
+ i: {},
37
+ em: {},
38
+ u: {},
39
+ s: {},
40
+ del: {},
41
+ ins: {},
42
+ mark: {},
43
+ small: {},
44
+ sub: {},
45
+ sup: {},
46
+ code: {},
47
+ kbd: {},
48
+ samp: {},
49
+ var: {},
50
+ cite: {},
51
+ dfn: { attrs: ["title"] },
52
+ q: { attrs: ["cite"] },
53
+ time: { attrs: ["datetime"] },
54
+ span: { attrs: ["lang", "dir"] },
55
+ br: { void: true },
56
+ wbr: { void: true },
57
+ p: { block: true },
58
+ ul: { block: true },
59
+ ol: { block: true, attrs: ["start", "reversed", "type"] },
60
+ li: { block: true, attrs: ["value"] },
61
+ dl: { block: true },
62
+ dt: { block: true },
63
+ dd: { block: true },
64
+ blockquote: { block: true, attrs: ["cite"] },
65
+ pre: { block: true },
66
+ figure: { block: true },
67
+ figcaption: { block: true },
68
+ h2: { block: true },
69
+ h3: { block: true },
70
+ h4: { block: true },
71
+ h5: { block: true },
72
+ h6: { block: true },
73
+ hr: { block: true, void: true },
74
+ img: { block: true, void: true, attrs: ["src", "alt", "width", "height", "loading", "decoding", "title"] },
75
+ };
76
+
77
+ const URL_ATTRS = new Set(["href", "src", "cite"]);
78
+ const REL_TOKENS = new Set(["nofollow", "sponsored", "ugc", "noopener", "noreferrer", "external", "me", "author", "license", "tag"]);
79
+ const DROP_BODY = new Set(["script", "style", "template", "noscript", "iframe", "object", "textarea", "title", "svg", "math"]);
80
+
81
+ const ESC: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
82
+ export const escapeHtml = (text: string) => String(text).replace(/[&<>"']/g, (c) => ESC[c] as string);
83
+
84
+ const NAMED: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
85
+
86
+ /** Decode the entities an attribute value may hold (enough to check a URL's scheme). */
87
+ function decodeAttr(value: string): string {
88
+ return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);?/gi, (whole, code: string) => {
89
+ if (code[0] === "#") {
90
+ const n = code[1] === "x" || code[1] === "X" ? Number.parseInt(code.slice(2), 16) : Number.parseInt(code.slice(1), 10);
91
+ return Number.isFinite(n) && n > 0 && n <= 0x10ffff && !(n >= 0xd800 && n <= 0xdfff) ? String.fromCodePoint(n) : "";
92
+ }
93
+ return NAMED[code.toLowerCase()] ?? whole;
94
+ });
95
+ }
96
+
97
+ /** A link or image URL that's safe to print: http(s), mailto, tel, or relative. Null otherwise. */
98
+ export function safeUrl(value: string): string | null {
99
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: browsers ignore these inside a scheme.
100
+ const url = decodeAttr(value).replace(/[\u0000- \u007f]+/g, (m, i: number) => (i === 0 ? "" : m)).trim();
101
+ if (!url || url.length > 2000) return null;
102
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: see above.
103
+ const scheme = url.replace(/[\u0000- \u007f]/g, "").match(/^([a-z][a-z0-9+.-]*):/i)?.[1]?.toLowerCase();
104
+ if (scheme && !["http", "https", "mailto", "tel"].includes(scheme)) return null;
105
+ return url;
106
+ }
107
+
108
+ /** Escape text, keeping valid entity references (&amp; &#8217; &nbsp; …) as written. */
109
+ function text(chunk: string): string {
110
+ return chunk.replace(/&(?!(?:#x[0-9a-f]{1,6}|#\d{1,7}|[a-z][a-z0-9]{1,31});)/gi, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
111
+ }
112
+
113
+ /** `[text](url)` links and `**bold**` inside an escaped text run. */
114
+ function markdownLite(escaped: string): string {
115
+ return escaped
116
+ .replace(/\[([^\]\n]{1,500})\]\(([^()\s]{1,2000})\)/g, (whole, label: string, href: string) => {
117
+ const url = safeUrl(href);
118
+ return url ? `<a href="${escapeHtml(url)}">${label}</a>` : whole;
119
+ })
120
+ .replace(/\*\*(?=\S)([^*\n]{1,1000}?)\*\*/g, "<strong>$1</strong>");
121
+ }
122
+
123
+ const TAG = /<!--[\s\S]*?(?:-->|$)|<(\/?)([a-zA-Z][a-zA-Z0-9]*)((?:\s+[^\s"'>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*)\s*(\/?)>/g;
124
+ const ATTR = /([^\s"'>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
125
+
126
+ const STYLE_PROPS = new Set(["color", "background-color", "font-size", "font-weight", "font-style", "font-variant", "text-decoration", "text-transform", "text-align", "letter-spacing", "white-space"]);
127
+
128
+ /** Inline styles WordPress content uses for emphasis (color, size, weight, …); anything else is dropped. */
129
+ export function cleanStyle(value: string): string {
130
+ return value
131
+ .split(";")
132
+ .map((decl) => {
133
+ const at = decl.indexOf(":");
134
+ if (at < 0) return "";
135
+ const prop = decl.slice(0, at).trim().toLowerCase();
136
+ const val = decl.slice(at + 1).trim();
137
+ if (!STYLE_PROPS.has(prop) || !val || val.length > 60) return "";
138
+ if (!/^[#a-z0-9.%,\s()-]+$/i.test(val) || /url|expression|image|var|attr|env/i.test(val)) return "";
139
+ return `${prop}:${val}`;
140
+ })
141
+ .filter(Boolean)
142
+ .join(";");
143
+ }
144
+
145
+ function cleanAttrs(name: string, raw: string): string {
146
+ const allowed = [...(RULES[name]?.attrs ?? []), ...(RULES[name]?.void ? [] : ["style"])];
147
+ if (!allowed.length || !raw.trim()) return "";
148
+ const out: Attrs = {};
149
+ for (const m of raw.matchAll(ATTR)) {
150
+ const key = (m[1] as string).toLowerCase();
151
+ if (!allowed.includes(key) || key in out) continue;
152
+ let value = decodeAttr(m[2] ?? m[3] ?? m[4] ?? "");
153
+ if (URL_ATTRS.has(key)) {
154
+ const url = safeUrl(value);
155
+ if (!url) continue;
156
+ value = url;
157
+ } else if (key === "rel") {
158
+ value = value
159
+ .toLowerCase()
160
+ .split(/\s+/)
161
+ .filter((t) => REL_TOKENS.has(t))
162
+ .join(" ");
163
+ if (!value) continue;
164
+ } else if (["width", "height", "start", "value"].includes(key)) {
165
+ if (!/^-?\d{1,6}$/.test(value.trim())) continue;
166
+ value = value.trim();
167
+ } else if (key === "loading" || key === "decoding") {
168
+ if (!["lazy", "eager", "async", "sync", "auto"].includes(value)) continue;
169
+ } else if (key === "reversed") {
170
+ value = "";
171
+ } else if (key === "style") {
172
+ value = cleanStyle(value);
173
+ if (!value) continue;
174
+ }
175
+ out[key] = value.slice(0, 2000);
176
+ }
177
+ if (name === "img" && !out.src) return "\u0000";
178
+ return Object.entries(out)
179
+ .map(([k, v]) => (k === "reversed" ? " reversed" : ` ${k}="${escapeHtml(v)}"`))
180
+ .join("");
181
+ }
182
+
183
+ export interface RichOptions {
184
+ /** One line of inline content (titles, citations): no paragraphs or block tags; line breaks become <br>. */
185
+ inline?: boolean;
186
+ }
187
+
188
+ /**
189
+ * Turn a field's text into safe HTML. Block content gets paragraphs (text that
190
+ * isn't already inside a <p>, list, … is wrapped); inline content doesn't.
191
+ */
192
+ export function renderRich(input: unknown, opts: RichOptions = {}): string {
193
+ if (typeof input !== "string" || !input.trim()) return "";
194
+ const source = input
195
+ .slice(0, 500_000)
196
+ .replace(/\r\n?/g, "\n")
197
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: strip control characters (keep tab and newline).
198
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "");
199
+ const inline = Boolean(opts.inline);
200
+ let out = "";
201
+ /** Open allowed elements, outermost first. */
202
+ const stack: string[] = [];
203
+ /** The paragraph being collected at the top level (block mode). */
204
+ let para = "";
205
+ let paraOpen: string[] = [];
206
+ let skipUntil: string | null = null;
207
+
208
+ const blockOpen = () => stack.some((t) => RULES[t]?.block);
209
+ const flush = () => {
210
+ // Close inline tags opened in this paragraph, then emit it.
211
+ while (paraOpen.length) para += `</${paraOpen.pop()}>`;
212
+ const body = para.replace(/^(?:\s|<br>)+|(?:\s|<br>)+$/g, "");
213
+ if (body) out += `<p>${body}</p>`;
214
+ para = "";
215
+ };
216
+ const emitText = (chunk: string) => {
217
+ if (!chunk) return;
218
+ if (inline) {
219
+ out += markdownLite(text(chunk)).replace(/\n+/g, "<br>");
220
+ return;
221
+ }
222
+ if (blockOpen()) {
223
+ out += markdownLite(text(chunk));
224
+ return;
225
+ }
226
+ const parts = chunk.split(/\n[ \t]*\n\s*/);
227
+ parts.forEach((part, i) => {
228
+ if (i > 0) flush();
229
+ para += markdownLite(text(part)).replace(/\n/g, "<br>");
230
+ });
231
+ };
232
+ const emitTag = (html: string, name: string, kind: "open" | "close" | "void") => {
233
+ const rule = RULES[name] as TagRule;
234
+ if (inline || blockOpen() || rule.block) {
235
+ if (!inline && rule.block && !blockOpen()) flush();
236
+ out += html;
237
+ if (kind === "open") stack.push(name);
238
+ return;
239
+ }
240
+ // An inline tag at the top level belongs to the current paragraph.
241
+ para += html;
242
+ if (kind === "open") paraOpen.push(name);
243
+ else if (kind === "close") paraOpen.splice(paraOpen.lastIndexOf(name), 1);
244
+ };
245
+
246
+ let last = 0;
247
+ TAG.lastIndex = 0;
248
+ for (let m = TAG.exec(source); m; m = TAG.exec(source)) {
249
+ const before = source.slice(last, m.index);
250
+ last = m.index + m[0].length;
251
+ if (skipUntil) {
252
+ if (m[1] && m[2]?.toLowerCase() === skipUntil) skipUntil = null;
253
+ continue;
254
+ }
255
+ emitText(before);
256
+ if (!m[2]) continue; // A comment.
257
+ const name = m[2].toLowerCase();
258
+ const closing = Boolean(m[1]);
259
+ if (!closing && DROP_BODY.has(name)) {
260
+ if (!m[4]) skipUntil = name;
261
+ continue;
262
+ }
263
+ const rule = RULES[name];
264
+ if (!rule || (inline && rule.block && name !== "br")) continue;
265
+ if (rule.void) {
266
+ if (closing) continue;
267
+ const attrs = cleanAttrs(name, m[3] ?? "");
268
+ if (attrs === "\u0000") continue;
269
+ emitTag(`<${name}${attrs}>`, name, "void");
270
+ continue;
271
+ }
272
+ if (!closing) {
273
+ const attrs = cleanAttrs(name, m[3] ?? "");
274
+ emitTag(`<${name}${attrs}>`, name, "open");
275
+ if (m[4]) emitTag(`</${name}>`, name, "close"); // <b/>: treat as empty.
276
+ continue;
277
+ }
278
+ // A closing tag: only when it closes something open; close what's inside it first.
279
+ if (!inline && !blockOpen() && paraOpen.includes(name)) {
280
+ while (paraOpen.length) {
281
+ const top = paraOpen[paraOpen.length - 1] as string;
282
+ emitTag(`</${top}>`, top, "close");
283
+ if (top === name) break;
284
+ }
285
+ continue;
286
+ }
287
+ const at = stack.lastIndexOf(name);
288
+ if (at < 0) continue;
289
+ while (stack.length > at) out += `</${stack.pop()}>`;
290
+ }
291
+ if (!skipUntil) emitText(source.slice(last));
292
+ if (!inline) {
293
+ while (stack.length) out += `</${stack.pop()}>`;
294
+ flush();
295
+ } else {
296
+ while (stack.length) out += `</${stack.pop()}>`;
297
+ }
298
+ return out.trim();
299
+ }
300
+
301
+ /** Plain text of a field (for labels, schema and checks): tags dropped, entities decoded, whitespace collapsed. */
302
+ export function plainText(input: unknown): string {
303
+ if (typeof input !== "string") return "";
304
+ return decodeAttr(
305
+ input
306
+ .replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, "")
307
+ .replace(/<[^>]*>/g, " ")
308
+ .replace(/\[([^\]\n]+)\]\([^()\s]+\)/g, "$1")
309
+ .replace(/\*\*([^*\n]+)\*\*/g, "$1"),
310
+ )
311
+ .replace(/\s+/g, " ")
312
+ .trim();
313
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Custom Blocks settings (the affiliate disclosure wording and the podcast
3
+ * links), read outside the plugin context (Astro renderer) from D1 in the
4
+ * feature switches' query, sharing their per-isolate cache.
5
+ *
6
+ * The disclosure wording keeps the storage key it had when the module was
7
+ * called Content Blocks ("contentBlocks"), so saved wording carries over.
8
+ */
9
+ import { invalidateFeatures, readSiteSetting, registerSiteSetting } from "../core/features.js";
10
+ import { DEFAULT_DISCLOSURE, DEFAULT_PODCAST, type DisclosureSettings, type PodcastSettings, normalizeDisclosureSettings, normalizePodcastSettings } from "./render.js";
11
+
12
+ /** Disclosure wording. Unchanged since 0.11.0 (Content Blocks): don't rename. */
13
+ export const SETTINGS_KEY = "contentBlocks";
14
+ /** Podcast links (0.12.0). */
15
+ export const PODCAST_SETTINGS_KEY = "customBlocksPodcast";
16
+
17
+ registerSiteSetting(SETTINGS_KEY);
18
+ registerSiteSetting(PODCAST_SETTINGS_KEY);
19
+
20
+ /** Per key: the last stored value and its normalized settings, so a cache hit doesn't normalize again. */
21
+ const memo = new Map<string, { raw: unknown; value: unknown }>();
22
+
23
+ export function invalidateCustomBlockSettings(): void {
24
+ invalidateFeatures();
25
+ }
26
+
27
+ async function readSetting<T>(key: string, normalize: (raw: unknown) => T, fallback: T, database: string): Promise<T> {
28
+ const read = await readSiteSetting(key, database);
29
+ if (!read) {
30
+ console.error("coywolf-pack: could not read the custom block settings");
31
+ return fallback;
32
+ }
33
+ const hit = memo.get(key);
34
+ if (hit && hit.raw === read.value) return hit.value as T;
35
+ const value = normalize(read.value);
36
+ memo.set(key, { raw: read.value, value });
37
+ return value;
38
+ }
39
+
40
+ export function siteDisclosureSettings(database = "DB"): Promise<DisclosureSettings> {
41
+ return readSetting(SETTINGS_KEY, normalizeDisclosureSettings, DEFAULT_DISCLOSURE, database);
42
+ }
43
+
44
+ export function sitePodcastSettings(database = "DB"): Promise<PodcastSettings> {
45
+ return readSetting(PODCAST_SETTINGS_KEY, normalizePodcastSettings, DEFAULT_PODCAST, database);
46
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Builds llms.txt, the news sitemap, and per-entry Markdown from EmDash
3
+ * content (inside the plugin context), with KV caching for the two lists.
4
+ */
5
+ import { siteName } from "../core/site.js";
6
+ import { type CollectionSchemaInfo, type PluginContentItem, type PluginContext, after, getSiteSetting } from "emdash";
7
+
8
+ import { absoluteUrl, matchEntryPath } from "../core/content-url.js";
9
+
10
+ import { buildLlmsTxt, type LlmsSection } from "./llms.js";
11
+ import { estimateTokens, frontmatter, markdownUrl, portableTextToMarkdown } from "./markdown.js";
12
+ import { NEWS_LIMIT, NEWS_WINDOW_MS, buildNewsSitemap, parseDate, selectNewsArticles, type NewsArticle } from "./news.js";
13
+ import { type DiscoverySettings, loadSettings, selected } from "./settings.js";
14
+ import { createUrlResolver, siteOrigin } from "./urls.js";
15
+
16
+ export const LLMS_CACHE = "cache:discovery:llms";
17
+ export const NEWS_CACHE = "cache:discovery:news";
18
+ /** The news sitemap's 48-hour window moves, so its cache is short. */
19
+ export const NEWS_TTL_MS = 5 * 60_000;
20
+ /** llms.txt is invalidated on content changes; this is only a safety net. */
21
+ export const LLMS_TTL_MS = 24 * 60 * 60_000;
22
+
23
+ export interface CachedDocument {
24
+ body: string;
25
+ count: number;
26
+ builtAt: string;
27
+ }
28
+
29
+ const str = (value: unknown) => (typeof value === "string" ? value : "");
30
+
31
+ export function entryTitle(collection: CollectionSchemaInfo, item: PluginContentItem): string {
32
+ return str(item.data[collection.titleField ?? "title"]) || str(item.data.title) || item.slug || item.id;
33
+ }
34
+
35
+ function entryNote(item: PluginContentItem): string {
36
+ return str(item.data.excerpt).trim() || str(item.seo?.description).trim();
37
+ }
38
+
39
+ /** Routable collections in the selection. Hidden collections are listed only when picked explicitly. */
40
+ export async function pickCollections(ctx: PluginContext, selection: string[]): Promise<CollectionSchemaInfo[]> {
41
+ const all = (await ctx.schema?.listCollections()) ?? [];
42
+ return all.filter((c) => c.routable && (selection.length ? selection.includes(c.slug) : !c.hidden));
43
+ }
44
+
45
+ /** Published entries of a collection, newest first, a page at a time. `stop` ends the walk early. */
46
+ async function* published(ctx: PluginContext, collection: string, max: number, stop?: (item: PluginContentItem) => boolean) {
47
+ if (!ctx.content || max <= 0) return;
48
+ let cursor: string | undefined;
49
+ let count = 0;
50
+ do {
51
+ const page = await ctx.content.list(collection, { limit: 100, cursor, orderBy: { publishedAt: "desc" }, where: { status: "published" } });
52
+ const batch: PluginContentItem[] = [];
53
+ let done = false;
54
+ for (const item of page.items) {
55
+ if (stop?.(item)) {
56
+ done = true;
57
+ break;
58
+ }
59
+ batch.push(item);
60
+ if (++count >= max) {
61
+ done = true;
62
+ break;
63
+ }
64
+ }
65
+ if (batch.length) yield batch;
66
+ if (done) return;
67
+ cursor = page.hasMore ? page.cursor : undefined;
68
+ } while (cursor);
69
+ }
70
+
71
+ // ── llms.txt ─────────────────────────────────────────────────────
72
+
73
+ export async function buildLlms(ctx: PluginContext, settings: DiscoverySettings): Promise<CachedDocument> {
74
+ const resolver = createUrlResolver(ctx);
75
+ const max = settings.llms.maxEntries;
76
+ const sections: LlmsSection[] = [];
77
+ let remaining = max;
78
+ for (const collection of await pickCollections(ctx, settings.llms.collections)) {
79
+ if (remaining <= 0) break;
80
+ const entries: LlmsSection["entries"] = [];
81
+ for await (const batch of published(ctx, collection.slug, remaining)) {
82
+ const listed = batch.filter((item) => !item.seo?.noIndex);
83
+ const urls = await resolver.urls(collection, listed);
84
+ for (const item of listed) {
85
+ const url = urls.get(item.id);
86
+ if (!url) continue;
87
+ entries.push({ title: entryTitle(collection, item), url: settings.llms.markdown ? markdownUrl(url) : url, note: entryNote(item) });
88
+ }
89
+ }
90
+ remaining -= entries.length;
91
+ if (entries.length) sections.push({ label: collection.label, entries });
92
+ }
93
+ const tagline = await getSiteSetting("tagline").catch(() => undefined);
94
+ const body = buildLlmsTxt({
95
+ name: await siteName(ctx),
96
+ siteUrl: siteOrigin(ctx),
97
+ summary: settings.llms.summary || tagline,
98
+ intro: settings.llms.intro,
99
+ markdownLinks: settings.llms.markdown,
100
+ sections,
101
+ maxEntries: max,
102
+ });
103
+ return { body, count: sections.reduce((n, s) => n + s.entries.length, 0), builtAt: new Date().toISOString() };
104
+ }
105
+
106
+ // ── News sitemap ─────────────────────────────────────────────────
107
+
108
+ export async function buildNews(ctx: PluginContext, settings: DiscoverySettings, now = Date.now()): Promise<CachedDocument> {
109
+ const resolver = createUrlResolver(ctx);
110
+ const articles: NewsArticle[] = [];
111
+ const tooOld = (item: PluginContentItem) => (parseDate(item.publishedAt)?.getTime() ?? 0) <= now - NEWS_WINDOW_MS;
112
+ // An empty news selection lists nothing (unlike llms.txt, where empty means all).
113
+ const collections = settings.news.collections.length ? await pickCollections(ctx, settings.news.collections) : [];
114
+ for (const collection of collections) {
115
+ for await (const batch of published(ctx, collection.slug, NEWS_LIMIT, tooOld)) {
116
+ const listed = batch.filter((item) => !item.seo?.noIndex && item.publishedAt);
117
+ const urls = await resolver.urls(collection, listed);
118
+ for (const item of listed) {
119
+ const url = urls.get(item.id);
120
+ if (url && item.publishedAt) articles.push({ url, title: entryTitle(collection, item), publishedAt: item.publishedAt });
121
+ }
122
+ }
123
+ }
124
+ const selectedArticles = selectNewsArticles(articles, now);
125
+ const body = buildNewsSitemap(selectedArticles, {
126
+ name: settings.news.publicationName || (await siteName(ctx)) || new URL(ctx.site.url).host,
127
+ language: settings.news.language || ctx.site.locale,
128
+ });
129
+ return { body, count: selectedArticles.length, builtAt: new Date(now).toISOString() };
130
+ }
131
+
132
+ // ── Caching (stale-while-revalidate) ─────────────────────────────
133
+ //
134
+ // Visitor requests only read the last good copy from plugin KV. Content
135
+ // hooks, settings saves and stale reads schedule a rebuild with after(),
136
+ // debounced per isolate, which overwrites the copy when it's done. The one
137
+ // exception is a cold start (nothing stored yet), which builds inline once.
138
+
139
+ export type DocKind = "llms" | "news";
140
+ const DOC_KEY: Record<DocKind, string> = { llms: LLMS_CACHE, news: NEWS_CACHE };
141
+ const DOC_TTL: Record<DocKind, number> = { llms: LLMS_TTL_MS, news: NEWS_TTL_MS };
142
+ /** Wait this long after a change for more changes before rebuilding. */
143
+ const REBUILD_DEBOUNCE_MS = 5000;
144
+
145
+ async function buildDoc(ctx: PluginContext, kind: DocKind): Promise<CachedDocument> {
146
+ const settings = await loadSettings(ctx);
147
+ const doc = kind === "llms" ? await buildLlms(ctx, settings) : await buildNews(ctx, settings);
148
+ await ctx.kv.set(DOC_KEY[kind], doc);
149
+ return doc;
150
+ }
151
+
152
+ const pendingRebuild = new Set<DocKind>();
153
+ let rebuildScheduled = false;
154
+ const coldBuilds = new Map<DocKind, Promise<CachedDocument>>();
155
+
156
+ /** Rebuild these documents in the background, after the response (debounced). */
157
+ export function scheduleRebuild(ctx: PluginContext, kinds: DocKind[], delayMs = REBUILD_DEBOUNCE_MS): void {
158
+ for (const kind of kinds) pendingRebuild.add(kind);
159
+ if (rebuildScheduled || !pendingRebuild.size) return;
160
+ rebuildScheduled = true;
161
+ after(async () => {
162
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
163
+ const kinds = [...pendingRebuild];
164
+ pendingRebuild.clear();
165
+ rebuildScheduled = false;
166
+ for (const kind of kinds) {
167
+ try {
168
+ await buildDoc(ctx, kind);
169
+ } catch (error) {
170
+ ctx.log.error(`Discovery: rebuilding ${kind} failed`, { error: String(error) });
171
+ }
172
+ }
173
+ });
174
+ }
175
+
176
+ /** The stored document; a stale one is served while a rebuild is scheduled. */
177
+ export async function readDoc(ctx: PluginContext, kind: DocKind): Promise<CachedDocument> {
178
+ const hit = await ctx.kv.get<CachedDocument>(DOC_KEY[kind]);
179
+ if (hit?.body) {
180
+ if (Date.now() - Date.parse(hit.builtAt) >= DOC_TTL[kind]) scheduleRebuild(ctx, [kind], 0);
181
+ return hit;
182
+ }
183
+ let cold = coldBuilds.get(kind);
184
+ if (!cold) {
185
+ cold = buildDoc(ctx, kind).finally(() => coldBuilds.delete(kind));
186
+ coldBuilds.set(kind, cold);
187
+ }
188
+ return cold;
189
+ }
190
+
191
+ /** Rebuild now (admin "Rebuild now"). */
192
+ export async function rebuildNow(ctx: PluginContext, kind: DocKind): Promise<CachedDocument> {
193
+ return buildDoc(ctx, kind);
194
+ }
195
+
196
+ // ── Per-entry Markdown ───────────────────────────────────────────
197
+
198
+ export interface MarkdownDocument {
199
+ body: string;
200
+ tokens: number;
201
+ pageUrl: string;
202
+ markdownUrl: string;
203
+ }
204
+
205
+ /**
206
+ * Find the published entry a site path belongs to: the pack-wide reverse
207
+ * resolver (core/content-url.ts matchEntryPath), which knows the site's
208
+ * `urls` overrides and otherwise uses EmDash's own routing, and only
209
+ * answers when the entry's canonical URL is this path.
210
+ */
211
+ async function resolveEntry(ctx: PluginContext, pagePath: string): Promise<{ collection: CollectionSchemaInfo; id: string; url: string } | null> {
212
+ const match = await matchEntryPath(ctx, pagePath);
213
+ if (!match) return null;
214
+ const collection = await ctx.schema?.getCollection(match.collection);
215
+ if (!collection) return null;
216
+ return { collection, id: match.id, url: absoluteUrl(match.path, siteOrigin(ctx)) };
217
+ }
218
+
219
+ export async function buildMarkdown(ctx: PluginContext, settings: DiscoverySettings, pagePath: string): Promise<MarkdownDocument | null> {
220
+ if (!settings.llms.markdown) return null;
221
+ const resolved = await resolveEntry(ctx, pagePath);
222
+ if (!resolved || !selected(settings.llms.collections, resolved.collection.slug)) return null;
223
+ if (!settings.llms.collections.length && resolved.collection.hidden) return null;
224
+ const item = await ctx.content?.get(resolved.collection.slug, resolved.id);
225
+ if (!item || item.status !== "published" || item.seo?.noIndex) return null;
226
+
227
+ const origin = siteOrigin(ctx);
228
+ const absolute = (href: string) => {
229
+ if (href.startsWith("#") || /^[a-z][a-z0-9+.-]*:/i.test(href)) return href;
230
+ try {
231
+ return new URL(href, `${origin}/`).href;
232
+ } catch {
233
+ return href;
234
+ }
235
+ };
236
+ const body = resolved.collection.fields
237
+ .filter((f) => f.type === "portableText")
238
+ .sort((a, b) => a.sortOrder - b.sortOrder)
239
+ .map((f) => portableTextToMarkdown(item.data[f.slug], { absolute }))
240
+ .filter(Boolean)
241
+ .join("\n\n");
242
+
243
+ const markdown = `${frontmatter({
244
+ title: entryTitle(resolved.collection, item),
245
+ url: resolved.url,
246
+ published: item.publishedAt ? (parseDate(item.publishedAt)?.toISOString() ?? undefined) : undefined,
247
+ updated: parseDate(item.updatedAt)?.toISOString() ?? undefined,
248
+ sources: [resolved.url],
249
+ license: settings.llms.license || undefined,
250
+ })}\n${body}\n`;
251
+ return { body: markdown, tokens: estimateTokens(markdown), pageUrl: resolved.url, markdownUrl: markdownUrl(resolved.url) };
252
+ }