@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,495 @@
1
+ type DocsNavEntry = {
2
+ path: string;
3
+ title: string;
4
+ order?: number;
5
+ };
6
+
7
+ type FolderNode = {
8
+ kind: 'folder';
9
+ key: string;
10
+ label: string;
11
+ index?: DocsNavEntry;
12
+ children: Map<string, FolderNode>;
13
+ pages: DocsNavEntry[];
14
+ };
15
+
16
+ type DocsUiState = {
17
+ nav?: DocsNavEntry[] | null;
18
+ navPromise?: Promise<DocsNavEntry[] | null>;
19
+ tocObserver?: IntersectionObserver;
20
+ };
21
+
22
+ function getState(): DocsUiState {
23
+ const w = window as unknown as Record<string, unknown>;
24
+ const key = '__webstirDocsUiStateV1';
25
+ const existing = w[key] as DocsUiState | undefined;
26
+ if (existing) {
27
+ return existing;
28
+ }
29
+ const state: DocsUiState = {};
30
+ w[key] = state;
31
+ return state;
32
+ }
33
+
34
+ const state = getState();
35
+
36
+ function normalizePath(value: string): string {
37
+ const trimmed = value.trim();
38
+ if (!trimmed.startsWith('/')) {
39
+ return `/${trimmed}`;
40
+ }
41
+ return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
42
+ }
43
+
44
+ function escapeHtml(value: string): string {
45
+ return value
46
+ .replace(/&/g, '&amp;')
47
+ .replace(/</g, '&lt;')
48
+ .replace(/>/g, '&gt;')
49
+ .replace(/"/g, '&quot;')
50
+ .replace(/'/g, '&#39;');
51
+ }
52
+
53
+ function toTitleCase(value: string): string {
54
+ return value
55
+ .split(/[-_\\s]+/g)
56
+ .filter(Boolean)
57
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
58
+ .join(' ');
59
+ }
60
+
61
+ function parseDocsSegments(urlPath: string): string[] {
62
+ const normalized = normalizePath(urlPath);
63
+ const parts = normalized.split('/').filter(Boolean);
64
+ if (parts[0] === 'docs') {
65
+ return parts.slice(1);
66
+ }
67
+ return parts;
68
+ }
69
+
70
+ function joinPath(segments: readonly string[]): string {
71
+ return segments.join('/');
72
+ }
73
+
74
+ async function loadJson<T>(path: string): Promise<T | null> {
75
+ try {
76
+ const response = await fetch(path, { headers: { Accept: 'application/json' } });
77
+ if (!response.ok) return null;
78
+ return (await response.json()) as T;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ async function ensureNavLoaded(): Promise<DocsNavEntry[] | null> {
85
+ if (state.nav !== undefined) {
86
+ return state.nav;
87
+ }
88
+ state.navPromise ??= loadJson<DocsNavEntry[]>('/docs-nav.json');
89
+ state.nav = await state.navPromise;
90
+ return state.nav;
91
+ }
92
+
93
+ function loadOpenState(): Record<string, boolean> {
94
+ try {
95
+ const raw = window.localStorage.getItem('webstir.docs.nav.open');
96
+ if (!raw) return {};
97
+ const parsed = JSON.parse(raw) as unknown;
98
+ if (!parsed || typeof parsed !== 'object') return {};
99
+ return parsed as Record<string, boolean>;
100
+ } catch {
101
+ return {};
102
+ }
103
+ }
104
+
105
+ function saveOpenState(map: Record<string, boolean>): void {
106
+ try {
107
+ window.localStorage.setItem('webstir.docs.nav.open', JSON.stringify(map));
108
+ } catch {
109
+ // ignore
110
+ }
111
+ }
112
+
113
+ function buildNavTree(entries: DocsNavEntry[]): { root: FolderNode; folderPaths: ReadonlySet<string> } {
114
+ const root: FolderNode = { kind: 'folder', key: '', label: '', children: new Map(), pages: [] };
115
+
116
+ const folderPaths = new Set<string>();
117
+ for (const entry of entries) {
118
+ const segments = parseDocsSegments(entry.path);
119
+ if (segments.length <= 1) continue;
120
+ for (let index = 0; index < segments.length - 1; index += 1) {
121
+ folderPaths.add(joinPath(segments.slice(0, index + 1)));
122
+ }
123
+ }
124
+
125
+ for (const entry of entries) {
126
+ const segments = parseDocsSegments(entry.path);
127
+ if (segments.length === 0) continue;
128
+
129
+ const first = segments[0];
130
+ if (!first) continue;
131
+
132
+ if (segments.length === 1 && !folderPaths.has(first)) {
133
+ root.pages.push(entry);
134
+ continue;
135
+ }
136
+
137
+ let cursor = root;
138
+ for (let index = 0; index < segments.length; index += 1) {
139
+ const segment = segments[index]!;
140
+ const isLeaf = index === segments.length - 1;
141
+ const prefixKey = joinPath(segments.slice(0, index + 1));
142
+ const shouldBeFolder = folderPaths.has(prefixKey) || !isLeaf;
143
+
144
+ if (shouldBeFolder) {
145
+ let next = cursor.children.get(segment);
146
+ if (!next) {
147
+ next = { kind: 'folder', key: prefixKey, label: toTitleCase(segment), children: new Map(), pages: [] };
148
+ cursor.children.set(segment, next);
149
+ }
150
+
151
+ if (isLeaf) {
152
+ next.index = entry;
153
+ next.label = entry.title?.trim() ? entry.title.trim() : next.label;
154
+ }
155
+
156
+ cursor = next;
157
+ } else {
158
+ cursor.pages.push(entry);
159
+ }
160
+ }
161
+ }
162
+
163
+ return { root, folderPaths };
164
+ }
165
+
166
+ function sortEntries(a: DocsNavEntry, b: DocsNavEntry): number {
167
+ const aOrder = typeof a.order === 'number' ? a.order : Number.POSITIVE_INFINITY;
168
+ const bOrder = typeof b.order === 'number' ? b.order : Number.POSITIVE_INFINITY;
169
+ if (aOrder !== bOrder) return aOrder - bOrder;
170
+ return normalizePath(a.path).localeCompare(normalizePath(b.path));
171
+ }
172
+
173
+ function normalizeEntries(entries: DocsNavEntry[]): DocsNavEntry[] {
174
+ return entries
175
+ .filter((entry) => entry && typeof entry.path === 'string' && typeof entry.title === 'string')
176
+ .map((entry) => ({ ...entry, path: normalizePath(entry.path) }));
177
+ }
178
+
179
+ function renderSidebar(entries: DocsNavEntry[], links: HTMLElement): void {
180
+
181
+ const normalizedCurrent = normalizePath(window.location.pathname);
182
+ const safeEntries = normalizeEntries(entries);
183
+
184
+ safeEntries.sort(sortEntries);
185
+
186
+ const openState = loadOpenState();
187
+ const currentSegments = parseDocsSegments(window.location.pathname);
188
+
189
+ const { root } = buildNavTree(safeEntries);
190
+
191
+ const renderLink = (entry: DocsNavEntry): string => {
192
+ const href = normalizePath(entry.path);
193
+ const isCurrent = href === normalizedCurrent;
194
+ const currentAttr = isCurrent ? ' aria-current="page"' : '';
195
+ return `<li><a href="${escapeHtml(href)}"${currentAttr}>${escapeHtml(entry.title)}</a></li>`;
196
+ };
197
+
198
+ const renderFolder = (node: FolderNode, depth: number): string => {
199
+ const hasChildren = node.children.size > 0 || node.pages.length > 0;
200
+ const hasIndexOnly = Boolean(node.index) && !hasChildren;
201
+ if (hasIndexOnly && node.index) {
202
+ return renderLink(node.index);
203
+ }
204
+
205
+ const folderKey = node.key;
206
+ const defaultOpen = depth === 0 || currentSegments.join('/').startsWith(folderKey);
207
+ const expanded = openState[folderKey] ?? defaultOpen;
208
+ const childrenHidden = expanded ? '' : ' hidden';
209
+
210
+ const childItems: string[] = [];
211
+
212
+ const shouldShowOverview =
213
+ Boolean(node.index)
214
+ && (node.pages.length > 0 || node.children.size > 0)
215
+ && node.index!.title.trim().toLowerCase() === node.label.trim().toLowerCase();
216
+
217
+ if (node.index) {
218
+ const entry = shouldShowOverview ? { ...node.index, title: 'Overview' } : node.index;
219
+ childItems.push(renderLink(entry));
220
+ }
221
+
222
+ const pages = [...node.pages].sort(sortEntries).map(renderLink);
223
+ childItems.push(...pages);
224
+
225
+ const folders = Array.from(node.children.values()).sort((a, b) => a.label.localeCompare(b.label));
226
+ for (const folder of folders) {
227
+ childItems.push(renderFolder(folder, depth + 1));
228
+ }
229
+
230
+ const toggleLabel = escapeHtml(node.label);
231
+ const caret = '<span class="docs-tree__caret" aria-hidden="true"></span>';
232
+ const ariaExpanded = expanded ? 'true' : 'false';
233
+
234
+ return [
235
+ '<li class="docs-tree">',
236
+ ` <button type="button" class="docs-tree__toggle" data-docs-folder="${escapeHtml(folderKey)}" aria-expanded="${ariaExpanded}">`,
237
+ ` ${caret}<span>${toggleLabel}</span>`,
238
+ ' </button>',
239
+ ` <ul class="docs-tree__children"${childrenHidden}>${childItems.join('')}</ul>`,
240
+ '</li>'
241
+ ].join('');
242
+ };
243
+
244
+ const rendered: string[] = [];
245
+
246
+ root.pages.sort(sortEntries).forEach((entry) => rendered.push(renderLink(entry)));
247
+
248
+ const topFolders = Array.from(root.children.values()).sort((a, b) => a.label.localeCompare(b.label));
249
+ topFolders.forEach((folder) => rendered.push(renderFolder(folder, 0)));
250
+
251
+ links.innerHTML = rendered.join('');
252
+
253
+ links.querySelectorAll<HTMLButtonElement>('button[data-docs-folder]').forEach((button) => {
254
+ button.addEventListener('click', () => {
255
+ const key = button.getAttribute('data-docs-folder');
256
+ if (!key) return;
257
+
258
+ const expanded = button.getAttribute('aria-expanded') === 'true';
259
+ const nextExpanded = !expanded;
260
+ button.setAttribute('aria-expanded', nextExpanded ? 'true' : 'false');
261
+
262
+ const parent = button.closest('li');
263
+ const children = parent?.querySelector<HTMLElement>('.docs-tree__children');
264
+ if (children) {
265
+ if (nextExpanded) {
266
+ children.removeAttribute('hidden');
267
+ } else {
268
+ children.setAttribute('hidden', '');
269
+ }
270
+ }
271
+
272
+ openState[key] = nextExpanded;
273
+ saveOpenState(openState);
274
+ });
275
+ });
276
+ }
277
+
278
+ function ensureBreadcrumbContainer(): HTMLElement | null {
279
+ const main = document.querySelector<HTMLElement>('.docs-main');
280
+ if (!main) return null;
281
+
282
+ let container = main.querySelector<HTMLElement>('.docs-breadcrumb');
283
+ if (!container) {
284
+ container = document.createElement('nav');
285
+ container.className = 'docs-breadcrumb';
286
+ container.setAttribute('aria-label', 'Breadcrumb');
287
+ main.insertBefore(container, main.firstChild);
288
+ }
289
+ return container;
290
+ }
291
+
292
+ function renderBreadcrumbs(entries: DocsNavEntry[]): void {
293
+ const segments = parseDocsSegments(window.location.pathname);
294
+ const container = ensureBreadcrumbContainer();
295
+ if (!container) return;
296
+
297
+ const safeEntries = normalizeEntries(entries);
298
+ const entryByPath = new Map<string, DocsNavEntry>();
299
+ safeEntries.forEach((entry) => {
300
+ entryByPath.set(normalizePath(entry.path), entry);
301
+ });
302
+
303
+ const { root } = buildNavTree(safeEntries);
304
+
305
+ const isRoot = segments.length === 0;
306
+ const crumbs: Array<{ label: string; href?: string; current?: boolean; home?: boolean }> = [
307
+ { label: 'Docs', href: isRoot ? undefined : '/docs/', current: isRoot, home: true }
308
+ ];
309
+
310
+ let cursor: FolderNode | undefined = root;
311
+ const prefix: string[] = [];
312
+
313
+ segments.forEach((segment, index) => {
314
+ prefix.push(segment);
315
+ const prefixKey = joinPath(prefix);
316
+ const path = normalizePath(`/docs/${prefixKey}`);
317
+ const entry = entryByPath.get(path);
318
+
319
+ let label = entry?.title?.trim() ? entry.title.trim() : toTitleCase(segment);
320
+ let href: string | undefined = entry ? path : undefined;
321
+
322
+ const next = cursor?.children.get(segment);
323
+ if (next) {
324
+ if (!entry) {
325
+ label = next.label || label;
326
+ if (next.index) {
327
+ href = normalizePath(next.index.path);
328
+ }
329
+ }
330
+ cursor = next;
331
+ }
332
+
333
+ const isCurrent = index === segments.length - 1;
334
+ crumbs.push({ label, href: isCurrent ? undefined : href, current: isCurrent });
335
+ });
336
+
337
+ const homeIcon = [
338
+ '<span class="docs-breadcrumb__icon" aria-hidden="true">',
339
+ ' <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">',
340
+ ' <path d="M11.3 3.4a1 1 0 0 1 1.4 0l8 6.6a1 1 0 0 1-.6 1.8h-1.1V20a1 1 0 0 1-1 1h-4.5a1 1 0 0 1-1-1v-5h-3v5a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1v-8.4H3.9a1 1 0 0 1-.6-1.8l8-6.6Z"></path>',
341
+ ' </svg>',
342
+ '</span>'
343
+ ].join('');
344
+
345
+ const items = crumbs.map((crumb) => {
346
+ const label = escapeHtml(crumb.label);
347
+ const currentClass = crumb.current ? ' docs-breadcrumb__label--current' : '';
348
+ const labelHtml = `<span class="docs-breadcrumb__label${currentClass}">${label}</span>`;
349
+ const content = crumb.home ? `${homeIcon}${labelHtml}` : labelHtml;
350
+
351
+ if (crumb.href && !crumb.current) {
352
+ const ariaLabel = crumb.home ? ' aria-label="Docs home"' : '';
353
+ return `<li><a class="docs-breadcrumb__item" href="${escapeHtml(crumb.href)}"${ariaLabel}>${content}</a></li>`;
354
+ }
355
+ const currentAttr = crumb.current ? ' aria-current="page"' : '';
356
+ return `<li><span class="docs-breadcrumb__item"${currentAttr}>${content}</span></li>`;
357
+ });
358
+
359
+ container.removeAttribute('hidden');
360
+ container.innerHTML = `<ol class="docs-breadcrumb__list">${items.join('')}</ol>`;
361
+ }
362
+
363
+ function slugifyHeading(value: string): string {
364
+ return value
365
+ .trim()
366
+ .toLowerCase()
367
+ .replace(/['"]/g, '')
368
+ .replace(/[^a-z0-9\\s-]/g, '')
369
+ .replace(/\\s+/g, '-')
370
+ .replace(/-+/g, '-');
371
+ }
372
+
373
+ function refreshToc(): void {
374
+ const layout = document.querySelector<HTMLElement>('.docs-layout');
375
+ const article = document.querySelector<HTMLElement>('.docs-article');
376
+ const tocAside = document.querySelector<HTMLElement>('.docs-toc');
377
+ const tocList = document.getElementById('docs-toc');
378
+
379
+ if (!layout || !tocAside || !tocList || !article) {
380
+ layout?.classList.remove('has-toc');
381
+ return;
382
+ }
383
+
384
+ if (state.tocObserver) {
385
+ state.tocObserver.disconnect();
386
+ state.tocObserver = undefined;
387
+ }
388
+
389
+ const headings = Array.from(article.querySelectorAll<HTMLElement>('h2, h3'));
390
+ if (headings.length === 0) {
391
+ tocAside.setAttribute('hidden', '');
392
+ layout.classList.remove('has-toc');
393
+ tocList.innerHTML = '';
394
+ return;
395
+ }
396
+
397
+ const used = new Set<string>();
398
+ const ensureId = (heading: HTMLElement): string => {
399
+ const existing = heading.id?.trim();
400
+ if (existing) {
401
+ used.add(existing);
402
+ return existing;
403
+ }
404
+
405
+ const base = slugifyHeading(heading.textContent ?? '') || 'section';
406
+ let candidate = base;
407
+ let counter = 2;
408
+ while (used.has(candidate)) {
409
+ candidate = `${base}-${counter}`;
410
+ counter += 1;
411
+ }
412
+ used.add(candidate);
413
+ heading.id = candidate;
414
+ return candidate;
415
+ };
416
+
417
+ const items = headings.map((heading) => {
418
+ const id = ensureId(heading);
419
+ const level = heading.tagName.toLowerCase() === 'h3' ? 3 : 2;
420
+ const className = level === 3 ? ' class="docs-toc__sub"' : '';
421
+ return `<li${className}><a href="#${escapeHtml(id)}">${escapeHtml(heading.textContent ?? '')}</a></li>`;
422
+ });
423
+
424
+ tocList.innerHTML = items.join('');
425
+ tocAside.removeAttribute('hidden');
426
+ layout.classList.add('has-toc');
427
+
428
+ const tocLinks = Array.from(tocList.querySelectorAll<HTMLAnchorElement>('a[href^=\"#\"]'));
429
+ const linkById = new Map<string, HTMLAnchorElement>();
430
+ tocLinks.forEach((anchor) => {
431
+ const raw = anchor.getAttribute('href') ?? '';
432
+ const id = raw.startsWith('#') ? raw.slice(1) : raw;
433
+ if (id) linkById.set(id, anchor);
434
+ });
435
+
436
+ tocLinks.forEach((anchor) => {
437
+ anchor.addEventListener('click', (event) => {
438
+ const raw = anchor.getAttribute('href') ?? '';
439
+ const id = raw.startsWith('#') ? raw.slice(1) : raw;
440
+ if (!id) return;
441
+ const target = document.getElementById(id);
442
+ if (!target) return;
443
+ event.preventDefault();
444
+ target.scrollIntoView({ behavior: 'smooth', block: 'start' });
445
+ history.pushState({}, '', `#${id}`);
446
+ });
447
+ });
448
+
449
+ const observer = new IntersectionObserver(
450
+ (entries) => {
451
+ const visible = entries
452
+ .filter((entry) => entry.isIntersecting)
453
+ .sort((a, b) => (a.boundingClientRect.top ?? 0) - (b.boundingClientRect.top ?? 0));
454
+ const active = visible[0]?.target as HTMLElement | undefined;
455
+ if (!active?.id) return;
456
+
457
+ tocLinks.forEach((link) => link.removeAttribute('aria-current'));
458
+ linkById.get(active.id)?.setAttribute('aria-current', 'true');
459
+ },
460
+ { root: null, rootMargin: '-20% 0px -70% 0px', threshold: [0, 1] }
461
+ );
462
+
463
+ headings.forEach((heading) => observer.observe(heading));
464
+ state.tocObserver = observer;
465
+ }
466
+
467
+ async function refresh(): Promise<void> {
468
+ const nav = await ensureNavLoaded();
469
+ if (nav) {
470
+ const sidebarLinks = document.getElementById('docs-links');
471
+ if (sidebarLinks) {
472
+ renderSidebar(nav, sidebarLinks);
473
+ }
474
+
475
+ renderBreadcrumbs(nav);
476
+ }
477
+ refreshToc();
478
+ }
479
+
480
+ function boot(): void {
481
+ const w = window as unknown as Record<string, unknown>;
482
+ const key = '__webstirDocsUiBootedV1';
483
+ if (w[key] === true) {
484
+ void refresh();
485
+ return;
486
+ }
487
+
488
+ w[key] = true;
489
+ window.addEventListener('webstir:client-nav', () => {
490
+ void refresh();
491
+ });
492
+ void refresh();
493
+ }
494
+
495
+ boot();
@@ -0,0 +1,91 @@
1
+ /* Index Page Styles */
2
+
3
+ @layer overrides {
4
+ .hero {
5
+ padding: 56px 0 48px;
6
+ }
7
+
8
+ .hero__inner {
9
+ text-align: center;
10
+ max-width: 880px;
11
+ margin-left: auto;
12
+ margin-right: auto;
13
+ --ws-flow-space: var(--ws-space-8);
14
+ --ws-hero-space: calc(var(--ws-space-8) + var(--ws-space-2));
15
+ }
16
+
17
+ .hero .ws-cluster {
18
+ justify-content: center;
19
+ }
20
+
21
+ .hero__kicker {
22
+ display: inline-block;
23
+ padding: 6px 10px;
24
+ border-radius: 999px;
25
+ border: 1px solid var(--ws-border);
26
+ color: var(--ws-muted);
27
+ font-weight: 600;
28
+ font-size: 12px;
29
+ letter-spacing: 0.06em;
30
+ text-transform: uppercase;
31
+ margin-top: var(--ws-hero-space);
32
+ }
33
+
34
+ .hero h1 {
35
+ font-size: clamp(2rem, 5vw, 3rem);
36
+ font-weight: 750;
37
+ letter-spacing: -0.03em;
38
+ margin: 0;
39
+ }
40
+
41
+ .hero__kicker + h1 {
42
+ margin-top: var(--ws-hero-space);
43
+ }
44
+
45
+ .hero__lead {
46
+ font-size: 1.15rem;
47
+ color: var(--ws-muted);
48
+ margin: 0 auto;
49
+ max-width: 84ch;
50
+ }
51
+
52
+ .hero__lead + .ws-cluster {
53
+ margin-top: 0;
54
+ padding-top: var(--ws-hero-space);
55
+ }
56
+
57
+ .start {
58
+ padding: 40px 0 88px;
59
+ }
60
+
61
+ .start .ws-container {
62
+ max-width: 720px;
63
+ width: min(720px, 100%);
64
+ margin-left: auto;
65
+ margin-right: auto;
66
+ text-align: left;
67
+ padding-left: var(--ws-container-pad, var(--ws-space-4));
68
+ padding-right: var(--ws-container-pad, var(--ws-space-4));
69
+ }
70
+
71
+ .start h2 {
72
+ font-size: 1.25rem;
73
+ margin: 0 0 16px;
74
+ }
75
+
76
+ .start__list {
77
+ margin: 0;
78
+ padding-left: 20px;
79
+ color: var(--ws-muted);
80
+ font-size: 1.05rem;
81
+ }
82
+
83
+ .start__list li {
84
+ margin: 12px 0;
85
+ }
86
+
87
+ .start__list code {
88
+ font-weight: 650;
89
+ color: var(--ws-fg);
90
+ }
91
+ }
@@ -0,0 +1,38 @@
1
+ <head>
2
+ <title>Webstir SSG Starter</title>
3
+ <link rel="stylesheet" href="index.css" />
4
+ <!--
5
+ This page is intentionally static-first.
6
+ To add JavaScript later, create index.ts and add a
7
+ <script type="module" src="index.js"></script> tag.
8
+ -->
9
+ </head>
10
+ <body>
11
+ <main>
12
+ <section class="hero">
13
+ <div class="ws-container hero__inner ws-flow">
14
+ <p class="hero__kicker">Webstir SSG Starter</p>
15
+ <h1>Welcome to your Webstir site</h1>
16
+ <p class="hero__lead">
17
+ A clean starting point for a static site: shared layout, simple pages, and static-first output.
18
+ </p>
19
+ <div class="ws-cluster">
20
+ <a data-ui="btn" data-variant="solid" data-tone="accent" href="/about">About</a>
21
+ </div>
22
+ </div>
23
+ </section>
24
+
25
+ <section class="start">
26
+ <div class="ws-container">
27
+ <h2>Some things to try</h2>
28
+ <ol class="start__list">
29
+ <li>Start the dev server: <code>webstir watch</code></li>
30
+ <li>Edit this page: <code>src/frontend/pages/home/index.html</code></li>
31
+ <li>Add a doc: <code>src/frontend/content/my-doc.md</code></li>
32
+ <li>Enable smoother navigation: <code>webstir enable client-nav</code></li>
33
+ <li>Publish output: <code>webstir publish</code></li>
34
+ </ol>
35
+ </div>
36
+ </section>
37
+ </main>
38
+ </body>
@@ -0,0 +1,24 @@
1
+ // Basic Home page test: verifies merged HTML has expected parts
2
+ // The default provider is configured via WEBSTIR_TESTING_PROVIDER or webstir.providers.json.
3
+ import { readFileSync } from 'node:fs';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { test, assert } from '@webstir-io/webstir-testing';
7
+
8
+ // Node runs this test as ESM; derive the directory from the module URL.
9
+ const currentDir = dirname(fileURLToPath(import.meta.url));
10
+
11
+ // Built HTML is at build/frontend/pages/home/index.html relative to the compiled test output.
12
+ test('home page has expected parts', () => {
13
+ const htmlPath = resolve(currentDir, '..', 'index.html');
14
+ const html = readFileSync(htmlPath, 'utf8');
15
+
16
+ assert.isTrue(html.includes('<title>Webstir SSG Starter</title>'), 'Missing page title');
17
+ assert.isTrue(
18
+ html.includes('<link rel="stylesheet" href="index.css"') || html.includes('<link rel="stylesheet" href="/pages/home/index.css"'),
19
+ 'Missing CSS link to index.css'
20
+ );
21
+ assert.isTrue(html.includes('<main'), 'Missing <main> container');
22
+ assert.isTrue(html.includes('Welcome to your Webstir site'), 'Missing hero heading content');
23
+ assert.isTrue(html.includes('href="/about"'), 'Missing About link');
24
+ });
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "../../base.tsconfig.json",
3
+ "compilerOptions": {
4
+ "target": "ES2022",
5
+ "lib": ["ES2022", "DOM"],
6
+ "outDir": "../../build/frontend",
7
+ "rootDir": ".",
8
+ "baseUrl": "."
9
+ },
10
+ "include": ["**/*.ts", "../../types/**/*.d.ts"],
11
+ "exclude": ["node_modules", "../../build", "../../dist"],
12
+ "references": []
13
+ }