@moldea.ai/website-ui 1.0.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.
- package/LICENSE +21 -0
- package/README.md +58 -0
- package/cover.png +0 -0
- package/dist/chunks/exceptions-BYCwx4kO.js +20 -0
- package/dist/chunks/exceptions-BYCwx4kO.js.map +1 -0
- package/dist/exceptions.d.ts +9 -0
- package/dist/exceptions.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/search/index.d.ts +22 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search.js +66 -0
- package/dist/search.js.map +1 -0
- package/dist/site/index.d.ts +41 -0
- package/dist/site/index.d.ts.map +1 -0
- package/dist/site.js +54 -0
- package/dist/site.js.map +1 -0
- package/dist/theme/index.d.ts +15 -0
- package/dist/theme/index.d.ts.map +1 -0
- package/dist/theme.js +18 -0
- package/dist/theme.js.map +1 -0
- package/package.json +118 -0
- package/src/components/action-button/action-button.component.astro +25 -0
- package/src/components/action-link/action-link.component.astro +18 -0
- package/src/components/brand-logo/brand-logo.component.astro +55 -0
- package/src/components/breadcrumbs/breadcrumbs.component.astro +52 -0
- package/src/components/inline-brand-text/inline-brand-text.component.astro +20 -0
- package/src/components/local-search/local-search.component.astro +188 -0
- package/src/components/theme-bootstrap/theme-bootstrap.component.astro +32 -0
- package/src/components/theme-control/theme-control.component.astro +126 -0
- package/src/exceptions.ts +21 -0
- package/src/search/index.ts +118 -0
- package/src/site/index.ts +74 -0
- package/src/styles.css +248 -0
- package/src/theme/index.ts +19 -0
- package/src/tokens.css +155 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { ChevronRight } from '@lucide/astro';
|
|
3
|
+
|
|
4
|
+
import { withBase } from '../../site/index.ts';
|
|
5
|
+
|
|
6
|
+
export interface IBreadcrumb {
|
|
7
|
+
href?: string;
|
|
8
|
+
label: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Props {
|
|
12
|
+
basePath?: string;
|
|
13
|
+
items: IBreadcrumb[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { basePath = import.meta.env.BASE_URL, items } = Astro.props;
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
<nav
|
|
20
|
+
aria-label="Breadcrumb"
|
|
21
|
+
class="min-w-0 text-sm text-muted-foreground"
|
|
22
|
+
>
|
|
23
|
+
<ol class="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-2">
|
|
24
|
+
{
|
|
25
|
+
items.map((item, index) => (
|
|
26
|
+
<li class="flex min-w-0 items-center gap-1.5">
|
|
27
|
+
{index > 0 && (
|
|
28
|
+
<ChevronRight
|
|
29
|
+
class="size-3.5 shrink-0"
|
|
30
|
+
aria-hidden="true"
|
|
31
|
+
/>
|
|
32
|
+
)}
|
|
33
|
+
{item.href ? (
|
|
34
|
+
<a
|
|
35
|
+
class="rounded-sm underline-offset-4 hover:text-foreground hover:underline"
|
|
36
|
+
href={withBase(item.href, basePath)}
|
|
37
|
+
>
|
|
38
|
+
{item.label}
|
|
39
|
+
</a>
|
|
40
|
+
) : (
|
|
41
|
+
<span
|
|
42
|
+
class="min-w-0 font-medium text-foreground"
|
|
43
|
+
aria-current="page"
|
|
44
|
+
>
|
|
45
|
+
{item.label}
|
|
46
|
+
</span>
|
|
47
|
+
)}
|
|
48
|
+
</li>
|
|
49
|
+
))
|
|
50
|
+
}
|
|
51
|
+
</ol>
|
|
52
|
+
</nav>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
export interface Props {
|
|
3
|
+
text: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const { text } = Astro.props;
|
|
7
|
+
const segments = text.split(/(\bmoldea\b)/gu);
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
{
|
|
11
|
+
segments.map((segment) =>
|
|
12
|
+
segment === 'moldea' ? (
|
|
13
|
+
<code class="rounded-md bg-code px-1.5 py-1 font-mono text-[0.86em] font-semibold text-code-foreground">
|
|
14
|
+
{segment}
|
|
15
|
+
</code>
|
|
16
|
+
) : (
|
|
17
|
+
segment
|
|
18
|
+
),
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Search } from '@lucide/astro';
|
|
3
|
+
|
|
4
|
+
import ActionButton from '../action-button/action-button.component.astro';
|
|
5
|
+
|
|
6
|
+
export interface Props {
|
|
7
|
+
action: string;
|
|
8
|
+
buttonLabel?: string;
|
|
9
|
+
failureMessage: string;
|
|
10
|
+
initialPrompt: string;
|
|
11
|
+
inputId?: string;
|
|
12
|
+
label?: string;
|
|
13
|
+
loadingMessage?: string;
|
|
14
|
+
noResultsMessage?: string;
|
|
15
|
+
placeholder: string;
|
|
16
|
+
searchIndexUrl: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const {
|
|
20
|
+
action,
|
|
21
|
+
buttonLabel = 'Search',
|
|
22
|
+
failureMessage,
|
|
23
|
+
initialPrompt,
|
|
24
|
+
inputId = 'documentation-search',
|
|
25
|
+
label = 'Search documentation',
|
|
26
|
+
loadingMessage = 'Searching the local documentation index…',
|
|
27
|
+
noResultsMessage = 'No matching documentation was found.',
|
|
28
|
+
placeholder,
|
|
29
|
+
searchIndexUrl,
|
|
30
|
+
} = Astro.props;
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
<div
|
|
34
|
+
data-local-search
|
|
35
|
+
data-failure-message={failureMessage}
|
|
36
|
+
data-initial-prompt={initialPrompt}
|
|
37
|
+
data-loading-message={loadingMessage}
|
|
38
|
+
data-no-results-message={noResultsMessage}
|
|
39
|
+
>
|
|
40
|
+
<form
|
|
41
|
+
class="mt-10"
|
|
42
|
+
data-search-form
|
|
43
|
+
data-search-index-url={searchIndexUrl}
|
|
44
|
+
{action}
|
|
45
|
+
method="get"
|
|
46
|
+
role="search"
|
|
47
|
+
>
|
|
48
|
+
<label
|
|
49
|
+
class="sr-only"
|
|
50
|
+
for={inputId}
|
|
51
|
+
>{label}</label
|
|
52
|
+
>
|
|
53
|
+
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
|
54
|
+
<div class="relative min-w-0 flex-1">
|
|
55
|
+
<Search
|
|
56
|
+
class="pointer-events-none absolute start-3 top-1/2 size-5 -translate-y-1/2 text-secondary-foreground"
|
|
57
|
+
aria-hidden="true"
|
|
58
|
+
/>
|
|
59
|
+
<input
|
|
60
|
+
id={inputId}
|
|
61
|
+
class="min-h-12 w-full min-w-0 rounded-lg border border-border bg-background py-2 ps-10 pe-3 text-lg shadow-inset transition-colors outline-none placeholder:text-secondary-foreground focus-visible:border-transparent! focus-visible:shadow-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-background dark:bg-border/30"
|
|
62
|
+
type="search"
|
|
63
|
+
name="query"
|
|
64
|
+
{placeholder}
|
|
65
|
+
autocomplete="off"
|
|
66
|
+
data-search-input
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
<ActionButton
|
|
70
|
+
class="px-5 py-3 font-bold"
|
|
71
|
+
size="lg"
|
|
72
|
+
type="submit"
|
|
73
|
+
>
|
|
74
|
+
{buttonLabel}
|
|
75
|
+
</ActionButton>
|
|
76
|
+
</div>
|
|
77
|
+
</form>
|
|
78
|
+
|
|
79
|
+
<p
|
|
80
|
+
class="mt-4 text-sm text-muted-foreground"
|
|
81
|
+
aria-live="polite"
|
|
82
|
+
data-search-status
|
|
83
|
+
>
|
|
84
|
+
{initialPrompt}
|
|
85
|
+
</p>
|
|
86
|
+
<ol
|
|
87
|
+
class="mt-8 grid gap-4"
|
|
88
|
+
data-search-results
|
|
89
|
+
>
|
|
90
|
+
</ol>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<script>
|
|
94
|
+
import {
|
|
95
|
+
parseSearchDocuments,
|
|
96
|
+
searchDocuments,
|
|
97
|
+
type ISearchDocument,
|
|
98
|
+
} from '../../search/index.ts';
|
|
99
|
+
|
|
100
|
+
const createSearchResult = (searchDocument: ISearchDocument): HTMLLIElement => {
|
|
101
|
+
const item = document.createElement('li');
|
|
102
|
+
const link = document.createElement('a');
|
|
103
|
+
const title = document.createElement('h2');
|
|
104
|
+
const excerpt = document.createElement('p');
|
|
105
|
+
|
|
106
|
+
item.className = 'surface-card interactive-card p-5';
|
|
107
|
+
link.className = 'block rounded-md';
|
|
108
|
+
link.href = searchDocument.url;
|
|
109
|
+
title.className = 'text-xl font-semibold tracking-tight';
|
|
110
|
+
title.textContent = searchDocument.title;
|
|
111
|
+
excerpt.className = 'mt-2 leading-6 text-muted-foreground';
|
|
112
|
+
excerpt.textContent = searchDocument.description;
|
|
113
|
+
link.append(title, excerpt);
|
|
114
|
+
item.append(link);
|
|
115
|
+
|
|
116
|
+
return item;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const initializeSearch = (root: HTMLElement): void => {
|
|
120
|
+
const form = root.querySelector<HTMLFormElement>('[data-search-form]');
|
|
121
|
+
const input = root.querySelector<HTMLInputElement>('[data-search-input]');
|
|
122
|
+
const status = root.querySelector<HTMLElement>('[data-search-status]');
|
|
123
|
+
const results = root.querySelector<HTMLOListElement>('[data-search-results]');
|
|
124
|
+
|
|
125
|
+
if (!form || !input || !status || !results || root.dataset.searchInitialized === 'true') {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const searchIndexUrl = form.dataset.searchIndexUrl;
|
|
130
|
+
|
|
131
|
+
if (!searchIndexUrl) return;
|
|
132
|
+
|
|
133
|
+
root.dataset.searchInitialized = 'true';
|
|
134
|
+
const documentsPromise = fetch(searchIndexUrl).then(async (response) => {
|
|
135
|
+
if (!response.ok) {
|
|
136
|
+
throw new Error('The documentation search index could not be loaded.');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return parseSearchDocuments(await response.json());
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const runSearch = async (query: string): Promise<void> => {
|
|
143
|
+
results.replaceChildren();
|
|
144
|
+
|
|
145
|
+
if (!query.trim()) {
|
|
146
|
+
status.textContent = root.dataset.initialPrompt ?? '';
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
status.textContent = root.dataset.loadingMessage ?? '';
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const matches = searchDocuments(query, await documentsPromise);
|
|
154
|
+
|
|
155
|
+
for (const match of matches) {
|
|
156
|
+
results.append(createSearchResult(match));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
status.textContent = matches.length
|
|
160
|
+
? `${matches.length} result${matches.length === 1 ? '' : 's'} for “${query}”.`
|
|
161
|
+
: (root.dataset.noResultsMessage ?? '');
|
|
162
|
+
} catch {
|
|
163
|
+
status.textContent = root.dataset.failureMessage ?? '';
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
form.addEventListener('submit', (event) => {
|
|
168
|
+
event.preventDefault();
|
|
169
|
+
void runSearch(input.value);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const query = new URLSearchParams(window.location.search).get('query');
|
|
173
|
+
|
|
174
|
+
if (query) {
|
|
175
|
+
input.value = query;
|
|
176
|
+
void runSearch(query);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const initializeLocalSearch = (): void => {
|
|
181
|
+
for (const root of document.querySelectorAll<HTMLElement>('[data-local-search]')) {
|
|
182
|
+
initializeSearch(root);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
document.addEventListener('astro:page-load', initializeLocalSearch);
|
|
187
|
+
initializeLocalSearch();
|
|
188
|
+
</script>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
export interface Props {
|
|
3
|
+
storageKey: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const { storageKey } = Astro.props;
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<script is:inline define:vars={{ storageKey }}>
|
|
10
|
+
(() => {
|
|
11
|
+
let stored = null;
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
stored = localStorage.getItem(storageKey);
|
|
15
|
+
} catch {
|
|
16
|
+
stored = null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const preference =
|
|
20
|
+
stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'system';
|
|
21
|
+
const isDark =
|
|
22
|
+
preference === 'dark' ||
|
|
23
|
+
(preference === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
|
|
24
|
+
const root = document.documentElement;
|
|
25
|
+
|
|
26
|
+
root.classList.remove('light', 'dark');
|
|
27
|
+
root.classList.add(isDark ? 'dark' : 'light');
|
|
28
|
+
root.dataset.theme = preference;
|
|
29
|
+
root.dataset.themeStorageKey = storageKey;
|
|
30
|
+
root.style.colorScheme = isDark ? 'dark' : 'light';
|
|
31
|
+
})();
|
|
32
|
+
</script>
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Moon, Sun } from '@lucide/astro';
|
|
3
|
+
|
|
4
|
+
export interface Props {
|
|
5
|
+
class?: string;
|
|
6
|
+
storageKey: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { class: className, storageKey } = Astro.props;
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<button
|
|
13
|
+
class:list={['action-control action-ghost action-size-icon max-md:size-11', className]}
|
|
14
|
+
type="button"
|
|
15
|
+
data-theme-control
|
|
16
|
+
data-theme-storage-key={storageKey}
|
|
17
|
+
aria-label="Use dark theme"
|
|
18
|
+
title="Use dark theme"
|
|
19
|
+
>
|
|
20
|
+
<Moon
|
|
21
|
+
class="theme-use-dark-icon size-4"
|
|
22
|
+
aria-hidden="true"
|
|
23
|
+
/>
|
|
24
|
+
<Sun
|
|
25
|
+
class="theme-use-light-icon hidden size-4"
|
|
26
|
+
aria-hidden="true"
|
|
27
|
+
/>
|
|
28
|
+
</button>
|
|
29
|
+
|
|
30
|
+
<script>
|
|
31
|
+
import { isDarkTheme, parseThemePreference, type IThemePreference } from '../../theme/index.ts';
|
|
32
|
+
|
|
33
|
+
const readPreference = (storageKey: string): IThemePreference => {
|
|
34
|
+
try {
|
|
35
|
+
return parseThemePreference(localStorage.getItem(storageKey));
|
|
36
|
+
} catch {
|
|
37
|
+
return 'system';
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const persistPreference = (storageKey: string, preference: IThemePreference): void => {
|
|
42
|
+
try {
|
|
43
|
+
localStorage.setItem(storageKey, preference);
|
|
44
|
+
} catch {
|
|
45
|
+
// theme selection still applies to the active page when storage is unavailable
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const applyTheme = (preference: IThemePreference, storageKey: string): void => {
|
|
50
|
+
const isDark = isDarkTheme(
|
|
51
|
+
preference,
|
|
52
|
+
window.matchMedia('(prefers-color-scheme: dark)').matches,
|
|
53
|
+
);
|
|
54
|
+
const root = document.documentElement;
|
|
55
|
+
|
|
56
|
+
root.classList.remove('light', 'dark');
|
|
57
|
+
root.classList.add(isDark ? 'dark' : 'light');
|
|
58
|
+
root.dataset.theme = preference;
|
|
59
|
+
root.dataset.themeStorageKey = storageKey;
|
|
60
|
+
root.style.colorScheme = isDark ? 'dark' : 'light';
|
|
61
|
+
|
|
62
|
+
const label = isDark ? 'Use light theme' : 'Use dark theme';
|
|
63
|
+
|
|
64
|
+
for (const control of document.querySelectorAll<HTMLButtonElement>(
|
|
65
|
+
`[data-theme-control][data-theme-storage-key="${CSS.escape(storageKey)}"]`,
|
|
66
|
+
)) {
|
|
67
|
+
control.dataset.nextTheme = isDark ? 'light' : 'dark';
|
|
68
|
+
control.setAttribute('aria-label', label);
|
|
69
|
+
control.title = label;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const synchronizeThemeControls = (): void => {
|
|
74
|
+
const controls = document.querySelectorAll<HTMLButtonElement>('[data-theme-control]');
|
|
75
|
+
const storageKeys = new Set(
|
|
76
|
+
[...controls]
|
|
77
|
+
.map((control) => control.dataset.themeStorageKey)
|
|
78
|
+
.filter((storageKey): storageKey is string => Boolean(storageKey)),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
for (const storageKey of storageKeys) {
|
|
82
|
+
applyTheme(readPreference(storageKey), storageKey);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
if (document.documentElement.dataset.themeControlInitialized !== 'true') {
|
|
87
|
+
document.documentElement.dataset.themeControlInitialized = 'true';
|
|
88
|
+
|
|
89
|
+
document.addEventListener('click', (event) => {
|
|
90
|
+
const target = event.target;
|
|
91
|
+
|
|
92
|
+
if (!(target instanceof Element)) return;
|
|
93
|
+
|
|
94
|
+
const control = target.closest<HTMLButtonElement>('[data-theme-control]');
|
|
95
|
+
const storageKey = control?.dataset.themeStorageKey;
|
|
96
|
+
|
|
97
|
+
if (!control || !storageKey) return;
|
|
98
|
+
|
|
99
|
+
const preference = control.dataset.nextTheme === 'light' ? 'light' : 'dark';
|
|
100
|
+
|
|
101
|
+
persistPreference(storageKey, preference);
|
|
102
|
+
applyTheme(preference, storageKey);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
document.addEventListener('astro:page-load', synchronizeThemeControls);
|
|
106
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
|
107
|
+
const storageKey = document.documentElement.dataset.themeStorageKey;
|
|
108
|
+
|
|
109
|
+
if (storageKey && document.documentElement.dataset.theme === 'system') {
|
|
110
|
+
applyTheme('system', storageKey);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
synchronizeThemeControls();
|
|
116
|
+
</script>
|
|
117
|
+
|
|
118
|
+
<style>
|
|
119
|
+
:global(html.dark) .theme-use-dark-icon {
|
|
120
|
+
display: none;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
:global(html.dark) .theme-use-light-icon {
|
|
124
|
+
display: block;
|
|
125
|
+
}
|
|
126
|
+
</style>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Exception } from 'error-message-utils';
|
|
2
|
+
|
|
3
|
+
// stable configuration failure contracts exposed by the package
|
|
4
|
+
export type IWebsiteUiConfigurationErrorCode = 'INVALID_BASE_PATH' | 'INVALID_SEARCH_INDEX';
|
|
5
|
+
|
|
6
|
+
const CONFIGURATION_ERROR_MESSAGES = {
|
|
7
|
+
INVALID_BASE_PATH: 'The website base path contains unsupported URL characters.',
|
|
8
|
+
INVALID_SEARCH_INDEX: 'The documentation search index is invalid.',
|
|
9
|
+
} as const satisfies Readonly<Record<IWebsiteUiConfigurationErrorCode, string>>;
|
|
10
|
+
|
|
11
|
+
/** Represents invalid configuration or generated input at a website UI boundary. */
|
|
12
|
+
export class WebsiteUiConfigurationException extends Exception {
|
|
13
|
+
public override readonly code: IWebsiteUiConfigurationErrorCode;
|
|
14
|
+
|
|
15
|
+
/** Creates the stable configuration exception for one failed boundary. */
|
|
16
|
+
public constructor(code: IWebsiteUiConfigurationErrorCode) {
|
|
17
|
+
super(CONFIGURATION_ERROR_MESSAGES[code], code);
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.name = 'WebsiteUiConfigurationException';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { WebsiteUiConfigurationException } from '../exceptions.js';
|
|
2
|
+
|
|
3
|
+
// one base-aware public document delivered by a static search index
|
|
4
|
+
export interface ISearchDocument {
|
|
5
|
+
description: string;
|
|
6
|
+
searchText: string;
|
|
7
|
+
title: string;
|
|
8
|
+
url: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const SEARCH_RESULT_LIMIT = 16;
|
|
12
|
+
const SEARCH_URL_ORIGIN = 'https://website-ui.invalid';
|
|
13
|
+
|
|
14
|
+
const normalizeSearchValue = (source: string): string =>
|
|
15
|
+
source
|
|
16
|
+
.normalize('NFKD')
|
|
17
|
+
.replaceAll(/\p{M}/gu, '')
|
|
18
|
+
.toLocaleLowerCase('en')
|
|
19
|
+
.replaceAll(/\s+/g, ' ')
|
|
20
|
+
.trim();
|
|
21
|
+
|
|
22
|
+
const isSafeSearchUrl = (source: string): boolean => {
|
|
23
|
+
const hasUnsupportedCharacter = [...source].some((character) => {
|
|
24
|
+
const codePoint = character.codePointAt(0);
|
|
25
|
+
|
|
26
|
+
return character === '\\' || codePoint === undefined || codePoint <= 31 || codePoint === 127;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!source.startsWith('/') || source.startsWith('//') || hasUnsupportedCharacter) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
return new URL(source, SEARCH_URL_ORIGIN).origin === SEARCH_URL_ORIGIN;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const isSearchDocument = (source: unknown): source is ISearchDocument => {
|
|
41
|
+
if (!source || typeof source !== 'object') {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const candidate = source as Record<string, unknown>;
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
typeof candidate['description'] === 'string' &&
|
|
49
|
+
typeof candidate['searchText'] === 'string' &&
|
|
50
|
+
typeof candidate['title'] === 'string' &&
|
|
51
|
+
typeof candidate['url'] === 'string' &&
|
|
52
|
+
isSafeSearchUrl(candidate['url'])
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validates the generated browser search-index boundary.
|
|
58
|
+
* @param source Unknown parsed search-index value.
|
|
59
|
+
* @returns Validated public search documents.
|
|
60
|
+
* @throws
|
|
61
|
+
* - INVALID_SEARCH_INDEX: The documentation search index is invalid.
|
|
62
|
+
*/
|
|
63
|
+
export const parseSearchDocuments = (source: unknown): ISearchDocument[] => {
|
|
64
|
+
if (!Array.isArray(source) || !source.every(isSearchDocument)) {
|
|
65
|
+
throw new WebsiteUiConfigurationException('INVALID_SEARCH_INDEX');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return source;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const getSearchScore = (query: string, tokens: string[], document: ISearchDocument): number => {
|
|
72
|
+
const title = normalizeSearchValue(document.title);
|
|
73
|
+
const description = normalizeSearchValue(document.description);
|
|
74
|
+
const searchText = normalizeSearchValue(document.searchText);
|
|
75
|
+
const completeText = `${title} ${description} ${searchText}`;
|
|
76
|
+
|
|
77
|
+
if (!tokens.every((token) => completeText.includes(token))) {
|
|
78
|
+
return -1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let score = title === query ? 64 : title.startsWith(query) ? 32 : 0;
|
|
82
|
+
|
|
83
|
+
for (const token of tokens) {
|
|
84
|
+
if (title.includes(token)) score += 16;
|
|
85
|
+
if (description.includes(token)) score += 4;
|
|
86
|
+
if (searchText.includes(token)) score += 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return score;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Searches public documentation locally with deterministic relevance and ordering.
|
|
94
|
+
* @param query Developer-entered search query.
|
|
95
|
+
* @param documents Generated public search documents.
|
|
96
|
+
* @returns The highest-scoring matching documents.
|
|
97
|
+
*/
|
|
98
|
+
export const searchDocuments = (query: string, documents: ISearchDocument[]): ISearchDocument[] => {
|
|
99
|
+
const normalizedQuery = normalizeSearchValue(query);
|
|
100
|
+
|
|
101
|
+
if (!normalizedQuery) {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const tokens = [...new Set(normalizedQuery.split(' '))];
|
|
106
|
+
|
|
107
|
+
return documents
|
|
108
|
+
.map((document) => ({ document, score: getSearchScore(normalizedQuery, tokens, document) }))
|
|
109
|
+
.filter(({ score }) => score >= 0)
|
|
110
|
+
.sort(
|
|
111
|
+
(left, right) =>
|
|
112
|
+
right.score - left.score ||
|
|
113
|
+
left.document.title.localeCompare(right.document.title) ||
|
|
114
|
+
left.document.url.localeCompare(right.document.url),
|
|
115
|
+
)
|
|
116
|
+
.slice(0, SEARCH_RESULT_LIMIT)
|
|
117
|
+
.map(({ document }) => document);
|
|
118
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { WebsiteUiConfigurationException } from '../exceptions.js';
|
|
2
|
+
|
|
3
|
+
// default public base path used outside framework-owned configuration
|
|
4
|
+
export const DEFAULT_BASE_PATH = '/';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns one normalized root-relative base path with a trailing slash.
|
|
8
|
+
* @param basePath Candidate public base path.
|
|
9
|
+
* @returns The normalized public base path.
|
|
10
|
+
* @throws
|
|
11
|
+
* - INVALID_BASE_PATH: The website base path contains unsupported URL characters.
|
|
12
|
+
*/
|
|
13
|
+
export const normalizeBasePath = (basePath: string): string => {
|
|
14
|
+
const path = `/${basePath}`.replaceAll(/\/{2,}/g, '/');
|
|
15
|
+
const normalizedPath = path === '/' ? '/' : `${path.replace(/\/$/, '')}/`;
|
|
16
|
+
|
|
17
|
+
if (!/^\/(?:[a-zA-Z0-9._~-]+\/)*$/u.test(normalizedPath)) {
|
|
18
|
+
throw new WebsiteUiConfigurationException('INVALID_BASE_PATH');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return normalizedPath;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Prefixes one root-relative public route with the configured deployment base.
|
|
26
|
+
* @param route Root-relative public route.
|
|
27
|
+
* @param basePath Configured public base path.
|
|
28
|
+
* @returns The base-aware route.
|
|
29
|
+
* @throws
|
|
30
|
+
* - INVALID_BASE_PATH: The website base path contains unsupported URL characters.
|
|
31
|
+
*/
|
|
32
|
+
export const withBase = (route: string, basePath = DEFAULT_BASE_PATH): string => {
|
|
33
|
+
const base = normalizeBasePath(basePath);
|
|
34
|
+
const routeWithoutRoot = route.replace(/^\//, '');
|
|
35
|
+
|
|
36
|
+
return `${base}${routeWithoutRoot}`.replaceAll(/\/{2,}/g, '/');
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Checks whether a public pathname identifies a route or one of its descendants.
|
|
41
|
+
* @param pathname Current public pathname.
|
|
42
|
+
* @param route Root-relative public route.
|
|
43
|
+
* @param basePath Configured public base path.
|
|
44
|
+
* @returns Whether the route is active for the current pathname.
|
|
45
|
+
* @throws
|
|
46
|
+
* - INVALID_BASE_PATH: The website base path contains unsupported URL characters.
|
|
47
|
+
*/
|
|
48
|
+
export const isPublicRouteActive = (
|
|
49
|
+
pathname: string,
|
|
50
|
+
route: string,
|
|
51
|
+
basePath = DEFAULT_BASE_PATH,
|
|
52
|
+
): boolean => {
|
|
53
|
+
const publicRoute = withBase(route, basePath);
|
|
54
|
+
|
|
55
|
+
return publicRoute === normalizeBasePath(basePath)
|
|
56
|
+
? pathname === publicRoute
|
|
57
|
+
: pathname.startsWith(publicRoute);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Builds a canonical absolute URL from an origin, base path, and public route.
|
|
62
|
+
* @param route Root-relative public route.
|
|
63
|
+
* @param siteUrl Public website origin.
|
|
64
|
+
* @param basePath Public deployment base path.
|
|
65
|
+
* @returns The canonical absolute URL.
|
|
66
|
+
* @throws
|
|
67
|
+
* - INVALID_BASE_PATH: The website base path contains unsupported URL characters.
|
|
68
|
+
*/
|
|
69
|
+
export const createCanonicalUrl = (route: string, siteUrl: string, basePath: string): string =>
|
|
70
|
+
new URL(withBase(route, basePath), siteUrl).href;
|
|
71
|
+
|
|
72
|
+
// exceptions
|
|
73
|
+
export { WebsiteUiConfigurationException } from '../exceptions.js';
|
|
74
|
+
export type { IWebsiteUiConfigurationErrorCode } from '../exceptions.js';
|