@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.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -0
  3. package/cover.png +0 -0
  4. package/dist/chunks/exceptions-BYCwx4kO.js +20 -0
  5. package/dist/chunks/exceptions-BYCwx4kO.js.map +1 -0
  6. package/dist/exceptions.d.ts +9 -0
  7. package/dist/exceptions.d.ts.map +1 -0
  8. package/dist/index.d.ts +8 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +5 -0
  11. package/dist/search/index.d.ts +22 -0
  12. package/dist/search/index.d.ts.map +1 -0
  13. package/dist/search.js +66 -0
  14. package/dist/search.js.map +1 -0
  15. package/dist/site/index.d.ts +41 -0
  16. package/dist/site/index.d.ts.map +1 -0
  17. package/dist/site.js +54 -0
  18. package/dist/site.js.map +1 -0
  19. package/dist/theme/index.d.ts +15 -0
  20. package/dist/theme/index.d.ts.map +1 -0
  21. package/dist/theme.js +18 -0
  22. package/dist/theme.js.map +1 -0
  23. package/package.json +118 -0
  24. package/src/components/action-button/action-button.component.astro +25 -0
  25. package/src/components/action-link/action-link.component.astro +18 -0
  26. package/src/components/brand-logo/brand-logo.component.astro +55 -0
  27. package/src/components/breadcrumbs/breadcrumbs.component.astro +52 -0
  28. package/src/components/inline-brand-text/inline-brand-text.component.astro +20 -0
  29. package/src/components/local-search/local-search.component.astro +188 -0
  30. package/src/components/theme-bootstrap/theme-bootstrap.component.astro +32 -0
  31. package/src/components/theme-control/theme-control.component.astro +126 -0
  32. package/src/exceptions.ts +21 -0
  33. package/src/search/index.ts +118 -0
  34. package/src/site/index.ts +74 -0
  35. package/src/styles.css +248 -0
  36. package/src/theme/index.ts +19 -0
  37. 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';