hexo-theme-stellar 1.34.0 → 1.35.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.
@@ -1,646 +0,0 @@
1
- /**
2
- * PJAX - Seamless page transitions for Stellar theme
3
- * Uses pushState + AJAX for smooth navigation without full page reloads
4
- */
5
-
6
- (function () {
7
- 'use strict';
8
-
9
- // PJAX configuration (can be overridden via window.StellarPjaxConfig)
10
- const defaultConfig = {
11
- selectors: ['title', '#l_cover', '.l_body'],
12
- timeout: 10000,
13
- cacheBust: false,
14
- minLoadTime: 200 // Minimum time (ms) to show loading animation for smooth transitions
15
- };
16
-
17
- const config = Object.assign({}, defaultConfig, window.StellarPjaxConfig || {});
18
- if (!config.selectors.includes('#l_cover')) {
19
- config.selectors.push('#l_cover');
20
- }
21
-
22
- // State management
23
- let isLoading = false;
24
- let abortController = null;
25
-
26
- /**
27
- * Check if a link should be handled by PJAX
28
- */
29
- function shouldHandleLink(link) {
30
- // Must be an anchor element with href
31
- if (!link || !link.href || link.tagName !== 'A') return false;
32
-
33
- const url = new URL(link.href);
34
- const currentUrl = new URL(window.location.href);
35
-
36
- // Skip external links
37
- if (url.origin !== currentUrl.origin) return false;
38
-
39
- // Skip hash-only links on same page
40
- if (url.pathname === currentUrl.pathname && url.hash) return false;
41
-
42
- // Skip links with target attribute (except _self)
43
- if (link.target && link.target !== '_self') return false;
44
-
45
- // Skip links with download attribute
46
- if (link.hasAttribute('download')) return false;
47
-
48
- // Skip links with data-pjax="false"
49
- if (link.dataset.pjax === 'false') return false;
50
-
51
- // Skip links to non-HTML resources
52
- const ext = url.pathname.split('.').pop().toLowerCase();
53
- const nonHtmlExts = ['xml', 'rss', 'pdf', 'zip', 'rar', 'exe', 'dmg', 'doc', 'xls', 'ppt', 'mp3', 'mp4', 'avi', 'mov'];
54
- if (nonHtmlExts.includes(ext)) return false;
55
-
56
- return true;
57
- }
58
-
59
- /**
60
- * Sync all attributes from source element to target element
61
- */
62
- function syncAttributes(source, target) {
63
- if (!source || !target) return;
64
- const oldAttrs = Array.from(target.attributes);
65
- const newAttrs = Array.from(source.attributes);
66
-
67
- // Remove attributes not in source
68
- for (let attr of oldAttrs) {
69
- if (!source.hasAttribute(attr.name)) target.removeAttribute(attr.name);
70
- }
71
- // Add or update attributes
72
- for (let attr of newAttrs) {
73
- if (target.getAttribute(attr.name) !== attr.value) {
74
- target.setAttribute(attr.name, attr.value);
75
- }
76
- }
77
- }
78
-
79
- /**
80
- * Extract content from HTML string
81
- */
82
- function extractContent(html) {
83
- const parser = new DOMParser();
84
- const doc = parser.parseFromString(html, 'text/html');
85
- return {
86
- contents: {
87
- title: doc.title,
88
- _bodyClasses: doc.body.className,
89
- _htmlAttrs: Array.from(doc.documentElement.attributes).reduce((acc, attr) => {
90
- acc[attr.name] = attr.value;
91
- return acc;
92
- }, {})
93
- },
94
- doc
95
- };
96
- }
97
-
98
- /**
99
- * Check if two widgets are identical (preserving dynamic content if API matches)
100
- * 使用轻量级比较策略以提高性能
101
- */
102
- function isWidgetIdentical(oldW, newW) {
103
- // 1. 快速检查:如果是同一个节点,直接返回
104
- if (oldW === newW) return true;
105
-
106
- // 2. 检查 widget ID 或类型
107
- const oldId = oldW.id || oldW.getAttribute('data-widget-id');
108
- const newId = newW.id || newW.getAttribute('data-widget-id');
109
- if (oldId && newId && oldId !== newId) return false;
110
-
111
- // 3. 检查 data-service 标识(动态内容)
112
- const oldDS = oldW.classList.contains('data-service') ? oldW : oldW.querySelector('.data-service');
113
- const newDS = newW.classList.contains('data-service') ? newW : newW.querySelector('.data-service');
114
- if (oldDS && newDS) {
115
- const oldApi = oldDS.getAttribute('data-api');
116
- const newApi = newDS.getAttribute('data-api');
117
- // 如果 API 相同,保留旧内容(可能已加载数据)
118
- if (oldApi && newApi && oldApi === newApi) return true;
119
- }
120
-
121
- // 4. 最后才使用 isEqualNode(比 innerHTML 更快)
122
- return oldW.isEqualNode(newW);
123
- }
124
-
125
- /**
126
- * Replace content in the current page
127
- */
128
- function replaceContent(contents, selectors, doc) {
129
- // 1. Update HTML attributes (theme except data-theme, lang, etc.)
130
- const newHtmlAttrs = contents._htmlAttrs;
131
- if (newHtmlAttrs) {
132
- // Remove data-theme from new attributes to prevent overwriting user's saved theme
133
- const attrsToSync = { ...newHtmlAttrs };
134
- delete attrsToSync['data-theme'];
135
-
136
- // Sync all attributes
137
- Object.keys(attrsToSync).forEach(attrName => {
138
- const newValue = attrsToSync[attrName];
139
- const oldValue = document.documentElement.getAttribute(attrName);
140
- if (newValue !== oldValue) {
141
- document.documentElement.setAttribute(attrName, newValue);
142
- }
143
- });
144
-
145
- // Remove attributes that exist in current html but not in new html (except data-theme)
146
- Array.from(document.documentElement.attributes).forEach(attr => {
147
- if (attr.name !== 'data-theme' && !(attr.name in attrsToSync)) {
148
- document.documentElement.removeAttribute(attr.name);
149
- }
150
- });
151
- }
152
-
153
- if (contents.title) document.title = contents.title;
154
- if (contents._bodyClasses) document.body.className = contents._bodyClasses;
155
-
156
- // 2. Replace content for each selector
157
- selectors.forEach(selector => {
158
- if (selector === 'title' || selector === 'html') return;
159
-
160
- const oldEl = document.querySelector(selector);
161
- const newEl = doc.querySelector(selector);
162
- if (oldEl && newEl) {
163
- syncAttributes(newEl, oldEl);
164
-
165
- if (selector === '.l_body') {
166
- // 1. Update main content
167
- const oMain = oldEl.querySelector('.l_main');
168
- const nMain = newEl.querySelector('.l_main');
169
- if (oMain && nMain) {
170
- oMain.replaceWith(nMain);
171
- } else if (oMain) {
172
- // Clear no longer existing main content
173
- oMain.innerHTML = '';
174
- }
175
-
176
- // 2. Special handling for sidebars (left and right)
177
- ['.l_left', '.l_right'].forEach(side => {
178
- const oSide = oldEl.querySelector(side);
179
- const nSide = newEl.querySelector(side);
180
- if (oSide && nSide) {
181
- // Update Sidebar components in-place
182
- ['.header', '.nav-area', '.widgets', '.footer'].forEach(part => {
183
- const op = oSide.querySelector(part);
184
- const np = nSide.querySelector(part);
185
- if (op && np) {
186
- if (part === '.widgets') {
187
- const savedScrollTop = op.scrollTop || 0;
188
- const oldChildren = Array.from(op.children);
189
- const newChildren = Array.from(np.children);
190
-
191
- // Simple positional merger for performance and order preservation
192
- const maxLength = Math.max(oldChildren.length, newChildren.length);
193
- for (let i = 0; i < maxLength; i++) {
194
- const oc = oldChildren[i];
195
- const nc = newChildren[i];
196
- if (oc && nc) {
197
- if (!isWidgetIdentical(oc, nc)) {
198
- oc.replaceWith(nc);
199
- }
200
- } else if (oc) {
201
- oc.remove();
202
- } else if (nc) {
203
- op.appendChild(nc);
204
- }
205
- }
206
- // 恢复滚动位置(在 DOM 更新后重新获取引用)
207
- const widgetsContainer = oSide.querySelector('.widgets');
208
- if (widgetsContainer) {
209
- widgetsContainer.style.scrollBehavior = 'auto';
210
- widgetsContainer.scrollTop = savedScrollTop;
211
- // 延迟恢复 scroll-behavior 以确保滚动完成
212
- requestAnimationFrame(() => {
213
- widgetsContainer.style.scrollBehavior = '';
214
- });
215
- }
216
- } else {
217
- // 使用 isEqualNode 而不是 innerHTML 比较
218
- const isIdentical = op.isEqualNode(np);
219
- if (!isIdentical) {
220
- op.replaceWith(np);
221
- }
222
- }
223
- } else if (op) {
224
- op.remove();
225
- } else if (np) {
226
- oSide.appendChild(np);
227
- }
228
- });
229
- } else if (oSide && !nSide) {
230
- oSide.remove();
231
- } else if (!oSide && nSide) {
232
- if (side === '.l_left') {
233
- oldEl.prepend(nSide);
234
- } else {
235
- oldEl.append(nSide);
236
- }
237
- }
238
- });
239
-
240
- // body already updated, skip general update
241
- return;
242
- }
243
-
244
- // Default replacement for other selectors (like #l_cover)
245
- if (!oldEl.isEqualNode(newEl)) {
246
- oldEl.replaceWith(newEl);
247
- }
248
- } else if (oldEl) {
249
- // If the selector exists in old page but not in new page, clear it
250
- oldEl.innerHTML = '';
251
- Array.from(oldEl.attributes).forEach(attr => {
252
- if (attr.name !== 'id' && attr.name !== 'class') {
253
- oldEl.removeAttribute(attr.name);
254
- }
255
- });
256
- }
257
- });
258
- }
259
-
260
- /**
261
- * Trigger custom events for other scripts to listen to
262
- */
263
- function triggerEvent(name, detail = {}) {
264
- const event = new CustomEvent(name, {
265
- bubbles: true,
266
- cancelable: true,
267
- detail: detail
268
- });
269
- document.dispatchEvent(event);
270
- }
271
-
272
- /**
273
- * Start loading indicator
274
- */
275
- function startLoading() {
276
- document.body.classList.add('pjax-loading');
277
- }
278
-
279
- /**
280
- * Stop loading indicator
281
- */
282
- function stopLoading() {
283
- document.body.classList.remove('pjax-loading');
284
- // Trigger fade-in animation
285
- document.body.classList.add('pjax-loaded');
286
-
287
- // Remove animation class after it completes (matches CSS animation duration)
288
- setTimeout(() => {
289
- document.body.classList.remove('pjax-loaded');
290
- }, 400);
291
- }
292
-
293
- /**
294
- * Fetch page content via AJAX
295
- */
296
- async function fetchPage(url) {
297
- // Abort any existing request
298
- if (abortController) {
299
- abortController.abort();
300
- }
301
-
302
- abortController = new AbortController();
303
-
304
- const fetchUrl = config.cacheBust
305
- ? url + (url.includes('?') ? '&' : '?') + '_pjax=' + Date.now()
306
- : url;
307
-
308
- let timeoutId = null;
309
- if (typeof config.timeout === 'number' && config.timeout > 0) {
310
- timeoutId = setTimeout(function () {
311
- // Abort the current request when the timeout is reached
312
- if (abortController) {
313
- abortController.abort();
314
- }
315
- }, config.timeout);
316
- }
317
- try {
318
- const response = await fetch(fetchUrl, {
319
- method: 'GET',
320
- headers: {
321
- 'X-PJAX': 'true',
322
- 'X-Requested-With': 'XMLHttpRequest'
323
- },
324
- signal: abortController.signal
325
- });
326
- if (!response.ok) {
327
- throw new Error(`HTTP ${response.status}`);
328
- }
329
- return await response.text();
330
- } finally {
331
- if (timeoutId !== null) {
332
- clearTimeout(timeoutId);
333
- }
334
- }
335
- }
336
-
337
- /**
338
- * Execute comment system scripts from the new page
339
- * This ensures comment init functions are registered after PJAX navigation
340
- */
341
- function executeCommentScripts(doc) {
342
- // Clear previous comment system init functions to avoid conflicts
343
- if (window.stellar && window.stellar.initComments) {
344
- window.stellar.initComments = {};
345
- }
346
-
347
- // Find and execute comment scripts from the new page
348
- const scriptsDiv = doc.querySelector('.scripts');
349
- if (!scriptsDiv) return;
350
-
351
- // Look for comment-related script tags
352
- const scripts = scriptsDiv.querySelectorAll('script');
353
- scripts.forEach(oldScript => {
354
- // Check if this is a comment system script by looking for initComments
355
- const scriptContent = oldScript.textContent || oldScript.innerHTML;
356
- if (scriptContent.includes('window.stellar.initComments')) {
357
- // Create and execute a new script element
358
- const newScript = document.createElement('script');
359
-
360
- // Copy all attributes including type="module" if present
361
- Array.from(oldScript.attributes).forEach(attr => {
362
- newScript.setAttribute(attr.name, attr.value);
363
- });
364
-
365
- // For module scripts, we need to use a blob URL to preserve import statements
366
- if (oldScript.type === 'module') {
367
- const blob = new Blob([scriptContent], { type: 'text/javascript' });
368
- const url = URL.createObjectURL(blob);
369
- newScript.src = url;
370
-
371
- // Clean up blob URL and script element after loading (or on error)
372
- const cleanup = () => {
373
- URL.revokeObjectURL(url);
374
- newScript.remove();
375
- };
376
- newScript.onload = cleanup;
377
- newScript.onerror = cleanup;
378
- } else {
379
- // For regular scripts, just copy the content
380
- newScript.textContent = scriptContent;
381
- // Script executes synchronously when appended, so we can remove it immediately
382
- }
383
-
384
- // Execute by appending to document
385
- document.head.appendChild(newScript);
386
- }
387
- });
388
- }
389
-
390
- /**
391
- * Navigate to a new page using PJAX
392
- */
393
- async function navigate(url, options = {}) {
394
- if (isLoading) return false;
395
-
396
- const isPop = options.isPop || false;
397
- const startTime = Date.now();
398
-
399
- // Trigger before event
400
- triggerEvent('pjax:before', { url });
401
-
402
- isLoading = true;
403
- startLoading();
404
-
405
- try {
406
- // Check if we need to animate out (scroll to content start) if current page has cover
407
- const currentWikiCover = document.querySelector('#l_cover .l_cover.wiki');
408
- const startEl = document.getElementById('start');
409
- let scrollPromise = Promise.resolve();
410
-
411
- if (currentWikiCover && startEl && !isPop) {
412
- // Scroll to #start so content slides up to top (pushing cover out of view)
413
- const rect = startEl.getBoundingClientRect();
414
- const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
415
- const targetTop = rect.top + scrollTop;
416
-
417
- // Only animate if we are not already there
418
- if (Math.abs(scrollTop - targetTop) > 5) {
419
- window.scrollTo({ top: targetTop, behavior: 'smooth' });
420
- // Wait for approx 400ms for scroll animation
421
- scrollPromise = new Promise(resolve => setTimeout(resolve, 400));
422
- }
423
- }
424
-
425
- const [html] = await Promise.all([
426
- fetchPage(url),
427
- scrollPromise
428
- ]);
429
-
430
- const { contents, doc } = extractContent(html, config.selectors);
431
- contents._targetUrl = url;
432
-
433
- // 确保最小加载时间以保证平滑动画
434
- const elapsed = Date.now() - startTime;
435
- const remainingTime = Math.max(0, config.minLoadTime - elapsed);
436
- if (remainingTime > 0) {
437
- await new Promise(resolve => setTimeout(resolve, remainingTime));
438
- }
439
-
440
- // Replace content
441
- replaceContent(contents, config.selectors, doc);
442
-
443
- // Execute comment scripts from the new page
444
- executeCommentScripts(doc);
445
-
446
- // Scroll to correct position
447
- if (!isPop) {
448
- // 如果有全屏 wiki cover,滚动到内容区域 (#start)
449
- // 否则滚动到顶部
450
- const wikiCover = document.querySelector('#l_cover .l_cover.wiki');
451
- const newStartEl = document.getElementById('start');
452
-
453
- if (wikiCover && newStartEl) {
454
- // 立即跳转到目标位置,不使用动画(避免与导航前的滚动冲突)
455
- const rect = newStartEl.getBoundingClientRect();
456
- const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
457
- window.scrollTo({
458
- top: rect.top + scrollTop,
459
- behavior: 'auto'
460
- });
461
- } else {
462
- window.scrollTo({ top: 0, behavior: 'auto' });
463
- }
464
- history.pushState({ pjax: true, url: url }, '', url);
465
- }
466
-
467
- // Re-initialize lazy loading
468
- if (window.lazyLoadInstance) {
469
- window.lazyLoadInstance.update();
470
- }
471
-
472
- // Trigger complete event for other scripts to re-initialize
473
- // This is fired after DOM is updated and main content is ready
474
- triggerEvent('pjax:complete', { url });
475
-
476
- stopLoading();
477
- isLoading = false;
478
-
479
- return true;
480
- } catch (error) {
481
- stopLoading();
482
- isLoading = false;
483
-
484
- // If aborted, ignore
485
- if (error.name === 'AbortError') {
486
- return false;
487
- }
488
-
489
- console.error('PJAX Error:', error);
490
-
491
- // Fallback to regular navigation
492
- window.location.href = url;
493
- return false;
494
- }
495
- }
496
-
497
- /**
498
- * Find the closest anchor element from event target
499
- */
500
- function findAnchorElement(target) {
501
- let element = target;
502
- while (element && element.tagName !== 'A') {
503
- element = element.parentElement;
504
- }
505
- return element;
506
- }
507
-
508
- /**
509
- * Check if link is a same-page hash link
510
- */
511
- function isSamePageHashLink(link, currentUrl) {
512
- if (!link || !link.href || link.tagName !== 'A') return false;
513
- if (link.target === '_blank') return false;
514
-
515
- try {
516
- const url = new URL(link.href);
517
- return url.origin === currentUrl.origin &&
518
- url.pathname === currentUrl.pathname &&
519
- !!url.hash;
520
- } catch (e) {
521
- return false;
522
- }
523
- }
524
-
525
- /**
526
- * Decode hash ID to handle Chinese and other non-ASCII characters
527
- * url.hash is URL-encoded (e.g., #%E4%B8%AD%E6%96%87), but getElementById needs decoded string
528
- */
529
- function decodeHashId(hash) {
530
- const rawId = hash.slice(1);
531
- try {
532
- return decodeURIComponent(rawId);
533
- } catch (e) {
534
- // If decoding fails, use the original value
535
- return rawId;
536
- }
537
- }
538
-
539
- /**
540
- * Scroll to element smoothly and update URL hash
541
- */
542
- function scrollToElement(element, hash) {
543
- const rect = element.getBoundingClientRect();
544
- const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
545
-
546
- window.scrollTo({
547
- top: rect.top + scrollTop,
548
- behavior: 'smooth'
549
- });
550
-
551
- // Update URL hash without scrolling again
552
- history.pushState(null, '', hash);
553
- }
554
-
555
- /**
556
- * Handle same-page hash link navigation
557
- * Returns true if handled, false otherwise
558
- */
559
- function handleHashLink(link, currentUrl) {
560
- if (!isSamePageHashLink(link, currentUrl)) return false;
561
-
562
- try {
563
- const url = new URL(link.href);
564
- const targetId = decodeHashId(url.hash);
565
- const target = document.getElementById(targetId);
566
-
567
- if (target) {
568
- scrollToElement(target, url.hash);
569
- return true;
570
- }
571
- } catch (e) {
572
- // Invalid URL, let it fall through
573
- }
574
-
575
- return false;
576
- }
577
-
578
- /**
579
- * Handle link clicks
580
- */
581
- function handleClick(event) {
582
- const link = findAnchorElement(event.target);
583
-
584
- // Handle in-page hash links FIRST (before shouldHandleLink check)
585
- // This prevents page reloads when clicking table of contents in wiki mode
586
- const currentUrl = new URL(window.location.href);
587
- if (handleHashLink(link, currentUrl)) {
588
- event.preventDefault();
589
- return;
590
- }
591
-
592
- // Check if PJAX should handle this link
593
- if (!shouldHandleLink(link)) {
594
- return;
595
- }
596
-
597
- // Check for modifier keys (new tab/window)
598
- if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
599
- return;
600
- }
601
-
602
- // Prevent default and use PJAX
603
- event.preventDefault();
604
- navigate(link.href);
605
- }
606
-
607
- /**
608
- * Handle browser back/forward buttons
609
- */
610
- function handlePopState(event) {
611
- // Only handle PJAX state or initial page
612
- if (event.state?.pjax || !event.state) {
613
- navigate(window.location.href, { isPop: true });
614
- }
615
- }
616
-
617
- /**
618
- * Initialize PJAX
619
- */
620
- function init() {
621
- // Set initial history state
622
- history.replaceState({ pjax: true, url: window.location.href }, '', window.location.href);
623
-
624
- // Listen for link clicks
625
- document.addEventListener('click', handleClick, false);
626
-
627
- // Listen for browser back/forward
628
- window.addEventListener('popstate', handlePopState, false);
629
-
630
- // Expose API
631
- window.stellar = window.stellar || {};
632
- window.stellar.pjax = {
633
- navigate: navigate,
634
- config: config
635
- };
636
-
637
- console.log('Stellar PJAX initialized');
638
- }
639
-
640
- // Initialize when DOM is ready
641
- if (document.readyState === 'loading') {
642
- document.addEventListener('DOMContentLoaded', init);
643
- } else {
644
- init();
645
- }
646
- })();