@hagicode/hagilight-starlight 0.1.2-dev.6.1.f3f7b3d → 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
@@ -13,34 +13,55 @@ const resolvedLinks = resolveSiteLinks(locale, links);
13
13
  ---
14
14
 
15
15
  <DefaultFooter />
16
- <nav class="hagilight-site-links" aria-label={resolvedLinks.labels.quickLinks}>
16
+ <div class="hagilight-site-links">
17
17
  {resolvedLinks.relatedSites.length > 0 && (
18
18
  <section>
19
19
  <h2>{resolvedLinks.labels.relatedSites}</h2>
20
- <ul>
21
- {resolvedLinks.relatedSites.map((site) => (
22
- <li><a href={site.href}>{site.name}</a></li>
23
- ))}
24
- </ul>
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>
25
32
  </section>
26
33
  )}
27
34
  <section>
28
35
  <h2>{resolvedLinks.labels.quickLinks}</h2>
29
- <ul>
30
- {resolvedLinks.quick.map((link) => (
31
- <li><a href={link.href} target={link.target} rel={link.rel}>{link.label}</a></li>
32
- ))}
33
- </ul>
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>
34
43
  </section>
35
44
  <section>
36
45
  <h2>{resolvedLinks.labels.community}</h2>
37
- <ul>
38
- {resolvedLinks.community.map((link) => (
39
- <li><a href={link.href} target={link.target} rel={link.rel}>{link.label}</a></li>
40
- ))}
41
- </ul>
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>
42
53
  </section>
43
- </nav>
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>
44
65
  <p class="hagilight-copyright"><Copyright name="HagiCode" /></p>
45
66
 
46
67
  <style>
@@ -53,6 +74,10 @@ const resolvedLinks = resolveSiteLinks(locale, links);
53
74
  padding-inline: 1rem;
54
75
  }
55
76
 
77
+ .hagilight-site-links section {
78
+ min-width: 0;
79
+ }
80
+
56
81
  .hagilight-site-links h2 {
57
82
  margin: 0 0 0.75rem;
58
83
  color: var(--sl-color-white);
@@ -77,6 +102,22 @@ const resolvedLinks = resolveSiteLinks(locale, links);
77
102
  color: var(--sl-color-white);
78
103
  }
79
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
+
80
121
  .hagilight-copyright {
81
122
  margin-block: 1rem;
82
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
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url';
3
3
 
4
4
  const GOOGLE_ID_PATTERN = /^G-[A-Z0-9]+$/u;
5
5
  const FIFTY_ONE_LA_ID_PATTERN = /^[A-Za-z0-9_-]+$/u;
6
+ const defaultLogo = fileURLToPath(import.meta.resolve('@hagicode/hagilight/logo.png'));
6
7
 
7
8
  function resolveProvider(config, { name, idKey, pattern }) {
8
9
  if (config === undefined) return undefined;
@@ -25,7 +26,19 @@ function resolveProvider(config, { name, idKey, pattern }) {
25
26
 
26
27
  function createConfiguredIntegration(instanceId, serializedOptions, componentIds) {
27
28
  const optionsId = `virtual:hagilight-starlight/${instanceId}/options`;
29
+ const headerPath = componentIds.header
30
+ ? fileURLToPath(new URL('./Header.astro', import.meta.url))
31
+ : undefined;
28
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;
29
42
  const promotionImport = serializedOptions.promotoEnabled
30
43
  ? "import PromotoBanner from '@hagicode/hagilight/PromotoBanner';"
31
44
  : '';
@@ -60,6 +73,7 @@ const isNotFound = /(?:^|\\/)404(?:\\.html)?\\/?$/u.test(Astro.url.pathname);
60
73
  )}
61
74
  `;
62
75
  const modules = new Map([
76
+ ...(componentIds.header ? [[componentIds.header, headerSource]] : []),
63
77
  [componentIds.footer, footerSource],
64
78
  ...(componentIds.head ? [[componentIds.head, headSource]] : []),
65
79
  [optionsId, `export default ${JSON.stringify(serializedOptions)};`],
@@ -88,6 +102,13 @@ export default function hagilight(options = {}) {
88
102
  if (!options || typeof options !== 'object' || Array.isArray(options)) {
89
103
  throw new TypeError('Hagilight Starlight options must be an object.');
90
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
+ }
91
112
  if (options.analytics !== undefined
92
113
  && (!options.analytics || typeof options.analytics !== 'object' || Array.isArray(options.analytics))) {
93
114
  throw new TypeError('Hagilight analytics options must be an object.');
@@ -104,7 +125,11 @@ export default function hagilight(options = {}) {
104
125
  pattern: FIFTY_ONE_LA_ID_PATTERN,
105
126
  });
106
127
  const instanceId = randomUUID();
128
+ const headerEnabled = options.header?.enabled !== false;
107
129
  const componentIds = {
130
+ header: headerEnabled
131
+ ? `virtual:hagilight-starlight/${instanceId}/Header.astro`
132
+ : undefined,
108
133
  footer: `virtual:hagilight-starlight/${instanceId}/Footer.astro`,
109
134
  head: googleAnalyticsMeasurementId
110
135
  ? `virtual:hagilight-starlight/${instanceId}/Head.astro`
@@ -121,6 +146,9 @@ export default function hagilight(options = {}) {
121
146
  name: '@hagicode/hagilight-starlight',
122
147
  hooks: {
123
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
+ }
124
152
  if (config.components?.Footer) {
125
153
  throw new Error('Hagilight cannot replace an existing Starlight Footer override.');
126
154
  }
@@ -130,8 +158,10 @@ export default function hagilight(options = {}) {
130
158
 
131
159
  addIntegration(createConfiguredIntegration(instanceId, serializedOptions, componentIds));
132
160
  updateConfig({
161
+ ...(config.logo === undefined ? { logo: { src: defaultLogo, alt: 'HagiCode' } } : {}),
133
162
  components: {
134
163
  ...config.components,
164
+ ...(componentIds.header ? { Header: componentIds.header } : {}),
135
165
  Footer: componentIds.footer,
136
166
  ...(componentIds.head ? { Head: componentIds.head } : {}),
137
167
  },
@@ -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.6.1.f3f7b3d",
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.6.1.f3f7b3d"
27
+ "@hagicode/hagilight": "0.1.2-dev.7.1.c02207e"
26
28
  },
27
29
  "peerDependencies": {
28
30
  "@astrojs/starlight": "^0.42.4",