@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,358 @@
1
+ export {};
2
+
3
+ type DocsNavEntry = {
4
+ path: string;
5
+ title: string;
6
+ section?: string;
7
+ order?: number;
8
+ };
9
+
10
+ type NavNode = {
11
+ segment: string;
12
+ path: string;
13
+ title: string;
14
+ children: NavNode[];
15
+ isPage: boolean;
16
+ position: number;
17
+ };
18
+
19
+ type ContentNavState = {
20
+ navEntries?: DocsNavEntry[];
21
+ };
22
+
23
+ const STATE_KEY = '__webstirContentNavState';
24
+ const BASE_PATH = resolveBasePath();
25
+ const NAV_URL = withBasePath('/docs-nav.json');
26
+ const NAV_LAYOUT_SELECTOR = '[data-content-nav="true"]';
27
+ const APP_NAV_SELECTOR = '.app-nav';
28
+ const APP_NAV_DOCS_SELECTOR = '[data-docs-nav-menu]';
29
+
30
+ function getState(): ContentNavState {
31
+ const w = window as unknown as Record<string, ContentNavState | undefined>;
32
+ if (!w[STATE_KEY]) {
33
+ w[STATE_KEY] = {};
34
+ }
35
+ return w[STATE_KEY] as ContentNavState;
36
+ }
37
+
38
+ function normalizeDocsPath(pathname: string): string {
39
+ const normalized = stripBasePath(pathname);
40
+ if (!normalized.startsWith('/docs')) {
41
+ return normalized;
42
+ }
43
+ if (normalized === '/docs') {
44
+ return '/docs/';
45
+ }
46
+ return normalized.endsWith('/') ? normalized : `${normalized}/`;
47
+ }
48
+
49
+ function resolveBasePath(): string {
50
+ const raw = document.documentElement?.getAttribute('data-webstir-base') ?? '';
51
+ return normalizeBasePath(raw);
52
+ }
53
+
54
+ function normalizeBasePath(value: string): string {
55
+ const trimmed = value.trim();
56
+ if (!trimmed || trimmed === '/') {
57
+ return '';
58
+ }
59
+ if (!trimmed.startsWith('/')) {
60
+ return `/${trimmed}`;
61
+ }
62
+ return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed;
63
+ }
64
+
65
+ function withBasePath(value: string): string {
66
+ if (!BASE_PATH) {
67
+ return value;
68
+ }
69
+ if (!value.startsWith('/') || value.startsWith('//')) {
70
+ return value;
71
+ }
72
+ if (value === BASE_PATH || value.startsWith(`${BASE_PATH}/`) || value.startsWith(`${BASE_PATH}?`) || value.startsWith(`${BASE_PATH}#`)) {
73
+ return value;
74
+ }
75
+ return `${BASE_PATH}${value}`;
76
+ }
77
+
78
+ function stripBasePath(value: string): string {
79
+ if (!BASE_PATH || !value.startsWith('/')) {
80
+ return value;
81
+ }
82
+ if (value === BASE_PATH) {
83
+ return '/';
84
+ }
85
+ if (value.startsWith(`${BASE_PATH}/`) || value.startsWith(`${BASE_PATH}?`) || value.startsWith(`${BASE_PATH}#`)) {
86
+ return value.slice(BASE_PATH.length);
87
+ }
88
+ return value;
89
+ }
90
+
91
+ async function fetchDocsNav(): Promise<DocsNavEntry[]> {
92
+ const state = getState();
93
+ if (state.navEntries) {
94
+ return state.navEntries;
95
+ }
96
+
97
+ try {
98
+ const response = await fetch(NAV_URL, { headers: { Accept: 'application/json' } });
99
+ if (!response.ok) {
100
+ state.navEntries = [];
101
+ return [];
102
+ }
103
+
104
+ const payload = await response.json();
105
+ if (!Array.isArray(payload)) {
106
+ state.navEntries = [];
107
+ return [];
108
+ }
109
+
110
+ const entries = payload
111
+ .filter((entry): entry is DocsNavEntry => Boolean(entry && entry.path && entry.title))
112
+ .map((entry) => ({
113
+ path: String(entry.path),
114
+ title: String(entry.title),
115
+ section: typeof entry.section === 'string' ? entry.section : undefined,
116
+ order: typeof entry.order === 'number' ? entry.order : undefined
117
+ }));
118
+
119
+ state.navEntries = entries;
120
+ return entries;
121
+ } catch {
122
+ state.navEntries = [];
123
+ return [];
124
+ }
125
+ }
126
+
127
+ function buildNavTree(entries: readonly DocsNavEntry[]): NavNode {
128
+ let position = 0;
129
+ const root: NavNode = {
130
+ segment: 'docs',
131
+ path: '/docs/',
132
+ title: 'Docs',
133
+ children: [],
134
+ isPage: false,
135
+ position: position++
136
+ };
137
+
138
+ for (const entry of entries) {
139
+ const normalizedPath = normalizeDocsPath(entry.path);
140
+ const segments = normalizedPath.split('/').filter(Boolean);
141
+ if (segments.length === 0) {
142
+ continue;
143
+ }
144
+
145
+ let current = root;
146
+ for (let index = 1; index < segments.length; index += 1) {
147
+ const segment = segments[index];
148
+ const nodePath = `/${segments.slice(0, index + 1).join('/')}/`;
149
+ let child = current.children.find((node) => node.segment === segment);
150
+ if (!child) {
151
+ child = {
152
+ segment,
153
+ path: nodePath,
154
+ title: toTitleCase(segment.replace(/[-_]/g, ' ')),
155
+ children: [],
156
+ isPage: false,
157
+ position: position++
158
+ };
159
+ current.children.push(child);
160
+ }
161
+ current = child;
162
+ }
163
+
164
+ current.title = entry.title;
165
+ current.isPage = true;
166
+ }
167
+
168
+ return root;
169
+ }
170
+
171
+ function renderNavList(nodes: readonly NavNode[], currentPath: string, depth = 0): HTMLOListElement {
172
+ const list = document.createElement('ol');
173
+ list.className = depth === 0 ? 'docs-nav__list' : 'docs-nav__list docs-nav__list--nested';
174
+
175
+ const sorted = [...nodes].sort((a, b) => a.position - b.position);
176
+ for (const node of sorted) {
177
+ const item = document.createElement('li');
178
+ item.className = 'docs-nav__item';
179
+
180
+ const isActive = node.path === currentPath;
181
+ const isBranch = !isActive && currentPath.startsWith(node.path);
182
+ if (isActive) {
183
+ item.dataset.active = 'true';
184
+ } else if (isBranch) {
185
+ item.dataset.activeBranch = 'true';
186
+ }
187
+
188
+ if (node.isPage) {
189
+ const link = document.createElement('a');
190
+ link.className = 'docs-nav__link';
191
+ link.href = withBasePath(node.path);
192
+ link.textContent = node.title;
193
+ if (isActive) {
194
+ link.setAttribute('aria-current', 'page');
195
+ }
196
+ item.appendChild(link);
197
+ } else {
198
+ const label = document.createElement('span');
199
+ label.className = 'docs-nav__label';
200
+ label.textContent = node.title;
201
+ item.appendChild(label);
202
+ }
203
+
204
+ if (node.children.length > 0) {
205
+ item.appendChild(renderNavList(node.children, currentPath, depth + 1));
206
+ }
207
+
208
+ list.appendChild(item);
209
+ }
210
+
211
+ return list;
212
+ }
213
+
214
+ function clearAppMenuDocsNav(): void {
215
+ const appNav = document.querySelector<HTMLElement>(APP_NAV_SELECTOR);
216
+ const existing = appNav?.querySelector<HTMLElement>(APP_NAV_DOCS_SELECTOR);
217
+ existing?.remove();
218
+ }
219
+
220
+ function renderAppMenuDocsNav(tree: NavNode, currentPath: string): void {
221
+ const appNav = document.querySelector<HTMLElement>(APP_NAV_SELECTOR);
222
+ if (!appNav) {
223
+ return;
224
+ }
225
+
226
+ const section = document.createElement('div');
227
+ section.className = 'app-nav__docs';
228
+ section.dataset.docsNavMenu = 'true';
229
+
230
+ const topNodes = tree.children;
231
+ const nodes =
232
+ topNodes.length === 1 && !topNodes[0].isPage && topNodes[0].children.length > 0
233
+ ? topNodes[0].children
234
+ : topNodes;
235
+
236
+ const list = renderNavList(nodes, currentPath);
237
+ section.appendChild(list);
238
+
239
+ const docsHref = withBasePath('/docs/');
240
+ const docsHrefNoSlash = docsHref.endsWith('/') ? docsHref.slice(0, -1) : docsHref;
241
+ const docsLink = appNav.querySelector<HTMLAnchorElement>(`a[href="${docsHref}"], a[href="${docsHrefNoSlash}"]`);
242
+ if (docsLink) {
243
+ docsLink.insertAdjacentElement('afterend', section);
244
+ } else {
245
+ appNav.appendChild(section);
246
+ }
247
+ }
248
+
249
+ function renderBreadcrumb(
250
+ root: HTMLElement,
251
+ titleByPath: ReadonlyMap<string, string>,
252
+ currentPath: string
253
+ ): boolean {
254
+ if (!currentPath.startsWith('/docs/')) {
255
+ root.setAttribute('aria-hidden', 'true');
256
+ root.dataset.breadcrumbVisible = 'false';
257
+ root.innerHTML = '';
258
+ return false;
259
+ }
260
+
261
+ const list = document.createElement('ol');
262
+ list.className = 'docs-breadcrumb__list';
263
+
264
+ const segments = currentPath.replace(/^\/docs\/?/, '').split('/').filter(Boolean);
265
+ const crumbs: Array<{ title: string; href: string }> = [];
266
+
267
+ const rootTitle = titleByPath.get('/docs/') ?? 'Docs';
268
+ crumbs.push({ title: rootTitle, href: '/docs/' });
269
+
270
+ let current = '/docs/';
271
+ for (const segment of segments) {
272
+ current = `${current}${segment}/`;
273
+ const title = titleByPath.get(current) ?? toTitleCase(segment.replace(/[-_]/g, ' '));
274
+ crumbs.push({ title, href: current });
275
+ }
276
+
277
+ for (let index = 0; index < crumbs.length; index += 1) {
278
+ const crumb = crumbs[index];
279
+ const item = document.createElement('li');
280
+ item.className = 'docs-breadcrumb__item';
281
+
282
+ if (index === crumbs.length - 1) {
283
+ const label = document.createElement('span');
284
+ label.textContent = crumb.title;
285
+ label.setAttribute('aria-current', 'page');
286
+ item.appendChild(label);
287
+ } else {
288
+ const link = document.createElement('a');
289
+ link.className = 'docs-breadcrumb__link';
290
+ link.href = withBasePath(crumb.href);
291
+ link.textContent = crumb.title;
292
+ item.appendChild(link);
293
+ }
294
+
295
+ list.appendChild(item);
296
+ }
297
+
298
+ root.innerHTML = '';
299
+ root.appendChild(list);
300
+ root.removeAttribute('aria-hidden');
301
+ root.dataset.breadcrumbVisible = 'true';
302
+ return true;
303
+ }
304
+
305
+ function toTitleCase(value: string): string {
306
+ return value
307
+ .split(/\s+/)
308
+ .filter((part) => part.length > 0)
309
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
310
+ .join(' ');
311
+ }
312
+
313
+ async function initContentNav(): Promise<void> {
314
+ const layouts = Array.from(document.querySelectorAll<HTMLElement>(NAV_LAYOUT_SELECTOR));
315
+ clearAppMenuDocsNav();
316
+ if (layouts.length === 0) {
317
+ return;
318
+ }
319
+
320
+ const navEntries = await fetchDocsNav();
321
+ const titleByPath = new Map<string, string>(
322
+ navEntries.map((entry) => [normalizeDocsPath(entry.path), entry.title])
323
+ );
324
+
325
+ const tree = buildNavTree(navEntries);
326
+ const currentPath = normalizeDocsPath(window.location.pathname);
327
+ if (navEntries.length > 0) {
328
+ renderAppMenuDocsNav(tree, currentPath);
329
+ }
330
+
331
+ for (const layout of layouts) {
332
+ const sidebar = layout.querySelector<HTMLElement>('[data-docs-sidebar]');
333
+ const navRoot = layout.querySelector<HTMLElement>('[data-docs-nav]');
334
+ const breadcrumb = layout.querySelector<HTMLElement>('[data-docs-breadcrumb]');
335
+ let hasNav = false;
336
+ if (navRoot && sidebar && navEntries.length > 0) {
337
+ const list = renderNavList(tree.children, currentPath);
338
+ navRoot.innerHTML = '';
339
+ navRoot.appendChild(list);
340
+ hasNav = true;
341
+ }
342
+
343
+ if (breadcrumb) {
344
+ renderBreadcrumb(breadcrumb, titleByPath, currentPath);
345
+ }
346
+
347
+ if (hasNav) {
348
+ layout.dataset.contentNavReady = 'true';
349
+ } else {
350
+ layout.dataset.contentNavReady = 'false';
351
+ }
352
+ }
353
+ }
354
+
355
+ void initContentNav();
356
+ window.addEventListener('webstir:client-nav', () => {
357
+ void initContentNav();
358
+ });
@@ -0,0 +1,6 @@
1
+ export type RouteParams = Record<string, string | undefined>;
2
+
3
+ export type RouteHandler = {
4
+ onEnter?: (params: RouteParams) => void | Promise<void>;
5
+ onLeave?: () => void | Promise<void>;
6
+ };
@@ -0,0 +1,118 @@
1
+ import type { RouteHandler, RouteParams } from './router-types.js';
2
+
3
+ export class Router {
4
+ private routes = new Map<string, RouteHandler>();
5
+ private currentHandler: RouteHandler | null = null;
6
+
7
+ public constructor() {
8
+ this.setupBrowserNavigation();
9
+ this.interceptLinkClicks();
10
+ }
11
+
12
+ public registerRoute(path: string, handler: RouteHandler): void {
13
+ this.routes.set(path, handler);
14
+ }
15
+
16
+ public async navigate(url: string): Promise<void> {
17
+ const targetPath = new URL(url, window.location.origin).pathname;
18
+ const routeHandler = this.routes.get(targetPath);
19
+
20
+ if (!routeHandler) {
21
+ window.location.href = url;
22
+ return;
23
+ }
24
+
25
+ window.history.pushState({}, '', url);
26
+ await this.handleRouteChange();
27
+ }
28
+
29
+ private setupBrowserNavigation(): void {
30
+ window.addEventListener('popstate', () => {
31
+ this.handleRouteChange();
32
+ });
33
+ }
34
+
35
+ private interceptLinkClicks(): void {
36
+ document.addEventListener('click', (event) => {
37
+ const target = event.target as EventTarget | null;
38
+ if (!(target instanceof Element)) {
39
+ return;
40
+ }
41
+ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
42
+ return;
43
+ }
44
+
45
+ const clickedLink = target.closest('a');
46
+ if (!clickedLink || !this.shouldInterceptLink(clickedLink as HTMLAnchorElement)) {
47
+ return;
48
+ }
49
+
50
+ event.preventDefault();
51
+ this.navigate((clickedLink as HTMLAnchorElement).href);
52
+ });
53
+ }
54
+
55
+ private async handleRouteChange(): Promise<void> {
56
+ const currentPath = window.location.pathname;
57
+ const routeParams = this.extractQueryParams();
58
+
59
+ await this.callOnLeaveHandler();
60
+
61
+ const newHandler = this.routes.get(currentPath);
62
+ if (newHandler) {
63
+ this.currentHandler = newHandler;
64
+ await this.callOnEnterHandler(newHandler, routeParams);
65
+ }
66
+ }
67
+
68
+ private async callOnLeaveHandler(): Promise<void> {
69
+ if (this.currentHandler?.onLeave) {
70
+ await this.currentHandler.onLeave();
71
+ }
72
+ }
73
+
74
+ private async callOnEnterHandler(handler: RouteHandler, params: RouteParams): Promise<void> {
75
+ if (handler.onEnter) {
76
+ await handler.onEnter(params);
77
+ }
78
+ }
79
+
80
+ private shouldInterceptLink(link: HTMLAnchorElement): boolean {
81
+ const isExternalLink = link.origin !== window.location.origin;
82
+ const isDownloadLink = link.hasAttribute('download');
83
+ const opensInNewTab = link.getAttribute('target') === '_blank';
84
+ const hasRouteHandler = this.routes.has(link.pathname);
85
+
86
+ return !isExternalLink && !isDownloadLink && !opensInNewTab && hasRouteHandler;
87
+ }
88
+
89
+ private extractQueryParams(): RouteParams {
90
+ const params: RouteParams = {};
91
+ const urlSearchParams = new URLSearchParams(window.location.search);
92
+
93
+ urlSearchParams.forEach((value, key) => {
94
+ params[key] = value;
95
+ });
96
+
97
+ return params;
98
+ }
99
+ }
100
+
101
+ let singleton: Router | null = null;
102
+
103
+ export function startRouter(): Router {
104
+ if (!singleton) {
105
+ singleton = new Router();
106
+ }
107
+ return singleton;
108
+ }
109
+
110
+ export function getRouter(): Router | null {
111
+ return singleton;
112
+ }
113
+
114
+ export function navigate(url: string): Promise<void> {
115
+ return startRouter().navigate(url);
116
+ }
117
+
118
+ export type { RouteHandler, RouteParams };
@@ -0,0 +1,204 @@
1
+ @layer features {
2
+ #webstir-search {
3
+ position: fixed;
4
+ inset-inline: 0;
5
+ bottom: 0;
6
+ top: var(--ws-drawer-top, 0px);
7
+ z-index: 9999;
8
+ opacity: 0;
9
+ pointer-events: none;
10
+ }
11
+
12
+ #webstir-search[data-open="true"] {
13
+ opacity: 1;
14
+ pointer-events: auto;
15
+ }
16
+
17
+ #webstir-search[data-open="true"] .ws-drawer-backdrop {
18
+ opacity: 1;
19
+ pointer-events: auto;
20
+ }
21
+
22
+ body.webstir-search-open {
23
+ overflow: hidden;
24
+ }
25
+
26
+ .webstir-search__trigger-icon {
27
+ display: inline-flex;
28
+ opacity: 0.9;
29
+ pointer-events: none;
30
+ }
31
+
32
+ .webstir-search__trigger-icon * {
33
+ pointer-events: none;
34
+ }
35
+
36
+ .webstir-search__drawer {
37
+ position: relative;
38
+ background: var(--ws-bg);
39
+ border-bottom: 1px solid var(--ws-border);
40
+ box-shadow: 0 1.5rem 3rem -1.5rem rgba(0, 0, 0, 0.28);
41
+ z-index: 20;
42
+ }
43
+
44
+ .webstir-search__drawer-inner {
45
+ padding-block: 1.25rem 1.5rem;
46
+ }
47
+
48
+ .webstir-search__field {
49
+ display: flex;
50
+ align-items: center;
51
+ gap: 0.75rem;
52
+ padding-block: 0.25rem;
53
+ }
54
+
55
+ .webstir-search__icon {
56
+ display: inline-flex;
57
+ opacity: 0.7;
58
+ }
59
+
60
+ .webstir-search__input {
61
+ flex: 1;
62
+ min-width: 0;
63
+ border: 0;
64
+ background: transparent;
65
+ color: inherit;
66
+ outline: none;
67
+ font-size: 1.75rem;
68
+ font-weight: 600;
69
+ padding: 0.25rem 0;
70
+ }
71
+
72
+ .webstir-search__input::placeholder {
73
+ color: var(--ws-muted);
74
+ opacity: 1;
75
+ }
76
+
77
+ .webstir-search__close {
78
+ padding: 0.375rem 0.625rem;
79
+ border-radius: 999px;
80
+ border: 1px solid var(--ws-border);
81
+ background: transparent;
82
+ color: var(--ws-muted);
83
+ font-size: 0.75rem;
84
+ font-weight: 600;
85
+ cursor: pointer;
86
+ }
87
+
88
+ .webstir-search__close:hover {
89
+ background: rgba(17, 24, 39, 0.04);
90
+ }
91
+
92
+ [data-theme="dark"] .webstir-search__close:hover {
93
+ background: rgba(255, 255, 255, 0.06);
94
+ }
95
+
96
+ .webstir-search__scopes {
97
+ display: flex;
98
+ flex-wrap: wrap;
99
+ gap: 0.5rem;
100
+ margin-top: 0.75rem;
101
+ }
102
+
103
+ .webstir-search__scopes[hidden] {
104
+ display: none;
105
+ }
106
+
107
+ .webstir-search__scopes button {
108
+ padding: 0.375rem 0.625rem;
109
+ border-radius: 999px;
110
+ border: 1px solid var(--ws-border);
111
+ background: transparent;
112
+ color: inherit;
113
+ cursor: pointer;
114
+ font-weight: 600;
115
+ font-size: 0.875rem;
116
+ }
117
+
118
+ .webstir-search__scopes button[aria-pressed="true"] {
119
+ background: rgba(37, 99, 235, 0.12);
120
+ border-color: rgba(37, 99, 235, 0.35);
121
+ }
122
+
123
+ .webstir-search__body {
124
+ margin-top: 1.25rem;
125
+ max-height: min(60vh, 36rem);
126
+ overflow: auto;
127
+ padding-bottom: 0.5rem;
128
+ }
129
+
130
+ .webstir-search__hint {
131
+ color: var(--ws-muted);
132
+ }
133
+
134
+ .webstir-search__label {
135
+ margin-top: 0.5rem;
136
+ font-size: 0.75rem;
137
+ font-weight: 600;
138
+ color: var(--ws-muted);
139
+ }
140
+
141
+ .webstir-search__quicklinks[hidden],
142
+ .webstir-search__matches[hidden] {
143
+ display: none;
144
+ }
145
+
146
+ .webstir-search__quicklinks-list,
147
+ .webstir-search__results-list {
148
+ margin: 0.5rem 0 0;
149
+ }
150
+
151
+ .webstir-search__quicklinks-list a:focus-visible,
152
+ .webstir-search__results-list a:focus-visible {
153
+ outline: 0.1875rem solid var(--ws-focus);
154
+ outline-offset: 0.125rem;
155
+ }
156
+
157
+ .webstir-search__results-list strong {
158
+ display: block;
159
+ font-weight: 600;
160
+ margin-bottom: 0.25rem;
161
+ }
162
+
163
+ .webstir-search__results-list span {
164
+ display: block;
165
+ color: var(--ws-muted);
166
+ line-height: 1.35;
167
+ }
168
+
169
+ .webstir-search__arrow {
170
+ opacity: 0.7;
171
+ }
172
+
173
+ .webstir-search__group {
174
+ margin-top: 0.75rem;
175
+ font-size: 0.75rem;
176
+ font-weight: 600;
177
+ color: var(--ws-muted);
178
+ }
179
+
180
+ .webstir-search__empty {
181
+ padding: 0.5rem 0;
182
+ color: var(--ws-muted);
183
+ }
184
+
185
+ @media (--ws-sm) {
186
+ .webstir-search__trigger {
187
+ order: -1;
188
+ }
189
+
190
+ .webstir-search__drawer-inner {
191
+ padding-block: 1rem 1.25rem;
192
+ }
193
+
194
+ .webstir-search__input {
195
+ font-size: 1.375rem;
196
+ }
197
+ }
198
+
199
+ @media (prefers-reduced-motion: reduce) {
200
+ #webstir-search {
201
+ transition: none;
202
+ }
203
+ }
204
+ }