@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,231 @@
1
+ /**
2
+ * Download card markup and CSS, shared by the coywolf-file block
3
+ * (src/astro/files/FileCard.astro) and the live preview on the Files
4
+ * settings tab, so the preview is exactly what the site prints. No imports
5
+ * beyond format.ts, so `node --test` can load it.
6
+ */
7
+ import { extensionOf, formatDate, formatSize, iconFor, safeColor } from "./format.js";
8
+
9
+ export interface FileCardData {
10
+ title: string;
11
+ description: string;
12
+ /** File name, for the type badge. */
13
+ name: string;
14
+ size: number;
15
+ uploadedAt: string;
16
+ /** Download link (site-relative). */
17
+ href: string;
18
+ /** Absolute download URL for Copy link. */
19
+ absolute: string;
20
+ }
21
+
22
+ export interface FileCardOptions {
23
+ scheme: "auto" | "light" | "dark";
24
+ /** Hex accent, or "" for the default. */
25
+ accent: string;
26
+ showIcon?: boolean;
27
+ showDescription?: boolean;
28
+ showMeta?: boolean;
29
+ showDownload?: boolean;
30
+ showCopyLink?: boolean;
31
+ }
32
+
33
+ const escapeHtml = (text: string) =>
34
+ String(text).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] as string);
35
+
36
+ const ICON_DOWNLOAD =
37
+ '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" x2="12" y1="15" y2="3" /></svg>';
38
+ const ICON_LINK =
39
+ '<svg class="cw-file__ic-link" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></svg>';
40
+ const ICON_CHECK =
41
+ '<svg class="cw-file__ic-check" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20 6 9 17l-5-5" /></svg>';
42
+
43
+ /** "PDF · 2.4 MB · Uploaded Mar 4, 2026". */
44
+ export function fileMeta(name: string, size: number, uploadedAt: string): string {
45
+ const ext = extensionOf(name);
46
+ const uploaded = formatDate(uploadedAt);
47
+ return [ext.toUpperCase(), size > 0 ? formatSize(size) : "", uploaded ? `Uploaded ${uploaded}` : ""].filter(Boolean).join(" · ");
48
+ }
49
+
50
+ /** The card's HTML. Options left undefined count as on, like the block's toggles. */
51
+ export function renderFileCardHtml(card: FileCardData, o: FileCardOptions): string {
52
+ const on = (value: boolean | undefined) => value !== false;
53
+ const icon = iconFor(extensionOf(card.name));
54
+ const title = escapeHtml(card.title);
55
+ const meta = fileMeta(card.name, card.size, card.uploadedAt);
56
+ const scheme = ["auto", "light", "dark"].includes(o.scheme) ? o.scheme : "auto";
57
+ const accent = safeColor(o.accent);
58
+ const parts: string[] = [];
59
+ if (on(o.showIcon))
60
+ parts.push(
61
+ `<div class="cw-file__icon"><svg width="40" height="48" viewBox="0 0 40 48" fill="none" aria-hidden="true" focusable="false"><path d="M10 3 h14 l10 10 v28 a4 4 0 0 1 -4 4 H10 a4 4 0 0 1 -4 -4 V7 a4 4 0 0 1 4 -4 z" fill="var(--cw-file-page)" stroke="var(--cw-file-stroke)" stroke-width="1.5" /><path d="M24 3 L24 13 L34 13" fill="var(--cw-file-fold)" stroke="var(--cw-file-stroke)" stroke-width="1.5" stroke-linejoin="round" /><rect x="2" y="26" width="27" height="15" rx="3.5" fill="${escapeHtml(icon.color)}" /><text x="15.5" y="36.5" text-anchor="middle" font-size="8.5" font-weight="700" fill="#fff" style="letter-spacing:0.02em">${escapeHtml(icon.label)}</text></svg></div>`,
62
+ );
63
+ parts.push(
64
+ `<div class="cw-file__body"><div class="cw-file__name">${title}</div>${
65
+ on(o.showDescription) && card.description ? `<div class="cw-file__desc">${escapeHtml(card.description)}</div>` : ""
66
+ }${on(o.showMeta) && meta ? `<div class="cw-file__meta">${escapeHtml(meta)}</div>` : ""}</div>`,
67
+ );
68
+ const actions = [
69
+ on(o.showDownload)
70
+ ? `<a class="cw-file__btn cw-file__download" href="${escapeHtml(card.href)}" download aria-label="Download ${title}" title="Download">${ICON_DOWNLOAD}</a>`
71
+ : "",
72
+ on(o.showCopyLink)
73
+ ? `<button type="button" class="cw-file__btn cw-file__copy" data-cw-file-url="${escapeHtml(card.absolute)}" aria-label="Copy download link for ${title}" title="Copy link">${ICON_LINK}${ICON_CHECK}</button>`
74
+ : "",
75
+ ].join("");
76
+ parts.push(`<div class="cw-file__actions">${actions}</div>`);
77
+ parts.push('<span class="cw-file__sr" role="status" aria-live="polite"></span>');
78
+ return `<div class="cw-file cw-file--${scheme}"${accent ? ` style="--cw-file-accent:${accent}"` : ""}>${parts.join("")}</div>`;
79
+ }
80
+
81
+ /** Sample file for the admin preview. */
82
+ export const SAMPLE_FILE: FileCardData = {
83
+ title: "Annual report 2026.pdf",
84
+ description: "Our year in numbers, with highlights from every team.",
85
+ name: "annual-report-2026.pdf",
86
+ size: 2_516_582,
87
+ uploadedAt: "2026-03-04T12:00:00Z",
88
+ href: "#",
89
+ absolute: "https://example.com/download/sample/annual-report-2026.pdf",
90
+ };
91
+
92
+ export const FILE_CSS = `.cw-file {
93
+ --cw-file-bg: #fff;
94
+ --cw-file-border: #e6e6e6;
95
+ --cw-file-text: #1e2a3b;
96
+ --cw-file-muted: #5b6472;
97
+ --cw-file-hover: rgba(0, 0, 0, 0.06);
98
+ --cw-file-accent: #007392;
99
+ --cw-file-page: #fff;
100
+ --cw-file-stroke: #d0d5dd;
101
+ --cw-file-fold: #eaecf0;
102
+ position: relative;
103
+ display: flex;
104
+ align-items: center;
105
+ gap: 1rem;
106
+ margin: 0 0 1.25em;
107
+ padding: 12px 14px;
108
+ border: 1px solid var(--cw-file-border);
109
+ border-radius: 12px;
110
+ background: var(--cw-file-bg);
111
+ color: var(--cw-file-text);
112
+ }
113
+ .cw-file--dark {
114
+ --cw-file-bg: #1b1b1c;
115
+ --cw-file-border: #343437;
116
+ --cw-file-text: #d5d8dc;
117
+ --cw-file-muted: #9ea3a8;
118
+ --cw-file-hover: rgba(255, 255, 255, 0.08);
119
+ --cw-file-accent: #21a1c4;
120
+ --cw-file-page: #e7eaee;
121
+ --cw-file-stroke: #aab1ba;
122
+ --cw-file-fold: #d0d5dd;
123
+ }
124
+ @media (prefers-color-scheme: dark) {
125
+ .cw-file--auto {
126
+ --cw-file-bg: #1b1b1c;
127
+ --cw-file-border: #343437;
128
+ --cw-file-text: #d5d8dc;
129
+ --cw-file-muted: #9ea3a8;
130
+ --cw-file-hover: rgba(255, 255, 255, 0.08);
131
+ --cw-file-accent: #21a1c4;
132
+ --cw-file-page: #e7eaee;
133
+ --cw-file-stroke: #aab1ba;
134
+ --cw-file-fold: #d0d5dd;
135
+ }
136
+ }
137
+ .cw-file__icon {
138
+ flex-shrink: 0;
139
+ line-height: 0;
140
+ }
141
+ .cw-file__icon svg {
142
+ display: block;
143
+ width: 40px;
144
+ height: 48px;
145
+ }
146
+ .cw-file__body {
147
+ flex: 1 1 auto;
148
+ min-width: 0;
149
+ }
150
+ .cw-file__name {
151
+ font-weight: 500;
152
+ line-height: 1.35;
153
+ overflow: hidden;
154
+ text-overflow: ellipsis;
155
+ white-space: nowrap;
156
+ }
157
+ .cw-file__desc,
158
+ .cw-file__meta {
159
+ margin-top: 2px;
160
+ font-size: 0.875em;
161
+ line-height: 1.4;
162
+ color: var(--cw-file-muted);
163
+ }
164
+ .cw-file__meta {
165
+ font-variant-numeric: tabular-nums;
166
+ overflow: hidden;
167
+ text-overflow: ellipsis;
168
+ white-space: nowrap;
169
+ }
170
+ .cw-file__actions {
171
+ flex-shrink: 0;
172
+ display: flex;
173
+ align-items: center;
174
+ gap: 4px;
175
+ }
176
+ .cw-file__btn {
177
+ display: inline-flex;
178
+ align-items: center;
179
+ justify-content: center;
180
+ width: 36px;
181
+ height: 36px;
182
+ margin: 0;
183
+ padding: 0;
184
+ border: 0;
185
+ border-radius: 50%;
186
+ background: transparent;
187
+ color: var(--cw-file-muted);
188
+ cursor: pointer;
189
+ text-decoration: none;
190
+ box-shadow: none;
191
+ appearance: none;
192
+ transition: background 0.15s ease, color 0.15s ease;
193
+ }
194
+ .cw-file__btn:hover {
195
+ background: var(--cw-file-hover);
196
+ color: var(--cw-file-text);
197
+ }
198
+ .cw-file__btn:focus-visible {
199
+ outline: 2px solid var(--cw-file-accent);
200
+ outline-offset: 2px;
201
+ }
202
+ .cw-file__download,
203
+ .cw-file__copy.is-copied {
204
+ color: var(--cw-file-accent);
205
+ }
206
+ .cw-file__btn svg {
207
+ display: block;
208
+ }
209
+ .cw-file__copy .cw-file__ic-check,
210
+ .cw-file__copy.is-copied .cw-file__ic-link {
211
+ display: none;
212
+ }
213
+ .cw-file__copy.is-copied .cw-file__ic-check {
214
+ display: block;
215
+ }
216
+ .cw-file__sr {
217
+ position: absolute;
218
+ width: 1px;
219
+ height: 1px;
220
+ margin: -1px;
221
+ padding: 0;
222
+ overflow: hidden;
223
+ clip: rect(0, 0, 0, 0);
224
+ white-space: nowrap;
225
+ border: 0;
226
+ }
227
+ @media (prefers-reduced-motion: reduce) {
228
+ .cw-file__btn {
229
+ transition: none;
230
+ }
231
+ }`;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Run: node --test src/files/*.test.ts
3
+ */
4
+ import assert from "node:assert/strict";
5
+ import { test } from "node:test";
6
+
7
+ // @ts-expect-error -- Node's type stripping needs the real .ts extension.
8
+ import { createDownloadCounter } from "./counts.ts";
9
+ // @ts-expect-error -- Node's type stripping needs the real .ts extension.
10
+ import { ifRangeMatches, parseRange, resolveRange } from "./range.ts";
11
+
12
+ /** A D1 stand-in that records the (id, n) of each upsert, with a controllable write delay. */
13
+ function stubDb(writeMs = 0) {
14
+ const writes: Array<Array<[string, number]>> = [];
15
+ return {
16
+ writes,
17
+ prepare: () => ({ bind: (...v: unknown[]) => [v[2] as string, v[3] as number] }),
18
+ batch: async (statements: unknown[]) => {
19
+ await new Promise((r) => setTimeout(r, writeMs));
20
+ writes.push(statements as Array<[string, number]>);
21
+ },
22
+ };
23
+ }
24
+
25
+ const total = (writes: Array<Array<[string, number]>>) => {
26
+ const out: Record<string, number> = {};
27
+ for (const batch of writes) for (const [id, n] of batch) out[id] = (out[id] ?? 0) + n;
28
+ return out;
29
+ };
30
+
31
+ test("counts in a burst are written in one batch", async () => {
32
+ const counter = createDownloadCounter("p", "c", 5);
33
+ const db = stubDb();
34
+ const waits: Promise<unknown>[] = [];
35
+ for (let i = 0; i < 3; i++) counter.count(db, "a", (p: Promise<unknown>) => waits.push(p));
36
+ counter.count(db, "b", (p: Promise<unknown>) => waits.push(p));
37
+ await Promise.all(waits);
38
+ assert.equal(db.writes.length, 1);
39
+ assert.deepEqual(total(db.writes), { a: 3, b: 1 });
40
+ assert.ok(counter.idle);
41
+ });
42
+
43
+ test("a count made during a write is not lost", async () => {
44
+ const counter = createDownloadCounter("p", "c", 5);
45
+ const db = stubDb(20);
46
+ const waits: Promise<unknown>[] = [];
47
+ const waitUntil = (p: Promise<unknown>) => waits.push(p);
48
+ counter.count(db, "a", waitUntil);
49
+ await new Promise((r) => setTimeout(r, 12)); // First batch is now being written.
50
+ counter.count(db, "a", waitUntil);
51
+ await Promise.all(waits);
52
+ assert.deepEqual(total(db.writes), { a: 2 });
53
+ assert.equal(counter.pendingSize, 0);
54
+ // After the flush finished, a new count starts a new flush.
55
+ counter.count(db, "z", waitUntil);
56
+ assert.ok(!counter.idle);
57
+ await Promise.all(waits);
58
+ assert.deepEqual(total(db.writes), { a: 2, z: 1 });
59
+ assert.equal(counter.pendingSize, 0);
60
+ });
61
+
62
+ test("a failed write doesn't stop later counts", async () => {
63
+ const counter = createDownloadCounter("p", "c", 1);
64
+ let fail = true;
65
+ const db = stubDb();
66
+ const batch = db.batch;
67
+ db.batch = async (s: unknown[]) => {
68
+ if (fail) {
69
+ fail = false;
70
+ throw new Error("D1 down");
71
+ }
72
+ return batch(s);
73
+ };
74
+ const waits: Promise<unknown>[] = [];
75
+ const orig = console.error;
76
+ console.error = () => undefined;
77
+ counter.count(db, "a", (p: Promise<unknown>) => waits.push(p));
78
+ await Promise.all(waits);
79
+ console.error = orig;
80
+ assert.ok(counter.idle);
81
+ counter.count(db, "b", (p: Promise<unknown>) => waits.push(p));
82
+ await Promise.all(waits);
83
+ assert.deepEqual(total(db.writes), { b: 1 });
84
+ });
85
+
86
+ test("Range parsing: single ranges only", () => {
87
+ assert.deepEqual(parseRange("bytes=0-99"), { offset: 0, end: 99 });
88
+ assert.deepEqual(parseRange("bytes=100-"), { offset: 100 });
89
+ assert.deepEqual(parseRange("bytes=-500"), { suffix: 500 });
90
+ assert.equal(parseRange("bytes=0-99,200-299"), null); // Multi-range → whole file (200).
91
+ assert.equal(parseRange("bytes=5-1"), null);
92
+ assert.equal(parseRange("bytes=-0"), null);
93
+ assert.equal(parseRange("items=0-1"), null);
94
+ assert.equal(parseRange(null), null);
95
+ });
96
+
97
+ test("Range resolution clamps to the file", () => {
98
+ assert.deepEqual(resolveRange({ suffix: 5000 }, 1000), { offset: 0, length: 1000 });
99
+ assert.deepEqual(resolveRange({ suffix: 10 }, 1000), { offset: 990, length: 10 });
100
+ assert.deepEqual(resolveRange({ offset: 900, end: 5000 }, 1000), { offset: 900, length: 100 });
101
+ assert.deepEqual(resolveRange({ offset: 10 }, 1000), { offset: 10, length: 990 });
102
+ assert.equal(resolveRange({ offset: 1000 }, 1000), null);
103
+ assert.equal(resolveRange({ suffix: 1 }, 0), null);
104
+ });
105
+
106
+ test("If-Range", () => {
107
+ const modified = new Date("2026-10-01T12:00:00Z");
108
+ assert.ok(ifRangeMatches(null, '"abc"', modified));
109
+ assert.ok(ifRangeMatches('"abc"', '"abc"', modified));
110
+ assert.ok(!ifRangeMatches('"old"', '"abc"', modified));
111
+ assert.ok(!ifRangeMatches('W/"abc"', '"abc"', modified));
112
+ assert.ok(ifRangeMatches("Thu, 01 Oct 2026 12:00:00 GMT", '"abc"', modified));
113
+ assert.ok(!ifRangeMatches("Wed, 30 Sep 2026 12:00:00 GMT", '"abc"', modified));
114
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Batched download counts. Counts made in one isolate within `delayMs` are
3
+ * written together in one D1 batch after the response. The flush loop drains
4
+ * `pending` until it's empty and clears `flushing` in the same synchronous
5
+ * step, so a count either lands in a running flush or starts a new one; none
6
+ * is left behind on a finished promise. No imports, so `node --test` can
7
+ * load this file directly.
8
+ */
9
+
10
+ /** The slice of D1 the counter uses (stubbed in tests). */
11
+ export interface CountDb {
12
+ prepare(sql: string): { bind(...values: unknown[]): unknown };
13
+ batch(statements: unknown[]): Promise<unknown>;
14
+ }
15
+
16
+ const UPSERT = `INSERT INTO _plugin_storage (plugin_id, collection, id, data, revision, created_at, updated_at)
17
+ VALUES (?1, ?2, ?3, json_object('downloads', ?4, 'lastDownload', ?5), ?6, ?5, ?5)
18
+ ON CONFLICT (plugin_id, collection, id) DO UPDATE SET
19
+ data = json_set(data, '$.downloads', COALESCE(json_extract(data, '$.downloads'), 0) + ?4, '$.lastDownload', ?5),
20
+ revision = ?6, updated_at = ?5`;
21
+
22
+ export function createDownloadCounter(pluginId: string, collection: string, delayMs = 1_000) {
23
+ const pending = new Map<string, number>();
24
+ let flushing: Promise<void> | null = null;
25
+
26
+ async function write(db: CountDb): Promise<void> {
27
+ const batch = [...pending.entries()];
28
+ pending.clear();
29
+ const now = new Date().toISOString();
30
+ await db.batch(batch.map(([id, n]) => db.prepare(UPSERT).bind(pluginId, collection, id, n, now, crypto.randomUUID())));
31
+ }
32
+
33
+ async function drain(db: CountDb): Promise<void> {
34
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
35
+ while (pending.size) {
36
+ try {
37
+ await write(db);
38
+ } catch (error) {
39
+ console.error("coywolf-pack files: could not record downloads", error);
40
+ }
41
+ }
42
+ flushing = null; // Same tick as the empty check above.
43
+ }
44
+
45
+ return {
46
+ /** Count one download of `id`; `waitUntil` keeps the shared flush alive past this response. */
47
+ count(db: CountDb, id: string, waitUntil: (p: Promise<unknown>) => void): void {
48
+ pending.set(id, (pending.get(id) ?? 0) + 1);
49
+ flushing ??= drain(db);
50
+ waitUntil(flushing);
51
+ },
52
+ /** For tests. */
53
+ get pendingSize() {
54
+ return pending.size;
55
+ },
56
+ get idle() {
57
+ return flushing === null;
58
+ },
59
+ };
60
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Files CSV export: an inventory of download files with their links, sizes,
3
+ * download counts and where each is used. Pure, so the admin page and the
4
+ * tests share it.
5
+ */
6
+ import { type CsvCell, toCsv } from "../core/csv.js";
7
+
8
+ export interface ExportFile {
9
+ id: string;
10
+ source: string;
11
+ status: string;
12
+ name: string;
13
+ type: string;
14
+ size: number;
15
+ uploadedAt: string | null;
16
+ url: string | null;
17
+ downloads: number;
18
+ lastDownload: string | null;
19
+ usedIn: Array<{ collection: string; id: string; title: string }>;
20
+ }
21
+
22
+ export const FILE_CSV_COLUMNS = ["name", "download_url", "type", "size_bytes", "source", "status", "uploaded_at", "downloads", "last_download", "entries", "used_in", "id"] as const;
23
+
24
+ /** Spreadsheet-safe CSV. `origin` turns site-relative download links into full URLs. */
25
+ export function filesToCsv(files: ExportFile[], origin = ""): string {
26
+ const rows: CsvCell[][] = [[...FILE_CSV_COLUMNS]];
27
+ for (const f of files) {
28
+ rows.push([
29
+ f.name,
30
+ f.url ? `${origin}${f.url}` : "",
31
+ f.type,
32
+ f.size,
33
+ f.source === "upload" ? "large upload" : f.source === "media" ? "media library" : "missing",
34
+ f.status,
35
+ f.uploadedAt ?? "",
36
+ f.downloads,
37
+ f.lastDownload ?? "",
38
+ f.usedIn.length,
39
+ f.usedIn.map((u) => `${u.title || u.id} (${u.collection}/${u.id})`).join(" | "),
40
+ f.id,
41
+ ]);
42
+ }
43
+ return toCsv(rows, { guard: true });
44
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Run: node --test src/files/*.test.ts
3
+ */
4
+ import assert from "node:assert/strict";
5
+ import { test } from "node:test";
6
+
7
+ // @ts-expect-error -- Node's type stripping needs the real .ts extension.
8
+ import * as f from "./format.ts";
9
+ // @ts-expect-error -- Node's type stripping needs the real .ts extension.
10
+ import { entryTitle, fileIdsIn, findFileBlocks } from "./walker.ts";
11
+
12
+ test("Content-Disposition: plain ASCII names", () => {
13
+ assert.equal(f.contentDisposition("report.pdf"), 'attachment; filename="report.pdf"');
14
+ assert.equal(f.contentDisposition("Q3 report (final).pdf"), 'attachment; filename="Q3 report (final).pdf"');
15
+ });
16
+
17
+ test("Content-Disposition: RFC 5987 for non-ASCII and unsafe characters", () => {
18
+ assert.equal(f.contentDisposition("résumé.pdf"), `attachment; filename="resume.pdf"; filename*=UTF-8''r%C3%A9sum%C3%A9.pdf`);
19
+ assert.equal(f.contentDisposition("日本語.txt"), `attachment; filename="___.txt"; filename*=UTF-8''%E6%97%A5%E6%9C%AC%E8%AA%9E.txt`);
20
+ assert.equal(f.contentDisposition('say "hi".txt'), `attachment; filename="say _hi_.txt"; filename*=UTF-8''say%20%22hi%22.txt`);
21
+ assert.equal(f.contentDisposition("it's (1)*.txt"), `attachment; filename="it's (1)*.txt"`);
22
+ assert.equal(f.contentDisposition("50%.txt"), `attachment; filename="50_.txt"; filename*=UTF-8''50%25.txt`);
23
+ assert.equal(f.contentDisposition("a'bé.txt"), `attachment; filename="a'be.txt"; filename*=UTF-8''a%27b%C3%A9.txt`);
24
+ });
25
+
26
+ test("Content-Disposition: header injection and paths", () => {
27
+ assert.equal(f.contentDisposition("evil\r\nSet-Cookie: x.pdf"), `attachment; filename="evil Set-Cookie: x.pdf"`);
28
+ assert.equal(f.contentDisposition("../../etc/passwd"), `attachment; filename="passwd"`);
29
+ assert.equal(f.contentDisposition(""), `attachment; filename="download"`);
30
+ });
31
+
32
+ test("safe file names and download paths", () => {
33
+ assert.equal(f.safeFilename("My Résumé (2026).PDF"), "My-Resume-2026.PDF");
34
+ assert.equal(f.safeFilename("../x/y z.tar.gz"), "y-z.tar.gz");
35
+ assert.equal(f.safeFilename("???"), "file");
36
+ assert.equal(f.downloadPath("download", "fabcdefghijklmno", "a b.pdf"), "/download/fabcdefghijklmno/a-b.pdf");
37
+ assert.equal(f.normalizeBase("/Files/"), "files");
38
+ assert.equal(f.normalizeBase("_emdash"), "download");
39
+ assert.equal(f.normalizeBase("a/b"), "download");
40
+ });
41
+
42
+ test("type badge mapping", () => {
43
+ assert.deepEqual(f.iconFor("pdf"), { color: "#B42318", label: "PDF" });
44
+ assert.deepEqual(f.iconFor("DOCX"), { color: "#155EEF", label: "DOCX" });
45
+ assert.deepEqual(f.iconFor("xlsx"), { color: "#067647", label: "XLSX" });
46
+ assert.deepEqual(f.iconFor("jpeg"), { color: "#C11574", label: "JPEG" });
47
+ assert.deepEqual(f.iconFor("7z"), { color: "#6941E0", label: "7Z" });
48
+ assert.deepEqual(f.iconFor("numbers"), { color: "#067647", label: "NUMB" });
49
+ assert.deepEqual(f.iconFor("weird"), { color: f.DEFAULT_ICON_COLOR, label: "WEIR" });
50
+ assert.deepEqual(f.iconFor(""), { color: f.DEFAULT_ICON_COLOR, label: "FILE" });
51
+ assert.equal(f.extensionOf("archive.TAR.GZ"), "gz");
52
+ assert.equal(f.extensionOf("README"), "");
53
+ });
54
+
55
+ test("sizes, colors and ids", () => {
56
+ assert.equal(f.formatSize(0), "0 B");
57
+ assert.equal(f.formatSize(1023), "1023 B");
58
+ assert.equal(f.formatSize(1536), "1.5 KB");
59
+ assert.equal(f.formatSize(5 * 1024 ** 3), "5.0 GB");
60
+ assert.equal(f.safeColor("#0a7"), "#0a7");
61
+ assert.equal(f.safeColor("red;}body{"), "");
62
+ const id = f.newUploadId();
63
+ assert.match(id, f.UPLOAD_ID);
64
+ assert.ok(f.isFileId("01J9ZQ3W4X5Y6Z7A8B9C0D1E2F"));
65
+ assert.ok(!f.isFileId("../etc"));
66
+ });
67
+
68
+ test("Portable Text walker finds nested coywolf-file blocks", () => {
69
+ const content = {
70
+ id: "c1",
71
+ slug: "hello",
72
+ data: {
73
+ title: "Hello",
74
+ body: [
75
+ { _type: "block", children: [{ _type: "span", text: "x" }] },
76
+ { _type: "coywolf-file", _key: "a", id: "fabcdefghijklmno", title: "Brochure" },
77
+ { _type: "columns", columns: [{ content: [{ _type: "coywolf-file", id: "01J9ZQ3W4X5Y6Z7A8B9C0D1E2F" }] }] },
78
+ { _type: "coywolf-file", id: "" },
79
+ { _type: "image", id: "01J9ZQ3W4X5Y6Z7A8B9C0D1E2G" },
80
+ ],
81
+ sidebar: [{ _type: "coywolf-file", id: "fabcdefghijklmno" }],
82
+ },
83
+ };
84
+ assert.deepEqual(findFileBlocks(content), [
85
+ { id: "fabcdefghijklmno", field: "body" },
86
+ { id: "01J9ZQ3W4X5Y6Z7A8B9C0D1E2F", field: "body" },
87
+ { id: "fabcdefghijklmno", field: "sidebar" },
88
+ ]);
89
+ assert.deepEqual(fileIdsIn(content), ["fabcdefghijklmno", "01J9ZQ3W4X5Y6Z7A8B9C0D1E2F"]);
90
+ assert.equal(entryTitle(content), "Hello");
91
+ assert.equal(entryTitle({ id: "x", slug: "s", data: {} }), "s");
92
+ assert.deepEqual(fileIdsIn(null), []);
93
+ });