@webstir-io/webstir 0.1.0

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 (123) hide show
  1. package/README.md +69 -0
  2. package/assets/features/client_nav/client_nav.ts +469 -0
  3. package/assets/features/content_nav/content_nav.css +170 -0
  4. package/assets/features/content_nav/content_nav.ts +358 -0
  5. package/assets/features/router/router-types.ts +6 -0
  6. package/assets/features/router/router.ts +118 -0
  7. package/assets/features/search/search.css +204 -0
  8. package/assets/features/search/search.ts +627 -0
  9. package/assets/templates/api/src/backend/index.ts +13 -0
  10. package/assets/templates/api/src/backend/tsconfig.json +15 -0
  11. package/assets/templates/api/src/shared/router-types.ts +23 -0
  12. package/assets/templates/api/src/shared/tsconfig.json +10 -0
  13. package/assets/templates/api/src/shared/types/index.ts +4 -0
  14. package/assets/templates/full/src/backend/index.ts +13 -0
  15. package/assets/templates/full/src/backend/tsconfig.json +15 -0
  16. package/assets/templates/full/src/frontend/app/app.css +65 -0
  17. package/assets/templates/full/src/frontend/app/app.html +13 -0
  18. package/assets/templates/full/src/frontend/app/app.ts +188 -0
  19. package/assets/templates/full/src/frontend/app/error.ts +127 -0
  20. package/assets/templates/full/src/frontend/app/hmr.js +355 -0
  21. package/assets/templates/full/src/frontend/app/navigation.ts +8 -0
  22. package/assets/templates/full/src/frontend/app/refresh.js +114 -0
  23. package/assets/templates/full/src/frontend/app/router.ts +126 -0
  24. package/assets/templates/full/src/frontend/app/styles/base.css +2 -0
  25. package/assets/templates/full/src/frontend/app/styles/reset.css +48 -0
  26. package/assets/templates/full/src/frontend/pages/home/index.css +21 -0
  27. package/assets/templates/full/src/frontend/pages/home/index.html +10 -0
  28. package/assets/templates/full/src/frontend/pages/home/index.ts +18 -0
  29. package/assets/templates/full/src/frontend/pages/home/tests/home.test.ts +21 -0
  30. package/assets/templates/full/src/frontend/tsconfig.json +20 -0
  31. package/assets/templates/full/src/shared/router-types.ts +23 -0
  32. package/assets/templates/full/src/shared/tsconfig.json +10 -0
  33. package/assets/templates/full/src/shared/types/index.ts +4 -0
  34. package/assets/templates/shared/Errors.404.html +23 -0
  35. package/assets/templates/shared/Errors.500.html +23 -0
  36. package/assets/templates/shared/Errors.default.html +23 -0
  37. package/assets/templates/shared/types/global.d.ts +32 -0
  38. package/assets/templates/shared/types.global.d.ts +32 -0
  39. package/assets/templates/spa/src/frontend/app/app.css +65 -0
  40. package/assets/templates/spa/src/frontend/app/app.html +13 -0
  41. package/assets/templates/spa/src/frontend/app/app.ts +188 -0
  42. package/assets/templates/spa/src/frontend/app/error.ts +127 -0
  43. package/assets/templates/spa/src/frontend/app/hmr.js +355 -0
  44. package/assets/templates/spa/src/frontend/app/navigation.ts +8 -0
  45. package/assets/templates/spa/src/frontend/app/refresh.js +114 -0
  46. package/assets/templates/spa/src/frontend/app/router.ts +126 -0
  47. package/assets/templates/spa/src/frontend/app/styles/base.css +2 -0
  48. package/assets/templates/spa/src/frontend/app/styles/reset.css +48 -0
  49. package/assets/templates/spa/src/frontend/pages/home/index.css +21 -0
  50. package/assets/templates/spa/src/frontend/pages/home/index.html +10 -0
  51. package/assets/templates/spa/src/frontend/pages/home/index.ts +18 -0
  52. package/assets/templates/spa/src/frontend/pages/home/tests/home.test.ts +21 -0
  53. package/assets/templates/spa/src/frontend/tsconfig.json +20 -0
  54. package/assets/templates/spa/src/shared/router-types.ts +23 -0
  55. package/assets/templates/spa/src/shared/tsconfig.json +10 -0
  56. package/assets/templates/spa/src/shared/types/index.ts +4 -0
  57. package/assets/templates/ssg/src/frontend/app/app.css +12 -0
  58. package/assets/templates/ssg/src/frontend/app/app.html +43 -0
  59. package/assets/templates/ssg/src/frontend/app/app.ts +190 -0
  60. package/assets/templates/ssg/src/frontend/app/error.ts +127 -0
  61. package/assets/templates/ssg/src/frontend/app/hmr.js +370 -0
  62. package/assets/templates/ssg/src/frontend/app/refresh.js +163 -0
  63. package/assets/templates/ssg/src/frontend/app/scripts/components/drawer.ts +183 -0
  64. package/assets/templates/ssg/src/frontend/app/scripts/components/menu.ts +75 -0
  65. package/assets/templates/ssg/src/frontend/app/styles/base.css +77 -0
  66. package/assets/templates/ssg/src/frontend/app/styles/components/buttons.css +108 -0
  67. package/assets/templates/ssg/src/frontend/app/styles/components/drawer.css +12 -0
  68. package/assets/templates/ssg/src/frontend/app/styles/components/header.css +164 -0
  69. package/assets/templates/ssg/src/frontend/app/styles/components/markdown.css +25 -0
  70. package/assets/templates/ssg/src/frontend/app/styles/layout.css +41 -0
  71. package/assets/templates/ssg/src/frontend/app/styles/reset.css +56 -0
  72. package/assets/templates/ssg/src/frontend/app/styles/tokens.css +72 -0
  73. package/assets/templates/ssg/src/frontend/app/styles/utilities.css +14 -0
  74. package/assets/templates/ssg/src/frontend/content/_sidebar.json +14 -0
  75. package/assets/templates/ssg/src/frontend/content/content-pipeline.md +82 -0
  76. package/assets/templates/ssg/src/frontend/content/css-playbook.md +68 -0
  77. package/assets/templates/ssg/src/frontend/content/hosting.md +48 -0
  78. package/assets/templates/ssg/src/frontend/pages/about/index.css +33 -0
  79. package/assets/templates/ssg/src/frontend/pages/about/index.html +60 -0
  80. package/assets/templates/ssg/src/frontend/pages/docs/index.css +505 -0
  81. package/assets/templates/ssg/src/frontend/pages/docs/index.html +52 -0
  82. package/assets/templates/ssg/src/frontend/pages/docs/index.ts +495 -0
  83. package/assets/templates/ssg/src/frontend/pages/home/index.css +91 -0
  84. package/assets/templates/ssg/src/frontend/pages/home/index.html +38 -0
  85. package/assets/templates/ssg/src/frontend/pages/home/tests/home.test.ts +24 -0
  86. package/assets/templates/ssg/src/frontend/tsconfig.json +13 -0
  87. package/package.json +41 -0
  88. package/scripts/pack-standalone.mjs +127 -0
  89. package/scripts/sync-assets.mjs +87 -0
  90. package/src/add-backend.ts +164 -0
  91. package/src/add.ts +112 -0
  92. package/src/api-watch.ts +84 -0
  93. package/src/backend-inspect.ts +45 -0
  94. package/src/backend-runtime.ts +286 -0
  95. package/src/build-plan.ts +12 -0
  96. package/src/build.ts +10 -0
  97. package/src/cli.ts +569 -0
  98. package/src/compile-tests.ts +61 -0
  99. package/src/dev-server.ts +393 -0
  100. package/src/enable-assets.ts +196 -0
  101. package/src/enable.ts +477 -0
  102. package/src/execute.ts +85 -0
  103. package/src/format.ts +254 -0
  104. package/src/frontend-watch.ts +145 -0
  105. package/src/full-watch.ts +80 -0
  106. package/src/index.ts +20 -0
  107. package/src/init-assets.ts +96 -0
  108. package/src/init.ts +339 -0
  109. package/src/paths.ts +26 -0
  110. package/src/providers.ts +88 -0
  111. package/src/publish.ts +8 -0
  112. package/src/refresh.ts +56 -0
  113. package/src/repair.ts +414 -0
  114. package/src/runtime.ts +48 -0
  115. package/src/smoke.ts +161 -0
  116. package/src/stop-signal.ts +26 -0
  117. package/src/test.ts +215 -0
  118. package/src/types.ts +29 -0
  119. package/src/watch-daemon-client.ts +171 -0
  120. package/src/watch-events.ts +195 -0
  121. package/src/watch.ts +66 -0
  122. package/src/workspace-watcher.ts +251 -0
  123. package/src/workspace.ts +55 -0
@@ -0,0 +1,627 @@
1
+ import { createDrawer, type DrawerController } from '../components/drawer.js';
2
+
3
+ export {};
4
+
5
+ type SearchScope = 'all' | 'docs' | 'page';
6
+
7
+ type SearchIndexEntry = {
8
+ path: string;
9
+ title: string;
10
+ description?: string;
11
+ excerpt: string;
12
+ headings: string[];
13
+ kind: 'docs' | 'page';
14
+ haystack: string;
15
+ };
16
+
17
+ type SearchUiState = {
18
+ entries: SearchIndexEntry[] | null;
19
+ entriesPromise: Promise<SearchIndexEntry[]> | null;
20
+ open: boolean;
21
+ scope: SearchScope;
22
+ trigger: HTMLButtonElement | null;
23
+ lastActiveElement: HTMLElement | null;
24
+ };
25
+
26
+ declare global {
27
+ interface Window {
28
+ __webstirSearchUiV2?: SearchUiState;
29
+ }
30
+ }
31
+
32
+ const BASE_PATH = resolveBasePath();
33
+
34
+ function getState(): SearchUiState {
35
+ if (window.__webstirSearchUiV2) {
36
+ return window.__webstirSearchUiV2;
37
+ }
38
+
39
+ const created: SearchUiState = {
40
+ entries: null,
41
+ entriesPromise: null,
42
+ open: false,
43
+ scope: 'all',
44
+ trigger: null,
45
+ lastActiveElement: null
46
+ };
47
+
48
+ window.__webstirSearchUiV2 = created;
49
+ return created;
50
+ }
51
+
52
+ const state = getState();
53
+ let drawer: DrawerController | null = null;
54
+
55
+ function resolveBasePath(): string {
56
+ const raw = document.documentElement?.getAttribute('data-webstir-base') ?? '';
57
+ return normalizeBasePath(raw);
58
+ }
59
+
60
+ function normalizeBasePath(value: string): string {
61
+ const trimmed = value.trim();
62
+ if (!trimmed || trimmed === '/') {
63
+ return '';
64
+ }
65
+ if (!trimmed.startsWith('/')) {
66
+ return `/${trimmed}`;
67
+ }
68
+ return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed;
69
+ }
70
+
71
+ function withBasePath(value: string): string {
72
+ if (!BASE_PATH) {
73
+ return value;
74
+ }
75
+ if (!value.startsWith('/') || value.startsWith('//')) {
76
+ return value;
77
+ }
78
+ if (value === BASE_PATH || value.startsWith(`${BASE_PATH}/`) || value.startsWith(`${BASE_PATH}?`) || value.startsWith(`${BASE_PATH}#`)) {
79
+ return value;
80
+ }
81
+ return `${BASE_PATH}${value}`;
82
+ }
83
+
84
+ function escapeHtml(value: unknown): string {
85
+ return String(value)
86
+ .replace(/&/g, '&amp;')
87
+ .replace(/</g, '&lt;')
88
+ .replace(/>/g, '&gt;')
89
+ .replace(/\"/g, '&quot;')
90
+ .replace(/'/g, '&#39;');
91
+ }
92
+
93
+ function stripAndNormalize(value: unknown): string {
94
+ return String(value).toLowerCase().replace(/\s+/g, ' ').trim();
95
+ }
96
+
97
+ function normalizeKind(kind: unknown): 'docs' | 'page' {
98
+ const normalized = typeof kind === 'string' ? kind.toLowerCase().trim() : '';
99
+ return normalized === 'page' ? 'page' : 'docs';
100
+ }
101
+
102
+ function computeScore(entry: SearchIndexEntry, query: string): number {
103
+ let score = 0;
104
+
105
+ if (entry.title && stripAndNormalize(entry.title).includes(query)) {
106
+ score += 10;
107
+ }
108
+ if (entry.description && stripAndNormalize(entry.description).includes(query)) {
109
+ score += 4;
110
+ }
111
+ if (entry.excerpt && stripAndNormalize(entry.excerpt).includes(query)) {
112
+ score += 2;
113
+ }
114
+
115
+ if (Array.isArray(entry.headings) && entry.headings.some((h) => stripAndNormalize(h).includes(query))) {
116
+ score += 6;
117
+ }
118
+
119
+ if (entry.kind === 'docs') {
120
+ score += 1;
121
+ }
122
+
123
+ return score;
124
+ }
125
+
126
+ async function ensureIndexLoaded(): Promise<SearchIndexEntry[]> {
127
+ if (state.entries !== null) {
128
+ return state.entries;
129
+ }
130
+
131
+ state.entriesPromise ??= loadIndex();
132
+ state.entries = await state.entriesPromise;
133
+ return state.entries;
134
+ }
135
+
136
+ async function loadIndex(): Promise<SearchIndexEntry[]> {
137
+ try {
138
+ const response = await fetch(withBasePath('/search.json'), { headers: { Accept: 'application/json' } });
139
+ if (!response.ok) {
140
+ return [];
141
+ }
142
+
143
+ const data: unknown = await response.json();
144
+ if (!Array.isArray(data)) {
145
+ return [];
146
+ }
147
+
148
+ return data
149
+ .filter((entry): entry is {
150
+ path: string;
151
+ title: string;
152
+ description?: unknown;
153
+ excerpt?: unknown;
154
+ headings?: unknown;
155
+ kind?: unknown;
156
+ } => {
157
+ if (!entry || typeof entry !== 'object') {
158
+ return false;
159
+ }
160
+
161
+ const record = entry as Record<string, unknown>;
162
+ return typeof record.path === 'string' && typeof record.title === 'string';
163
+ })
164
+ .map((entry) => {
165
+ const kind = normalizeKind(entry.kind);
166
+ const headings = Array.isArray(entry.headings)
167
+ ? entry.headings.filter((h): h is string => typeof h === 'string')
168
+ : [];
169
+ const haystack = stripAndNormalize(
170
+ `${entry.title} ${entry.description ?? ''} ${entry.excerpt ?? ''} ${headings.join(' ')}`
171
+ );
172
+ return {
173
+ path: entry.path,
174
+ title: entry.title,
175
+ description: typeof entry.description === 'string' ? entry.description : undefined,
176
+ excerpt: typeof entry.excerpt === 'string' ? entry.excerpt : '',
177
+ headings,
178
+ kind,
179
+ haystack
180
+ };
181
+ });
182
+ } catch {
183
+ return [];
184
+ }
185
+ }
186
+
187
+ function parseScope(scope: string | null | undefined): SearchScope {
188
+ if (scope === 'docs' || scope === 'page' || scope === 'all') {
189
+ return scope;
190
+ }
191
+ return 'all';
192
+ }
193
+
194
+ function setScope(scope: SearchScope): void {
195
+ state.scope = scope;
196
+
197
+ const root = document.getElementById('webstir-search');
198
+ if (!root) {
199
+ return;
200
+ }
201
+
202
+ root.querySelectorAll<HTMLButtonElement>('.webstir-search__scopes button[data-scope]').forEach((button) => {
203
+ const value = parseScope(button.getAttribute('data-scope'));
204
+ button.setAttribute('aria-pressed', value === scope ? 'true' : 'false');
205
+ });
206
+
207
+ void refreshResults();
208
+ }
209
+
210
+ function getPlatformShortcut(): string {
211
+ const platform = (navigator as unknown as { userAgentData?: { platform?: string } }).userAgentData?.platform
212
+ ?? navigator.platform
213
+ ?? '';
214
+ return /mac/i.test(platform) ? '⌘K' : 'Ctrl K';
215
+ }
216
+
217
+ function ensureTrigger(): HTMLButtonElement | null {
218
+ if (state.trigger && document.contains(state.trigger)) {
219
+ return state.trigger;
220
+ }
221
+
222
+ const existing = document.querySelector<HTMLButtonElement>('[data-webstir-search-trigger="true"]');
223
+ if (existing) {
224
+ state.trigger = existing;
225
+ return existing;
226
+ }
227
+
228
+ const menu = document.querySelector<HTMLElement>('[data-app-menu]');
229
+ if (!menu) {
230
+ return null;
231
+ }
232
+
233
+ const shortcut = getPlatformShortcut();
234
+ const button = document.createElement('button');
235
+ button.type = 'button';
236
+ button.className = 'webstir-search__trigger ws-icon-button';
237
+ button.innerHTML = [
238
+ '<span class="webstir-search__trigger-icon" aria-hidden="true">',
239
+ ' <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">',
240
+ ' <circle cx="11" cy="11" r="7"></circle>',
241
+ ' <path d="M20 20l-3.5-3.5"></path>',
242
+ ' </svg>',
243
+ '</span>'
244
+ ].join('\n');
245
+ button.setAttribute('data-webstir-search-trigger', 'true');
246
+ button.setAttribute('aria-controls', 'webstir-search');
247
+ button.setAttribute('aria-expanded', 'false');
248
+ button.setAttribute('aria-label', `Search (${shortcut})`);
249
+ button.setAttribute('title', `Search (${shortcut})`);
250
+
251
+ const nav = menu.querySelector('.app-nav');
252
+ if (nav) {
253
+ nav.insertAdjacentElement('afterend', button);
254
+ } else {
255
+ menu.appendChild(button);
256
+ }
257
+
258
+ state.trigger = button;
259
+ return button;
260
+ }
261
+
262
+ function ensureUi(): HTMLElement {
263
+ let root = document.getElementById('webstir-search');
264
+ if (root) {
265
+ ensureBackdrop(root);
266
+ return root;
267
+ }
268
+
269
+ root = document.createElement('div');
270
+ root.id = 'webstir-search';
271
+ root.innerHTML = [
272
+ '<div class="ws-drawer-backdrop" data-webstir-search-close></div>',
273
+ '<div class="webstir-search__drawer" role="dialog" aria-modal="true" aria-label="Search">',
274
+ ' <div class="webstir-search__drawer-inner ws-container">',
275
+ ' <div class="webstir-search__field">',
276
+ ' <span class="webstir-search__icon" aria-hidden="true">',
277
+ ' <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">',
278
+ ' <circle cx="11" cy="11" r="7"></circle>',
279
+ ' <path d="M20 20l-3.5-3.5"></path>',
280
+ ' </svg>',
281
+ ' </span>',
282
+ ' <input class="webstir-search__input" type="search" placeholder="Search…" autocomplete="off" spellcheck="false" autocapitalize="none" />',
283
+ ' <button type="button" class="webstir-search__close" data-webstir-search-close aria-label="Close search">',
284
+ ' <span aria-hidden="true">Esc</span>',
285
+ ' </button>',
286
+ ' </div>',
287
+ ' <div class="webstir-search__scopes" role="toolbar" aria-label="Search scope">',
288
+ ' <button type="button" data-scope="all" aria-pressed="true">All</button>',
289
+ ' <button type="button" data-scope="docs" aria-pressed="false">Docs</button>',
290
+ ' <button type="button" data-scope="page" aria-pressed="false">Pages</button>',
291
+ ' </div>',
292
+ ' <div class="webstir-search__body">',
293
+ ' <div class="webstir-search__hint" role="status" aria-live="polite"></div>',
294
+ ' <div class="webstir-search__quicklinks" hidden>',
295
+ ' <div class="webstir-search__label">Quick links</div>',
296
+ ' <ul class="webstir-search__quicklinks-list"></ul>',
297
+ ' </div>',
298
+ ' <div class="webstir-search__matches" hidden>',
299
+ ' <ul class="webstir-search__results-list"></ul>',
300
+ ' </div>',
301
+ ' </div>',
302
+ ' </div>',
303
+ '</div>'
304
+ ].join('\n');
305
+
306
+ document.body.appendChild(root);
307
+ return root;
308
+ }
309
+
310
+ function ensureBackdrop(root: HTMLElement): void {
311
+ const existing = root.querySelector<HTMLElement>('.ws-drawer-backdrop');
312
+ if (existing) {
313
+ return;
314
+ }
315
+
316
+ const legacy = root.querySelector<HTMLElement>('.webstir-search__backdrop');
317
+ if (legacy) {
318
+ legacy.classList.add('ws-drawer-backdrop');
319
+ legacy.classList.remove('webstir-search__backdrop');
320
+ legacy.setAttribute('data-webstir-search-close', '');
321
+ legacy.setAttribute('aria-hidden', 'true');
322
+ return;
323
+ }
324
+
325
+ const created = document.createElement('div');
326
+ created.className = 'ws-drawer-backdrop';
327
+ created.setAttribute('data-webstir-search-close', '');
328
+ created.setAttribute('aria-hidden', 'true');
329
+ root.prepend(created);
330
+ }
331
+
332
+ function openSearch(options?: { initialQuery?: string }): void {
333
+ if (state.open) {
334
+ const input = document.querySelector<HTMLInputElement>('#webstir-search .webstir-search__input');
335
+ input?.focus();
336
+ input?.select();
337
+ return;
338
+ }
339
+
340
+ const root = ensureUi();
341
+ const activeDrawer = drawer ?? createDrawer({
342
+ root,
343
+ openAttribute: 'data-open',
344
+ bodyClass: 'webstir-search-open',
345
+ overlay: {
346
+ headerSelector: '.app-header',
347
+ target: root,
348
+ varName: '--ws-drawer-top'
349
+ },
350
+ closeOnEscape: true,
351
+ trapFocus: true,
352
+ onOpen: () => {
353
+ state.open = true;
354
+ const trigger = ensureTrigger();
355
+ trigger?.setAttribute('aria-expanded', 'true');
356
+ },
357
+ onClose: () => {
358
+ state.open = false;
359
+ const trigger = ensureTrigger();
360
+ trigger?.setAttribute('aria-expanded', 'false');
361
+ state.lastActiveElement?.focus?.();
362
+ state.lastActiveElement = null;
363
+ }
364
+ });
365
+ drawer = activeDrawer;
366
+ activeDrawer.syncOverlayOffset();
367
+
368
+ state.lastActiveElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
369
+ activeDrawer.open();
370
+
371
+ const menu = document.querySelector<HTMLElement>('[data-app-menu]');
372
+ if (menu?.classList.contains('is-open')) {
373
+ menu.querySelector<HTMLButtonElement>('.app-menu__toggle')?.click();
374
+ }
375
+
376
+ const input = root.querySelector<HTMLInputElement>('.webstir-search__input');
377
+ if (input) {
378
+ if (options?.initialQuery) {
379
+ input.value = options.initialQuery;
380
+ }
381
+ input.focus();
382
+ input.select();
383
+ }
384
+
385
+ void refreshResults();
386
+ }
387
+
388
+ function closeSearch(): void {
389
+ if (!state.open) {
390
+ return;
391
+ }
392
+
393
+ drawer?.close();
394
+ }
395
+
396
+ function toggleSearch(): void {
397
+ if (state.open) {
398
+ closeSearch();
399
+ } else {
400
+ openSearch();
401
+ }
402
+ }
403
+
404
+ function renderQuickLinks(): string {
405
+ const links = Array.from(document.querySelectorAll<HTMLAnchorElement>('.app-nav a'))
406
+ .filter((link) => !!link.getAttribute('href'))
407
+ .slice(0, 6);
408
+
409
+ if (links.length === 0) {
410
+ return '<li class="webstir-search__empty">Search docs and pages.</li>';
411
+ }
412
+
413
+ return links
414
+ .map((link) => {
415
+ const href = escapeHtml(withBasePath(link.getAttribute('href') ?? '#'));
416
+ const label = escapeHtml(link.textContent ?? '');
417
+ return `<li><a href="${href}"><span>${label}</span><span class="webstir-search__arrow" aria-hidden="true">→</span></a></li>`;
418
+ })
419
+ .join('');
420
+ }
421
+
422
+ async function refreshResults(): Promise<void> {
423
+ const root = document.getElementById('webstir-search');
424
+ if (!root) {
425
+ return;
426
+ }
427
+
428
+ const input = root.querySelector<HTMLInputElement>('.webstir-search__input');
429
+ const hint = root.querySelector<HTMLElement>('.webstir-search__hint');
430
+ const quickLinks = root.querySelector<HTMLElement>('.webstir-search__quicklinks');
431
+ const quickLinksList = root.querySelector<HTMLElement>('.webstir-search__quicklinks-list');
432
+ const matchesRoot = root.querySelector<HTMLElement>('.webstir-search__matches');
433
+ const resultsList = root.querySelector<HTMLElement>('.webstir-search__results-list');
434
+ const scopes = root.querySelector<HTMLElement>('.webstir-search__scopes');
435
+
436
+ if (!input || !hint || !quickLinks || !quickLinksList || !matchesRoot || !resultsList || !scopes) {
437
+ return;
438
+ }
439
+
440
+ const entries = await ensureIndexLoaded();
441
+ const kinds = new Set(entries.map((e) => e.kind));
442
+
443
+ const query = stripAndNormalize(input.value);
444
+ const showScopes = kinds.size > 1 && query.length >= 2;
445
+ if (showScopes) {
446
+ scopes.removeAttribute('hidden');
447
+ } else {
448
+ scopes.setAttribute('hidden', '');
449
+ }
450
+
451
+ if (!query) {
452
+ hint.textContent = '';
453
+ quickLinksList.innerHTML = renderQuickLinks();
454
+ quickLinks.removeAttribute('hidden');
455
+ matchesRoot.setAttribute('hidden', '');
456
+ resultsList.innerHTML = '';
457
+ return;
458
+ }
459
+
460
+ quickLinks.setAttribute('hidden', '');
461
+
462
+ if (query.length < 2) {
463
+ hint.textContent = 'Search docs and pages.';
464
+ matchesRoot.setAttribute('hidden', '');
465
+ resultsList.innerHTML = '';
466
+ return;
467
+ }
468
+
469
+ const matches = entries
470
+ .filter((entry) => (state.scope === 'all' ? true : entry.kind === state.scope))
471
+ .filter((entry) => entry.haystack.includes(query))
472
+ .map((entry) => ({ entry, score: computeScore(entry, query) }))
473
+ .sort((a, b) => {
474
+ if (a.score !== b.score) {
475
+ return b.score - a.score;
476
+ }
477
+ return String(a.entry.title).localeCompare(String(b.entry.title));
478
+ })
479
+ .map((item) => item.entry);
480
+
481
+ matchesRoot.removeAttribute('hidden');
482
+
483
+ if (matches.length === 0) {
484
+ hint.textContent = '';
485
+ resultsList.innerHTML = '<li class="webstir-search__empty">No results.</li>';
486
+ return;
487
+ }
488
+
489
+ hint.textContent = '';
490
+
491
+ const renderEntry = (entry: SearchIndexEntry): string => {
492
+ const title = escapeHtml(entry.title);
493
+ const excerpt = escapeHtml(entry.excerpt);
494
+ const href = escapeHtml(withBasePath(entry.path));
495
+ return `<li><a href="${href}"><strong>${title}</strong><span>${excerpt}</span></a></li>`;
496
+ };
497
+
498
+ const shouldGroup = state.scope === 'all' && kinds.size > 1;
499
+ if (!shouldGroup) {
500
+ resultsList.innerHTML = matches.slice(0, 12).map(renderEntry).join('');
501
+ return;
502
+ }
503
+
504
+ const docs = matches.filter((m) => m.kind === 'docs').slice(0, 6);
505
+ const pages = matches.filter((m) => m.kind === 'page').slice(0, 6);
506
+
507
+ const sections: string[] = [];
508
+ if (docs.length > 0) {
509
+ sections.push('<li class="webstir-search__group">Docs</li>');
510
+ sections.push(...docs.map(renderEntry));
511
+ }
512
+ if (pages.length > 0) {
513
+ sections.push('<li class="webstir-search__group">Pages</li>');
514
+ sections.push(...pages.map(renderEntry));
515
+ }
516
+
517
+ resultsList.innerHTML = sections.join('');
518
+ }
519
+
520
+ function getResultLinks(root: HTMLElement): HTMLAnchorElement[] {
521
+ const links = Array.from(
522
+ root.querySelectorAll<HTMLAnchorElement>('.webstir-search__results-list a, .webstir-search__quicklinks-list a')
523
+ );
524
+ return links.filter((link) => !link.closest('[hidden]') && link.offsetParent !== null);
525
+ }
526
+
527
+ function boot(): void {
528
+ const root = ensureUi();
529
+
530
+ const trigger = ensureTrigger();
531
+ if (trigger) {
532
+ trigger.addEventListener('click', () => toggleSearch());
533
+ }
534
+
535
+ document.addEventListener('keydown', (event) => {
536
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
537
+ event.preventDefault();
538
+ toggleSearch();
539
+ }
540
+ });
541
+
542
+ root.addEventListener('keydown', (event) => {
543
+ const target = event.target;
544
+ if (target instanceof HTMLAnchorElement
545
+ && target.matches('.webstir-search__results-list a, .webstir-search__quicklinks-list a')) {
546
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Home' || event.key === 'End') {
547
+ const links = getResultLinks(root);
548
+ const index = links.indexOf(target);
549
+ if (index >= 0) {
550
+ let nextIndex = index;
551
+ if (event.key === 'Home') {
552
+ nextIndex = 0;
553
+ } else if (event.key === 'End') {
554
+ nextIndex = links.length - 1;
555
+ } else if (event.key === 'ArrowDown') {
556
+ nextIndex = Math.min(index + 1, links.length - 1);
557
+ } else {
558
+ nextIndex = Math.max(index - 1, 0);
559
+ }
560
+ event.preventDefault();
561
+ links[nextIndex]?.focus();
562
+ }
563
+ return;
564
+ }
565
+ }
566
+
567
+ });
568
+
569
+ root.addEventListener('click', (event) => {
570
+ const target = event.target;
571
+ if (!(target instanceof Element)) {
572
+ return;
573
+ }
574
+
575
+ if (target.closest('[data-webstir-search-close]')) {
576
+ closeSearch();
577
+ return;
578
+ }
579
+
580
+ if (target.matches('.ws-drawer-backdrop')) {
581
+ closeSearch();
582
+ return;
583
+ }
584
+
585
+ const resultLink = target.closest('.webstir-search__results-list a, .webstir-search__quicklinks-list a');
586
+ if (resultLink) {
587
+ closeSearch();
588
+ }
589
+ });
590
+
591
+ root.querySelectorAll<HTMLButtonElement>('.webstir-search__scopes button[data-scope]').forEach((button) => {
592
+ button.addEventListener('click', () => setScope(parseScope(button.getAttribute('data-scope'))));
593
+ });
594
+
595
+ const input = root.querySelector<HTMLInputElement>('.webstir-search__input');
596
+ if (input) {
597
+ input.addEventListener('input', () => void refreshResults());
598
+ input.addEventListener('keydown', (event) => {
599
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') {
600
+ return;
601
+ }
602
+
603
+ const links = getResultLinks(root);
604
+ if (links.length > 0) {
605
+ event.preventDefault();
606
+ const target = event.key === 'ArrowDown' ? links[0] : links[links.length - 1];
607
+ target?.focus();
608
+ }
609
+ });
610
+ }
611
+
612
+ window.addEventListener('resize', () => {
613
+ if (!state.open) {
614
+ return;
615
+ }
616
+
617
+ drawer?.syncOverlayOffset();
618
+ });
619
+
620
+ window.addEventListener('webstir:client-nav', () => {
621
+ if (state.open) {
622
+ closeSearch();
623
+ }
624
+ });
625
+ }
626
+
627
+ boot();
@@ -0,0 +1,13 @@
1
+ import http from 'node:http';
2
+
3
+ const PORT = Number(process.env.PORT || 4321);
4
+ const server = http.createServer((req, res) => {
5
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
6
+ res.end('API server running');
7
+ });
8
+
9
+ server.listen(PORT, '0.0.0.0', () => {
10
+ console.log(`API server running at http://localhost:${PORT}`);
11
+ });
12
+
13
+ export default server;
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "../../base.tsconfig.json",
3
+ "compilerOptions": {
4
+ "target": "ES2022",
5
+ "lib": ["ES2022"],
6
+ "outDir": "../../build/backend",
7
+ "rootDir": ".",
8
+ "baseUrl": ".",
9
+ "incremental": true,
10
+ "tsBuildInfoFile": "../../build/backend/.tsbuildinfo"
11
+ },
12
+ "include": ["**/*.ts", "../../types/**/*.d.ts"],
13
+ "exclude": ["node_modules", "../../build", "../../dist"],
14
+ "references": []
15
+ }
@@ -0,0 +1,23 @@
1
+ export interface RouteHandler {
2
+ onEnter?: (params: RouteParams) => void | Promise<void>;
3
+ onLeave?: () => void | Promise<void>;
4
+ onUpdate?: (params: RouteParams) => void | Promise<void>;
5
+ }
6
+
7
+ export interface RouteParams {
8
+ [key: string]: string;
9
+ }
10
+
11
+ export interface RoutingMetadata {
12
+ pages: {
13
+ [pageName: string]: PageRouteInfo;
14
+ };
15
+ hasSpaPages: boolean;
16
+ }
17
+
18
+ export interface PageRouteInfo {
19
+ pageName: string;
20
+ route: string;
21
+ isSpaEnabled: boolean;
22
+ typeScriptPath: string;
23
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../base.tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../../build/shared",
5
+ "rootDir": ".",
6
+ "composite": true,
7
+ "declaration": true
8
+ },
9
+ "include": ["**/*.ts", "../../types/**/*.d.ts"]
10
+ }
@@ -0,0 +1,4 @@
1
+ export interface ApiResponse<T> {
2
+ data?: T;
3
+ error?: string;
4
+ }