@hagicode/hagilight-starlight 0.1.1 → 0.1.2-dev.10.1.aebe89d

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.
@@ -0,0 +1,12 @@
1
+ ---
2
+ import { getAIDisclosureCopy } from './ai-disclosures.mjs';
3
+ const { kind, lang, sourceHref } = Astro.props;
4
+ const copy = getAIDisclosureCopy(lang);
5
+ ---
6
+
7
+ <aside class="hagilight-ai-disclosure" role="note">
8
+ {kind === 'author' ? copy.author : copy.translation}
9
+ {kind === 'translation' && sourceHref && (
10
+ <> <a href={sourceHref}>{copy.source}</a></>
11
+ )}
12
+ </aside>
@@ -0,0 +1,31 @@
1
+ ---
2
+ import { getContentWidthLabels } from './content-width-i18n.mjs';
3
+ const { lang } = Astro.locals.starlightRoute;
4
+ const labels = getContentWidthLabels(lang);
5
+ ---
6
+
7
+ <div class="hagilight-content-width-toggle" role="group" aria-label={labels.group}>
8
+ <button type="button" data-hagilight-content-width-choice="wide" aria-pressed="true">
9
+ {labels.wide}
10
+ </button>
11
+ <button type="button" data-hagilight-content-width-choice="narrow" aria-pressed="false">
12
+ {labels.narrow}
13
+ </button>
14
+ </div>
15
+
16
+ <script>
17
+ import {
18
+ restoreContentWidth,
19
+ setContentWidth,
20
+ synchronizeContentWidthFromStorage,
21
+ } from './content-width.mjs';
22
+
23
+ const root = document.documentElement;
24
+ restoreContentWidth(root);
25
+ document.querySelectorAll('[data-hagilight-content-width-choice]').forEach((button) => {
26
+ button.addEventListener('click', () => {
27
+ setContentWidth(button.dataset.hagilightContentWidthChoice, root);
28
+ });
29
+ });
30
+ window.addEventListener('storage', (event) => synchronizeContentWidthFromStorage(event, root));
31
+ </script>
package/Footer.astro CHANGED
@@ -1,12 +1,116 @@
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
+ </a>
27
+ </li>
28
+ ))}
29
+ </ul>
30
+ </nav>
31
+ </section>
32
+ )}
33
+ <section>
34
+ <h2>{resolvedLinks.labels.quickLinks}</h2>
35
+ <nav aria-label={resolvedLinks.labels.navigation.quickLinks}>
36
+ <ul>
37
+ {resolvedLinks.quick.map((link) => (
38
+ <li><a href={link.href} target={link.target} rel={link.rel}>{link.label}</a></li>
39
+ ))}
40
+ </ul>
41
+ </nav>
42
+ </section>
43
+ <section>
44
+ <h2>{resolvedLinks.labels.community}</h2>
45
+ <nav aria-label={resolvedLinks.labels.navigation.community}>
46
+ <ul>
47
+ {resolvedLinks.community.map((link) => (
48
+ <li><a href={link.href} target={link.target} rel={link.rel}>{link.label}</a></li>
49
+ ))}
50
+ </ul>
51
+ </nav>
52
+ </section>
53
+ <nav class="hagilight-filings" aria-label={resolvedLinks.labels.navigation.filings}>
54
+ {resolvedLinks.filings.map((link) => (
55
+ <a
56
+ href={link.href}
57
+ target={link.target}
58
+ rel={link.rel}
59
+ aria-label={link.ariaLabel}
60
+ >{link.label}</a>
61
+ ))}
62
+ </nav>
63
+ </div>
7
64
  <p class="hagilight-copyright"><Copyright name="HagiCode" /></p>
8
65
 
9
66
  <style>
67
+ .hagilight-site-links {
68
+ display: grid;
69
+ grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
70
+ gap: 1.5rem;
71
+ margin: 2rem auto 1rem;
72
+ max-width: 72rem;
73
+ padding-inline: 1rem;
74
+ }
75
+
76
+ .hagilight-site-links section {
77
+ min-width: 0;
78
+ }
79
+
80
+ .hagilight-site-links h2 {
81
+ margin: 0 0 0.75rem;
82
+ color: var(--sl-color-white);
83
+ font-size: var(--sl-text-sm);
84
+ }
85
+
86
+ .hagilight-site-links ul {
87
+ display: grid;
88
+ gap: 0.5rem;
89
+ margin: 0;
90
+ padding: 0;
91
+ list-style: none;
92
+ }
93
+
94
+ .hagilight-site-links a {
95
+ color: var(--sl-color-gray-3);
96
+ font-size: var(--sl-text-sm);
97
+ text-decoration: none;
98
+ }
99
+
100
+ .hagilight-site-links a:hover {
101
+ color: var(--sl-color-white);
102
+ }
103
+
104
+ .hagilight-filings {
105
+ grid-column: 1 / -1;
106
+ display: flex;
107
+ flex-wrap: wrap;
108
+ justify-content: center;
109
+ gap: 0.75rem 1.5rem;
110
+ border-top: 1px solid var(--sl-color-gray-5);
111
+ padding-top: 1rem;
112
+ }
113
+
10
114
  .hagilight-copyright {
11
115
  margin-block: 1rem;
12
116
  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,362 @@
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
+ position: fixed;
234
+ top: 50%;
235
+ left: 50%;
236
+ width: min(54rem, calc(100vw - 3rem));
237
+ max-width: none;
238
+ max-height: min(30rem, calc(100dvh - 3rem));
239
+ margin: 0;
240
+ padding: 1.1rem 1.1rem 1rem;
241
+ border: 1px solid var(--sl-color-gray-5);
242
+ border-radius: 1rem;
243
+ background: color-mix(in srgb, var(--sl-color-bg) 94%, transparent);
244
+ color: var(--sl-color-text);
245
+ box-shadow: 0 24px 64px rgba(2, 6, 23, 0.32);
246
+ transform: translate(-50%, -50%);
247
+ }
248
+
249
+ .language-dialog::backdrop {
250
+ background: rgba(15, 23, 42, 0.52);
251
+ }
252
+
253
+ .dialog-header {
254
+ display: flex;
255
+ align-items: flex-start;
256
+ justify-content: space-between;
257
+ gap: 1rem;
258
+ margin-bottom: 1rem;
259
+ }
260
+
261
+ .dialog-title {
262
+ margin: 0;
263
+ color: var(--sl-color-white);
264
+ font-size: 1rem;
265
+ font-weight: 700;
266
+ }
267
+
268
+ .dialog-current {
269
+ margin: 0.35rem 0 0;
270
+ font-size: var(--sl-text-sm);
271
+ }
272
+
273
+ .dialog-close {
274
+ display: inline-flex;
275
+ align-items: center;
276
+ justify-content: center;
277
+ width: 2.25rem;
278
+ height: 2.25rem;
279
+ border: 1px solid var(--sl-color-gray-5);
280
+ border-radius: 999px;
281
+ background: transparent;
282
+ color: var(--sl-color-text-high);
283
+ cursor: pointer;
284
+ font: inherit;
285
+ font-size: 1.125rem;
286
+ }
287
+
288
+ .language-grid-scroller {
289
+ max-height: min(20rem, calc(100dvh - 11rem));
290
+ overflow: auto;
291
+ border: 1px solid var(--sl-color-gray-5);
292
+ border-radius: 0.875rem;
293
+ background: color-mix(in srgb, var(--sl-color-black) 6%, transparent);
294
+ padding: 0.95rem 1rem;
295
+ }
296
+
297
+ .language-grid {
298
+ display: grid;
299
+ grid-template-columns: repeat(5, minmax(7.75rem, 8.75rem));
300
+ justify-content: center;
301
+ gap: 0.7rem;
302
+ }
303
+
304
+ .language-option {
305
+ display: flex;
306
+ min-height: 4.2rem;
307
+ width: 100%;
308
+ flex-direction: column;
309
+ align-items: center;
310
+ justify-content: center;
311
+ gap: 0.45rem;
312
+ border: 1px solid var(--sl-color-gray-5);
313
+ border-radius: 0.875rem;
314
+ background: color-mix(in srgb, var(--sl-color-bg-nav) 88%, transparent);
315
+ color: var(--sl-color-text-high);
316
+ cursor: pointer;
317
+ font: inherit;
318
+ font-size: 0.95rem;
319
+ font-weight: 700;
320
+ padding: 0.75rem 0.65rem;
321
+ text-align: center;
322
+ }
323
+
324
+ .language-option-selected {
325
+ border-color: var(--sl-color-accent-high);
326
+ background: color-mix(in srgb, var(--sl-color-accent-high) 18%, transparent);
327
+ }
328
+
329
+ .selected-badge {
330
+ display: inline-flex;
331
+ align-items: center;
332
+ justify-content: center;
333
+ min-height: 1.45rem;
334
+ padding: 0 0.55rem;
335
+ border-radius: 999px;
336
+ background: color-mix(in srgb, var(--sl-color-accent-high) 18%, transparent);
337
+ color: var(--sl-color-accent-high);
338
+ font-size: 0.75rem;
339
+ }
340
+
341
+ @media (max-width: 72rem) {
342
+ .language-dialog {
343
+ width: min(46rem, calc(100vw - 2.5rem));
344
+ }
345
+
346
+ .language-grid {
347
+ grid-template-columns: repeat(4, minmax(7.35rem, 8.35rem));
348
+ }
349
+ }
350
+
351
+ @media (max-width: 50rem) {
352
+ .language-dialog {
353
+ width: calc(100vw - 2rem);
354
+ max-height: calc(100dvh - 2rem);
355
+ padding: 1rem;
356
+ }
357
+
358
+ .language-grid {
359
+ grid-template-columns: repeat(2, minmax(0, 1fr));
360
+ }
361
+ }
362
+ </style>
@@ -0,0 +1,36 @@
1
+ ---
2
+ import { getCollection } from 'astro:content';
3
+ import DefaultMarkdownContent from '@astrojs/starlight/components/MarkdownContent.astro';
4
+ import AIDisclosureNotice from './AIDisclosureNotice.astro';
5
+ import {
6
+ getSourcePath,
7
+ isTranslationLocale,
8
+ resolveAIDisclosureFlags,
9
+ } from './ai-disclosures.mjs';
10
+
11
+ const { aiDisclosures = { isAITranslation: false, isAIAuthor: false, sourceLocale: 'root' } } = Astro.props;
12
+ const route = Astro.locals.starlightRoute;
13
+ const isDocsEntry = route.entry.id !== '404' && !route.entry.id.endsWith('/404');
14
+ const flags = isDocsEntry
15
+ ? resolveAIDisclosureFlags(route.entry.data, aiDisclosures)
16
+ : { isAITranslation: false, isAIAuthor: false };
17
+ const showTranslation = flags.isAITranslation
18
+ && isTranslationLocale(route.entryMeta.locale, aiDisclosures.sourceLocale);
19
+ let sourceHref;
20
+
21
+ if (showTranslation) {
22
+ const docs = await getCollection('docs');
23
+ sourceHref = getSourcePath({
24
+ docs,
25
+ routeId: route.id,
26
+ currentLocale: route.locale,
27
+ sourceLocale: aiDisclosures.sourceLocale,
28
+ pathname: Astro.url.pathname,
29
+ baseUrl: import.meta.env.BASE_URL,
30
+ });
31
+ }
32
+ ---
33
+
34
+ {flags.isAIAuthor && <AIDisclosureNotice kind="author" lang={route.lang} />}
35
+ <DefaultMarkdownContent><slot /></DefaultMarkdownContent>
36
+ {showTranslation && <AIDisclosureNotice kind="translation" lang={route.lang} sourceHref={sourceHref} />}
@@ -0,0 +1,7 @@
1
+ ---
2
+ import DefaultPageTitle from '@astrojs/starlight/components/PageTitle.astro';
3
+ import ContentLayoutToggle from './ContentLayoutToggle.astro';
4
+ ---
5
+
6
+ <DefaultPageTitle />
7
+ <ContentLayoutToggle />
@@ -0,0 +1,7 @@
1
+ ---
2
+ import PromotoBanner from '@hagicode/hagilight/PromotoBanner';
3
+ import Footer from './Footer.astro';
4
+ ---
5
+
6
+ <Footer />
7
+ <PromotoBanner />
@@ -0,0 +1,9 @@
1
+ import { z } from 'astro/zod';
2
+
3
+ const aiDisclosureSchema = z.object({
4
+ isAITranslation: z.boolean().optional(),
5
+ isAIAuthor: z.boolean().optional(),
6
+ });
7
+
8
+ export { aiDisclosureSchema };
9
+ export default aiDisclosureSchema;
@@ -0,0 +1,108 @@
1
+ const COPY = {
2
+ 'de-DE': {
3
+ author: 'Dieser Inhalt wurde mit KI-Unterstützung erstellt. Bitte prüfen Sie wichtige Angaben.',
4
+ translation: 'Dieser Beitrag wurde mit KI übersetzt.',
5
+ source: 'Original ansehen',
6
+ },
7
+ 'en-US': {
8
+ author: 'This content was created with AI assistance. Please verify important details.',
9
+ translation: 'This post was translated with AI.',
10
+ source: 'View source',
11
+ },
12
+ 'es-ES': {
13
+ author: 'Este contenido se creó con ayuda de IA. Verifica los detalles importantes.',
14
+ translation: 'Esta publicación se tradujo con IA.',
15
+ source: 'Ver original',
16
+ },
17
+ 'fr-FR': {
18
+ author: 'Ce contenu a été créé avec l’aide de l’IA. Vérifiez les informations importantes.',
19
+ translation: 'Cet article a été traduit avec l’IA.',
20
+ source: 'Voir la source',
21
+ },
22
+ 'ja-JP': {
23
+ author: 'この記事は AI の支援を受けて作成されました。重要な情報はご確認ください。',
24
+ translation: 'この記事は AI によって翻訳されました。',
25
+ source: '原文を見る',
26
+ },
27
+ 'ko-KR': {
28
+ author: '이 콘텐츠는 AI의 도움을 받아 작성되었습니다. 중요한 세부 정보는 확인해 주세요.',
29
+ translation: '이 게시물은 AI로 번역되었습니다.',
30
+ source: '원문 보기',
31
+ },
32
+ 'pt-BR': {
33
+ author: 'Este conteúdo foi criado com auxílio de IA. Confira os detalhes importantes.',
34
+ translation: 'Esta publicação foi traduzida com IA.',
35
+ source: 'Ver original',
36
+ },
37
+ 'ru-RU': {
38
+ author: 'Этот материал создан с помощью ИИ. Проверьте важные сведения.',
39
+ translation: 'Эта публикация переведена с помощью ИИ.',
40
+ source: 'Открыть оригинал',
41
+ },
42
+ 'zh-CN': {
43
+ author: '本文内容由 AI 辅助创作。请核实重要信息。',
44
+ translation: '本文由 AI 翻译。',
45
+ source: '查看原文',
46
+ },
47
+ 'zh-Hant': {
48
+ author: '本文內容由 AI 輔助創作。請核實重要資訊。',
49
+ translation: '本文由 AI 翻譯。',
50
+ source: '查看原文',
51
+ },
52
+ };
53
+
54
+ export function resolveAIDisclosureFlags(frontmatter, defaults) {
55
+ for (const key of ['isAITranslation', 'isAIAuthor']) {
56
+ if (frontmatter[key] !== undefined && typeof frontmatter[key] !== 'boolean') {
57
+ throw new TypeError(`Hagilight frontmatter ${key} must be a boolean.`);
58
+ }
59
+ if (typeof defaults[key] !== 'boolean') {
60
+ throw new TypeError(`Hagilight aiDisclosures ${key} default must be a boolean.`);
61
+ }
62
+ }
63
+ return {
64
+ isAITranslation: frontmatter.isAITranslation ?? defaults.isAITranslation,
65
+ isAIAuthor: frontmatter.isAIAuthor ?? defaults.isAIAuthor,
66
+ };
67
+ }
68
+
69
+ export function getAIDisclosureCopy(lang) {
70
+ if (COPY[lang]) return COPY[lang];
71
+ const language = lang?.split('-')[0];
72
+ return COPY[language] ?? COPY['en-US'];
73
+ }
74
+
75
+ export function getSourceEntryId(routeId, currentLocale, sourceLocale) {
76
+ const prefix = currentLocale && currentLocale !== 'root' ? `${currentLocale}/` : '';
77
+ const routeSlug = prefix && routeId.startsWith(prefix) ? routeId.slice(prefix.length) : routeId;
78
+ if (sourceLocale === 'root') return routeSlug === 'index' ? '' : routeSlug;
79
+ return `${sourceLocale}/${routeSlug || 'index'}`;
80
+ }
81
+
82
+ export function normalizeDocsEntryId(id) {
83
+ return id === 'index' ? '' : id;
84
+ }
85
+
86
+ export function getSourcePath({ docs, routeId, currentLocale, sourceLocale, pathname, baseUrl }) {
87
+ const sourceId = getSourceEntryId(routeId, currentLocale, sourceLocale);
88
+ if (!docs.some((entry) => normalizeDocsEntryId(entry.id) === sourceId)) return undefined;
89
+ return buildSourcePathname(pathname, baseUrl, currentLocale, sourceLocale);
90
+ }
91
+
92
+ export function isTranslationLocale(locale, sourceLocale) {
93
+ return (locale ?? 'root') !== sourceLocale;
94
+ }
95
+
96
+ export function buildSourcePathname(pathname, baseUrl, currentLocale, sourceLocale) {
97
+ const basePath = new URL(baseUrl, 'https://hagilight.invalid').pathname;
98
+ const normalizedBase = basePath.endsWith('/') ? basePath : `${basePath}/`;
99
+ const relativePath = pathname.startsWith(normalizedBase)
100
+ ? pathname.slice(normalizedBase.length)
101
+ : pathname.startsWith('/') ? pathname.slice(1) : pathname;
102
+ const segments = relativePath.split('/');
103
+ if (currentLocale && currentLocale !== 'root' && segments[0] === currentLocale) segments.shift();
104
+ if (sourceLocale !== 'root') segments.unshift(sourceLocale);
105
+ return `${normalizedBase}${segments.join('/')}`;
106
+ }
107
+
108
+ export { COPY };
@@ -0,0 +1,16 @@
1
+ const labels = {
2
+ 'de-DE': { group: 'Inhaltsbreite', wide: 'Breit', narrow: 'Schmal' },
3
+ 'en-US': { group: 'Content width', wide: 'Wide', narrow: 'Narrow' },
4
+ 'es-ES': { group: 'Ancho del contenido', wide: 'Ancho', narrow: 'Estrecho' },
5
+ 'fr-FR': { group: 'Largeur du contenu', wide: 'Large', narrow: 'Étroit' },
6
+ 'ja-JP': { group: 'コンテンツ幅', wide: '広い', narrow: '狭い' },
7
+ 'ko-KR': { group: '콘텐츠 너비', wide: '넓게', narrow: '좁게' },
8
+ 'pt-BR': { group: 'Largura do conteúdo', wide: 'Ampla', narrow: 'Estreita' },
9
+ 'ru-RU': { group: 'Ширина содержимого', wide: 'Широкая', narrow: 'Узкая' },
10
+ 'zh-CN': { group: '内容宽度', wide: '宽', narrow: '窄' },
11
+ 'zh-Hant': { group: '內容寬度', wide: '寬', narrow: '窄' },
12
+ };
13
+
14
+ export function getContentWidthLabels(lang) {
15
+ return labels[lang] ?? labels['en-US'];
16
+ }
@@ -0,0 +1,51 @@
1
+ @media (min-width: 50em) {
2
+ :root[data-hagilight-content-width='wide'] {
3
+ --sl-content-width: min(67.5rem, calc(100vw - 2rem));
4
+ }
5
+
6
+ .hagilight-content-width-toggle {
7
+ display: inline-flex;
8
+ gap: 0.25rem;
9
+ padding: 0.25rem;
10
+ border: 1px solid var(--sl-color-hairline);
11
+ border-radius: 0.5rem;
12
+ background: var(--sl-color-bg-sidebar);
13
+ }
14
+
15
+ .hagilight-content-width-toggle button {
16
+ border: 0;
17
+ border-radius: 0.25rem;
18
+ padding: 0.35rem 0.75rem;
19
+ color: var(--sl-color-text);
20
+ background: transparent;
21
+ cursor: pointer;
22
+ }
23
+
24
+ .hagilight-content-width-toggle button[aria-pressed='true'] {
25
+ color: var(--sl-color-text-invert);
26
+ background: var(--sl-color-bg-accent);
27
+ }
28
+
29
+ .hagilight-content-width-toggle button:focus-visible {
30
+ outline: 2px solid var(--sl-color-accent);
31
+ outline-offset: 2px;
32
+ }
33
+ }
34
+
35
+ @media (max-width: 49.999em) {
36
+ .hagilight-content-width-toggle {
37
+ display: none;
38
+ }
39
+ }
40
+
41
+ .hagilight-ai-disclosure {
42
+ border-inline-start: 0.25rem solid var(--sl-color-accent);
43
+ padding: 0.75rem 1rem;
44
+ color: var(--sl-color-text);
45
+ background: var(--sl-color-bg-sidebar);
46
+ font-size: var(--sl-text-sm);
47
+ }
48
+
49
+ .hagilight-ai-disclosure a {
50
+ margin-inline-start: 0.5rem;
51
+ }
@@ -0,0 +1,46 @@
1
+ const STORAGE_KEY = 'hagilight-content-width';
2
+ const VALID_MODES = new Set(['wide', 'narrow']);
3
+
4
+ function normalizeMode(value) {
5
+ return VALID_MODES.has(value) ? value : 'wide';
6
+ }
7
+
8
+ export function restoreContentWidth(root = document.documentElement) {
9
+ let mode = 'wide';
10
+ try {
11
+ mode = normalizeMode(globalThis.localStorage.getItem(STORAGE_KEY));
12
+ } catch {
13
+ mode = 'wide';
14
+ }
15
+ root.dataset.hagilightContentWidth = mode;
16
+ synchronizeButtons(root, mode);
17
+ return mode;
18
+ }
19
+
20
+ export function setContentWidth(mode, root = document.documentElement) {
21
+ if (!VALID_MODES.has(mode)) {
22
+ throw new TypeError('Hagilight content width must be "wide" or "narrow".');
23
+ }
24
+ root.dataset.hagilightContentWidth = mode;
25
+ synchronizeButtons(root, mode);
26
+ try {
27
+ globalThis.localStorage.setItem(STORAGE_KEY, mode);
28
+ } catch {
29
+ return;
30
+ }
31
+ }
32
+
33
+ export function synchronizeContentWidthFromStorage(event, root = document.documentElement) {
34
+ if (event.key !== STORAGE_KEY && event.key !== null) return;
35
+ const mode = normalizeMode(event.newValue);
36
+ root.dataset.hagilightContentWidth = mode;
37
+ synchronizeButtons(root, mode);
38
+ }
39
+
40
+ function synchronizeButtons(root, mode) {
41
+ root.querySelectorAll('[data-hagilight-content-width-choice]').forEach((button) => {
42
+ button.setAttribute('aria-pressed', String(button.dataset.hagilightContentWidthChoice === mode));
43
+ });
44
+ }
45
+
46
+ export { STORAGE_KEY };
package/index.mjs CHANGED
@@ -1,18 +1,257 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { fileURLToPath } from 'node:url';
2
3
 
3
- export default function hagilight() {
4
+ const GOOGLE_ID_PATTERN = /^G-[A-Z0-9]+$/u;
5
+ const FIFTY_ONE_LA_ID_PATTERN = /^[A-Za-z0-9_-]+$/u;
6
+ const DEFAULT_GOOGLE_ANALYTICS_ID = 'G-EN03FMT2Q4';
7
+ const DEFAULT_51LA_ID = 'L6b88a5yK4h2Xnci';
8
+ const defaultLogo = fileURLToPath(import.meta.resolve('@hagicode/hagilight/logo.png'));
9
+ const contentWidthCssPath = fileURLToPath(new URL('./content-width.css', import.meta.url));
10
+ const contentWidthHeadScript = `(() => {
11
+ let mode = 'wide';
12
+ try {
13
+ const stored = localStorage.getItem('hagilight-content-width');
14
+ if (stored === 'wide' || stored === 'narrow') mode = stored;
15
+ } catch {
16
+ mode = 'wide';
17
+ }
18
+ document.documentElement.dataset.hagilightContentWidth = mode;
19
+ })();`;
20
+
21
+ function resolveProvider(config, { name, idKey, pattern, defaultId }) {
22
+ if (config !== undefined && (!config || typeof config !== 'object' || Array.isArray(config))) {
23
+ throw new TypeError(`Hagilight ${name} analytics options must be an object.`);
24
+ }
25
+
26
+ const enabled = config === undefined ? true : config.enabled ?? Boolean(config[idKey]);
27
+ if (!enabled) return undefined;
28
+
29
+ const id = config === undefined ? defaultId : config[idKey];
30
+ if (typeof id !== 'string' || !id.trim()) {
31
+ throw new Error(`Hagilight ${name} analytics is enabled but its ${idKey} is missing.`);
32
+ }
33
+ if (!pattern.test(id)) {
34
+ throw new Error(`Hagilight ${name} analytics ${idKey} has an invalid format.`);
35
+ }
36
+ return id;
37
+ }
38
+
39
+ function resolveAIDisclosures(config) {
40
+ if (config === undefined) {
41
+ return {
42
+ isAITranslation: false,
43
+ isAIAuthor: false,
44
+ sourceLocale: 'root',
45
+ };
46
+ }
47
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
48
+ throw new TypeError('Hagilight aiDisclosures options must be an object.');
49
+ }
50
+ for (const key of ['isAITranslation', 'isAIAuthor']) {
51
+ if (config[key] !== undefined && typeof config[key] !== 'boolean') {
52
+ throw new TypeError(`Hagilight aiDisclosures ${key} option must be a boolean.`);
53
+ }
54
+ }
55
+ const sourceLocale = config.sourceLocale === undefined ? 'root' : config.sourceLocale;
56
+ if (typeof sourceLocale !== 'string' || !sourceLocale.trim() || /[/\\]/u.test(sourceLocale)) {
57
+ throw new TypeError('Hagilight aiDisclosures sourceLocale option must be a non-empty string.');
58
+ }
59
+ return {
60
+ isAITranslation: config.isAITranslation ?? false,
61
+ isAIAuthor: config.isAIAuthor ?? false,
62
+ sourceLocale: sourceLocale.trim(),
63
+ };
64
+ }
65
+
66
+ function createConfiguredIntegration(instanceId, serializedOptions, componentIds) {
67
+ const optionsId = `virtual:hagilight-starlight/${instanceId}/options`;
68
+ const headerPath = componentIds.header
69
+ ? fileURLToPath(new URL('./Header.astro', import.meta.url))
70
+ : undefined;
71
+ const footerPath = fileURLToPath(new URL('./Footer.astro', import.meta.url));
72
+ const pageTitlePath = fileURLToPath(new URL('./PageTitle.astro', import.meta.url));
73
+ const markdownContentPath = fileURLToPath(new URL('./MarkdownContent.astro', import.meta.url));
74
+ const headerSource = headerPath
75
+ ? `---
76
+ import Header from ${JSON.stringify(headerPath)};
77
+ import options from '${optionsId}';
78
+ ---
79
+
80
+ <Header links={options.links} />
81
+ `
82
+ : undefined;
83
+ const promotionImport = serializedOptions.promotoEnabled
84
+ ? "import PromotoBanner from '@hagicode/hagilight/PromotoBanner';"
85
+ : '';
86
+ const promotionRender = serializedOptions.promotoEnabled ? '<PromotoBanner />' : '';
87
+ const analyticsImport = serializedOptions.fiftyOneLaId
88
+ ? "import Analytics51LA from '@hagicode/hagilight/Analytics51LA';"
89
+ : '';
90
+ const analyticsRender = serializedOptions.fiftyOneLaId
91
+ ? '<Analytics51LA siteId={options.fiftyOneLaId} />'
92
+ : '';
93
+ const footerSource = `---
94
+ import Footer from ${JSON.stringify(footerPath)};
95
+ ${promotionImport}
96
+ ${analyticsImport}
97
+ import options from '${optionsId}';
98
+ ---
99
+
100
+ <Footer locale={Astro.locals?.starlightRoute?.locale} links={options.links} />
101
+ ${promotionRender}
102
+ ${analyticsRender}
103
+ `;
104
+ const pageTitleSource = `---
105
+ import PageTitle from ${JSON.stringify(pageTitlePath)};
106
+ ---
107
+
108
+ <PageTitle />
109
+ `;
110
+ const markdownContentSource = `---
111
+ import MarkdownContent from ${JSON.stringify(markdownContentPath)};
112
+ import options from '${optionsId}';
113
+ ---
114
+
115
+ <MarkdownContent aiDisclosures={options.aiDisclosures}><slot /></MarkdownContent>
116
+ `;
117
+ const headSource = `---
118
+ import DefaultHead from '@astrojs/starlight/components/Head.astro';
119
+ import GoogleAnalytics from '@hagicode/hagilight/GoogleAnalytics';
120
+ import options from '${optionsId}';
121
+ const isNotFound = /(?:^|\\/)404(?:\\.html)?\\/?$/u.test(Astro.url.pathname);
122
+ ---
123
+
124
+ <DefaultHead />
125
+ {!isNotFound && options.googleAnalyticsMeasurementId && (
126
+ <GoogleAnalytics measurementId={options.googleAnalyticsMeasurementId} />
127
+ )}
128
+ `;
129
+ const modules = new Map([
130
+ ...(componentIds.header ? [[componentIds.header, headerSource]] : []),
131
+ [componentIds.footer, footerSource],
132
+ ...(componentIds.pageTitle ? [[componentIds.pageTitle, pageTitleSource]] : []),
133
+ ...(componentIds.markdownContent ? [[componentIds.markdownContent, markdownContentSource]] : []),
134
+ ...(componentIds.head ? [[componentIds.head, headSource]] : []),
135
+ [optionsId, `export default ${JSON.stringify(serializedOptions)};`],
136
+ ]);
137
+ const vitePlugin = {
138
+ name: `@hagicode/hagilight-starlight:${instanceId}`,
139
+ resolveId(id) {
140
+ return modules.has(id) ? `\0${id}` : null;
141
+ },
142
+ load(id) {
143
+ return id.startsWith('\0') ? modules.get(id.slice(1)) ?? null : null;
144
+ },
145
+ };
146
+
147
+ return {
148
+ name: `@hagicode/hagilight-starlight:${instanceId}`,
149
+ hooks: {
150
+ 'astro:config:setup'({ updateConfig }) {
151
+ updateConfig({ vite: { plugins: [vitePlugin] } });
152
+ },
153
+ },
154
+ };
155
+ }
156
+
157
+ export default function hagilight(options = {}) {
158
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
159
+ throw new TypeError('Hagilight Starlight options must be an object.');
160
+ }
161
+ if (options.header !== undefined
162
+ && (!options.header || typeof options.header !== 'object' || Array.isArray(options.header))) {
163
+ throw new TypeError('Hagilight header options must be an object.');
164
+ }
165
+ if (options.header?.enabled !== undefined && typeof options.header.enabled !== 'boolean') {
166
+ throw new TypeError('Hagilight header enabled option must be a boolean.');
167
+ }
168
+ if (options.analytics !== undefined
169
+ && (!options.analytics || typeof options.analytics !== 'object' || Array.isArray(options.analytics))) {
170
+ throw new TypeError('Hagilight analytics options must be an object.');
171
+ }
172
+ if (options.contentComponents !== undefined
173
+ && (!options.contentComponents || typeof options.contentComponents !== 'object' || Array.isArray(options.contentComponents))) {
174
+ throw new TypeError('Hagilight contentComponents options must be an object.');
175
+ }
176
+ for (const key of ['pageTitle', 'markdownContent']) {
177
+ if (options.contentComponents?.[key] !== undefined && typeof options.contentComponents[key] !== 'boolean') {
178
+ throw new TypeError(`Hagilight contentComponents ${key} option must be a boolean.`);
179
+ }
180
+ }
181
+ const analytics = options.analytics ?? {};
182
+ const aiDisclosures = resolveAIDisclosures(options.aiDisclosures);
183
+ const googleAnalyticsMeasurementId = resolveProvider(analytics.googleAnalytics, {
184
+ name: 'Google Analytics',
185
+ idKey: 'measurementId',
186
+ pattern: GOOGLE_ID_PATTERN,
187
+ defaultId: DEFAULT_GOOGLE_ANALYTICS_ID,
188
+ });
189
+ const fiftyOneLaId = resolveProvider(analytics.fiftyOneLa, {
190
+ name: '51LA',
191
+ idKey: 'siteId',
192
+ pattern: FIFTY_ONE_LA_ID_PATTERN,
193
+ defaultId: DEFAULT_51LA_ID,
194
+ });
195
+ const instanceId = randomUUID();
196
+ const headerEnabled = options.header?.enabled !== false;
197
+ const pageTitleEnabled = options.contentComponents?.pageTitle !== false;
198
+ const markdownContentEnabled = options.contentComponents?.markdownContent !== false;
199
+ const componentIds = {
200
+ header: headerEnabled
201
+ ? `virtual:hagilight-starlight/${instanceId}/Header.astro`
202
+ : undefined,
203
+ footer: `virtual:hagilight-starlight/${instanceId}/Footer.astro`,
204
+ pageTitle: pageTitleEnabled ? `virtual:hagilight-starlight/${instanceId}/PageTitle.astro` : undefined,
205
+ markdownContent: markdownContentEnabled
206
+ ? `virtual:hagilight-starlight/${instanceId}/MarkdownContent.astro`
207
+ : undefined,
208
+ head: googleAnalyticsMeasurementId
209
+ ? `virtual:hagilight-starlight/${instanceId}/Head.astro`
210
+ : undefined,
211
+ };
212
+ const serializedOptions = {
213
+ promotoEnabled: options.promoto?.enabled !== false,
214
+ links: options.links ?? {},
215
+ googleAnalyticsMeasurementId,
216
+ fiftyOneLaId,
217
+ aiDisclosures,
218
+ };
219
+
4
220
  return {
5
221
  name: '@hagicode/hagilight-starlight',
6
222
  hooks: {
7
- 'config:setup'({ config, updateConfig }) {
223
+ 'config:setup'({ config, updateConfig, addIntegration }) {
224
+ if (headerEnabled && config.components?.Header) {
225
+ 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.');
226
+ }
8
227
  if (config.components?.Footer) {
9
228
  throw new Error('Hagilight cannot replace an existing Starlight Footer override.');
10
229
  }
230
+ if (pageTitleEnabled && config.components?.PageTitle) {
231
+ throw new Error('Hagilight cannot replace an existing Starlight PageTitle override. Set contentComponents: { pageTitle: false } and compose @hagicode/hagilight-starlight/ContentLayoutToggle into your PageTitle component.');
232
+ }
233
+ if (markdownContentEnabled && config.components?.MarkdownContent) {
234
+ throw new Error('Hagilight cannot replace an existing Starlight MarkdownContent override. Set contentComponents: { markdownContent: false } and compose @hagicode/hagilight-starlight/MarkdownContent into your MarkdownContent component.');
235
+ }
236
+ if (googleAnalyticsMeasurementId && config.components?.Head) {
237
+ throw new Error('Hagilight Google Analytics cannot replace an existing Starlight Head override. Compose @hagicode/hagilight/GoogleAnalytics in your Head instead.');
238
+ }
11
239
 
240
+ addIntegration(createConfiguredIntegration(instanceId, serializedOptions, componentIds));
12
241
  updateConfig({
242
+ ...(config.logo === undefined ? { logo: { src: defaultLogo, alt: 'HagiCode' } } : {}),
243
+ customCss: [...(config.customCss ?? []), contentWidthCssPath],
244
+ head: [
245
+ ...(config.head ?? []),
246
+ { tag: 'script', content: contentWidthHeadScript },
247
+ ],
13
248
  components: {
14
249
  ...config.components,
15
- Footer: fileURLToPath(new URL('./Footer.astro', import.meta.url)),
250
+ ...(componentIds.header ? { Header: componentIds.header } : {}),
251
+ Footer: componentIds.footer,
252
+ ...(componentIds.pageTitle ? { PageTitle: componentIds.pageTitle } : {}),
253
+ ...(componentIds.markdownContent ? { MarkdownContent: componentIds.markdownContent } : {}),
254
+ ...(componentIds.head ? { Head: componentIds.head } : {}),
16
255
  },
17
256
  });
18
257
  },
@@ -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.1",
3
+ "version": "0.1.2-dev.10.1.aebe89d",
4
4
  "type": "module",
5
5
  "description": "Starlight customizations for HagiCode documentation",
6
6
  "repository": {
@@ -14,14 +14,22 @@
14
14
  },
15
15
  "exports": {
16
16
  ".": "./index.mjs",
17
- "./Footer": "./Footer.astro"
17
+ "./ai-disclosure-schema": "./ai-disclosure-schema.mjs",
18
+ "./ContentLayoutToggle": "./ContentLayoutToggle.astro",
19
+ "./Header": "./Header.astro",
20
+ "./Footer": "./Footer.astro",
21
+ "./MarkdownContent": "./MarkdownContent.astro",
22
+ "./PageTitle": "./PageTitle.astro",
23
+ "./PromotoFooter": "./PromotoFooter.astro"
18
24
  },
19
25
  "files": [
20
26
  "index.mjs",
27
+ "*.css",
28
+ "*.mjs",
21
29
  "*.astro"
22
30
  ],
23
31
  "dependencies": {
24
- "@hagicode/hagilight": "0.1.1"
32
+ "@hagicode/hagilight": "0.1.2-dev.10.1.aebe89d"
25
33
  },
26
34
  "peerDependencies": {
27
35
  "@astrojs/starlight": "^0.42.4",