@hagicode/hagilight-starlight 0.1.2-dev.5.1.c41051d → 0.1.2-dev.7.1.c02207e

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/Footer.astro CHANGED
@@ -1,12 +1,123 @@
1
1
  ---
2
2
  import Copyright from '@hagicode/hagilight/Copyright';
3
3
  import DefaultFooter from '@astrojs/starlight/components/Footer.astro';
4
+ import { resolveSiteLinks, type SiteLinksOptions } from '@hagicode/hagilight/site-links';
5
+
6
+ interface Props {
7
+ locale?: string;
8
+ links?: SiteLinksOptions;
9
+ }
10
+
11
+ const { locale = Astro.locals?.starlightRoute?.locale, links } = Astro.props;
12
+ const resolvedLinks = resolveSiteLinks(locale, links);
4
13
  ---
5
14
 
6
15
  <DefaultFooter />
16
+ <div class="hagilight-site-links">
17
+ {resolvedLinks.relatedSites.length > 0 && (
18
+ <section>
19
+ <h2>{resolvedLinks.labels.relatedSites}</h2>
20
+ <nav aria-label={resolvedLinks.labels.navigation.relatedSites}>
21
+ <ul>
22
+ {resolvedLinks.relatedSites.map((site) => (
23
+ <li>
24
+ <a href={site.href} target={site.target} rel={site.rel}>
25
+ {site.name}
26
+ {site.description && <span class="hagilight-site-description">{site.description}</span>}
27
+ </a>
28
+ </li>
29
+ ))}
30
+ </ul>
31
+ </nav>
32
+ </section>
33
+ )}
34
+ <section>
35
+ <h2>{resolvedLinks.labels.quickLinks}</h2>
36
+ <nav aria-label={resolvedLinks.labels.navigation.quickLinks}>
37
+ <ul>
38
+ {resolvedLinks.quick.map((link) => (
39
+ <li><a href={link.href} target={link.target} rel={link.rel}>{link.label}</a></li>
40
+ ))}
41
+ </ul>
42
+ </nav>
43
+ </section>
44
+ <section>
45
+ <h2>{resolvedLinks.labels.community}</h2>
46
+ <nav aria-label={resolvedLinks.labels.navigation.community}>
47
+ <ul>
48
+ {resolvedLinks.community.map((link) => (
49
+ <li><a href={link.href} target={link.target} rel={link.rel}>{link.label}</a></li>
50
+ ))}
51
+ </ul>
52
+ </nav>
53
+ </section>
54
+ <nav class="hagilight-filings" aria-label={resolvedLinks.labels.navigation.filings}>
55
+ {resolvedLinks.filings.map((link) => (
56
+ <a
57
+ href={link.href}
58
+ target={link.target}
59
+ rel={link.rel}
60
+ aria-label={link.ariaLabel}
61
+ >{link.label}</a>
62
+ ))}
63
+ </nav>
64
+ </div>
7
65
  <p class="hagilight-copyright"><Copyright name="HagiCode" /></p>
8
66
 
9
67
  <style>
68
+ .hagilight-site-links {
69
+ display: grid;
70
+ grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
71
+ gap: 1.5rem;
72
+ margin: 2rem auto 1rem;
73
+ max-width: 72rem;
74
+ padding-inline: 1rem;
75
+ }
76
+
77
+ .hagilight-site-links section {
78
+ min-width: 0;
79
+ }
80
+
81
+ .hagilight-site-links h2 {
82
+ margin: 0 0 0.75rem;
83
+ color: var(--sl-color-white);
84
+ font-size: var(--sl-text-sm);
85
+ }
86
+
87
+ .hagilight-site-links ul {
88
+ display: grid;
89
+ gap: 0.5rem;
90
+ margin: 0;
91
+ padding: 0;
92
+ list-style: none;
93
+ }
94
+
95
+ .hagilight-site-links a {
96
+ color: var(--sl-color-gray-3);
97
+ font-size: var(--sl-text-sm);
98
+ text-decoration: none;
99
+ }
100
+
101
+ .hagilight-site-links a:hover {
102
+ color: var(--sl-color-white);
103
+ }
104
+
105
+ .hagilight-site-description {
106
+ display: block;
107
+ margin-top: 0.25rem;
108
+ font-size: var(--sl-text-xs);
109
+ }
110
+
111
+ .hagilight-filings {
112
+ grid-column: 1 / -1;
113
+ display: flex;
114
+ flex-wrap: wrap;
115
+ justify-content: center;
116
+ gap: 0.75rem 1.5rem;
117
+ border-top: 1px solid var(--sl-color-gray-5);
118
+ padding-top: 1rem;
119
+ }
120
+
10
121
  .hagilight-copyright {
11
122
  margin-block: 1rem;
12
123
  color: var(--sl-color-gray-3);
package/Header.astro ADDED
@@ -0,0 +1,129 @@
1
+ ---
2
+ import config from 'virtual:starlight/user-config';
3
+ import Search from 'virtual:starlight/components/Search';
4
+ import SiteTitle from 'virtual:starlight/components/SiteTitle';
5
+ import SocialIcons from 'virtual:starlight/components/SocialIcons';
6
+ import ThemeSelect from 'virtual:starlight/components/ThemeSelect';
7
+ import { resolveSiteLinks, type SiteLinksOptions } from '@hagicode/hagilight/site-links';
8
+ import LanguageChooser from './LanguageChooser.astro';
9
+
10
+ interface Props {
11
+ links?: SiteLinksOptions;
12
+ }
13
+
14
+ const { links } = Astro.props;
15
+ const locale = Astro.locals.starlightRoute.locale;
16
+ const resolvedLinks = resolveSiteLinks(locale, links);
17
+ const shouldRenderSearch =
18
+ config.pagefind || config.components.Search !== '@astrojs/starlight/components/Search.astro';
19
+ ---
20
+
21
+ <div class="header">
22
+ <div class="title-wrapper sl-flex">
23
+ <SiteTitle />
24
+ </div>
25
+ <div class="sl-flex print:hidden">
26
+ {shouldRenderSearch && <Search />}
27
+ </div>
28
+ <div class="sl-hidden md:sl-flex print:hidden right-group">
29
+ <nav class="header-links" aria-label="Site navigation">
30
+ {resolvedLinks.header.map((link) => (
31
+ <a href={link.href} target={link.target} rel={link.rel}>{link.label}</a>
32
+ ))}
33
+ </nav>
34
+ <div class="sl-flex social-icons">
35
+ <SocialIcons />
36
+ </div>
37
+ <ThemeSelect />
38
+ <LanguageChooser />
39
+ </div>
40
+ </div>
41
+
42
+ <style>
43
+ @layer starlight.core {
44
+ .header {
45
+ display: flex;
46
+ gap: var(--sl-nav-gap);
47
+ justify-content: space-between;
48
+ align-items: center;
49
+ height: 100%;
50
+ }
51
+
52
+ .title-wrapper {
53
+ overflow: clip;
54
+ padding: 0.25rem;
55
+ margin: -0.25rem;
56
+ min-width: 0;
57
+ }
58
+
59
+ .right-group,
60
+ .social-icons {
61
+ gap: 1rem;
62
+ align-items: center;
63
+ }
64
+
65
+ .right-group {
66
+ flex-wrap: nowrap;
67
+ }
68
+
69
+ .social-icons:has(a)::after {
70
+ content: '';
71
+ height: 2rem;
72
+ border-inline-end: 1px solid var(--sl-color-gray-5);
73
+ }
74
+
75
+ .header-links {
76
+ display: flex;
77
+ align-items: center;
78
+ gap: 0.5rem;
79
+ white-space: nowrap;
80
+ }
81
+
82
+ .header-links a {
83
+ padding: 0.375rem 0.5rem;
84
+ border-radius: var(--sl-radius-sm);
85
+ color: var(--sl-color-text-high);
86
+ font-size: var(--sl-text-sm);
87
+ text-decoration: none;
88
+ }
89
+
90
+ .header-links a:hover {
91
+ background: var(--sl-color-gray-6);
92
+ color: var(--sl-color-white);
93
+ }
94
+
95
+ @media (max-width: 72rem) {
96
+ .header-links {
97
+ display: none;
98
+ }
99
+ }
100
+
101
+ @media (min-width: 50rem) {
102
+ :global(:root[data-has-sidebar]) {
103
+ --__sidebar-pad: calc(2 * var(--sl-nav-pad-x));
104
+ }
105
+ :global(:root:not([data-has-toc])) {
106
+ --__toc-width: 0rem;
107
+ }
108
+ .header {
109
+ --__sidebar-width: max(0rem, var(--sl-content-inline-start, 0rem) - var(--sl-nav-pad-x));
110
+ --__main-column-fr: calc(
111
+ (
112
+ 100% + var(--__sidebar-pad, 0rem) - var(--__toc-width, var(--sl-sidebar-width)) -
113
+ (2 * var(--__toc-width, var(--sl-nav-pad-x))) - var(--sl-content-inline-start, 0rem) -
114
+ var(--sl-content-width)
115
+ ) / 2
116
+ );
117
+ display: grid;
118
+ grid-template-columns:
119
+ minmax(
120
+ calc(var(--__sidebar-width) + max(0rem, var(--__main-column-fr) - var(--sl-nav-gap))),
121
+ auto
122
+ )
123
+ 1fr
124
+ auto;
125
+ align-content: center;
126
+ }
127
+ }
128
+ }
129
+ </style>
@@ -0,0 +1,357 @@
1
+ ---
2
+ import config from 'virtual:starlight/user-config';
3
+ import context from 'virtual:starlight/project-context';
4
+ import {
5
+ buildLocaleNavigationTarget,
6
+ getConfiguredLanguageOptions,
7
+ } from './language-routing.mjs';
8
+
9
+ const currentLocale = Astro.locals.starlightRoute.locale ?? config.defaultLocale?.locale ?? 'root';
10
+ const options = getConfiguredLanguageOptions(config.locales, currentLocale);
11
+ const currentOption = options.find((option) => option.selected) ?? options[0];
12
+ const accessibleLabel = Astro.locals.t('languageSelect.accessibleLabel');
13
+ const copy = {
14
+ 'zh-cn': ['当前语言', '已选', '关闭语言选择'],
15
+ 'en-us': ['Current language', 'Selected', 'Close language selector'],
16
+ 'zh-hant': ['目前語言', '已選取', '關閉語言選擇'],
17
+ 'fr-fr': ['Langue actuelle', 'Sélectionné', 'Fermer le sélecteur de langue'],
18
+ 'de-de': ['Aktuelle Sprache', 'Ausgewählt', 'Sprachauswahl schließen'],
19
+ 'es-es': ['Idioma actual', 'Seleccionado', 'Cerrar selector de idioma'],
20
+ 'ja-jp': ['現在の言語', '選択中', '言語選択を閉じる'],
21
+ 'ko-kr': ['현재 언어', '선택됨', '언어 선택 닫기'],
22
+ 'pt-br': ['Idioma atual', 'Selecionado', 'Fechar seletor de idioma'],
23
+ 'ru-ru': ['Текущий язык', 'Выбрано', 'Закрыть выбор языка'],
24
+ };
25
+ const langKey = (currentOption?.lang ?? currentLocale).toLowerCase();
26
+ const languagePrefix = langKey.split('-')[0];
27
+ const fallbackCopy = copy[langKey]
28
+ ?? Object.entries(copy).find(([key]) => key.startsWith(`${languagePrefix}-`))?.[1]
29
+ ?? copy['en-us'];
30
+ const currentLabel = fallbackCopy[0];
31
+ const selectedLabel = fallbackCopy[1];
32
+ const closeLabel = fallbackCopy[2];
33
+ const basePath = import.meta.env.BASE_URL;
34
+ const routeLocales = Object.keys(config.locales ?? {});
35
+ const navigationOptions = options.map((option) => ({
36
+ ...option,
37
+ href: buildLocaleNavigationTarget(
38
+ Astro.url,
39
+ option.code,
40
+ routeLocales,
41
+ basePath,
42
+ context.trailingSlash,
43
+ ).pathname,
44
+ }));
45
+ ---
46
+
47
+ {
48
+ config.isMultilingual && navigationOptions.length > 1 && (
49
+ <hagilight-language-chooser class="language-switcher">
50
+ <button
51
+ type="button"
52
+ class="language-trigger"
53
+ aria-haspopup="dialog"
54
+ aria-controls="hagilight-language-dialog"
55
+ aria-expanded="false"
56
+ aria-label={`${accessibleLabel}: ${currentOption?.label ?? currentLocale}`}
57
+ title={currentOption?.label ?? currentLocale}
58
+ >
59
+ <span class="trigger-label">{currentOption?.label ?? currentLocale}</span>
60
+ <span class="trigger-chevron" aria-hidden="true">▾</span>
61
+ </button>
62
+ <dialog
63
+ id="hagilight-language-dialog"
64
+ class="language-dialog"
65
+ aria-modal="true"
66
+ aria-labelledby="language-dialog-title"
67
+ >
68
+ <div class="dialog-header">
69
+ <div>
70
+ <h2 id="language-dialog-title" class="dialog-title">{accessibleLabel}</h2>
71
+ <p class="dialog-current">
72
+ {currentLabel}: <strong>{currentOption?.label ?? currentLocale}</strong>
73
+ </p>
74
+ </div>
75
+ <button type="button" class="dialog-close" aria-label={closeLabel}>×</button>
76
+ </div>
77
+ <div class="language-grid-scroller">
78
+ <div class="language-grid" role="listbox" aria-label={accessibleLabel}>
79
+ {navigationOptions.map((option) => (
80
+ <button
81
+ type="button"
82
+ role="option"
83
+ data-locale={option.code}
84
+ data-href={option.href}
85
+ lang={option.lang}
86
+ aria-selected={option.selected ? 'true' : 'false'}
87
+ class:list={['language-option', { 'language-option-selected': option.selected }]}
88
+ >
89
+ <span class="language-option-label">{option.label}</span>
90
+ {option.selected && <span class="selected-badge">{selectedLabel}</span>}
91
+ </button>
92
+ ))}
93
+ </div>
94
+ </div>
95
+ </dialog>
96
+ </hagilight-language-chooser>
97
+ )
98
+ }
99
+
100
+ <script>
101
+ import {
102
+ getKeyboardTargetIndex,
103
+ persistStarlightLocaleSelection,
104
+ } from './language-routing.mjs';
105
+
106
+ class HagilightLanguageChooser extends HTMLElement {
107
+ connectedCallback() {
108
+ if (this.dataset.initialized) return;
109
+ this.dataset.initialized = 'true';
110
+
111
+ const trigger = this.querySelector('.language-trigger');
112
+ const dialog = this.querySelector('.language-dialog');
113
+ const closeButton = this.querySelector('.dialog-close');
114
+ const options = [...this.querySelectorAll('[role="option"]')];
115
+ if (!(trigger instanceof HTMLButtonElement)
116
+ || !(dialog instanceof HTMLDialogElement)
117
+ || !(closeButton instanceof HTMLButtonElement)) return;
118
+
119
+ const closeDialog = () => {
120
+ if (dialog.open) dialog.close();
121
+ else trigger.focus();
122
+ };
123
+
124
+ trigger.addEventListener('click', () => {
125
+ if (dialog.open) {
126
+ closeDialog();
127
+ return;
128
+ }
129
+ dialog.showModal();
130
+ trigger.setAttribute('aria-expanded', 'true');
131
+ const activeOption = options.find((option) => option.getAttribute('aria-selected') === 'true');
132
+ (activeOption ?? options[0])?.focus();
133
+ });
134
+ closeButton.addEventListener('click', closeDialog);
135
+ dialog.addEventListener('cancel', (event) => {
136
+ event.preventDefault();
137
+ closeDialog();
138
+ });
139
+ dialog.addEventListener('close', () => {
140
+ trigger.setAttribute('aria-expanded', 'false');
141
+ trigger.focus();
142
+ });
143
+ dialog.addEventListener('click', (event) => {
144
+ if (event.target === dialog) closeDialog();
145
+ });
146
+ dialog.querySelector('[role="listbox"]')?.addEventListener('keydown', (event) => {
147
+ if (!(event.target instanceof HTMLButtonElement)) return;
148
+ const index = options.indexOf(event.target);
149
+ const nextIndex = getKeyboardTargetIndex(index, event.key, options.length);
150
+ if (nextIndex !== index) {
151
+ event.preventDefault();
152
+ options[nextIndex]?.focus();
153
+ }
154
+ });
155
+ for (const option of options) {
156
+ option.addEventListener('click', () => {
157
+ const locale = option.dataset.locale;
158
+ const href = option.dataset.href;
159
+ if (!locale || !href) return;
160
+ closeDialog();
161
+ if (option.getAttribute('aria-selected') === 'true') return;
162
+ persistStarlightLocaleSelection(locale);
163
+ const target = new URL(href, window.location.origin);
164
+ target.search = window.location.search;
165
+ target.hash = window.location.hash;
166
+ window.location.assign(target.toString());
167
+ });
168
+ }
169
+ }
170
+ }
171
+
172
+ if (!customElements.get('hagilight-language-chooser')) {
173
+ customElements.define('hagilight-language-chooser', HagilightLanguageChooser);
174
+ }
175
+ </script>
176
+
177
+ <style>
178
+ .language-switcher {
179
+ display: inline-flex;
180
+ align-items: center;
181
+ }
182
+
183
+ .language-trigger {
184
+ display: inline-flex;
185
+ align-items: center;
186
+ justify-content: center;
187
+ gap: 0.55rem;
188
+ min-width: 7.25rem;
189
+ min-height: 2.125rem;
190
+ padding: 0 0.8rem;
191
+ border: 1px solid var(--sl-color-gray-5);
192
+ border-radius: 999px;
193
+ background: color-mix(in srgb, var(--sl-color-black) 8%, transparent);
194
+ color: var(--sl-color-text-high);
195
+ cursor: pointer;
196
+ font: inherit;
197
+ transition: background-color var(--sl-transition-fast), border-color var(--sl-transition-fast);
198
+ }
199
+
200
+ .language-trigger:hover,
201
+ .dialog-close:hover,
202
+ .language-option:hover {
203
+ background: var(--sl-color-gray-6);
204
+ border-color: var(--sl-color-accent-high);
205
+ }
206
+
207
+ .language-trigger:focus-visible,
208
+ .dialog-close:focus-visible,
209
+ .language-option:focus-visible {
210
+ outline: 2px solid var(--sl-color-accent-high);
211
+ outline-offset: 2px;
212
+ }
213
+
214
+ .trigger-label {
215
+ max-width: 10rem;
216
+ overflow: hidden;
217
+ text-overflow: ellipsis;
218
+ white-space: nowrap;
219
+ font-size: var(--sl-text-sm);
220
+ font-weight: var(--sl-font-weight-medium);
221
+ }
222
+
223
+ .trigger-chevron {
224
+ font-size: 0.75rem;
225
+ transition: transform var(--sl-transition-fast);
226
+ }
227
+
228
+ .language-switcher:has(.language-dialog[open]) .trigger-chevron {
229
+ transform: rotate(180deg);
230
+ }
231
+
232
+ .language-dialog {
233
+ width: min(54rem, calc(100vw - 3rem));
234
+ max-width: none;
235
+ max-height: min(30rem, calc(100dvh - 3rem));
236
+ padding: 1.1rem 1.1rem 1rem;
237
+ border: 1px solid var(--sl-color-gray-5);
238
+ border-radius: 1rem;
239
+ background: color-mix(in srgb, var(--sl-color-bg) 94%, transparent);
240
+ color: var(--sl-color-text);
241
+ box-shadow: 0 24px 64px rgba(2, 6, 23, 0.32);
242
+ }
243
+
244
+ .language-dialog::backdrop {
245
+ background: rgba(15, 23, 42, 0.52);
246
+ }
247
+
248
+ .dialog-header {
249
+ display: flex;
250
+ align-items: flex-start;
251
+ justify-content: space-between;
252
+ gap: 1rem;
253
+ margin-bottom: 1rem;
254
+ }
255
+
256
+ .dialog-title {
257
+ margin: 0;
258
+ color: var(--sl-color-white);
259
+ font-size: 1rem;
260
+ font-weight: 700;
261
+ }
262
+
263
+ .dialog-current {
264
+ margin: 0.35rem 0 0;
265
+ font-size: var(--sl-text-sm);
266
+ }
267
+
268
+ .dialog-close {
269
+ display: inline-flex;
270
+ align-items: center;
271
+ justify-content: center;
272
+ width: 2.25rem;
273
+ height: 2.25rem;
274
+ border: 1px solid var(--sl-color-gray-5);
275
+ border-radius: 999px;
276
+ background: transparent;
277
+ color: var(--sl-color-text-high);
278
+ cursor: pointer;
279
+ font: inherit;
280
+ font-size: 1.125rem;
281
+ }
282
+
283
+ .language-grid-scroller {
284
+ max-height: min(20rem, calc(100dvh - 11rem));
285
+ overflow: auto;
286
+ border: 1px solid var(--sl-color-gray-5);
287
+ border-radius: 0.875rem;
288
+ background: color-mix(in srgb, var(--sl-color-black) 6%, transparent);
289
+ padding: 0.95rem 1rem;
290
+ }
291
+
292
+ .language-grid {
293
+ display: grid;
294
+ grid-template-columns: repeat(5, minmax(7.75rem, 8.75rem));
295
+ justify-content: center;
296
+ gap: 0.7rem;
297
+ }
298
+
299
+ .language-option {
300
+ display: flex;
301
+ min-height: 4.2rem;
302
+ width: 100%;
303
+ flex-direction: column;
304
+ align-items: center;
305
+ justify-content: center;
306
+ gap: 0.45rem;
307
+ border: 1px solid var(--sl-color-gray-5);
308
+ border-radius: 0.875rem;
309
+ background: color-mix(in srgb, var(--sl-color-bg-nav) 88%, transparent);
310
+ color: var(--sl-color-text-high);
311
+ cursor: pointer;
312
+ font: inherit;
313
+ font-size: 0.95rem;
314
+ font-weight: 700;
315
+ padding: 0.75rem 0.65rem;
316
+ text-align: center;
317
+ }
318
+
319
+ .language-option-selected {
320
+ border-color: var(--sl-color-accent-high);
321
+ background: color-mix(in srgb, var(--sl-color-accent-high) 18%, transparent);
322
+ }
323
+
324
+ .selected-badge {
325
+ display: inline-flex;
326
+ align-items: center;
327
+ justify-content: center;
328
+ min-height: 1.45rem;
329
+ padding: 0 0.55rem;
330
+ border-radius: 999px;
331
+ background: color-mix(in srgb, var(--sl-color-accent-high) 18%, transparent);
332
+ color: var(--sl-color-accent-high);
333
+ font-size: 0.75rem;
334
+ }
335
+
336
+ @media (max-width: 72rem) {
337
+ .language-dialog {
338
+ width: min(46rem, calc(100vw - 2.5rem));
339
+ }
340
+
341
+ .language-grid {
342
+ grid-template-columns: repeat(4, minmax(7.35rem, 8.35rem));
343
+ }
344
+ }
345
+
346
+ @media (max-width: 50rem) {
347
+ .language-dialog {
348
+ width: calc(100vw - 2rem);
349
+ max-height: calc(100dvh - 2rem);
350
+ padding: 1rem;
351
+ }
352
+
353
+ .language-grid {
354
+ grid-template-columns: repeat(2, minmax(0, 1fr));
355
+ }
356
+ }
357
+ </style>
package/index.mjs CHANGED
@@ -1,19 +1,169 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { fileURLToPath } from 'node:url';
2
3
 
4
+ const GOOGLE_ID_PATTERN = /^G-[A-Z0-9]+$/u;
5
+ const FIFTY_ONE_LA_ID_PATTERN = /^[A-Za-z0-9_-]+$/u;
6
+ const defaultLogo = fileURLToPath(import.meta.resolve('@hagicode/hagilight/logo.png'));
7
+
8
+ function resolveProvider(config, { name, idKey, pattern }) {
9
+ if (config === undefined) return undefined;
10
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
11
+ throw new TypeError(`Hagilight ${name} analytics options must be an object.`);
12
+ }
13
+
14
+ const enabled = config.enabled ?? Boolean(config[idKey]);
15
+ if (!enabled) return undefined;
16
+
17
+ const id = config[idKey];
18
+ if (typeof id !== 'string' || !id.trim()) {
19
+ throw new Error(`Hagilight ${name} analytics is enabled but its ${idKey} is missing.`);
20
+ }
21
+ if (!pattern.test(id)) {
22
+ throw new Error(`Hagilight ${name} analytics ${idKey} has an invalid format.`);
23
+ }
24
+ return id;
25
+ }
26
+
27
+ function createConfiguredIntegration(instanceId, serializedOptions, componentIds) {
28
+ const optionsId = `virtual:hagilight-starlight/${instanceId}/options`;
29
+ const headerPath = componentIds.header
30
+ ? fileURLToPath(new URL('./Header.astro', import.meta.url))
31
+ : undefined;
32
+ const footerPath = fileURLToPath(new URL('./Footer.astro', import.meta.url));
33
+ const headerSource = headerPath
34
+ ? `---
35
+ import Header from ${JSON.stringify(headerPath)};
36
+ import options from '${optionsId}';
37
+ ---
38
+
39
+ <Header links={options.links} />
40
+ `
41
+ : undefined;
42
+ const promotionImport = serializedOptions.promotoEnabled
43
+ ? "import PromotoBanner from '@hagicode/hagilight/PromotoBanner';"
44
+ : '';
45
+ const promotionRender = serializedOptions.promotoEnabled ? '<PromotoBanner />' : '';
46
+ const analyticsImport = serializedOptions.fiftyOneLaId
47
+ ? "import Analytics51LA from '@hagicode/hagilight/Analytics51LA';"
48
+ : '';
49
+ const analyticsRender = serializedOptions.fiftyOneLaId
50
+ ? '<Analytics51LA siteId={options.fiftyOneLaId} />'
51
+ : '';
52
+ const footerSource = `---
53
+ import Footer from ${JSON.stringify(footerPath)};
54
+ ${promotionImport}
55
+ ${analyticsImport}
56
+ import options from '${optionsId}';
57
+ ---
58
+
59
+ <Footer locale={Astro.locals?.starlightRoute?.locale} links={options.links} />
60
+ ${promotionRender}
61
+ ${analyticsRender}
62
+ `;
63
+ const headSource = `---
64
+ import DefaultHead from '@astrojs/starlight/components/Head.astro';
65
+ import GoogleAnalytics from '@hagicode/hagilight/GoogleAnalytics';
66
+ import options from '${optionsId}';
67
+ const isNotFound = /(?:^|\\/)404(?:\\.html)?\\/?$/u.test(Astro.url.pathname);
68
+ ---
69
+
70
+ <DefaultHead />
71
+ {!isNotFound && options.googleAnalyticsMeasurementId && (
72
+ <GoogleAnalytics measurementId={options.googleAnalyticsMeasurementId} />
73
+ )}
74
+ `;
75
+ const modules = new Map([
76
+ ...(componentIds.header ? [[componentIds.header, headerSource]] : []),
77
+ [componentIds.footer, footerSource],
78
+ ...(componentIds.head ? [[componentIds.head, headSource]] : []),
79
+ [optionsId, `export default ${JSON.stringify(serializedOptions)};`],
80
+ ]);
81
+ const vitePlugin = {
82
+ name: `@hagicode/hagilight-starlight:${instanceId}`,
83
+ resolveId(id) {
84
+ return modules.has(id) ? `\0${id}` : null;
85
+ },
86
+ load(id) {
87
+ return id.startsWith('\0') ? modules.get(id.slice(1)) ?? null : null;
88
+ },
89
+ };
90
+
91
+ return {
92
+ name: `@hagicode/hagilight-starlight:${instanceId}`,
93
+ hooks: {
94
+ 'astro:config:setup'({ updateConfig }) {
95
+ updateConfig({ vite: { plugins: [vitePlugin] } });
96
+ },
97
+ },
98
+ };
99
+ }
100
+
3
101
  export default function hagilight(options = {}) {
4
- const footer = options.promoto?.enabled === false ? 'Footer.astro' : 'PromotoFooter.astro';
102
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
103
+ throw new TypeError('Hagilight Starlight options must be an object.');
104
+ }
105
+ if (options.header !== undefined
106
+ && (!options.header || typeof options.header !== 'object' || Array.isArray(options.header))) {
107
+ throw new TypeError('Hagilight header options must be an object.');
108
+ }
109
+ if (options.header?.enabled !== undefined && typeof options.header.enabled !== 'boolean') {
110
+ throw new TypeError('Hagilight header enabled option must be a boolean.');
111
+ }
112
+ if (options.analytics !== undefined
113
+ && (!options.analytics || typeof options.analytics !== 'object' || Array.isArray(options.analytics))) {
114
+ throw new TypeError('Hagilight analytics options must be an object.');
115
+ }
116
+ const analytics = options.analytics ?? {};
117
+ const googleAnalyticsMeasurementId = resolveProvider(analytics.googleAnalytics, {
118
+ name: 'Google Analytics',
119
+ idKey: 'measurementId',
120
+ pattern: GOOGLE_ID_PATTERN,
121
+ });
122
+ const fiftyOneLaId = resolveProvider(analytics.fiftyOneLa, {
123
+ name: '51LA',
124
+ idKey: 'siteId',
125
+ pattern: FIFTY_ONE_LA_ID_PATTERN,
126
+ });
127
+ const instanceId = randomUUID();
128
+ const headerEnabled = options.header?.enabled !== false;
129
+ const componentIds = {
130
+ header: headerEnabled
131
+ ? `virtual:hagilight-starlight/${instanceId}/Header.astro`
132
+ : undefined,
133
+ footer: `virtual:hagilight-starlight/${instanceId}/Footer.astro`,
134
+ head: googleAnalyticsMeasurementId
135
+ ? `virtual:hagilight-starlight/${instanceId}/Head.astro`
136
+ : undefined,
137
+ };
138
+ const serializedOptions = {
139
+ promotoEnabled: options.promoto?.enabled !== false,
140
+ links: options.links ?? {},
141
+ googleAnalyticsMeasurementId,
142
+ fiftyOneLaId,
143
+ };
144
+
5
145
  return {
6
146
  name: '@hagicode/hagilight-starlight',
7
147
  hooks: {
8
- 'config:setup'({ config, updateConfig }) {
148
+ 'config:setup'({ config, updateConfig, addIntegration }) {
149
+ if (headerEnabled && config.components?.Header) {
150
+ throw new Error('Hagilight cannot replace an existing Starlight Header override. Set header: { enabled: false } to keep it, or compose @hagicode/hagilight-starlight/Header directly.');
151
+ }
9
152
  if (config.components?.Footer) {
10
153
  throw new Error('Hagilight cannot replace an existing Starlight Footer override.');
11
154
  }
155
+ if (googleAnalyticsMeasurementId && config.components?.Head) {
156
+ throw new Error('Hagilight Google Analytics cannot replace an existing Starlight Head override. Compose @hagicode/hagilight/GoogleAnalytics in your Head instead.');
157
+ }
12
158
 
159
+ addIntegration(createConfiguredIntegration(instanceId, serializedOptions, componentIds));
13
160
  updateConfig({
161
+ ...(config.logo === undefined ? { logo: { src: defaultLogo, alt: 'HagiCode' } } : {}),
14
162
  components: {
15
163
  ...config.components,
16
- Footer: fileURLToPath(new URL(`./${footer}`, import.meta.url)),
164
+ ...(componentIds.header ? { Header: componentIds.header } : {}),
165
+ Footer: componentIds.footer,
166
+ ...(componentIds.head ? { Head: componentIds.head } : {}),
17
167
  },
18
168
  });
19
169
  },
@@ -0,0 +1,114 @@
1
+ export const DEFAULT_LANGUAGE_OPTIONS = [
2
+ { code: 'root', label: '简体中文', lang: 'zh-CN' },
3
+ { code: 'en-US', label: 'English', lang: 'en-US' },
4
+ { code: 'zh-Hant', label: '繁體中文', lang: 'zh-Hant' },
5
+ { code: 'fr-FR', label: 'Français', lang: 'fr-FR' },
6
+ { code: 'de-DE', label: 'Deutsch', lang: 'de-DE' },
7
+ { code: 'es-ES', label: 'Español (España)', lang: 'es-ES' },
8
+ { code: 'ja-JP', label: '日本語', lang: 'ja-JP' },
9
+ { code: 'ko-KR', label: '한국어', lang: 'ko-KR' },
10
+ { code: 'pt-BR', label: 'Português (Brasil)', lang: 'pt-BR' },
11
+ { code: 'ru-RU', label: 'Русский', lang: 'ru-RU' },
12
+ ];
13
+
14
+ const normalizeLang = (lang) => lang?.replaceAll('_', '-').toLowerCase();
15
+
16
+ export function getConfiguredLanguageOptions(locales, currentLocale) {
17
+ const catalog = DEFAULT_LANGUAGE_OPTIONS;
18
+ return Object.entries(locales ?? {})
19
+ .map(([code, locale]) => {
20
+ const lang = typeof locale === 'string' ? locale : locale?.lang ?? code;
21
+ const catalogEntry = catalog.find((entry) =>
22
+ entry.code === code || normalizeLang(entry.lang) === normalizeLang(lang),
23
+ );
24
+ return {
25
+ code,
26
+ label: typeof locale === 'object' && locale?.label
27
+ ? locale.label
28
+ : catalogEntry?.label ?? code,
29
+ lang,
30
+ selected: code === currentLocale,
31
+ catalogOrder: catalogEntry ? catalog.indexOf(catalogEntry) : catalog.length,
32
+ };
33
+ })
34
+ .sort((left, right) => left.catalogOrder - right.catalogOrder)
35
+ .map(({ catalogOrder, ...option }) => option);
36
+ }
37
+
38
+ export function getKeyboardTargetIndex(index, key, count) {
39
+ if (count < 1) return -1;
40
+ switch (key) {
41
+ case 'ArrowDown':
42
+ case 'ArrowRight':
43
+ return (index + 1) % count;
44
+ case 'ArrowUp':
45
+ case 'ArrowLeft':
46
+ return (index - 1 + count) % count;
47
+ case 'Home':
48
+ return 0;
49
+ case 'End':
50
+ return count - 1;
51
+ default:
52
+ return index;
53
+ }
54
+ }
55
+
56
+ export function buildLocaleNavigationTarget(
57
+ currentUrl,
58
+ targetLocale,
59
+ configuredLocales,
60
+ basePath = '/',
61
+ trailingSlash = 'ignore',
62
+ ) {
63
+ const targetUrl = new URL(currentUrl);
64
+ const base = basePath.replace(/\/+$/u, '');
65
+ const hasBase = base === ''
66
+ ? true
67
+ : targetUrl.pathname === base || targetUrl.pathname.startsWith(`${base}/`);
68
+ let pathname = hasBase ? targetUrl.pathname.slice(base.length) || '/' : targetUrl.pathname;
69
+ const [, firstSegment = '', remainder = ''] = pathname.match(/^\/([^/]*)(.*)$/u) ?? [];
70
+ const isHtmlRoute = firstSegment.endsWith('.html');
71
+ const currentSlug = isHtmlRoute ? firstSegment.slice(0, -5) : firstSegment;
72
+ const currentLocale = configuredLocales.find((locale) => locale !== 'root' && locale === currentSlug);
73
+
74
+ if (currentLocale) {
75
+ if (targetLocale === 'root') {
76
+ pathname = isHtmlRoute ? '/index.html' : remainder || '/';
77
+ } else {
78
+ pathname = `/${targetLocale}${isHtmlRoute ? '.html' : remainder}`;
79
+ }
80
+ } else if (targetLocale !== 'root') {
81
+ pathname = firstSegment === 'index.html'
82
+ ? `/${targetLocale}.html`
83
+ : `/${targetLocale}${pathname}`;
84
+ }
85
+
86
+ targetUrl.pathname = `${hasBase ? base : ''}${pathname}` || '/';
87
+ if (trailingSlash === 'never') {
88
+ targetUrl.pathname = targetUrl.pathname.replace(/\/+$/u, '') || '/';
89
+ }
90
+ return targetUrl;
91
+ }
92
+
93
+ export function persistStarlightLocaleSelection(locale) {
94
+ try {
95
+ const storedValue = window.localStorage.getItem('starlight-route');
96
+ let routePreference = {};
97
+ if (storedValue) {
98
+ try {
99
+ const parsed = JSON.parse(storedValue);
100
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
101
+ routePreference = parsed;
102
+ }
103
+ } catch {
104
+ routePreference = {};
105
+ }
106
+ }
107
+ window.localStorage.setItem(
108
+ 'starlight-route',
109
+ JSON.stringify({ ...routePreference, lang: locale }),
110
+ );
111
+ } catch {
112
+ // Language navigation must remain available when browser storage is blocked.
113
+ }
114
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hagicode/hagilight-starlight",
3
- "version": "0.1.2-dev.5.1.c41051d",
3
+ "version": "0.1.2-dev.7.1.c02207e",
4
4
  "type": "module",
5
5
  "description": "Starlight customizations for HagiCode documentation",
6
6
  "repository": {
@@ -14,15 +14,17 @@
14
14
  },
15
15
  "exports": {
16
16
  ".": "./index.mjs",
17
+ "./Header": "./Header.astro",
17
18
  "./Footer": "./Footer.astro",
18
19
  "./PromotoFooter": "./PromotoFooter.astro"
19
20
  },
20
21
  "files": [
21
22
  "index.mjs",
23
+ "*.mjs",
22
24
  "*.astro"
23
25
  ],
24
26
  "dependencies": {
25
- "@hagicode/hagilight": "0.1.2-dev.5.1.c41051d"
27
+ "@hagicode/hagilight": "0.1.2-dev.7.1.c02207e"
26
28
  },
27
29
  "peerDependencies": {
28
30
  "@astrojs/starlight": "^0.42.4",