@coywolf/emdash 0.0.0-stage → 0.35.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -2
  3. package/docs/README.md +45 -0
  4. package/docs/ai.md +39 -0
  5. package/docs/backups.md +88 -0
  6. package/docs/breadcrumbs.md +59 -0
  7. package/docs/code-blocks.md +21 -0
  8. package/docs/content-urls.md +41 -0
  9. package/docs/custom-blocks.md +27 -0
  10. package/docs/discovery.md +32 -0
  11. package/docs/files.md +44 -0
  12. package/docs/form-uploads.md +59 -0
  13. package/docs/headings.md +37 -0
  14. package/docs/images.md +141 -0
  15. package/docs/internals.md +13 -0
  16. package/docs/links.md +34 -0
  17. package/docs/performance.md +72 -0
  18. package/docs/redirects.md +84 -0
  19. package/docs/releasing.md +21 -0
  20. package/docs/reviews.md +89 -0
  21. package/docs/robots.md +81 -0
  22. package/docs/schema.md +79 -0
  23. package/docs/search.md +114 -0
  24. package/docs/videos.md +48 -0
  25. package/docs/wordpress-import.md +198 -0
  26. package/package.json +79 -5
  27. package/src/admin/admin-css.ts +57 -0
  28. package/src/admin/ai.tsx +915 -0
  29. package/src/admin/backups.tsx +613 -0
  30. package/src/admin/breadcrumbs.tsx +542 -0
  31. package/src/admin/codeBlocks.tsx +204 -0
  32. package/src/admin/customBlocks.tsx +321 -0
  33. package/src/admin/dirty.ts +20 -0
  34. package/src/admin/discovery.tsx +463 -0
  35. package/src/admin/download.ts +28 -0
  36. package/src/admin/feature-switches.tsx +124 -0
  37. package/src/admin/features.tsx +142 -0
  38. package/src/admin/files.tsx +791 -0
  39. package/src/admin/formUploads.tsx +445 -0
  40. package/src/admin/guides.tsx +18 -0
  41. package/src/admin/headings.tsx +262 -0
  42. package/src/admin/images.tsx +825 -0
  43. package/src/admin/index.tsx +62 -0
  44. package/src/admin/links.tsx +980 -0
  45. package/src/admin/performance.tsx +330 -0
  46. package/src/admin/preview.tsx +63 -0
  47. package/src/admin/redirects-removed.tsx +266 -0
  48. package/src/admin/redirects.tsx +470 -0
  49. package/src/admin/reviews.tsx +232 -0
  50. package/src/admin/robots-bots.tsx +647 -0
  51. package/src/admin/robots-shared.tsx +109 -0
  52. package/src/admin/robots-wizard.tsx +748 -0
  53. package/src/admin/robots.tsx +911 -0
  54. package/src/admin/save-bar.tsx +134 -0
  55. package/src/admin/schema.tsx +1187 -0
  56. package/src/admin/search.tsx +355 -0
  57. package/src/admin/settings-ui.tsx +87 -0
  58. package/src/admin/trash-prompt-core.ts +135 -0
  59. package/src/admin/trash-prompt.tsx +156 -0
  60. package/src/admin/usage.tsx +301 -0
  61. package/src/admin/videos-display.tsx +248 -0
  62. package/src/admin/videos.tsx +1202 -0
  63. package/src/admin/wpImport.tsx +1516 -0
  64. package/src/ai/entities.ts +59 -0
  65. package/src/ai/export.ts +61 -0
  66. package/src/ai/logic.ts +606 -0
  67. package/src/ai/pack.ts +121 -0
  68. package/src/ai/prompts.ts +87 -0
  69. package/src/ai/providers.ts +276 -0
  70. package/src/ai/routes.ts +359 -0
  71. package/src/ai/store.ts +301 -0
  72. package/src/ai/wikidata.ts +87 -0
  73. package/src/ai/worker.ts +530 -0
  74. package/src/astro/astro-modules.d.ts +6 -0
  75. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  76. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  77. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  78. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  79. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  80. package/src/astro/customBlocks/Details.astro +8 -0
  81. package/src/astro/customBlocks/Disclosure.astro +8 -0
  82. package/src/astro/customBlocks/Note.astro +8 -0
  83. package/src/astro/customBlocks/Podcast.astro +8 -0
  84. package/src/astro/customBlocks/Quote.astro +8 -0
  85. package/src/astro/customBlocks/Testimonial.astro +8 -0
  86. package/src/astro/files/FileCard.astro +103 -0
  87. package/src/astro/headings/HeadingBlock.astro +129 -0
  88. package/src/astro/headings/TableOfContents.astro +45 -0
  89. package/src/astro/headings/request.ts +19 -0
  90. package/src/astro/images/ResponsiveImage.astro +60 -0
  91. package/src/astro/index.ts +84 -0
  92. package/src/astro/reviews/CoywolfReview.astro +27 -0
  93. package/src/astro/reviews/Review.astro +49 -0
  94. package/src/astro/search/SearchBox.astro +61 -0
  95. package/src/astro/search/SearchBoxForm.astro +509 -0
  96. package/src/astro/tables/CaptionedTable.astro +72 -0
  97. package/src/astro/videos/CoywolfVideo.astro +448 -0
  98. package/src/backups/download.ts +112 -0
  99. package/src/backups/dump.ts +288 -0
  100. package/src/backups/module.ts +276 -0
  101. package/src/backups/pack.ts +33 -0
  102. package/src/backups/restore.ts +209 -0
  103. package/src/backups/store.ts +409 -0
  104. package/src/breadcrumbs/pack.ts +113 -0
  105. package/src/breadcrumbs/settings.ts +102 -0
  106. package/src/breadcrumbs/stamp.ts +40 -0
  107. package/src/breadcrumbs/trail.ts +123 -0
  108. package/src/codeBlocks/contrast.ts +179 -0
  109. package/src/codeBlocks/hljs.d.ts +5 -0
  110. package/src/codeBlocks/pack.ts +110 -0
  111. package/src/codeBlocks/render.ts +304 -0
  112. package/src/codeBlocks/settings.ts +18 -0
  113. package/src/codeBlocks/themes.generated.ts +304 -0
  114. package/src/codeBlocks/themes.ts +114 -0
  115. package/src/core/compose.ts +244 -0
  116. package/src/core/content-url.ts +888 -0
  117. package/src/core/csv.ts +119 -0
  118. package/src/core/d1-batch.ts +65 -0
  119. package/src/core/entry-docs.ts +175 -0
  120. package/src/core/features-module.ts +48 -0
  121. package/src/core/features.ts +392 -0
  122. package/src/core/generation.ts +88 -0
  123. package/src/core/guide-content.ts +166 -0
  124. package/src/core/module.ts +104 -0
  125. package/src/core/read-failures.ts +57 -0
  126. package/src/core/request-memo.ts +71 -0
  127. package/src/core/secrets.ts +83 -0
  128. package/src/core/settings-enhance.ts +74 -0
  129. package/src/core/site.ts +36 -0
  130. package/src/core/storage.ts +27 -0
  131. package/src/customBlocks/pack.ts +260 -0
  132. package/src/customBlocks/render.ts +424 -0
  133. package/src/customBlocks/rich.ts +313 -0
  134. package/src/customBlocks/settings.ts +46 -0
  135. package/src/discovery/build.ts +252 -0
  136. package/src/discovery/feed-build.ts +217 -0
  137. package/src/discovery/feed.ts +172 -0
  138. package/src/discovery/indexnow.ts +114 -0
  139. package/src/discovery/llms.ts +79 -0
  140. package/src/discovery/markdown.ts +294 -0
  141. package/src/discovery/memory-cache.ts +97 -0
  142. package/src/discovery/middleware.ts +186 -0
  143. package/src/discovery/module.ts +353 -0
  144. package/src/discovery/news.ts +99 -0
  145. package/src/discovery/pack.ts +58 -0
  146. package/src/discovery/settings.ts +91 -0
  147. package/src/discovery/urls.ts +35 -0
  148. package/src/env.d.ts +10 -0
  149. package/src/files/card.ts +231 -0
  150. package/src/files/counts.ts +60 -0
  151. package/src/files/export.ts +44 -0
  152. package/src/files/format.ts +163 -0
  153. package/src/files/module.ts +596 -0
  154. package/src/files/pack.ts +92 -0
  155. package/src/files/r2-rules.ts +41 -0
  156. package/src/files/range.ts +50 -0
  157. package/src/files/s3.ts +114 -0
  158. package/src/files/serve.ts +141 -0
  159. package/src/files/sigv4.ts +160 -0
  160. package/src/files/site.ts +137 -0
  161. package/src/files/walker.ts +58 -0
  162. package/src/formUploads/lib.ts +266 -0
  163. package/src/formUploads/pack.ts +55 -0
  164. package/src/formUploads/wrap.ts +592 -0
  165. package/src/forms.ts +16 -0
  166. package/src/headings/pack.ts +154 -0
  167. package/src/headings/render.ts +139 -0
  168. package/src/headings/settings.ts +117 -0
  169. package/src/headings/slug.ts +68 -0
  170. package/src/headings/stamp.ts +142 -0
  171. package/src/headings/test-hooks.mjs +14 -0
  172. package/src/headings/toc.ts +48 -0
  173. package/src/images/cloudflare.ts +329 -0
  174. package/src/images/lib.ts +221 -0
  175. package/src/images/module.ts +372 -0
  176. package/src/images/pack.ts +804 -0
  177. package/src/images/pending.ts +37 -0
  178. package/src/images/settings.ts +48 -0
  179. package/src/images/setup.ts +192 -0
  180. package/src/images/variants-estimate.ts +162 -0
  181. package/src/images/variants-store.ts +475 -0
  182. package/src/images/variants.ts +311 -0
  183. package/src/index.ts +143 -0
  184. package/src/links/check.ts +101 -0
  185. package/src/links/classify.ts +270 -0
  186. package/src/links/export.ts +59 -0
  187. package/src/links/module.ts +652 -0
  188. package/src/links/pack.ts +43 -0
  189. package/src/links/pt.ts +325 -0
  190. package/src/links/store.ts +704 -0
  191. package/src/middleware.ts +287 -0
  192. package/src/modules.ts +53 -0
  193. package/src/options.ts +66 -0
  194. package/src/pageCache/lib.ts +274 -0
  195. package/src/pageCache/pack.ts +240 -0
  196. package/src/pageCache/usage-core.ts +152 -0
  197. package/src/pageCache/usage.ts +262 -0
  198. package/src/pageCache/warm.ts +722 -0
  199. package/src/redirects/middleware.ts +169 -0
  200. package/src/redirects/module.ts +124 -0
  201. package/src/redirects/pack.ts +89 -0
  202. package/src/redirects/removed-core.ts +151 -0
  203. package/src/redirects/removed.ts +293 -0
  204. package/src/redirects/rules.ts +355 -0
  205. package/src/redirects/slug-changes.ts +207 -0
  206. package/src/redirects/slug-core.ts +49 -0
  207. package/src/redirects/transfer.ts +92 -0
  208. package/src/reviews/lib.ts +624 -0
  209. package/src/reviews/pack.ts +135 -0
  210. package/src/reviews/schema.ts +128 -0
  211. package/src/reviews/settings.ts +26 -0
  212. package/src/robots/bots.ts +321 -0
  213. package/src/robots/data/bots.json +696 -0
  214. package/src/robots/data/verified.json +582 -0
  215. package/src/robots/directory.ts +26 -0
  216. package/src/robots/explain.ts +191 -0
  217. package/src/robots/guided.ts +300 -0
  218. package/src/robots/importer.ts +263 -0
  219. package/src/robots/middleware.ts +151 -0
  220. package/src/robots/module.ts +446 -0
  221. package/src/robots/pack.ts +69 -0
  222. package/src/robots/radar.ts +129 -0
  223. package/src/robots/rep.ts +362 -0
  224. package/src/robots/rules.ts +883 -0
  225. package/src/robots/transfer.ts +45 -0
  226. package/src/robots/validate.ts +524 -0
  227. package/src/schema/catalog.ts +146 -0
  228. package/src/schema/graph.ts +791 -0
  229. package/src/schema/module.ts +1138 -0
  230. package/src/schema/og-image.ts +100 -0
  231. package/src/schema/pack.ts +58 -0
  232. package/src/search/engine.ts +578 -0
  233. package/src/search/fallback.ts +80 -0
  234. package/src/search/live-cache.ts +116 -0
  235. package/src/search/live-client.ts +770 -0
  236. package/src/search/live-serve.ts +282 -0
  237. package/src/search/module.ts +261 -0
  238. package/src/search/pack.ts +57 -0
  239. package/src/search/query.ts +132 -0
  240. package/src/search/ratelimit-core.ts +118 -0
  241. package/src/search/ratelimit.ts +79 -0
  242. package/src/search/snippet.ts +135 -0
  243. package/src/search/title-index.ts +139 -0
  244. package/src/search/title-match.ts +192 -0
  245. package/src/shared.ts +71 -0
  246. package/src/version.ts +2 -0
  247. package/src/videos/date.ts +40 -0
  248. package/src/videos/engagement.ts +75 -0
  249. package/src/videos/lib.ts +679 -0
  250. package/src/videos/module.ts +1160 -0
  251. package/src/videos/pack.ts +187 -0
  252. package/src/videos/pause.ts +42 -0
  253. package/src/videos/poster.ts +324 -0
  254. package/src/videos/render.ts +336 -0
  255. package/src/videos/store.ts +551 -0
  256. package/src/videos/stream.ts +134 -0
  257. package/src/wpImport/convert.ts +702 -0
  258. package/src/wpImport/guests.ts +329 -0
  259. package/src/wpImport/gutenberg.ts +157 -0
  260. package/src/wpImport/markers.ts +66 -0
  261. package/src/wpImport/pack.ts +451 -0
  262. package/src/wpImport/parents.ts +202 -0
  263. package/src/wpImport/prepare.ts +592 -0
  264. package/src/wpImport/redirects.ts +358 -0
  265. package/src/wpImport/sources.ts +67 -0
  266. package/src/wpImport/stream.ts +160 -0
  267. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,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,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,163 @@
1
+ /**
2
+ * Pure helpers shared by the download card, the download middleware and the
3
+ * admin page: file-type badges, sizes, safe names, and Content-Disposition.
4
+ * No imports, so `node --test` can load this file directly.
5
+ */
6
+
7
+ /** Badge color per extension (the palette Coywolf Files uses in WordPress). */
8
+ const ICON_COLORS: Record<string, string> = {};
9
+ const FAMILIES: Array<[string, string[]]> = [
10
+ ["#B42318", ["pdf"]],
11
+ ["#155EEF", ["doc", "docx", "rtf", "odt", "pages"]],
12
+ ["#475467", ["txt", "md"]],
13
+ ["#067647", ["xls", "xlsx", "csv", "ods", "numbers"]],
14
+ ["#C4320A", ["ppt", "pptx", "key", "odp"]],
15
+ ["#C11574", ["png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "heic", "bmp", "tif", "tiff"]],
16
+ ["#93264A", ["mp4", "mov", "webm", "mkv", "avi", "m4v"]],
17
+ ["#026AA2", ["mp3", "wav", "m4a", "aac", "ogg", "flac"]],
18
+ ["#6941E0", ["zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "xz", "dmg", "iso"]],
19
+ ["#B54708", ["json", "js", "jsx", "ts", "tsx", "html", "css", "xml", "yml", "yaml"]],
20
+ ];
21
+ for (const [color, exts] of FAMILIES) for (const ext of exts) ICON_COLORS[ext] = color;
22
+
23
+ export const DEFAULT_ICON_COLOR = "#667085";
24
+
25
+ /** Lowercase extension of a file name, without the dot ("" when there is none). */
26
+ export function extensionOf(name: string): string {
27
+ const match = /\.([a-z0-9]+)$/i.exec(String(name ?? "").trim());
28
+ return match ? match[1].toLowerCase() : "";
29
+ }
30
+
31
+ /** Badge color and label (at most four characters) for an extension. */
32
+ export function iconFor(ext: string): { color: string; label: string } {
33
+ const clean = String(ext ?? "")
34
+ .toLowerCase()
35
+ .replace(/[^a-z0-9]/g, "");
36
+ return { color: ICON_COLORS[clean] ?? DEFAULT_ICON_COLOR, label: clean ? clean.toUpperCase().slice(0, 4) : "FILE" };
37
+ }
38
+
39
+ /** 1024-based size with one decimal, e.g. "2.4 MB". */
40
+ export function formatSize(bytes: number): string {
41
+ const n = Math.max(0, Math.floor(Number(bytes) || 0));
42
+ if (n < 1024) return `${n} B`;
43
+ const units = ["KB", "MB", "GB", "TB", "PB"];
44
+ let value = n / 1024;
45
+ let i = 0;
46
+ while (value >= 1024 && i < units.length - 1) {
47
+ value /= 1024;
48
+ i++;
49
+ }
50
+ return `${value.toFixed(1)} ${units[i]}`;
51
+ }
52
+
53
+ /** "Mar 4, 2026" (UTC, so server and build output agree). */
54
+ export function formatDate(iso: string): string {
55
+ const date = new Date(iso);
56
+ if (Number.isNaN(date.getTime())) return "";
57
+ return new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" }).format(date);
58
+ }
59
+
60
+ /**
61
+ * A file name that is safe in an object key and a URL path segment: ASCII
62
+ * letters, digits, dot, dash and underscore. Keeps the extension.
63
+ */
64
+ export function safeFilename(name: string): string {
65
+ const base = String(name ?? "")
66
+ .split(/[\\/]/)
67
+ .pop()!
68
+ .normalize("NFKD")
69
+ .replace(/[\u0300-\u036f]/g, "")
70
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
71
+ .replace(/-{2,}/g, "-")
72
+ .replace(/-+\./g, ".")
73
+ .replace(/^[-.]+|[-.]+$/g, "");
74
+ return (base || "file").slice(0, 120);
75
+ }
76
+
77
+ /** RFC 3986 percent-encoding as RFC 5987 requires (attr-char only). */
78
+ function encodeRfc5987(value: string): string {
79
+ return encodeURIComponent(value).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
80
+ }
81
+
82
+ /**
83
+ * Content-Disposition for a download: an ASCII `filename` fallback plus a
84
+ * UTF-8 `filename*` (RFC 6266 / RFC 5987) when the name isn't plain ASCII.
85
+ */
86
+ export function contentDisposition(filename: string, type: "attachment" | "inline" = "attachment"): string {
87
+ const name = String(filename ?? "")
88
+ .replace(/[\r\n\t\0]/g, " ")
89
+ .split(/[\\/]/)
90
+ .pop()!
91
+ .trim() || "download";
92
+ const fallback = name
93
+ .normalize("NFKD")
94
+ .replace(/[\u0300-\u036f]/g, "")
95
+ .replace(/[^\x20-\x7e]/g, "_")
96
+ .replace(/["\\]/g, "_")
97
+ .replace(/%/g, "_");
98
+ if (fallback === name) return `${type}; filename="${fallback}"`;
99
+ return `${type}; filename="${fallback}"; filename*=UTF-8''${encodeRfc5987(name)}`;
100
+ }
101
+
102
+ /** Download URL path: /<base>/<id>/<filename>. */
103
+ export function downloadPath(base: string, id: string, filename: string): string {
104
+ return `/${base}/${encodeURIComponent(id)}/${encodeURIComponent(safeFilename(filename))}`;
105
+ }
106
+
107
+ /** A valid base slug: one lowercase path segment. */
108
+ export function normalizeBase(value: unknown): string {
109
+ const slug = String(value ?? "")
110
+ .trim()
111
+ .replace(/^\/+|\/+$/g, "")
112
+ .toLowerCase();
113
+ return /^[a-z0-9][a-z0-9_-]{0,63}$/.test(slug) && !slug.startsWith("_") ? slug : "download";
114
+ }
115
+
116
+ /** A #rgb / #rrggbb accent color, or "" when the value isn't one. */
117
+ export function safeColor(value: unknown): string {
118
+ const color = String(value ?? "").trim();
119
+ return /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(color) ? color : "";
120
+ }
121
+
122
+ /** Large-upload ids: "f" + 15 lowercase base-32 characters. Media library ids are ULIDs (26 uppercase). */
123
+ export const UPLOAD_ID = /^f[a-z2-7]{15}$/;
124
+ export const MEDIA_ID = /^[0-9A-HJKMNP-TV-Z]{26}$/;
125
+ /**
126
+ * Files moved from Coywolf Files for WordPress keep their WordPress id (20
127
+ * lowercase hex characters), so old download links and imported blocks still
128
+ * resolve. They're stored like large uploads.
129
+ */
130
+ export const WORDPRESS_FILE_ID = /^[0-9a-f]{20}$/;
131
+
132
+ /** Ids stored as large uploads (plugin storage) rather than media library items. */
133
+ export function isUploadId(id: string): boolean {
134
+ return UPLOAD_ID.test(id) || WORDPRESS_FILE_ID.test(id);
135
+ }
136
+
137
+ export function isFileId(id: string): boolean {
138
+ return isUploadId(id) || MEDIA_ID.test(id);
139
+ }
140
+
141
+ export function newUploadId(): string {
142
+ const alphabet = "abcdefghijklmnopqrstuvwxyz234567";
143
+ const bytes = crypto.getRandomValues(new Uint8Array(15));
144
+ let id = "f";
145
+ for (const b of bytes) id += alphabet[b & 31];
146
+ return id;
147
+ }
148
+
149
+ /** Parse /<base>/<id>[/<filename>] (null when it isn't a download URL). */
150
+ export function parseDownloadPath(pathname: string, base: string): { id: string; filename: string | null } | null {
151
+ const prefix = `/${base}/`;
152
+ if (!pathname.startsWith(prefix)) return null;
153
+ const rest = pathname.slice(prefix.length).split("/");
154
+ if (rest.length > 2 || !rest[0]) return null;
155
+ let id: string;
156
+ try {
157
+ id = decodeURIComponent(rest[0]);
158
+ } catch {
159
+ return null;
160
+ }
161
+ if (!isFileId(id)) return null;
162
+ return { id, filename: rest[1] ? rest[1] : null };
163
+ }