hexo-theme-stellar 1.37.0 → 1.39.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.
Files changed (87) hide show
  1. package/.claude/skills/stellar-theme-dev/SKILL.md +50 -0
  2. package/CHANGELOG.md +75 -0
  3. package/CONTRIBUTING.md +155 -0
  4. package/README.md +56 -35
  5. package/_config.yml +16 -13
  6. package/_data/icons.yml +3 -0
  7. package/languages/en.yml +37 -0
  8. package/languages/zh-CN.yml +37 -0
  9. package/languages/zh-TW.yml +37 -0
  10. package/layout/_partial/comments/layout.ejs +2 -1
  11. package/layout/_partial/main/navbar/article_banner.ejs +11 -5
  12. package/layout/_partial/main/navbar/nav_tabs_blog.ejs +2 -1
  13. package/layout/_partial/main/navbar/nav_tabs_wiki.ejs +1 -0
  14. package/layout/_partial/main/notebook/paginator.ejs +3 -2
  15. package/layout/_partial/main/pin_slider.ejs +325 -0
  16. package/layout/_partial/main/post_list/paginator.ejs +4 -3
  17. package/layout/_partial/main/post_list/post_card.ejs +2 -4
  18. package/layout/_partial/scripts/defines.ejs +2 -1
  19. package/layout/_partial/scripts/lazyload.ejs +24 -0
  20. package/layout/_partial/scripts/utils.ejs +58 -15
  21. package/layout/_partial/sidebar/grad-def.ejs +26 -0
  22. package/layout/_partial/sidebar/index_leftbar.ejs +25 -10
  23. package/layout/_partial/sidebar/logo.ejs +22 -1
  24. package/layout/_partial/sidebar/menu.ejs +11 -5
  25. package/layout/_partial/widgets/components/linklist.ejs +2 -2
  26. package/layout/_partial/widgets/toc.ejs +12 -8
  27. package/layout/_plugins/scrollreveal.ejs +17 -2
  28. package/layout/index.ejs +36 -1
  29. package/layout/layout.ejs +1 -1
  30. package/layout/notebooks.ejs +3 -1
  31. package/layout/notes.ejs +3 -1
  32. package/package.json +5 -2
  33. package/scripts/tags/index.js +1 -0
  34. package/scripts/tags/lib/mbti.js +169 -0
  35. package/source/css/_common/canonical.styl +9 -10
  36. package/source/css/_common/device.styl +12 -12
  37. package/source/css/_common/highlight.styl +0 -2
  38. package/source/css/_common/html.styl +2 -1
  39. package/source/css/_common/svg.styl +9 -1
  40. package/source/css/_components/layout.styl +11 -8
  41. package/source/css/_components/list.styl +55 -21
  42. package/source/css/_components/main.styl +2 -2
  43. package/source/css/_components/md.styl +0 -1
  44. package/source/css/_components/pages/article-story.styl +1 -3
  45. package/source/css/_components/partial/article-banner.styl +17 -11
  46. package/source/css/_components/partial/article-footer.styl +0 -1
  47. package/source/css/_components/partial/navbar.styl +10 -27
  48. package/source/css/_components/partial/paginator.styl +9 -11
  49. package/source/css/_components/pin-slider.styl +240 -0
  50. package/source/css/_components/sidebar/footer.styl +1 -1
  51. package/source/css/_components/sidebar/logo.styl +27 -1
  52. package/source/css/_components/sidebar/menu.styl +53 -30
  53. package/source/css/_components/sidebar/nav-area.styl +1 -1
  54. package/source/css/_components/sidebar/search.styl +50 -11
  55. package/source/css/_components/sidebar/sidebar.styl +32 -5
  56. package/source/css/_components/tag-plugins/banner.styl +2 -1
  57. package/source/css/_components/tag-plugins/chat.styl +3 -1
  58. package/source/css/_components/tag-plugins/folding.styl +1 -2
  59. package/source/css/_components/tag-plugins/friends.styl +1 -0
  60. package/source/css/_components/tag-plugins/friends_posts.styl +2 -1
  61. package/source/css/_components/tag-plugins/gallery.styl +1 -2
  62. package/source/css/_components/tag-plugins/grid.styl +1 -2
  63. package/source/css/_components/tag-plugins/image.styl +0 -2
  64. package/source/css/_components/tag-plugins/link.styl +0 -1
  65. package/source/css/_components/tag-plugins/mbti.styl +80 -0
  66. package/source/css/_components/tag-plugins/note.styl +1 -2
  67. package/source/css/_components/tag-plugins/okr.styl +1 -2
  68. package/source/css/_components/tag-plugins/sites.styl +0 -1
  69. package/source/css/_components/tag-plugins/timeline.styl +8 -6
  70. package/source/css/_components/widgets/components.styl +19 -7
  71. package/source/css/_components/widgets/ghuser.styl +3 -2
  72. package/source/css/_components/widgets/list.styl +20 -8
  73. package/source/css/_components/widgets/markdown.styl +5 -2
  74. package/source/css/_components/widgets/related.styl +6 -4
  75. package/source/css/_components/widgets/timeline.styl +1 -1
  76. package/source/css/_components/widgets/toc.styl +9 -6
  77. package/source/css/_components/widgets/widgets.styl +11 -10
  78. package/source/css/_custom.styl +5 -2
  79. package/source/css/_defines/const.styl +2 -2
  80. package/source/css/_defines/func.styl +49 -3
  81. package/source/css/_defines/theme_base.styl +22 -1
  82. package/source/css/_plugins/comments/artalk.styl +2 -2
  83. package/source/css/_plugins/comments/twikoo.styl +0 -2
  84. package/source/css/_plugins/tianli_gpt.styl +3 -0
  85. package/source/js/main.js +39 -17
  86. package/source/js/services/artalk_latest_comment.js +43 -1
  87. package/source/js/services/memos.js +12 -3
@@ -6,7 +6,7 @@ if (cmt.service) {
6
6
  cmt[cmt.service] = Object.assign({}, theme.comments[cmt.service]);
7
7
  }
8
8
  var loadComment = false;
9
- if (theme.comments.service && theme.comments.service.length > 0) {
9
+ if (cmt.service && cmt.service.length > 0) {
10
10
  if (page.comments == undefined || page.comments != false) {
11
11
  loadComment = true;
12
12
  }
@@ -34,6 +34,7 @@ if (cmt.service && page[cmt.service]) {
34
34
  Object.assign(cmt[cmt.service], page[cmt.service]);
35
35
  }
36
36
  page.cmt = cmt;
37
+ page.cmt_rendered = loadComment;
37
38
  %>
38
39
  <% if (loadComment) { %>
39
40
  <div class="related-wrap md-text<%- scrollreveal(' ') %>" id="comments">
@@ -105,23 +105,29 @@ function layoutDiv() {
105
105
  return ``
106
106
  }
107
107
  const top = layoutBreadcrumb()
108
- var style = ``
108
+ var bannerStyle = ``
109
+ var contentStyle = ``
109
110
  var el = ``
110
- el += `<div class="article banner${scrollreveal(' ')} top">`
111
+ if (banner.url?.length > 0) {
112
+ bannerStyle += ` style="--bg-url:url('${banner.url.replace(/'/g, '%27')}')"`
113
+ }
114
+ el += `<div class="article-banner-wrap${scrollreveal(' ')}">`
115
+ el += `<div class="article banner top"${bannerStyle}>`
111
116
  if (banner.url?.length > 0) {
112
117
  el += `<img class="lazy bg" data-src="${banner.url}">`
113
118
  if (banner.color) {
114
- style += ' style="--text-banner:' + banner.color + '"'
119
+ contentStyle += ' style="--text-banner:' + banner.color + '"'
115
120
  }
116
121
  }
117
122
  el += `
118
- <div class="content"${style}>
123
+ <div class="content"${contentStyle}>
119
124
  ${top}
120
125
  ${bottom}
121
126
  </div>
122
127
  `
123
128
  el += `</div>`
129
+ el += `</div>`
124
130
  return el
125
131
  }
126
132
  %>
127
- <%- layoutDiv() %>
133
+ <%- layoutDiv() %>
@@ -1,6 +1,7 @@
1
1
  <%
2
2
  function layoutDiv() {
3
3
  var el = '';
4
+ el += partial('_partial/main/pin_slider', {type: 'post'});
4
5
  el += '<div class="navbar top">';
5
6
  el += '<div class="navbar-blur">';
6
7
  el += '<div class="navbar-container">';
@@ -68,4 +69,4 @@ function layoutDiv() {
68
69
  }
69
70
  %>
70
71
 
71
- <%- layoutDiv() %>
72
+ <%- layoutDiv() %>
@@ -1,6 +1,7 @@
1
1
  <%
2
2
  function layoutDiv() {
3
3
  var el = '';
4
+ el += partial('_partial/main/pin_slider', {type: 'wiki'});
4
5
  el += '<div class="navbar top">';
5
6
  el += '<div class="navbar-blur">';
6
7
  el += '<div class="navbar-container">';
@@ -1,9 +1,10 @@
1
1
  <% if ((page.total || 0) > 1) { %>
2
2
  <div class="paginator-wrap dis-select<%= scrollreveal(' ') %>">
3
3
  <%- paginator({
4
- prev_text: '',
5
- next_text: '',
4
+ prev_text: icon('solar:double-alt-arrow-left-bold-duotone'),
5
+ next_text: icon('solar:double-alt-arrow-right-bold-duotone'),
6
6
  force_prev_next: true,
7
+ escape: false,
7
8
  }) %>
8
9
  </div>
9
10
  <% } %>
@@ -0,0 +1,325 @@
1
+ <%
2
+ // 置顶内容轮播:所有带 navbar top 的列表页上方共用。
3
+ // type: post / wiki / topic
4
+ var pinType = type || 'post'
5
+ var pinItems = []
6
+
7
+ function pinValue(item) {
8
+ return item.pin != null ? item.pin : item.sticky
9
+ }
10
+
11
+ // 只要设置了 pin(含 0、负数)即视为置顶,未设置 / false 不置顶
12
+ function isPinned(item) {
13
+ var v = pinValue(item)
14
+ return v !== false && v != null
15
+ }
16
+
17
+ // 排序仅比较数值大小,true 视作 1
18
+ function pinWeight(item) {
19
+ var v = pinValue(item)
20
+ if (v === true) {
21
+ return 1
22
+ }
23
+ var n = Number(v)
24
+ return isNaN(n) ? 0 : n
25
+ }
26
+
27
+ if (pinType == 'wiki') {
28
+ var shelf = theme.wiki?.shelf || []
29
+ for (var wi = 0; wi < shelf.length; wi++) {
30
+ var wproj = theme.wiki.tree[shelf[wi]]
31
+ if (wproj != null && isPinned(wproj)) {
32
+ pinItems.push(wproj)
33
+ }
34
+ }
35
+ } else {
36
+ var allPosts = site.posts.toArray() || []
37
+ for (var pi = 0; pi < allPosts.length; pi++) {
38
+ var post = allPosts[pi]
39
+ if (post.indexing === false) {
40
+ continue
41
+ }
42
+ if (isPinned(post)) {
43
+ pinItems.push(post)
44
+ }
45
+ }
46
+ }
47
+
48
+ pinItems.sort(function(a, b) {
49
+ return pinWeight(b) - pinWeight(a)
50
+ })
51
+
52
+ // 文章幻灯片:固定「标题 + 一行小字」结构,宽高比统一取自 pin_slider.ratio。
53
+ // 标题:poster.headline > title;小字:poster.caption > description > excerpt(截断 50 字)。
54
+ // 样式与 poster 卡片 cover-info 一致;无封面时为纯白卡片(文字按普通文章颜色)。
55
+ function postSubtitle(post) {
56
+ if (post.poster != null && post.poster.caption != null && post.poster.caption.length > 0) {
57
+ return post.poster.caption
58
+ }
59
+ if (post.description != null && post.description.length > 0) {
60
+ return post.description
61
+ }
62
+ var text = post.excerpt || post.content || ''
63
+ text = strip_html(text).replace(/\s+/g, ' ').trim()
64
+ return text.length > 0 ? truncate(text, {length: 50}) : ''
65
+ }
66
+
67
+ function postSlide(post) {
68
+ var noCover = !(post.cover != null && post.cover.length > 0)
69
+ var headline = (post.poster != null && post.poster.headline != null && post.poster.headline.length > 0) ? post.poster.headline : post.title
70
+ var subtitle = postSubtitle(post)
71
+ var el = ''
72
+ el += '<a class="pin-slide post-slide' + (noCover ? ' no-cover' : '') + '" href="' + pretty_url(post.link || post.path) + '"'
73
+ if (!noCover) {
74
+ el += ' style="--pin-cover-url:url(\'' + post.cover.replace(/'/g, '%27') + '\')"'
75
+ }
76
+ el += '>'
77
+ if (!noCover) {
78
+ el += '<img class="pin-slide-bg" src="' + post.cover + '" alt=""/>'
79
+ }
80
+ el += '<div class="pin-slide-text">'
81
+ el += '<div class="pin-slide-headline">' + headline + '</div>'
82
+ if (subtitle.length > 0) {
83
+ el += '<div class="pin-slide-caption">' + subtitle + '</div>'
84
+ }
85
+ el += '</div>'
86
+ el += '</a>'
87
+ return el
88
+ }
89
+
90
+ function projectChips(tags) {
91
+ var caps = ''
92
+ if (tags && tags.length > 0) {
93
+ caps += '<span class="pin-slide-chip">' + tags.join('</span><span class="pin-slide-chip">') + '</span>'
94
+ }
95
+ return caps
96
+ }
97
+
98
+ function wikiSlide(proj) {
99
+ var img = proj.cover || proj.icon || theme.default.project
100
+ var title = proj.title || proj.name || proj.id
101
+ var excerpt = proj.description || ''
102
+ var el = ''
103
+ el += '<a class="pin-slide" href="' + pretty_url(proj.homepage?.path || '/') + '">'
104
+ el += '<img class="pin-slide-bg" src="' + img + '" alt=""/>'
105
+ el += '<div class="pin-slide-mask"></div>'
106
+ el += '<div class="pin-slide-info">'
107
+ el += '<div class="pin-slide-caps">' + projectChips(proj.tags) + '</div>'
108
+ el += '<div class="pin-slide-title">' + title + '</div>'
109
+ if (excerpt.length > 0) {
110
+ el += '<div class="pin-slide-excerpt">' + excerpt + '</div>'
111
+ }
112
+ el += '</div>'
113
+ el += '</a>'
114
+ return el
115
+ }
116
+
117
+ if (pinItems.length > 0) {
118
+ var slidesHtml = ''
119
+ var dotsHtml = ''
120
+ for (var si = 0; si < pinItems.length; si++) {
121
+ var item = pinItems[si]
122
+ if (pinType == 'wiki') {
123
+ slidesHtml += wikiSlide(item)
124
+ } else {
125
+ slidesHtml += postSlide(item)
126
+ }
127
+ if (pinItems.length > 1) {
128
+ var label = item.title || item.name || item.id || (si + 1)
129
+ dotsHtml += '<button type="button" class="pin-slider-dot' + (si == 0 ? ' active' : '') + '" data-index="' + si + '" aria-label="' + label + '"></button>'
130
+ }
131
+ }
132
+ var groupKey = pinType == 'wiki' ? 'wiki' : 'post'
133
+ var sliderInterval = 5000 // 自动轮播间隔固定 5000ms
134
+ var el = ''
135
+ el += '<div class="pin-slider" id="pin-slider" data-interval="' + sliderInterval + '" data-key="' + pinType + '" data-group="' + groupKey + '">'
136
+ el += '<div class="pin-slider-track">'
137
+ el += slidesHtml
138
+ el += '</div>'
139
+ if (dotsHtml.length > 0) {
140
+ el += '<div class="pin-slider-dots">' + dotsHtml + '</div>'
141
+ el += '<button type="button" class="pin-slider-nav prev">' + icon('solar:double-alt-arrow-left-bold-duotone') + '</button>'
142
+ el += '<button type="button" class="pin-slider-nav next">' + icon('solar:double-alt-arrow-right-bold-duotone') + '</button>'
143
+ }
144
+ el += '</div>'
145
+ %>
146
+ <%- el %>
147
+ <script>
148
+ function initWhenReady() {
149
+ if (typeof utils !== 'undefined' && utils.initPlugin) {
150
+ utils.initPlugin(initPinSlider, 'pin-slider');
151
+ } else {
152
+ document.addEventListener('DOMContentLoaded', function() {
153
+ if (typeof utils !== 'undefined' && utils.initPlugin) {
154
+ utils.initPlugin(initPinSlider, 'pin-slider');
155
+ }
156
+ });
157
+ }
158
+ }
159
+ initWhenReady();
160
+ function initPinSlider() {
161
+ var slider = document.getElementById('pin-slider');
162
+ if (!slider) return;
163
+ var track = slider.querySelector('.pin-slider-track');
164
+ var slides = slider.querySelectorAll('.pin-slide');
165
+ var count = slides.length;
166
+ if (!track || count <= 1) {
167
+ slider.style.visibility = 'visible';
168
+ return;
169
+ }
170
+ var interval = parseInt(slider.getAttribute('data-interval'), 10) || 4000;
171
+ var type = slider.getAttribute('data-key') || 'post';
172
+ var group = slider.getAttribute('data-group') || 'post';
173
+ var contentKey = type + '::' + Array.prototype.map.call(slides, function(a) {
174
+ var link = a.querySelector('a.post-card, a.pin-slide');
175
+ return link ? link.getAttribute('href') : a.getAttribute('href');
176
+ }).join('|');
177
+ var index = restorePinIndex(group, contentKey, count);
178
+ var reduced = false;
179
+ try {
180
+ reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
181
+ } catch (e) {}
182
+ slider.style.setProperty('--pin-duration', interval + 'ms');
183
+ if (reduced) {
184
+ slider.classList.add('reduced');
185
+ }
186
+ function goTo(n) {
187
+ index = (n + count) % count;
188
+ track.style.transform = 'translateX(-' + (index * 100) + '%)';
189
+ var dots = slider.querySelector('.pin-slider-dots');
190
+ var items = dots ? dots.children : [];
191
+ for (var i = 0; i < count; i++) {
192
+ if (!items[i]) continue;
193
+ if (i === index) {
194
+ items[i].classList.add('active');
195
+ items[i].setAttribute('aria-current', 'true');
196
+ } else {
197
+ items[i].classList.remove('active');
198
+ items[i].removeAttribute('aria-current');
199
+ }
200
+ }
201
+ savePinIndex(group, contentKey, index, count);
202
+ }
203
+ function next() { goTo(index + 1); }
204
+ function prev() { goTo(index - 1); }
205
+ function start() {
206
+ if (reduced) return;
207
+ slider.classList.remove('paused');
208
+ }
209
+ function stop() {
210
+ slider.classList.add('paused');
211
+ }
212
+ function onVis() {
213
+ if (document.hidden) {
214
+ stop();
215
+ } else {
216
+ start();
217
+ }
218
+ }
219
+ var startX = null;
220
+ var startY = null;
221
+ function onTouchStart(e) {
222
+ stop();
223
+ var t = e.changedTouches[0];
224
+ startX = t.clientX;
225
+ startY = t.clientY;
226
+ }
227
+ function onTouchEnd(e) {
228
+ if (startX == null) return;
229
+ var t = e.changedTouches[0];
230
+ var dx = t.clientX - startX;
231
+ var dy = t.clientY - startY;
232
+ startX = null;
233
+ startY = null;
234
+ if (Math.abs(dx) > 30 && Math.abs(dx) > Math.abs(dy)) {
235
+ if (dx < 0) {
236
+ next();
237
+ } else {
238
+ prev();
239
+ }
240
+ }
241
+ start();
242
+ }
243
+ var dotsEl = slider.querySelector('.pin-slider-dots');
244
+ function onProgressEnd(e) {
245
+ if (e.animationName === 'pin-slider-progress') {
246
+ next();
247
+ }
248
+ }
249
+ if (dotsEl) {
250
+ dotsEl.addEventListener('click', function(e) {
251
+ var btn = e.target.closest ? e.target.closest('.pin-slider-dot') : null;
252
+ if (btn) {
253
+ goTo(parseInt(btn.getAttribute('data-index'), 10) || 0);
254
+ }
255
+ });
256
+ dotsEl.addEventListener('animationend', onProgressEnd);
257
+ }
258
+ var navPrev = slider.querySelector('.pin-slider-nav.prev');
259
+ var navNext = slider.querySelector('.pin-slider-nav.next');
260
+ if (navPrev) {
261
+ navPrev.addEventListener('click', prev);
262
+ }
263
+ if (navNext) {
264
+ navNext.addEventListener('click', next);
265
+ }
266
+ slider.addEventListener('mouseenter', stop);
267
+ slider.addEventListener('mouseleave', start);
268
+ slider.addEventListener('focusin', stop);
269
+ slider.addEventListener('focusout', start);
270
+ slider.addEventListener('touchstart', onTouchStart, { passive: true });
271
+ slider.addEventListener('touchend', onTouchEnd, { passive: true });
272
+ document.addEventListener('visibilitychange', onVis);
273
+ // 初始化直接定位到缓存序号:先禁用过渡再恢复,避免每次进页面播放“从第一张滑过来”的动画
274
+ track.style.transition = 'none';
275
+ goTo(index);
276
+ void track.offsetWidth;
277
+ track.style.transition = '';
278
+ slider.style.visibility = 'visible';
279
+ start();
280
+ return function() {
281
+ stop();
282
+ slider.removeEventListener('mouseenter', stop);
283
+ slider.removeEventListener('mouseleave', start);
284
+ slider.removeEventListener('focusin', stop);
285
+ slider.removeEventListener('focusout', start);
286
+ slider.removeEventListener('touchstart', onTouchStart);
287
+ slider.removeEventListener('touchend', onTouchEnd);
288
+ if (dotsEl) {
289
+ dotsEl.removeEventListener('animationend', onProgressEnd);
290
+ }
291
+ if (navPrev) {
292
+ navPrev.removeEventListener('click', prev);
293
+ }
294
+ if (navNext) {
295
+ navNext.removeEventListener('click', next);
296
+ }
297
+ document.removeEventListener('visibilitychange', onVis);
298
+ };
299
+ }
300
+ function restorePinIndex(group, contentKey, count) {
301
+ try {
302
+ var raw = localStorage.getItem('stellar.pin-slider.' + group);
303
+ if (!raw) return 0;
304
+ var data = JSON.parse(raw);
305
+ var sets = data && data.sets ? data.sets : {};
306
+ var rec = sets[contentKey];
307
+ if (rec && rec.t === count && rec.i >= 0 && rec.i < count) {
308
+ return rec.i;
309
+ }
310
+ } catch (e) {}
311
+ return 0;
312
+ }
313
+ function savePinIndex(group, contentKey, index, count) {
314
+ try {
315
+ var raw = localStorage.getItem('stellar.pin-slider.' + group);
316
+ var data = raw ? JSON.parse(raw) : {};
317
+ if (!data.sets) data.sets = {};
318
+ data.sets[contentKey] = { i: index, t: count };
319
+ localStorage.setItem('stellar.pin-slider.' + group, JSON.stringify(data));
320
+ } catch (e) {}
321
+ }
322
+ </script>
323
+ <%
324
+ }
325
+ %>
@@ -1,9 +1,10 @@
1
1
  <% if ((is_home() || is_category() || is_tag()) && page.total > 1) { %>
2
2
  <div class='paginator-wrap dis-select<%- scrollreveal(' ') %>'>
3
3
  <%- paginator({
4
- prev_text: '',
5
- next_text: '',
6
- force_prev_next:true
4
+ prev_text: icon('solar:double-alt-arrow-left-bold-duotone'),
5
+ next_text: icon('solar:double-alt-arrow-right-bold-duotone'),
6
+ force_prev_next: true,
7
+ escape: false
7
8
  }) %>
8
9
  </div>
9
10
  <% } %>
@@ -92,9 +92,7 @@ function div_default() {
92
92
  }
93
93
  }
94
94
  }
95
- if (post.sticky) {
96
- el += `<span class="pin">${icon('default:pin')}</span>`
97
- }
95
+ // 置顶文章已由列表页 navbar 上方的置顶轮播展示,卡片内不再显示 pin 图标。
98
96
  el += '</div>';
99
97
  el += '</article>';
100
98
  return el;
@@ -109,7 +107,7 @@ function div_photo() {
109
107
  position = 'bottom';
110
108
  }
111
109
  }
112
- el += '<div class="cover" position="' + position + '">';
110
+ el += '<div class="cover" position="' + position + '" style="--cover-url:url(\'' + obj.image.replace(/'/g, '%27') + '\')">';
113
111
  el += '<img src="' + obj.image + '"/>';
114
112
  if (position.length > 0) {
115
113
  el += '<div class="cover-info" position="' + position + '"';
@@ -31,7 +31,8 @@
31
31
  }
32
32
  const anchor = document.getElementById(anchorId);
33
33
  if (anchor) {
34
- const offset = 32; // 与 TOC 点击滚动保持一致
34
+ // #start 锚点贴顶,不预留 offset;其余锚点与 TOC 点击滚动保持一致
35
+ const offset = anchorId === 'start' ? 0 : 32;
35
36
  const targetY = () => anchor.getBoundingClientRect().top + window.scrollY - offset;
36
37
  let timer = null;
37
38
  let lastY = null;
@@ -26,6 +26,30 @@
26
26
  window.lazyLoadInstance?.update();
27
27
  });
28
28
 
29
+ // 兜底:动态插入的 .lazy 元素(如站点自定义脚本在 update() 之后追加的图片)
30
+ // 不会被 vanilla-lazyload 自动扫描,未注册的 img.lazy 会被 CSS opacity:0 永久隐藏。
31
+ // MutationObserver 检测到新增 .lazy 元素后自动调用 update() 重新注册。
32
+ if (window.MutationObserver) {
33
+ window.lazyMutationObserver = new MutationObserver((mutations) => {
34
+ if (window.lazyMutationObserver._pending) return;
35
+ const hasLazy = mutations.some((m) =>
36
+ Array.prototype.some.call(m.addedNodes, (node) =>
37
+ node.nodeType === 1 &&
38
+ (node.matches?.('.lazy') || !!node.querySelector?.('.lazy'))
39
+ )
40
+ );
41
+ if (!hasLazy) return;
42
+ window.lazyMutationObserver._pending = requestAnimationFrame(() => {
43
+ window.lazyMutationObserver._pending = null;
44
+ window.lazyLoadInstance?.update();
45
+ });
46
+ });
47
+ window.lazyMutationObserver.observe(document.documentElement, {
48
+ childList: true,
49
+ subtree: true
50
+ });
51
+ }
52
+
29
53
  window.wrapLazyloadImages = (container) => {
30
54
  if (typeof container === 'string') {
31
55
  container = document.querySelector(container);
@@ -321,6 +321,31 @@
321
321
  // 动态数据本地缓存:TTL 未过期直接命中;过期后先显示缓存再后台刷新(stale-while-revalidate)
322
322
  _cacheEnabled: true,
323
323
  _cacheMaxEntryBytes: 200 * 1024,
324
+ // 同 URL 并发请求去重:页面内多个组件请求同一接口时共享一次 fetch
325
+ _pendingRequests: {},
326
+ _fetchShared: (url, options) => {
327
+ const key = (options && options.method || 'GET') + ' ' + url;
328
+ if (utils._pendingRequests[key]) {
329
+ return utils._pendingRequests[key].then(resp => resp.clone());
330
+ }
331
+ const promise = fetch(url, options).then(resp => {
332
+ if (!resp.ok) throw new Error('响应失败');
333
+ return resp;
334
+ }).finally(() => {
335
+ delete utils._pendingRequests[key];
336
+ });
337
+ utils._pendingRequests[key] = promise;
338
+ // 每个调用方各自 clone,避免 Response body 被消费后其他调用方取不到数据
339
+ return promise.then(resp => resp.clone());
340
+ },
341
+ // 延迟执行:缓存写入等非关键操作让出主线程
342
+ _defer: (fn) => {
343
+ if (window.requestIdleCallback) {
344
+ window.requestIdleCallback(fn, { timeout: 3000 });
345
+ } else {
346
+ setTimeout(fn, 0);
347
+ }
348
+ },
324
349
  cache: {
325
350
  prefix: 'Stellar.data_cache.v1.',
326
351
  enabled: () => !!def.data_cache?.enable && utils._cacheEnabled,
@@ -500,21 +525,21 @@
500
525
  }
501
526
  }, 5000);
502
527
 
503
- fetch(url).then(resp => {
528
+ utils._fetchShared(url).then(resp => {
504
529
  if (timedOut) return;
505
530
  clearTimeout(timeout);
506
-
507
- if (!resp.ok) throw new Error('响应失败');
508
531
  return resp;
509
532
  }).then(data => {
510
533
  if (timedOut) return;
511
534
  // 标记元素实例为已加载
512
535
  if (el) utils._loadedElements.add(el);
513
- // 写入缓存(克隆响应,避免影响回调读取)
536
+ // 写入缓存(克隆响应,避免影响回调读取;延迟执行,避免阻塞渲染)
514
537
  if (cacheable && data.ok) {
515
- data.clone().text().then(text => {
516
- utils.cache.set(url, text, data.headers.get('Content-Type') || 'application/json', ttl);
517
- }).catch(() => {});
538
+ utils._defer(() => {
539
+ data.clone().text().then(text => {
540
+ utils.cache.set(url, text, data.headers.get('Content-Type') || 'application/json', ttl);
541
+ }).catch(() => {});
542
+ });
518
543
  }
519
544
  // 后台刷新渲染前恢复初始结构,避免缓存渲染的内容重复
520
545
  if (el && initialHTML !== null) {
@@ -562,30 +587,48 @@
562
587
 
563
588
  return new Promise((resolve, reject) => {
564
589
  let retryCount = 0;
590
+ // 与 request 保持一致:最终失败时回退 stale 缓存,避免闪失败态
591
+ const fallback = () => {
592
+ if (cached) {
593
+ resolve(utils.cache.toResponse(cached));
594
+ } else {
595
+ reject('timeout');
596
+ }
597
+ };
565
598
 
566
599
  const tryRequest = () => {
567
600
  let timedOut = false;
568
601
  const timer = setTimeout(() => {
569
602
  timedOut = true;
570
- if (++retryCount > maxRetry) reject('timeout');
603
+ if (++retryCount > maxRetry) fallback();
571
604
  else tryRequest();
572
605
  }, timeout);
573
606
 
574
- fetch(url, options)
607
+ utils._fetchShared(url, options)
575
608
  .then(resp => {
576
609
  clearTimeout(timer);
577
- if (!resp.ok) throw new Error('bad response');
610
+ if (timedOut) return;
578
611
  if (cacheable) {
579
- resp.clone().text().then(text => {
580
- utils.cache.set(url, text, resp.headers.get('Content-Type') || 'application/json', ttl);
581
- }).catch(() => {});
612
+ utils._defer(() => {
613
+ resp.clone().text().then(text => {
614
+ utils.cache.set(url, text, resp.headers.get('Content-Type') || 'application/json', ttl);
615
+ }).catch(() => {});
616
+ });
582
617
  }
583
618
  resolve(resp);
584
619
  })
585
620
  .catch(err => {
586
621
  clearTimeout(timer);
587
- if (++retryCount > maxRetry) reject(err);
588
- else setTimeout(tryRequest, 500);
622
+ if (timedOut) return;
623
+ if (++retryCount > maxRetry) {
624
+ if (cached) {
625
+ resolve(utils.cache.toResponse(cached));
626
+ } else {
627
+ reject(err);
628
+ }
629
+ } else {
630
+ setTimeout(tryRequest, 500);
631
+ }
589
632
  });
590
633
  };
591
634
 
@@ -0,0 +1,26 @@
1
+ <%
2
+ // 隐藏的 SVG 线性渐变定义,配合 --item-grad 使用(用法见 menu.ejs 与 theme_base.styl 注释)。
3
+ // 渐变角度来自 style.gradient.angle(CSS 角度,0=向上、顺时针),与 CSS 渐变方向保持一致;
4
+ // 颜色使用 --item-theme(默认 var(--theme))与 --item-theme-light 两个 CSS 变量,
5
+ // 因此同一份定义可被任意元素复用,局部覆盖 --item-theme 的元素需同时设置 --item-theme-light。
6
+ var gradAngle = 225
7
+ if (theme.style && theme.style.gradient && theme.style.gradient.angle) {
8
+ gradAngle = parseFloat(theme.style.gradient.angle)
9
+ if (isNaN(gradAngle)) {
10
+ gradAngle = 225
11
+ }
12
+ }
13
+ var gradRad = gradAngle * Math.PI / 180
14
+ var gradVX = Math.sin(gradRad)
15
+ var gradVY = -Math.cos(gradRad)
16
+ var gradProj = [[0,0],[1,0],[0,1],[1,1]].map(function(c) {
17
+ return c[0] * gradVX + c[1] * gradVY
18
+ })
19
+ var gradMin = Math.min.apply(null, gradProj)
20
+ var gradMax = Math.max.apply(null, gradProj)
21
+ var gradX1 = Math.round((0.5 + gradVX * gradMin) * 1000) / 10
22
+ var gradY1 = Math.round((0.5 + gradVY * gradMin) * 1000) / 10
23
+ var gradX2 = Math.round((0.5 + gradVX * gradMax) * 1000) / 10
24
+ var gradY2 = Math.round((0.5 + gradVY * gradMax) * 1000) / 10
25
+ %>
26
+ <svg class="grad-def" width="0" height="0" aria-hidden="true" focusable="false"><defs><linearGradient id="<%= id %>" x1="<%= gradX1 %>%" y1="<%= gradY1 %>%" x2="<%= gradX2 %>%" y2="<%= gradY2 %>%"><stop offset="20%" style="stop-color:var(--item-theme-light)"/><stop offset="60%" style="stop-color:var(--item-theme)"/></linearGradient></defs></svg>