@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,509 @@
1
+ ---
2
+ import type { Props as SearchBoxProps } from "./SearchBox.astro";
3
+
4
+ /**
5
+ * The SearchBox form, styles and script. Rendered only by SearchBox.astro
6
+ * when the "search.box" feature is on, so nothing here ships while it's off:
7
+ * the style is inline and the script is rendered with the component.
8
+ */
9
+
10
+ export type Props = Omit<SearchBoxProps, "database"> & {
11
+ /** Live results ("search.live"). Without them it's a plain search form. */
12
+ live?: boolean;
13
+ };
14
+
15
+ const {
16
+ action = "/search",
17
+ name = "q",
18
+ label = "Search",
19
+ showLabel = false,
20
+ placeholder = "Search",
21
+ collections,
22
+ locale = Astro.currentLocale ?? null,
23
+ minChars = 2,
24
+ debounce = 200,
25
+ limit = 8,
26
+ showType = true,
27
+ showSnippets = true,
28
+ submitButton = true,
29
+ value,
30
+ class: className,
31
+ id = "cw-search",
32
+ live = true,
33
+ } = Astro.props;
34
+
35
+ const inputId = `${id}-input`;
36
+ ---
37
+
38
+ <form
39
+ class:list={["cw-search", className]}
40
+ role="search"
41
+ action={action}
42
+ method="get"
43
+ data-cw-search
44
+ data-live={live ? "1" : undefined}
45
+ data-endpoint="/_emdash/api/plugins/coywolf-pack/search/live"
46
+ data-min-chars={minChars}
47
+ data-debounce={debounce}
48
+ data-limit={limit}
49
+ data-collections={collections?.join(",")}
50
+ data-locale={locale ?? undefined}
51
+ data-show-type={showType ? "1" : undefined}
52
+ data-show-snippets={showSnippets ? "1" : undefined}
53
+ >
54
+ <label for={inputId} class:list={["cw-search__label", { "cw-search__visually-hidden": !showLabel }]}>
55
+ {label}
56
+ </label>
57
+ <div class="cw-search__field">
58
+ <input
59
+ id={inputId}
60
+ class="cw-search__input"
61
+ type="search"
62
+ name={name}
63
+ value={value}
64
+ placeholder={placeholder}
65
+ autocomplete="off"
66
+ autocapitalize="off"
67
+ spellcheck="false"
68
+ enterkeyhint="search"
69
+ />
70
+ <button type="button" class="cw-search__clear" aria-label="Clear search" hidden>
71
+ <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">
72
+ <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" fill="none" />
73
+ </svg>
74
+ </button>
75
+ {submitButton && (
76
+ <button type="submit" class="cw-search__submit" aria-label="Search">
77
+ <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">
78
+ <circle cx="7" cy="7" r="4.75" stroke="currentColor" stroke-width="1.75" fill="none" />
79
+ <path d="M10.5 10.5L14 14" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" />
80
+ </svg>
81
+ </button>
82
+ )}
83
+ </div>
84
+ <ul id={`${id}-list`} class="cw-search__list" role="listbox" aria-label="Suggestions" hidden />
85
+ <div class="cw-search__visually-hidden" role="status" aria-live="polite" aria-atomic="true" />
86
+ </form>
87
+
88
+ <style is:inline>
89
+ .cw-search {
90
+ --cw-search-bg: #fff;
91
+ --cw-search-fg: #1a1a1a;
92
+ --cw-search-muted: #5c5c5c;
93
+ --cw-search-border: #d4d4d4;
94
+ --cw-search-active-bg: #f1f1f1;
95
+ --cw-search-active-border: currentColor;
96
+ --cw-search-active-fg: inherit;
97
+ --cw-search-mark: rgb(255 214 0 / 0.35);
98
+ --cw-search-radius: 0.5rem;
99
+ --cw-search-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
100
+ position: relative;
101
+ max-width: 100%;
102
+ }
103
+ @media (prefers-color-scheme: dark) {
104
+ .cw-search {
105
+ --cw-search-bg: #1e1e1e;
106
+ --cw-search-fg: #f2f2f2;
107
+ --cw-search-muted: #b0b0b0;
108
+ --cw-search-border: #3d3d3d;
109
+ --cw-search-active-bg: rgb(255 255 255 / 0.08);
110
+ --cw-search-mark: rgb(255 214 0 / 0.25);
111
+ --cw-search-shadow: 0 8px 24px rgb(0 0 0 / 0.5);
112
+ }
113
+ }
114
+ .cw-search__visually-hidden {
115
+ position: absolute !important;
116
+ width: 1px;
117
+ height: 1px;
118
+ margin: -1px;
119
+ padding: 0;
120
+ overflow: hidden;
121
+ clip: rect(0 0 0 0);
122
+ white-space: nowrap;
123
+ border: 0;
124
+ }
125
+ .cw-search__field {
126
+ display: flex;
127
+ align-items: center;
128
+ gap: 0.25rem;
129
+ border: 1px solid var(--cw-search-border);
130
+ border-radius: var(--cw-search-radius);
131
+ background: var(--cw-search-bg);
132
+ color: var(--cw-search-fg);
133
+ padding-inline: 0.5rem 0.25rem;
134
+ }
135
+ .cw-search__field:focus-within {
136
+ outline: 2px solid var(--cw-search-active-border);
137
+ outline-offset: 1px;
138
+ }
139
+ .cw-search__input {
140
+ flex: 1;
141
+ min-width: 0;
142
+ border: 0;
143
+ background: transparent;
144
+ color: inherit;
145
+ font: inherit;
146
+ padding: 0.5rem 0.25rem;
147
+ outline: none;
148
+ }
149
+ .cw-search__input::-webkit-search-cancel-button {
150
+ appearance: none;
151
+ }
152
+ .cw-search__clear,
153
+ .cw-search__submit {
154
+ display: inline-grid;
155
+ place-items: center;
156
+ width: 2rem;
157
+ height: 2rem;
158
+ border: 0;
159
+ border-radius: calc(var(--cw-search-radius) - 2px);
160
+ background: transparent;
161
+ color: var(--cw-search-muted);
162
+ cursor: pointer;
163
+ }
164
+ .cw-search__clear[hidden] {
165
+ display: none;
166
+ }
167
+ .cw-search__clear:hover,
168
+ .cw-search__submit:hover {
169
+ color: var(--cw-search-fg);
170
+ background: var(--cw-search-active-bg);
171
+ }
172
+ .cw-search__list {
173
+ position: absolute;
174
+ z-index: 50;
175
+ inset-inline: 0;
176
+ top: calc(100% + 0.25rem);
177
+ min-width: min(20rem, 100vw - 2rem);
178
+ max-height: min(26rem, 70vh);
179
+ overflow-y: auto;
180
+ margin: 0;
181
+ padding: 0.25rem;
182
+ list-style: none;
183
+ background: var(--cw-search-bg);
184
+ color: var(--cw-search-fg);
185
+ border: 1px solid var(--cw-search-border);
186
+ border-radius: var(--cw-search-radius);
187
+ box-shadow: var(--cw-search-shadow);
188
+ opacity: 0;
189
+ }
190
+ .cw-search__list[hidden] {
191
+ display: none;
192
+ }
193
+ .cw-search__list.is-open {
194
+ opacity: 1;
195
+ }
196
+ @media (prefers-reduced-motion: no-preference) {
197
+ .cw-search__list {
198
+ transition: opacity 120ms ease-out;
199
+ }
200
+ }
201
+ .cw-search__option {
202
+ border-inline-start: 3px solid transparent;
203
+ border-radius: calc(var(--cw-search-radius) - 2px);
204
+ }
205
+ .cw-search__link {
206
+ display: block;
207
+ padding: 0.5rem 0.625rem;
208
+ color: inherit;
209
+ text-decoration: none;
210
+ }
211
+ .cw-search__option[aria-selected="true"],
212
+ .cw-search__option:hover {
213
+ background: var(--cw-search-active-bg);
214
+ border-inline-start-color: var(--cw-search-active-border);
215
+ color: var(--cw-search-active-fg);
216
+ }
217
+ .cw-search__title {
218
+ display: flex;
219
+ align-items: baseline;
220
+ justify-content: space-between;
221
+ gap: 0.75rem;
222
+ font-weight: 600;
223
+ }
224
+ .cw-search__title mark {
225
+ background: none;
226
+ color: inherit;
227
+ text-decoration: underline;
228
+ text-underline-offset: 0.15em;
229
+ }
230
+ .cw-search__type {
231
+ flex: none;
232
+ font-size: 0.75em;
233
+ font-weight: 400;
234
+ color: var(--cw-search-muted);
235
+ }
236
+ .cw-search__snippet {
237
+ margin-top: 0.125rem;
238
+ font-size: 0.875em;
239
+ color: var(--cw-search-muted);
240
+ overflow: hidden;
241
+ display: -webkit-box;
242
+ -webkit-line-clamp: 2;
243
+ -webkit-box-orient: vertical;
244
+ }
245
+ .cw-search__snippet mark {
246
+ background: var(--cw-search-mark);
247
+ color: inherit;
248
+ border-radius: 2px;
249
+ }
250
+ .cw-search__note,
251
+ .cw-search__empty {
252
+ padding: 0.5rem 0.625rem;
253
+ font-size: 0.875em;
254
+ color: var(--cw-search-muted);
255
+ }
256
+ @media (forced-colors: active) {
257
+ .cw-search__option[aria-selected="true"] {
258
+ outline: 2px solid Highlight;
259
+ forced-color-adjust: none;
260
+ background: Highlight;
261
+ color: HighlightText;
262
+ }
263
+ }
264
+ </style>
265
+
266
+ <script>
267
+ const MARK = /<(?!\/?mark>)/g;
268
+
269
+ interface Item {
270
+ title?: string;
271
+ snippet?: string;
272
+ url: string;
273
+ type: string;
274
+ }
275
+
276
+ function terms(query: string): string[] {
277
+ return [...new Set(query.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean))];
278
+ }
279
+
280
+ /** The title as text nodes, with the visitor's words (as word prefixes) wrapped in <mark>. */
281
+ function highlight(target: HTMLElement, text: string, words: string[]) {
282
+ if (!words.length) {
283
+ target.textContent = text;
284
+ return;
285
+ }
286
+ const escaped = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
287
+ const pattern = new RegExp(`(^|[^\\p{L}\\p{N}])(${escaped})`, "giu");
288
+ let last = 0;
289
+ for (const m of text.matchAll(pattern)) {
290
+ const start = (m.index ?? 0) + m[1].length;
291
+ target.append(text.slice(last, start));
292
+ const mark = document.createElement("mark");
293
+ mark.textContent = m[2];
294
+ target.append(mark);
295
+ last = start + m[2].length;
296
+ }
297
+ target.append(text.slice(last));
298
+ }
299
+
300
+ function enhance(form: HTMLFormElement) {
301
+ const found = {
302
+ input: form.querySelector<HTMLInputElement>(".cw-search__input"),
303
+ list: form.querySelector<HTMLUListElement>(".cw-search__list"),
304
+ status: form.querySelector<HTMLElement>("[role=status]"),
305
+ clear: form.querySelector<HTMLButtonElement>(".cw-search__clear"),
306
+ };
307
+ if (!found.input || !found.list || !found.status || !found.clear) return;
308
+ const input: HTMLInputElement = found.input;
309
+ const list: HTMLUListElement = found.list;
310
+ const status: HTMLElement = found.status;
311
+ const clear: HTMLButtonElement = found.clear;
312
+
313
+ const d = form.dataset;
314
+ const minChars = Number(d.minChars) || 2;
315
+ const wait = Number(d.debounce) || 200;
316
+ const cache = new Map<string, { items: Item[]; fallback: boolean }>();
317
+ let timer: ReturnType<typeof setTimeout> | undefined;
318
+ let controller: AbortController | undefined;
319
+ let options: HTMLElement[] = [];
320
+ let active = -1;
321
+
322
+ // Without live results ("search.live" off) only the clear button is wired up.
323
+ const live = d.live === "1";
324
+ if (live) {
325
+ input.setAttribute("role", "combobox");
326
+ input.setAttribute("aria-autocomplete", "list");
327
+ input.setAttribute("aria-controls", list.id);
328
+ input.setAttribute("aria-expanded", "false");
329
+ }
330
+
331
+ const isOpen = () => !list.hidden;
332
+
333
+ function close() {
334
+ list.hidden = true;
335
+ list.classList.remove("is-open");
336
+ if (live) input.setAttribute("aria-expanded", "false");
337
+ input.removeAttribute("aria-activedescendant");
338
+ active = -1;
339
+ }
340
+
341
+ function open() {
342
+ list.hidden = false;
343
+ input.setAttribute("aria-expanded", "true");
344
+ requestAnimationFrame(() => list.classList.add("is-open"));
345
+ }
346
+
347
+ function setActive(index: number) {
348
+ options.forEach((o, i) => o.setAttribute("aria-selected", i === index ? "true" : "false"));
349
+ active = index;
350
+ const option = options[index];
351
+ if (option) {
352
+ input.setAttribute("aria-activedescendant", option.id);
353
+ option.scrollIntoView({ block: "nearest" });
354
+ } else {
355
+ input.removeAttribute("aria-activedescendant");
356
+ }
357
+ }
358
+
359
+ function render(query: string, items: Item[], fallback: boolean) {
360
+ list.replaceChildren();
361
+ options = [];
362
+ active = -1;
363
+ const words = terms(query);
364
+ if (fallback && items.length) {
365
+ const note = document.createElement("li");
366
+ note.className = "cw-search__note";
367
+ note.setAttribute("aria-hidden", "true");
368
+ note.textContent = "No results for all of your words. Showing results for some of them.";
369
+ list.append(note);
370
+ }
371
+ items.forEach((item, i) => {
372
+ // The option is a real link (so middle-click and "open in new tab" work), in a
373
+ // presentational list item: a listbox may only contain options.
374
+ const row = document.createElement("li");
375
+ row.setAttribute("role", "none");
376
+ const link = document.createElement("a");
377
+ link.id = `${list.id}-${i}`;
378
+ link.className = "cw-search__option cw-search__link";
379
+ link.setAttribute("role", "option");
380
+ link.setAttribute("aria-selected", "false");
381
+ link.dataset.url = item.url;
382
+ link.href = item.url;
383
+ link.tabIndex = -1;
384
+ const title = document.createElement("span");
385
+ title.className = "cw-search__title";
386
+ const text = document.createElement("span");
387
+ highlight(text, item.title || item.url, words);
388
+ title.append(text);
389
+ if (d.showType && item.type) {
390
+ const type = document.createElement("span");
391
+ type.className = "cw-search__type";
392
+ type.textContent = item.type;
393
+ title.append(type);
394
+ }
395
+ link.append(title);
396
+ if (d.showSnippets && item.snippet) {
397
+ const snippet = document.createElement("span");
398
+ snippet.className = "cw-search__snippet";
399
+ // EmDash escapes snippets and adds only <mark>; escape anything else again to be safe.
400
+ snippet.innerHTML = item.snippet.replace(MARK, "&lt;");
401
+ link.append(snippet);
402
+ }
403
+ row.append(link);
404
+ // Keep focus in the field while clicking; the link still navigates.
405
+ link.addEventListener("mousedown", (e) => e.preventDefault());
406
+ list.append(row);
407
+ options.push(link);
408
+ });
409
+ if (!items.length) {
410
+ const empty = document.createElement("li");
411
+ empty.className = "cw-search__empty";
412
+ empty.setAttribute("aria-hidden", "true");
413
+ empty.textContent = "No matching results.";
414
+ list.append(empty);
415
+ }
416
+ status.textContent = items.length
417
+ ? `${items.length} ${items.length === 1 ? "suggestion" : "suggestions"}${fallback ? " matching some of your words" : ""}. Use the up and down arrows to review.`
418
+ : `No results for ${query}.`;
419
+ open();
420
+ }
421
+
422
+ async function suggest(query: string) {
423
+ const cached = cache.get(query);
424
+ if (cached) return render(query, cached.items, cached.fallback);
425
+ controller?.abort();
426
+ controller = new AbortController();
427
+ const params = new URLSearchParams({ q: query, limit: d.limit || "8" });
428
+ if (d.collections) params.set("collections", d.collections);
429
+ if (d.locale) params.set("locale", d.locale);
430
+ try {
431
+ const response = await fetch(`${d.endpoint}?${params}`, { signal: controller.signal, headers: { Accept: "application/json" } });
432
+ if (!response.ok) return close();
433
+ const body = await response.json();
434
+ const data = (body.data ?? body) as { items: Item[]; fallback: boolean };
435
+ cache.set(query, data);
436
+ if (input.value.trim() === query) render(query, data.items, data.fallback);
437
+ } catch (error) {
438
+ if ((error as Error).name !== "AbortError") close();
439
+ }
440
+ }
441
+
442
+ input.addEventListener("input", () => {
443
+ const query = input.value.trim();
444
+ clear.hidden = input.value === "";
445
+ clearTimeout(timer);
446
+ if (!live) return;
447
+ if (query.length < minChars) {
448
+ controller?.abort();
449
+ status.textContent = "";
450
+ return close();
451
+ }
452
+ timer = setTimeout(() => void suggest(query), wait);
453
+ });
454
+
455
+ // Keys work whenever the list is open (the listener is on the form, not just the field).
456
+ form.addEventListener("keydown", (e) => {
457
+ // Keys pressed while an input method (Chinese, Japanese, Korean) is composing belong to
458
+ // it: Enter confirms a candidate, Escape cancels it. keyCode 229 covers Safari before 27,
459
+ // which ends the composition before the confirming keydown.
460
+ if (e.isComposing || e.keyCode === 229) return;
461
+ if (e.key === "Escape") {
462
+ if (isOpen()) {
463
+ close();
464
+ } else if (input.value) {
465
+ input.value = "";
466
+ clear.hidden = true;
467
+ status.textContent = "";
468
+ } else {
469
+ return;
470
+ }
471
+ e.preventDefault();
472
+ input.focus();
473
+ return;
474
+ }
475
+ if (!isOpen() || !options.length) return;
476
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
477
+ e.preventDefault();
478
+ const step = e.key === "ArrowDown" ? 1 : -1;
479
+ setActive(active < 0 ? (step > 0 ? 0 : options.length - 1) : (active + step + options.length) % options.length);
480
+ } else if (e.key === "Enter" && active >= 0) {
481
+ e.preventDefault();
482
+ const url = options[active]?.dataset.url;
483
+ if (url) window.location.assign(url);
484
+ } else if (e.key === "Tab") {
485
+ close();
486
+ }
487
+ });
488
+
489
+ form.addEventListener("focusout", (e) => {
490
+ if (!form.contains(e.relatedTarget as Node | null)) close();
491
+ });
492
+
493
+ input.addEventListener("focus", () => {
494
+ if (options.length && input.value.trim().length >= minChars) open();
495
+ });
496
+
497
+ clear.hidden = input.value === "";
498
+ clear.addEventListener("click", () => {
499
+ input.value = "";
500
+ clear.hidden = true;
501
+ status.textContent = "";
502
+ controller?.abort();
503
+ close();
504
+ input.focus();
505
+ });
506
+ }
507
+
508
+ for (const form of document.querySelectorAll<HTMLFormElement>("form[data-cw-search]")) enhance(form);
509
+ </script>
@@ -0,0 +1,72 @@
1
+ ---
2
+ /**
3
+ * EmDash's table, made keyboard-scrollable, plus its caption.
4
+ *
5
+ * Scrolling: wide tables scroll sideways. EmDash's wrapper is the scroller but
6
+ * can't take focus, and Safari (unlike Chrome and Firefox) never makes a
7
+ * scroller focusable on its own, so keyboard users there can't scroll the
8
+ * table. The table goes in a focusable, named region that is the scroller
9
+ * instead (EmDash's wrapper inside stops scrolling).
10
+ *
11
+ * Caption: tables imported from WordPress keep their <figcaption> as a
12
+ * `caption` field on the table block (WordPress import module; EmDash's
13
+ * editor keeps the field when it saves). With a caption the table is wrapped
14
+ * in <figure class="cw-table"> with a <figcaption>, which also names the
15
+ * region; without one the region is named by `label`.
16
+ *
17
+ * Included in `portableTextComponents`; or pass it yourself:
18
+ *
19
+ * import { CaptionedTable } from "@coywolf/emdash/astro";
20
+ * <PortableText value={value} components={{ type: { table: CaptionedTable } }} />
21
+ */
22
+ import { emdashComponents } from "emdash/ui";
23
+
24
+ interface Props {
25
+ node: { _key?: unknown; caption?: unknown };
26
+ /** The region's accessible name when the table has no caption. */
27
+ label?: string;
28
+ }
29
+
30
+ const { node, label = "Table" } = Astro.props;
31
+ const Table = emdashComponents.type.table;
32
+ const caption = typeof node?.caption === "string" ? node.caption.trim() : "";
33
+ const key = typeof node?._key === "string" && /^[\w-]+$/.test(node._key) ? node._key : Math.random().toString(36).slice(2, 10);
34
+ const captionId = `cw-table-caption-${key}`;
35
+ ---
36
+
37
+ {caption ? (
38
+ <figure class="cw-table">
39
+ <div class="cw-table-scroll" tabindex="0" role="region" aria-labelledby={captionId}>
40
+ <Table node={node} />
41
+ </div>
42
+ <figcaption id={captionId}>{caption}</figcaption>
43
+ </figure>
44
+ ) : (
45
+ <div class="cw-table-scroll" tabindex="0" role="region" aria-label={label}>
46
+ <Table node={node} />
47
+ </div>
48
+ )}
49
+
50
+ <style is:global>
51
+ .cw-table-scroll {
52
+ max-inline-size: 100%;
53
+ margin-block: 1.5rem;
54
+ overflow-x: auto;
55
+ overscroll-behavior-inline: contain;
56
+ }
57
+ /* In a captioned figure, the figure owns the spacing. */
58
+ .cw-table > .cw-table-scroll {
59
+ margin-block: 0;
60
+ }
61
+ /* EmDash's wrapper (and any theme rule that makes it scroll) stops scrolling, and its margin
62
+ moves to the region so the focus outline hugs the table. The doubled class outranks EmDash's
63
+ scoped rule (class + data-astro-cid attribute). */
64
+ .cw-table-scroll > .emdash-table-wrapper.emdash-table-wrapper {
65
+ overflow: visible;
66
+ margin-block: 0;
67
+ }
68
+ .cw-table-scroll:focus-visible {
69
+ outline: 2px solid currentColor;
70
+ outline-offset: 2px;
71
+ }
72
+ </style>