@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,770 @@
1
+ /**
2
+ * Live results client (feature "search.live"): the script and styles that
3
+ * turn any search form on the page into one that shows matching entries in
4
+ * a dropdown as the visitor types. A port of the Coywolf Search WordPress
5
+ * plugin's typeahead: on first focus of a search field it loads a compact
6
+ * title index (search/index, cached by the browser per content version) and
7
+ * sends a tiny warm-up request, then shows title matches the moment the
8
+ * visitor types (title-match.ts), while the search/live route's answer
9
+ * (titles first, then full text, with excerpts) merges in without reshuffling.
10
+ *
11
+ * Added by the search module's page:fragments hook as a deferred script with
12
+ * a versioned URL (LIVE_ASSET, served by the pack middleware or the
13
+ * search/live-client route and cached by browsers for a year), so it needs no
14
+ * build step and isn't resent with every page. Written as plain ES2020 inside String.raw
15
+ * (no backticks or "${" inside). It attaches to GET forms with a search
16
+ * field (type="search", or a field named "s" or "q"), skipping the pack's
17
+ * own SearchBox, which has its suggestions built in. Progressive
18
+ * enhancement: without it, or before it runs, the form submits as before.
19
+ */
20
+
21
+ import { MATCH_SOURCE } from "./title-match.js";
22
+
23
+ export interface LiveClientConfig {
24
+ endpoint: string;
25
+ /** The title index route, or null for server results only. */
26
+ indexEndpoint: string | null;
27
+ /** The search content version (cache-busts the index and live URLs after publishing). */
28
+ version: string;
29
+ limit: number;
30
+ minChars: number;
31
+ debounce: number;
32
+ locale: string | null;
33
+ /** Preselect the first result so Enter opens it (as in Coywolf Search). */
34
+ enterOpensTop: boolean;
35
+ }
36
+
37
+ /**
38
+ * Inherits the theme: font and text color come from the form, the panel's
39
+ * background from the nearest opaque ancestor (set as --cw-live-bg by the
40
+ * script), and every tint is currentColor mixed down, so it reads right on
41
+ * light and dark themes alike.
42
+ */
43
+ export const LIVE_CSS = String.raw`.cw-live-list {
44
+ position: absolute;
45
+ box-sizing: border-box;
46
+ z-index: 1000;
47
+ max-height: min(60vh, 26em);
48
+ overflow-y: auto;
49
+ overscroll-behavior: contain;
50
+ margin: 0;
51
+ padding: 0;
52
+ background: var(--cw-live-bg, Canvas);
53
+ color: var(--cw-live-fg, CanvasText);
54
+ border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
55
+ border-radius: 6px;
56
+ box-shadow: 0 6px 24px rgb(0 0 0 / 0.14);
57
+ text-align: start;
58
+ line-height: 1.35;
59
+ }
60
+ .cw-live-list[hidden],
61
+ .cw-live-clear[hidden] {
62
+ display: none;
63
+ }
64
+ .cw-live-option {
65
+ display: block;
66
+ padding: 0.6em 0.85em;
67
+ color: inherit;
68
+ text-decoration: none;
69
+ cursor: pointer;
70
+ border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent);
71
+ }
72
+ .cw-live-option:last-child {
73
+ border-bottom: 0;
74
+ }
75
+ .cw-live-option.is-active {
76
+ background: color-mix(in srgb, currentColor 7%, var(--cw-live-bg, Canvas));
77
+ box-shadow: inset 3px 0 0 0 currentColor;
78
+ }
79
+ .cw-live-title {
80
+ display: block;
81
+ font-weight: 700;
82
+ overflow-wrap: anywhere;
83
+ }
84
+ .cw-live-snippet {
85
+ display: block;
86
+ font-weight: 400;
87
+ margin-top: 0.2em;
88
+ font-size: 0.85em;
89
+ opacity: 0.8;
90
+ overflow-wrap: anywhere;
91
+ }
92
+ .cw-live-list mark {
93
+ background: transparent;
94
+ color: inherit;
95
+ font-weight: 700;
96
+ padding: 0;
97
+ }
98
+ .cw-live-title mark {
99
+ text-decoration: underline;
100
+ text-decoration-thickness: 2px;
101
+ text-underline-offset: 0.15em;
102
+ }
103
+ .cw-live-all {
104
+ font-size: 0.9em;
105
+ font-weight: 600;
106
+ }
107
+ .cw-live-empty {
108
+ padding: 0.75em 0.85em;
109
+ font-size: 0.9em;
110
+ opacity: 0.7;
111
+ cursor: default;
112
+ }
113
+ .cw-live-clear {
114
+ position: absolute;
115
+ z-index: 999;
116
+ display: flex;
117
+ align-items: center;
118
+ justify-content: center;
119
+ box-sizing: border-box;
120
+ padding: 0;
121
+ border: 0;
122
+ border-radius: 50%;
123
+ background: color-mix(in srgb, currentColor 18%, transparent);
124
+ color: var(--cw-live-fg, CanvasText);
125
+ cursor: pointer;
126
+ }
127
+ .cw-live-clear:hover,
128
+ .cw-live-clear:focus-visible {
129
+ background: color-mix(in srgb, currentColor 32%, transparent);
130
+ }
131
+ input[data-cw-live]::-webkit-search-cancel-button,
132
+ input[data-cw-live]::-webkit-search-decoration {
133
+ -webkit-appearance: none;
134
+ appearance: none;
135
+ }
136
+ .cw-live-status {
137
+ position: absolute;
138
+ width: 1px;
139
+ height: 1px;
140
+ margin: -1px;
141
+ padding: 0;
142
+ overflow: hidden;
143
+ clip-path: inset(50%);
144
+ white-space: nowrap;
145
+ border: 0;
146
+ }
147
+ @media (forced-colors: active) {
148
+ .cw-live-list {
149
+ background: Canvas;
150
+ color: CanvasText;
151
+ }
152
+ .cw-live-option.is-active {
153
+ background: Highlight;
154
+ color: HighlightText;
155
+ forced-color-adjust: none;
156
+ }
157
+ }
158
+ @media (prefers-reduced-motion: no-preference) {
159
+ .cw-live-option {
160
+ transition: background-color 90ms ease;
161
+ }
162
+ .cw-live-list.is-open {
163
+ animation: cw-live-in 140ms ease;
164
+ }
165
+ .cw-live-list.is-closing {
166
+ animation: cw-live-out 140ms ease forwards;
167
+ }
168
+ }
169
+ @keyframes cw-live-in {
170
+ from { opacity: 0; transform: translateY(-2px); }
171
+ to { opacity: 1; transform: none; }
172
+ }
173
+ @keyframes cw-live-out {
174
+ from { opacity: 1; transform: none; }
175
+ to { opacity: 0; transform: translateY(-2px); }
176
+ }`;
177
+
178
+ const SCRIPT = String.raw`function (config, css, tools) {
179
+ "use strict";
180
+ if (window.__cwLive || !window.fetch || !window.AbortController) return;
181
+ window.__cwLive = 1;
182
+
183
+ // Narrow fields (a header search, a sidebar widget) still need a readable dropdown.
184
+ var MIN_WIDTH = 300;
185
+ var EDGE_GAP = 8;
186
+ // Kept in step with the fade in the CSS; instant for reduced motion.
187
+ var FADE_MS = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 140;
188
+ var MARK = /<(?!\/?mark>)/g;
189
+ var counter = 0;
190
+ var styled = false;
191
+ // Shared by every field on the page: one title index, one warm-up.
192
+ var titles = { promise: null, prepared: null, listeners: [] };
193
+ var warmed = false;
194
+
195
+ function loadTitles() {
196
+ if (!config.indexEndpoint || titles.promise) return;
197
+ var params = new URLSearchParams({ v: config.version });
198
+ if (config.locale) params.set("locale", config.locale);
199
+ titles.promise = fetch(config.indexEndpoint + "?" + params, { credentials: "same-origin", headers: { Accept: "application/json" } })
200
+ .then(function (r) {
201
+ return r.ok ? r.json() : null;
202
+ })
203
+ .then(function (body) {
204
+ var data = body && (body.entries ? body : body.data);
205
+ titles.prepared = tools.prepare(data);
206
+ })
207
+ .catch(function () {
208
+ titles.prepared = [];
209
+ })
210
+ .then(function () {
211
+ titles.listeners.forEach(function (fn) {
212
+ fn();
213
+ });
214
+ });
215
+ }
216
+
217
+ /** First focus: fetch the title index and wake a server isolate, so the first real search is quick. */
218
+ function prime() {
219
+ loadTitles();
220
+ if (warmed) return;
221
+ warmed = true;
222
+ fetch(config.endpoint + "?warm=1", { credentials: "same-origin" }).catch(function () {});
223
+ }
224
+
225
+ function addStyles() {
226
+ if (styled) return;
227
+ styled = true;
228
+ var style = document.createElement("style");
229
+ style.id = "cw-live-css";
230
+ style.textContent = css;
231
+ document.head.appendChild(style);
232
+ }
233
+
234
+ function safeHtml(html) {
235
+ // The server escapes everything and adds only <mark>; escape any other tag again to be safe.
236
+ return String(html || "").replace(MARK, "&lt;");
237
+ }
238
+
239
+ function webUrl(url) {
240
+ try {
241
+ var u = new URL(url, location.href);
242
+ return u.protocol === "http:" || u.protocol === "https:" ? u.href : null;
243
+ } catch (e) {
244
+ return null;
245
+ }
246
+ }
247
+
248
+ /** The nearest ancestor with an opaque background, for the panel (it lives at the end of the body). */
249
+ function backgroundOf(el) {
250
+ for (var node = el; node && node.nodeType === 1; node = node.parentElement) {
251
+ var bg = getComputedStyle(node).backgroundColor;
252
+ if (bg && bg !== "transparent" && !/rgba\([^)]*,\s*0\)$/.test(bg) && !/\/\s*0\)$/.test(bg)) return bg;
253
+ }
254
+ return "";
255
+ }
256
+
257
+ function attach(input) {
258
+ if (input.dataset.cwLive) return;
259
+ input.dataset.cwLive = "1";
260
+ addStyles();
261
+
262
+ var form = input.form;
263
+ var id = "cw-live-" + ++counter;
264
+ var items = [];
265
+ var rows = [];
266
+ var active = -1;
267
+ var open = false;
268
+ var timer = 0;
269
+ var fadeTimer = 0;
270
+ var controller = null;
271
+ var token = 0;
272
+ var cache = new Map();
273
+ var announced = "";
274
+ var hinted = false;
275
+ var lastQuery = "";
276
+ var announceTimer = 0;
277
+ // Set when the visitor picks a row (arrows, pointer), so a second render keeps it selected.
278
+ var userMoved = false;
279
+ var activeUrl = "";
280
+
281
+ // Nothing is wrapped around the field: themes lay search forms out with flexbox and
282
+ // direct-child selectors, so the panel and clear button are positioned against the document.
283
+ var list = document.createElement("div");
284
+ list.className = "cw-live-list";
285
+ list.id = id;
286
+ list.setAttribute("role", "listbox");
287
+ list.setAttribute("aria-label", "Search results");
288
+ list.hidden = true;
289
+ document.body.appendChild(list);
290
+
291
+ var status = document.createElement("div");
292
+ status.className = "cw-live-status";
293
+ status.setAttribute("role", "status");
294
+ status.setAttribute("aria-live", "polite");
295
+ status.setAttribute("aria-atomic", "true");
296
+ document.body.appendChild(status);
297
+
298
+ // Pointer and touch only (it sits at the end of the document, far from the field in tab
299
+ // order); Escape is the keyboard path to the same thing, and the hint says so.
300
+ var clear = document.createElement("button");
301
+ clear.type = "button";
302
+ clear.className = "cw-live-clear";
303
+ clear.tabIndex = -1;
304
+ clear.setAttribute("aria-label", "Clear search");
305
+ clear.innerHTML = '<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true" focusable="false"><path d="M2 2l12 12M14 2L2 14" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/></svg>';
306
+ clear.hidden = true;
307
+ document.body.appendChild(clear);
308
+
309
+ input.setAttribute("role", "combobox");
310
+ input.setAttribute("aria-autocomplete", "list");
311
+ input.setAttribute("aria-expanded", "false");
312
+ input.setAttribute("aria-controls", id);
313
+ input.setAttribute("autocomplete", "off");
314
+
315
+ function visible() {
316
+ if (!input.offsetParent && getComputedStyle(input).position !== "fixed") return false;
317
+ var r = input.getBoundingClientRect();
318
+ return r.width > 0 && r.height > 0;
319
+ }
320
+
321
+ function theme() {
322
+ var source = form || input;
323
+ var cs = getComputedStyle(source);
324
+ var bg = backgroundOf(source);
325
+ list.style.setProperty("--cw-live-fg", cs.color);
326
+ clear.style.setProperty("--cw-live-fg", getComputedStyle(input).color);
327
+ if (bg) list.style.setProperty("--cw-live-bg", bg);
328
+ list.style.fontFamily = cs.fontFamily;
329
+ }
330
+
331
+ function position() {
332
+ var r = input.getBoundingClientRect();
333
+ var viewport = document.documentElement.clientWidth;
334
+ var width = Math.min(Math.max(r.width, MIN_WIDTH), viewport - 2 * EDGE_GAP);
335
+ var left = Math.max(scrollX + EDGE_GAP, Math.min(r.left + scrollX, scrollX + viewport - width - EDGE_GAP));
336
+ list.style.top = r.bottom + scrollY + 4 + "px";
337
+ list.style.left = left + "px";
338
+ list.style.width = width + "px";
339
+ var size = Math.min(28, Math.max(20, r.height * 0.55));
340
+ clear.style.width = size + "px";
341
+ clear.style.height = size + "px";
342
+ clear.style.top = r.top + scrollY + (r.height - size) / 2 + "px";
343
+ clear.style.left = r.right + scrollX - size - 8 + "px";
344
+ }
345
+
346
+ function show() {
347
+ clearTimeout(fadeTimer);
348
+ list.classList.remove("is-closing");
349
+ if (!list.hidden) return;
350
+ theme();
351
+ list.hidden = false;
352
+ void list.offsetHeight;
353
+ list.classList.add("is-open");
354
+ }
355
+
356
+ function hide() {
357
+ if (list.hidden) return;
358
+ clearTimeout(fadeTimer);
359
+ list.classList.remove("is-open");
360
+ list.classList.add("is-closing");
361
+ fadeTimer = setTimeout(function () {
362
+ list.hidden = true;
363
+ list.classList.remove("is-closing");
364
+ list.replaceChildren();
365
+ }, FADE_MS);
366
+ }
367
+
368
+ function close() {
369
+ open = false;
370
+ active = -1;
371
+ hide();
372
+ input.setAttribute("aria-expanded", "false");
373
+ input.removeAttribute("aria-activedescendant");
374
+ }
375
+
376
+ function showClear() {
377
+ clear.hidden = input.value === "" || !visible();
378
+ if (!clear.hidden) position();
379
+ }
380
+
381
+ /** Empty the field and start over (the clear button, and Escape with the list closed). */
382
+ function reset() {
383
+ input.value = "";
384
+ items = [];
385
+ lastQuery = "";
386
+ token++;
387
+ if (controller) controller.abort();
388
+ clearTimeout(timer);
389
+ clearTimeout(announceTimer);
390
+ close();
391
+ showClear();
392
+ status.textContent = "";
393
+ announced = "";
394
+ }
395
+
396
+ /** The full results page: what submitting the form would load. */
397
+ function allUrl(query) {
398
+ if (!form || !input.name) return null;
399
+ try {
400
+ var data = new FormData(form);
401
+ data.set(input.name, query);
402
+ var url = new URL(form.action || location.href, location.href);
403
+ var params = new URLSearchParams();
404
+ data.forEach(function (value, key) {
405
+ if (typeof value === "string") params.append(key, value);
406
+ });
407
+ url.search = params.toString();
408
+ return webUrl(url.href);
409
+ } catch (e) {
410
+ return null;
411
+ }
412
+ }
413
+
414
+ function announce(message) {
415
+ clearTimeout(announceTimer);
416
+ if (message === announced) return;
417
+ announced = message;
418
+ status.textContent = message;
419
+ }
420
+
421
+ // Instant (local) results are announced only if the server's answer is slow, so a screen
422
+ // reader hears one count, not two in quick succession.
423
+ function announceLater(message) {
424
+ clearTimeout(announceTimer);
425
+ announceTimer = setTimeout(function () {
426
+ announce(message);
427
+ }, 700);
428
+ }
429
+
430
+ function paint() {
431
+ for (var i = 0; i < rows.length; i++) {
432
+ var on = i === active;
433
+ rows[i].setAttribute("aria-selected", on ? "true" : "false");
434
+ rows[i].classList.toggle("is-active", on);
435
+ }
436
+ if (active >= 0 && rows[active]) {
437
+ input.setAttribute("aria-activedescendant", rows[active].id);
438
+ rows[active].scrollIntoView({ block: "nearest" });
439
+ } else {
440
+ input.removeAttribute("aria-activedescendant");
441
+ }
442
+ }
443
+
444
+ function option(index, url) {
445
+ var a = document.createElement("a");
446
+ a.className = "cw-live-option";
447
+ a.id = id + "-" + index;
448
+ a.href = url;
449
+ a.tabIndex = -1;
450
+ a.setAttribute("role", "option");
451
+ a.setAttribute("aria-selected", "false");
452
+ a.dataset.index = String(index);
453
+ return a;
454
+ }
455
+
456
+ function render(query, data, partial) {
457
+ if (!visible()) return close();
458
+ items = (data && data.items) || [];
459
+ rows = [];
460
+ list.replaceChildren();
461
+ position();
462
+
463
+ if (!items.length) {
464
+ // Say so rather than vanish, which reads as broken. Not an option: nothing to open.
465
+ active = -1;
466
+ var empty = document.createElement("div");
467
+ empty.className = "cw-live-empty";
468
+ empty.setAttribute("aria-hidden", "true");
469
+ empty.textContent = "No matching results.";
470
+ list.appendChild(empty);
471
+ show();
472
+ open = true;
473
+ input.setAttribute("aria-expanded", "true");
474
+ input.removeAttribute("aria-activedescendant");
475
+ announce("No matching results.");
476
+ return;
477
+ }
478
+
479
+ items.forEach(function (item, i) {
480
+ var url = webUrl(item.url);
481
+ if (!url) return;
482
+ var row = option(rows.length, url);
483
+ var title = document.createElement("span");
484
+ title.className = "cw-live-title";
485
+ title.innerHTML = safeHtml(item.titleHtml);
486
+ row.appendChild(title);
487
+ if (item.snippet) {
488
+ var snippet = document.createElement("span");
489
+ snippet.className = "cw-live-snippet";
490
+ snippet.innerHTML = safeHtml(item.snippet);
491
+ row.appendChild(snippet);
492
+ }
493
+ list.appendChild(row);
494
+ rows.push(row);
495
+ });
496
+ var count = rows.length;
497
+ var all = allUrl(query);
498
+ if (all) {
499
+ var more = option(rows.length, all);
500
+ more.classList.add("cw-live-all");
501
+ more.textContent = "View all results";
502
+ list.appendChild(more);
503
+ rows.push(more);
504
+ }
505
+
506
+ // The first result selects itself, so what Enter will open is visible before it's pressed;
507
+ // a row the visitor picked stays picked when the server's answer arrives.
508
+ active = config.enterOpensTop && count ? 0 : -1;
509
+ if (userMoved && activeUrl) {
510
+ for (var r = 0; r < rows.length; r++) if (rows[r].href === activeUrl) active = r;
511
+ }
512
+ show();
513
+ open = true;
514
+ input.setAttribute("aria-expanded", "true");
515
+ var message = count + (count === 1 ? " result" : " results") + (data.fallback ? " matching some of your words." : ".");
516
+ if (!hinted && !partial) {
517
+ message += " Use the up and down arrows to review, Enter to open, and Escape to close.";
518
+ hinted = true;
519
+ }
520
+ if (partial) announceLater(message);
521
+ else announce(message);
522
+ paint();
523
+ }
524
+
525
+ /** Title matches from the index, at once (false when there's no index yet or nothing matches). */
526
+ function showLocal(query) {
527
+ if (!titles.prepared) return false;
528
+ var local = tools.localItems(titles.prepared, query, config.limit);
529
+ if (!local.length) return false;
530
+ render(query, { items: local, fallback: false }, true);
531
+ return true;
532
+ }
533
+
534
+ /** The server's answer, merged into the title matches already showing. */
535
+ function showServer(query, data) {
536
+ var local = titles.prepared ? tools.localItems(titles.prepared, query, config.limit) : [];
537
+ var server = (data && data.items) || [];
538
+ render(query, { items: tools.merge(local, server, config.limit), fallback: !!(data && data.fallback) && !local.length }, false);
539
+ }
540
+
541
+ // The index arrived while the visitor was typing: show its matches unless the server already answered.
542
+ titles.listeners.push(function () {
543
+ var query = input.value.trim();
544
+ if (document.activeElement === input && query.length >= config.minChars && query === lastQuery && !cache.has(query)) showLocal(query);
545
+ });
546
+
547
+ function fetchResults(query) {
548
+ var cached = cache.get(query);
549
+ if (cached) return showServer(query, cached);
550
+ if (controller) controller.abort();
551
+ controller = new AbortController();
552
+ var mine = ++token;
553
+ var params = new URLSearchParams({ q: query, limit: String(config.limit) });
554
+ if (config.locale) params.set("locale", config.locale);
555
+ params.set("v", config.version);
556
+ fetch(config.endpoint + "?" + params, { signal: controller.signal, credentials: "same-origin", headers: { Accept: "application/json" } })
557
+ .then(function (r) {
558
+ return r.ok ? r.json() : null;
559
+ })
560
+ .then(function (body) {
561
+ if (!body) return;
562
+ var data = body.data || body;
563
+ cache.set(query, data);
564
+ // A slower earlier answer must not replace a newer one.
565
+ if (mine === token && input.value.trim() === query) showServer(query, data);
566
+ })
567
+ .catch(function () {
568
+ // Aborted or offline: whatever is showing stands; the form still submits.
569
+ });
570
+ }
571
+
572
+ input.addEventListener("input", function () {
573
+ var query = input.value.trim();
574
+ showClear();
575
+ clearTimeout(timer);
576
+ if (query.length < config.minChars) {
577
+ lastQuery = "";
578
+ token++;
579
+ if (controller) controller.abort();
580
+ items = [];
581
+ announced = "";
582
+ clearTimeout(announceTimer);
583
+ status.textContent = "";
584
+ return close();
585
+ }
586
+ if (query === lastQuery && open) return;
587
+ lastQuery = query;
588
+ userMoved = false;
589
+ activeUrl = "";
590
+ if (cache.has(query)) return showServer(query, cache.get(query));
591
+ showLocal(query);
592
+ timer = setTimeout(function () {
593
+ fetchResults(query);
594
+ }, config.debounce);
595
+ });
596
+
597
+ function move(step) {
598
+ if (!rows.length) return;
599
+ active = active < 0 ? (step > 0 ? 0 : rows.length - 1) : (active + step + rows.length) % rows.length;
600
+ userMoved = true;
601
+ activeUrl = rows[active].href;
602
+ paint();
603
+ }
604
+
605
+ input.addEventListener("keydown", function (e) {
606
+ // Keys pressed while an input method is composing belong to it (Enter confirms a
607
+ // candidate, Escape cancels it); keyCode 229 covers Safari before 27.
608
+ if (e.isComposing || e.keyCode === 229) return;
609
+ switch (e.key) {
610
+ case "ArrowDown":
611
+ case "ArrowUp":
612
+ // Reopen a list closed with Escape, for the same query.
613
+ if (!open && e.key === "ArrowDown" && input.value.trim().length >= config.minChars) {
614
+ var again = input.value.trim();
615
+ if (cache.has(again)) {
616
+ e.preventDefault();
617
+ showServer(again, cache.get(again));
618
+ return;
619
+ }
620
+ if (showLocal(again)) {
621
+ e.preventDefault();
622
+ return;
623
+ }
624
+ }
625
+ if (!open || !rows.length) return;
626
+ e.preventDefault();
627
+ move(e.key === "ArrowDown" ? 1 : -1);
628
+ break;
629
+ case "Enter":
630
+ // Opens what's highlighted; with nothing highlighted the form submits as usual.
631
+ if (open && active >= 0 && rows[active]) {
632
+ e.preventDefault();
633
+ location.assign(rows[active].href);
634
+ }
635
+ break;
636
+ case "Escape":
637
+ // Two stages, per the combobox pattern: close the list first, keeping the query;
638
+ // with the list closed, clear the field.
639
+ if (open) {
640
+ e.preventDefault();
641
+ e.stopPropagation();
642
+ close();
643
+ } else if (input.value) {
644
+ e.preventDefault();
645
+ e.stopPropagation();
646
+ reset();
647
+ }
648
+ break;
649
+ case "Tab":
650
+ close();
651
+ break;
652
+ }
653
+ });
654
+
655
+ input.addEventListener("focus", function () {
656
+ prime();
657
+ showClear();
658
+ var query = input.value.trim();
659
+ if (items.length && query.length >= config.minChars && cache.has(query)) showServer(query, cache.get(query));
660
+ });
661
+
662
+ // Keep focus in the field while clicking a result; the link itself navigates (so
663
+ // middle-click and "open in new tab" work too).
664
+ list.addEventListener("mousedown", function (e) {
665
+ e.preventDefault();
666
+ });
667
+ list.addEventListener("mousemove", function (e) {
668
+ var row = e.target.closest && e.target.closest(".cw-live-option");
669
+ if (!row) return;
670
+ var index = Number(row.dataset.index);
671
+ if (index !== active) {
672
+ active = index;
673
+ userMoved = true;
674
+ activeUrl = row.href;
675
+ paint();
676
+ }
677
+ });
678
+
679
+ clear.addEventListener("mousedown", function (e) {
680
+ e.preventDefault();
681
+ });
682
+ clear.addEventListener("click", function () {
683
+ reset();
684
+ input.focus();
685
+ });
686
+
687
+ document.addEventListener("click", function (e) {
688
+ if (open && e.target !== input && !list.contains(e.target) && e.target !== clear && !clear.contains(e.target)) close();
689
+ });
690
+
691
+ // The panel is anchored to the document, so it follows the field when the page moves.
692
+ addEventListener("scroll", function () {
693
+ if (open || !clear.hidden) position();
694
+ }, { passive: true });
695
+ addEventListener("resize", function () {
696
+ if (open || !clear.hidden) position();
697
+ if (!clear.hidden) showClear();
698
+ });
699
+
700
+ if (form) form.addEventListener("submit", close);
701
+ showClear();
702
+ // Attached on focus (a search form rendered later): that focus has already happened.
703
+ if (document.activeElement === input) prime();
704
+ }
705
+
706
+ var FIELDS = 'form input[type="search"], form input[name="s"], form input[name="q"]';
707
+
708
+ function candidate(input) {
709
+ var form = input.form;
710
+ if (!form || (form.method || "get").toLowerCase() !== "get") return false;
711
+ // The pack's SearchBox has its own suggestions.
712
+ if (form.hasAttribute("data-cw-search") || form.classList.contains("cw-search")) return false;
713
+ return input.type === "search" || input.type === "text";
714
+ }
715
+
716
+ function boot() {
717
+ var fields = document.querySelectorAll(FIELDS);
718
+ for (var i = 0; i < fields.length; i++) if (candidate(fields[i])) attach(fields[i]);
719
+ // Search forms rendered later (a header search opened on demand) attach on first focus.
720
+ document.addEventListener("focusin", function (e) {
721
+ var t = e.target;
722
+ if (t && t.matches && t.matches(FIELDS) && !t.dataset.cwLive && candidate(t)) attach(t);
723
+ });
724
+ }
725
+
726
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
727
+ else boot();
728
+ }`;
729
+
730
+ /**
731
+ * The script and styles as sent: comment lines, indentation and blank lines
732
+ * dropped (line breaks stay, so semicolon insertion is unaffected). It ships
733
+ * on every page while the feature is on, so this halves its size.
734
+ */
735
+ const compact = (source: string) => source.replace(/^\s*(\/\/.*|\/\*\*.*\*\/)$/gm, "").replace(/^\s+/gm, "").replace(/\n{2,}/g, "\n");
736
+ const COMPACT_SCRIPT = compact(SCRIPT);
737
+ const COMPACT_MATCH = compact(MATCH_SOURCE);
738
+ const COMPACT_CSS = LIVE_CSS.replace(/\n\s*/g, "");
739
+
740
+ // JSON in a script element: escape "<" so nothing in it can close the tag or open a comment.
741
+ // (U+2028/2029 are legal in JS string literals since ES2019, so JSON needs nothing more.)
742
+ const json = (value: unknown) => JSON.stringify(value).replace(/</g, "\\u003c");
743
+
744
+ /** The inline script for one page: the client above, called with this config. */
745
+ export function liveScript(config: LiveClientConfig): string {
746
+ return `(${COMPACT_SCRIPT})(${json(config)},${json(COMPACT_CSS)},(${COMPACT_MATCH})());`;
747
+ }
748
+
749
+ /**
750
+ * The same client as a static file, so browsers download it once and cache
751
+ * it instead of receiving it inline on every page. It reads the page's config
752
+ * from its own <script> element's data-cw-live attribute (liveScriptConfig).
753
+ */
754
+ export const LIVE_ASSET = `(function(){var s=document.currentScript,c=null;try{c=JSON.parse(s&&s.getAttribute("data-cw-live")||"null")}catch(e){}
755
+ if(c)(${COMPACT_SCRIPT})(c,${json(COMPACT_CSS)},(${COMPACT_MATCH})());})();`;
756
+
757
+ /** A short fingerprint of LIVE_ASSET (FNV-1a), for its immutable URL. Changes whenever the client does. */
758
+ export const LIVE_ASSET_HASH = (() => {
759
+ let hash = 0x811c9dc5;
760
+ for (let i = 0; i < LIVE_ASSET.length; i++) {
761
+ hash ^= LIVE_ASSET.charCodeAt(i);
762
+ hash = Math.imul(hash, 0x01000193);
763
+ }
764
+ return (hash >>> 0).toString(36);
765
+ })();
766
+
767
+ /** The data-cw-live attribute value for the LIVE_ASSET script (the page's renderer escapes it for HTML). */
768
+ export function liveScriptConfig(config: LiveClientConfig): string {
769
+ return JSON.stringify(config);
770
+ }