@ouraihub/hugo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,117 @@
1
+ {{- $schemaType := .Params.schemaType | default "WebPage" -}}
2
+ {{- $title := .Title | default .Site.Title -}}
3
+ {{- $description := .Description | default .Summary | default .Site.Params.description -}}
4
+ {{- $author := .Params.author | default .Site.Params.author -}}
5
+ {{- $image := .Params.image | default .Site.Params.defaultImage -}}
6
+ {{- $siteName := .Site.Title -}}
7
+ {{- $siteUrl := .Site.BaseURL | strings.TrimSuffix "/" -}}
8
+ {{- $logo := .Site.Params.logo | default "" -}}
9
+
10
+ <script type="application/ld+json">
11
+ {
12
+ "@context": "https://schema.org",
13
+ "@type": "{{ $schemaType }}",
14
+ {{- if eq $schemaType "Article" }}
15
+ "headline": {{ $title | plainify | jsonify }},
16
+ "description": {{ $description | plainify | jsonify }},
17
+ "url": {{ .Permalink | jsonify }},
18
+ {{- with $image }}
19
+ {{- if hasPrefix . "http" }}
20
+ "image": {{ . | jsonify }},
21
+ {{- else }}
22
+ "image": {{ . | absURL | jsonify }},
23
+ {{- end }}
24
+ {{- end }}
25
+ {{- with .PublishDate }}
26
+ "datePublished": {{ .Format "2006-01-02T15:04:05Z07:00" | jsonify }},
27
+ {{- end }}
28
+ {{- with .Lastmod }}
29
+ "dateModified": {{ .Format "2006-01-02T15:04:05Z07:00" | jsonify }},
30
+ {{- end }}
31
+ {{- with $author }}
32
+ "author": {
33
+ "@type": "Person",
34
+ "name": {{ . | plainify | jsonify }}
35
+ },
36
+ {{- end }}
37
+ "publisher": {
38
+ "@type": "Organization",
39
+ "name": {{ $siteName | plainify | jsonify }},
40
+ "url": {{ $siteUrl | jsonify }}
41
+ {{- with $logo }}
42
+ {{- if hasPrefix . "http" }},
43
+ "logo": {
44
+ "@type": "ImageObject",
45
+ "url": {{ . | jsonify }}
46
+ }
47
+ {{- else }},
48
+ "logo": {
49
+ "@type": "ImageObject",
50
+ "url": {{ . | absURL | jsonify }}
51
+ }
52
+ {{- end }}
53
+ {{- end }}
54
+ },
55
+ "mainEntityOfPage": {
56
+ "@type": "WebPage",
57
+ "@id": {{ .Permalink | jsonify }}
58
+ }
59
+ {{- with .Params.tags }},
60
+ "keywords": {{ delimit . ", " | plainify | jsonify }}
61
+ {{- end }}
62
+ {{- with .WordCount }},
63
+ "wordCount": {{ . }}
64
+ {{- end }}
65
+ {{- else if eq $schemaType "WebPage" }}
66
+ "name": {{ $title | plainify | jsonify }},
67
+ "description": {{ $description | plainify | jsonify }},
68
+ "url": {{ .Permalink | jsonify }},
69
+ {{- with $image }}
70
+ {{- if hasPrefix . "http" }}
71
+ "image": {{ . | jsonify }},
72
+ {{- else }}
73
+ "image": {{ . | absURL | jsonify }},
74
+ {{- end }}
75
+ {{- end }}
76
+ "publisher": {
77
+ "@type": "Organization",
78
+ "name": {{ $siteName | plainify | jsonify }},
79
+ "url": {{ $siteUrl | jsonify }}
80
+ {{- with $logo }}
81
+ {{- if hasPrefix . "http" }},
82
+ "logo": {
83
+ "@type": "ImageObject",
84
+ "url": {{ . | jsonify }}
85
+ }
86
+ {{- else }},
87
+ "logo": {
88
+ "@type": "ImageObject",
89
+ "url": {{ . | absURL | jsonify }}
90
+ }
91
+ {{- end }}
92
+ {{- end }}
93
+ }
94
+ {{- else if eq $schemaType "Organization" }}
95
+ "name": {{ $siteName | plainify | jsonify }},
96
+ "url": {{ $siteUrl | jsonify }},
97
+ {{- with $logo }}
98
+ {{- if hasPrefix . "http" }}
99
+ "logo": {{ . | jsonify }},
100
+ {{- else }}
101
+ "logo": {{ . | absURL | jsonify }},
102
+ {{- end }}
103
+ {{- end }}
104
+ {{- with .Site.Params.organizationDescription }}
105
+ "description": {{ . | plainify | jsonify }},
106
+ {{- end }}
107
+ {{- with .Site.Params.socialLinks }}
108
+ "sameAs": [
109
+ {{- $length := len . -}}
110
+ {{- range $index, $link := . -}}
111
+ {{ $link | jsonify }}{{ if lt (add $index 1) $length }},{{ end }}
112
+ {{- end -}}
113
+ ]
114
+ {{- end }}
115
+ {{- end }}
116
+ }
117
+ </script>
@@ -0,0 +1,30 @@
1
+ <button
2
+ data-ui-component="theme-toggle"
3
+ data-ui-storage-key="theme"
4
+ data-ui-attribute="data-theme"
5
+ class="theme-toggle-btn"
6
+ aria-label="切换主题"
7
+ >
8
+ <span class="theme-icon-light">☀️</span>
9
+ <span class="theme-icon-dark">🌙</span>
10
+ </button>
11
+
12
+ <style>
13
+ .theme-toggle-btn {
14
+ border: 1px solid var(--ui-border, #e0e0e0);
15
+ background: var(--ui-background, #ffffff);
16
+ padding: 0.5rem;
17
+ border-radius: 0.25rem;
18
+ cursor: pointer;
19
+ transition: background-color 0.2s ease;
20
+ }
21
+
22
+ .theme-toggle-btn:hover {
23
+ background: var(--ui-background-hover, #f5f5f5);
24
+ }
25
+
26
+ [data-theme="light"] .theme-icon-dark,
27
+ [data-theme="dark"] .theme-icon-light {
28
+ display: none;
29
+ }
30
+ </style>
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@ouraihub/hugo",
3
+ "version": "0.1.0",
4
+ "description": "Hugo theme components for @ouraihub/ui-library",
5
+ "type": "module",
6
+ "peerDependencies": {
7
+ "@ouraihub/core": "0.1.0"
8
+ },
9
+ "devDependencies": {
10
+ "@playwright/test": "^1.48.0"
11
+ },
12
+ "files": [
13
+ "layouts",
14
+ "static"
15
+ ],
16
+ "keywords": [
17
+ "hugo",
18
+ "theme",
19
+ "components",
20
+ "ui"
21
+ ],
22
+ "author": "OurAI Hub",
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/ouraihub/ui-library.git",
27
+ "directory": "packages/hugo"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "scripts": {
33
+ "test:e2e": "playwright test",
34
+ "test:e2e:ui": "playwright test --ui",
35
+ "test:e2e:headed": "playwright test --headed",
36
+ "test:e2e:debug": "playwright test --debug"
37
+ }
38
+ }
@@ -0,0 +1,375 @@
1
+ /**
2
+ * ThemeManager - 主题切换管理器
3
+ * 核心逻辑:light/dark/system 三态切换,localStorage 持久化
4
+ */
5
+ class ThemeManager {
6
+ constructor(element = document.documentElement, options = {}) {
7
+ this.element = element;
8
+ this.storageKey = options.storageKey || 'theme';
9
+ this.attribute = options.attribute || 'data-theme';
10
+ this.currentTheme = options.defaultTheme || 'system';
11
+ this.mediaQuery = null;
12
+ this.listeners = [];
13
+
14
+ this.init();
15
+ }
16
+
17
+ init() {
18
+ const saved = this.loadFromStorage();
19
+ if (saved) {
20
+ this.currentTheme = saved;
21
+ }
22
+
23
+ this.applyTheme();
24
+ this.watchSystemTheme();
25
+ }
26
+
27
+ loadFromStorage() {
28
+ try {
29
+ const saved = localStorage.getItem(this.storageKey);
30
+ if (saved === 'light' || saved === 'dark' || saved === 'system') {
31
+ return saved;
32
+ }
33
+ } catch (e) {
34
+ console.warn('[ThemeManager] localStorage unavailable');
35
+ }
36
+ return null;
37
+ }
38
+
39
+ saveToStorage(theme) {
40
+ try {
41
+ localStorage.setItem(this.storageKey, theme);
42
+ } catch (e) {
43
+ console.warn('[ThemeManager] Failed to save theme');
44
+ }
45
+ }
46
+
47
+ watchSystemTheme() {
48
+ if (typeof window === 'undefined' || !window.matchMedia) return;
49
+
50
+ this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
51
+
52
+ const handler = () => {
53
+ if (this.currentTheme === 'system') {
54
+ this.applyTheme();
55
+ this.notifyListeners();
56
+ }
57
+ };
58
+
59
+ if (this.mediaQuery.addEventListener) {
60
+ this.mediaQuery.addEventListener('change', handler);
61
+ } else {
62
+ this.mediaQuery.addListener(handler);
63
+ }
64
+ }
65
+
66
+ resolveTheme() {
67
+ if (this.currentTheme === 'system') {
68
+ if (this.mediaQuery?.matches) {
69
+ return 'dark';
70
+ }
71
+ return 'light';
72
+ }
73
+ return this.currentTheme;
74
+ }
75
+
76
+ applyTheme() {
77
+ const resolved = this.resolveTheme();
78
+ this.element.setAttribute(this.attribute, resolved);
79
+ }
80
+
81
+ notifyListeners() {
82
+ const resolved = this.resolveTheme();
83
+ this.listeners.forEach(fn => fn(resolved));
84
+ }
85
+
86
+ setTheme(mode) {
87
+ this.currentTheme = mode;
88
+ this.saveToStorage(mode);
89
+ this.applyTheme();
90
+ this.notifyListeners();
91
+ }
92
+
93
+ getTheme() {
94
+ return this.currentTheme;
95
+ }
96
+
97
+ toggle() {
98
+ const resolved = this.resolveTheme();
99
+ const next = resolved === 'light' ? 'dark' : 'light';
100
+ this.setTheme(next);
101
+ }
102
+
103
+ onThemeChange(callback) {
104
+ this.listeners.push(callback);
105
+ return () => {
106
+ const index = this.listeners.indexOf(callback);
107
+ if (index > -1) {
108
+ this.listeners.splice(index, 1);
109
+ }
110
+ };
111
+ }
112
+ }
113
+
114
+ /**
115
+ * 自动初始化主题切换按钮
116
+ * 扫描所有 [data-ui-component="theme-toggle"] 元素
117
+ * 为每个元素创建 ThemeManager 实例并绑定点击事件
118
+ */
119
+ function initThemeToggles() {
120
+ const toggles = document.querySelectorAll('[data-ui-component="theme-toggle"]');
121
+
122
+ toggles.forEach((element) => {
123
+ const storageKey = element.getAttribute('data-ui-storage-key') || 'theme';
124
+ const attribute = element.getAttribute('data-ui-attribute') || 'data-theme';
125
+
126
+ const manager = new ThemeManager(document.documentElement, {
127
+ storageKey,
128
+ attribute,
129
+ });
130
+
131
+ element.addEventListener('click', () => {
132
+ manager.toggle();
133
+ });
134
+
135
+ manager.onThemeChange((theme) => {
136
+ console.log('[ThemeManager] Theme changed:', theme);
137
+ });
138
+ });
139
+ }
140
+
141
+ function initNavigationControllers() {
142
+ const navs = document.querySelectorAll('[data-ui-component="navigation"]');
143
+
144
+ navs.forEach((element) => {
145
+ const mobileBreakpoint = parseInt(element.getAttribute('data-ui-mobile-breakpoint') || '768', 10);
146
+ const menu = element.querySelector('.navigation-menu');
147
+ const toggle = element.querySelector('.navigation-mobile-toggle');
148
+
149
+ if (!menu || !toggle) return;
150
+
151
+ const isMobile = () => window.innerWidth < mobileBreakpoint;
152
+
153
+ toggle.addEventListener('click', () => {
154
+ const isExpanded = toggle.getAttribute('aria-expanded') === 'true';
155
+ toggle.setAttribute('aria-expanded', !isExpanded);
156
+ menu.setAttribute('aria-hidden', isExpanded);
157
+ });
158
+
159
+ const dropdownToggles = element.querySelectorAll('.navigation-dropdown-toggle');
160
+ dropdownToggles.forEach((dropdownToggle) => {
161
+ dropdownToggle.addEventListener('click', (e) => {
162
+ if (isMobile()) {
163
+ e.preventDefault();
164
+ const isExpanded = dropdownToggle.getAttribute('aria-expanded') === 'true';
165
+ dropdownToggle.setAttribute('aria-expanded', !isExpanded);
166
+ }
167
+ });
168
+ });
169
+
170
+ window.addEventListener('resize', () => {
171
+ if (!isMobile()) {
172
+ toggle.setAttribute('aria-expanded', 'false');
173
+ menu.setAttribute('aria-hidden', 'true');
174
+ }
175
+ });
176
+ });
177
+ }
178
+
179
+ function initLazyLoaders() {
180
+ const images = document.querySelectorAll('[data-ui-component="lazy-image"]');
181
+
182
+ if ('IntersectionObserver' in window) {
183
+ const observer = new IntersectionObserver((entries) => {
184
+ entries.forEach((entry) => {
185
+ if (entry.isIntersecting) {
186
+ const img = entry.target;
187
+ const src = img.getAttribute('data-src');
188
+
189
+ if (src) {
190
+ img.src = src;
191
+ img.addEventListener('load', () => {
192
+ img.classList.add('loaded');
193
+ });
194
+ img.addEventListener('error', () => {
195
+ img.classList.add('error');
196
+ });
197
+ observer.unobserve(img);
198
+ }
199
+ }
200
+ });
201
+ }, {
202
+ rootMargin: '50px'
203
+ });
204
+
205
+ images.forEach((img) => observer.observe(img));
206
+ } else {
207
+ images.forEach((img) => {
208
+ const src = img.getAttribute('data-src');
209
+ if (src) {
210
+ img.src = src;
211
+ img.classList.add('loaded');
212
+ }
213
+ });
214
+ }
215
+ }
216
+
217
+ function initSearchModals() {
218
+ const modals = document.querySelectorAll('[data-ui-component="search-modal"]');
219
+
220
+ modals.forEach((modal) => {
221
+ const overlay = modal.querySelector('.search-modal-overlay');
222
+ const closeBtn = modal.querySelector('.search-modal-close');
223
+ const input = modal.querySelector('.search-input');
224
+ const resultsList = modal.querySelector('.search-results-list');
225
+ const loading = modal.querySelector('.search-loading');
226
+ const noResults = modal.querySelector('.search-no-results');
227
+ const endpoint = modal.getAttribute('data-ui-search-endpoint') || '/search.json';
228
+
229
+ let searchData = [];
230
+ let selectedIndex = -1;
231
+
232
+ const openModal = () => {
233
+ modal.setAttribute('aria-hidden', 'false');
234
+ input?.focus();
235
+ };
236
+
237
+ const closeModal = () => {
238
+ modal.setAttribute('aria-hidden', 'true');
239
+ if (input) input.value = '';
240
+ selectedIndex = -1;
241
+ if (resultsList) resultsList.innerHTML = '';
242
+ };
243
+
244
+ const performSearch = (query) => {
245
+ if (!query.trim()) {
246
+ if (resultsList) resultsList.setAttribute('aria-hidden', 'true');
247
+ if (noResults) noResults.setAttribute('aria-hidden', 'true');
248
+ return;
249
+ }
250
+
251
+ const results = searchData.filter((item) => {
252
+ const searchText = `${item.title} ${item.content}`.toLowerCase();
253
+ return searchText.includes(query.toLowerCase());
254
+ }).slice(0, 10);
255
+
256
+ if (results.length === 0) {
257
+ if (resultsList) resultsList.setAttribute('aria-hidden', 'true');
258
+ if (noResults) noResults.setAttribute('aria-hidden', 'false');
259
+ } else {
260
+ if (noResults) noResults.setAttribute('aria-hidden', 'true');
261
+ if (resultsList) {
262
+ resultsList.setAttribute('aria-hidden', 'false');
263
+ resultsList.innerHTML = results.map((result, index) => `
264
+ <li class="search-result-item" role="option">
265
+ <a href="${result.url}" class="search-result-link" ${index === 0 ? 'aria-selected="true"' : ''}>
266
+ <div class="search-result-title">${result.title}</div>
267
+ <div class="search-result-excerpt">${result.excerpt || ''}</div>
268
+ </a>
269
+ </li>
270
+ `).join('');
271
+ selectedIndex = 0;
272
+ }
273
+ }
274
+ };
275
+
276
+ fetch(endpoint)
277
+ .then((res) => res.json())
278
+ .then((data) => {
279
+ searchData = data;
280
+ })
281
+ .catch(() => {
282
+ console.warn('[SearchModal] Failed to load search data');
283
+ });
284
+
285
+ if (overlay) {
286
+ overlay.addEventListener('click', closeModal);
287
+ }
288
+
289
+ if (closeBtn) {
290
+ closeBtn.addEventListener('click', closeModal);
291
+ }
292
+
293
+ if (input) {
294
+ input.addEventListener('input', (e) => {
295
+ performSearch(e.target.value);
296
+ });
297
+
298
+ input.addEventListener('keydown', (e) => {
299
+ const items = resultsList?.querySelectorAll('.search-result-link');
300
+ if (!items || items.length === 0) return;
301
+
302
+ if (e.key === 'ArrowDown') {
303
+ e.preventDefault();
304
+ selectedIndex = Math.min(selectedIndex + 1, items.length - 1);
305
+ items.forEach((item, i) => {
306
+ item.setAttribute('aria-selected', i === selectedIndex ? 'true' : 'false');
307
+ });
308
+ } else if (e.key === 'ArrowUp') {
309
+ e.preventDefault();
310
+ selectedIndex = Math.max(selectedIndex - 1, 0);
311
+ items.forEach((item, i) => {
312
+ item.setAttribute('aria-selected', i === selectedIndex ? 'true' : 'false');
313
+ });
314
+ } else if (e.key === 'Enter' && selectedIndex >= 0) {
315
+ e.preventDefault();
316
+ items[selectedIndex]?.click();
317
+ } else if (e.key === 'Escape') {
318
+ closeModal();
319
+ }
320
+ });
321
+ }
322
+
323
+ document.addEventListener('keydown', (e) => {
324
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
325
+ e.preventDefault();
326
+ openModal();
327
+ } else if (e.key === 'Escape' && modal.getAttribute('aria-hidden') === 'false') {
328
+ closeModal();
329
+ }
330
+ });
331
+ });
332
+ }
333
+
334
+ function initSEOManagers() {
335
+ const seoElements = document.querySelectorAll('[data-ui-component="seo"]');
336
+
337
+ seoElements.forEach((element) => {
338
+ try {
339
+ const configAttr = element.getAttribute('data-ui-seo-config');
340
+ if (!configAttr) return;
341
+
342
+ const config = JSON.parse(configAttr);
343
+
344
+ if (typeof window.SEOManager !== 'undefined') {
345
+ const seo = new window.SEOManager(config);
346
+
347
+ const metaTags = seo.generateMetaTags();
348
+ if (metaTags) {
349
+ document.head.insertAdjacentHTML('beforeend', metaTags);
350
+ }
351
+
352
+ const schemaScript = seo.generateSchemaOrg();
353
+ if (schemaScript) {
354
+ document.head.insertAdjacentHTML('beforeend', schemaScript);
355
+ }
356
+ }
357
+ } catch (e) {
358
+ console.warn('[SEOManager] Failed to initialize:', e);
359
+ }
360
+ });
361
+ }
362
+
363
+ function initAllComponents() {
364
+ initThemeToggles();
365
+ initNavigationControllers();
366
+ initLazyLoaders();
367
+ initSearchModals();
368
+ initSEOManagers();
369
+ }
370
+
371
+ if (document.readyState === 'loading') {
372
+ document.addEventListener('DOMContentLoaded', initAllComponents);
373
+ } else {
374
+ initAllComponents();
375
+ }