hexo-theme-stellar 1.42.0 → 1.43.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 (122) hide show
  1. package/.claude/skills/stellar-theme-dev/SKILL.md +2 -0
  2. package/CHANGELOG.md +42 -0
  3. package/THIRD-PARTY-NOTICES.md +28 -0
  4. package/_config.yml +20 -10
  5. package/_data/icons.yml +10 -9
  6. package/languages/en.yml +11 -0
  7. package/languages/zh-CN.yml +11 -0
  8. package/languages/zh-TW.yml +11 -0
  9. package/layout/_partial/comments/artalk/layout.ejs +1 -1
  10. package/layout/_partial/comments/beaudar/layout.ejs +1 -1
  11. package/layout/_partial/comments/giscus/layout.ejs +1 -1
  12. package/layout/_partial/comments/twikoo/layout.ejs +1 -1
  13. package/layout/_partial/comments/utterances/layout.ejs +1 -1
  14. package/layout/_partial/comments/waline/layout.ejs +1 -1
  15. package/layout/_partial/components/collection-item.ejs +44 -0
  16. package/layout/_partial/components/collection.ejs +26 -0
  17. package/layout/_partial/components/widget-frame.ejs +35 -0
  18. package/layout/_partial/cover/wiki_cover.ejs +82 -26
  19. package/layout/_partial/dropdown.ejs +62 -0
  20. package/layout/_partial/head.ejs +9 -0
  21. package/layout/_partial/main/navbar/breadcrumb/blog.ejs +1 -1
  22. package/layout/_partial/main/post_list/archive_item.ejs +12 -0
  23. package/layout/_partial/main/post_list/topic_card.ejs +21 -9
  24. package/layout/_partial/main/post_list/wiki_card.ejs +49 -10
  25. package/layout/_partial/scripts/bootstrap.ejs +3 -1
  26. package/layout/_partial/scripts/defines.ejs +2 -8
  27. package/layout/_partial/scripts/lazyload.ejs +0 -3
  28. package/layout/_partial/scripts.ejs +1 -0
  29. package/layout/_partial/sidebar/index_leftbar.ejs +12 -2
  30. package/layout/_partial/sidebar/logo.ejs +1 -1
  31. package/layout/_partial/sidebar/menu.ejs +21 -23
  32. package/layout/_partial/widgets/components/linklist.ejs +23 -21
  33. package/layout/_partial/widgets/linklist.ejs +7 -12
  34. package/layout/_partial/widgets/recent.ejs +52 -41
  35. package/layout/_partial/widgets/related.ejs +47 -36
  36. package/layout/_partial/widgets/tagtree.ejs +70 -54
  37. package/layout/_partial/widgets/toc.ejs +37 -62
  38. package/layout/_partial/widgets/tree.ejs +38 -51
  39. package/layout/_plugins/index.ejs +25 -0
  40. package/layout/_plugins/mermaid.ejs +3 -4
  41. package/layout/archive.ejs +11 -20
  42. package/layout/collection-preview.ejs +60 -0
  43. package/layout/index_topic.ejs +12 -6
  44. package/layout/index_wiki.ejs +49 -3
  45. package/layout/layout.ejs +4 -3
  46. package/package.json +1 -1
  47. package/scripts/events/index.js +0 -2
  48. package/scripts/filters/lib/img_lazyload.js +184 -40
  49. package/scripts/generators/stellar-icons.js +1 -1
  50. package/scripts/lib/subtitle.js +5 -1
  51. package/scripts/tags/index.js +1 -0
  52. package/scripts/tags/lib/about.js +1 -1
  53. package/scripts/tags/lib/albums.js +1 -1
  54. package/scripts/tags/lib/banner.js +1 -1
  55. package/scripts/tags/lib/dropdown.js +64 -0
  56. package/scripts/tags/lib/friends.js +1 -1
  57. package/scripts/tags/lib/gallery.js +3 -3
  58. package/scripts/tags/lib/image.js +1 -1
  59. package/scripts/tags/lib/posters.js +1 -1
  60. package/scripts/tags/lib/sites.js +8 -4
  61. package/source/css/_common/base.styl +4 -4
  62. package/source/css/_common/blockquote.styl +2 -2
  63. package/source/css/_common/dropdown.styl +132 -0
  64. package/source/css/_common/html.styl +1 -1
  65. package/source/css/_common/loading.styl +5 -2
  66. package/source/css/_common/title.styl +8 -8
  67. package/source/css/_components/collection.styl +188 -0
  68. package/source/css/_components/layout.styl +1 -1
  69. package/source/css/_components/list.styl +44 -46
  70. package/source/css/_components/md.styl +3 -3
  71. package/source/css/_components/page-transition.styl +22 -0
  72. package/source/css/_components/pages/archives.styl +19 -18
  73. package/source/css/_components/pages/article-indent.styl +2 -2
  74. package/source/css/_components/pages/article-story.styl +9 -5
  75. package/source/css/_components/pages/collection-preview.styl +68 -0
  76. package/source/css/_components/pages/notebook.styl +11 -9
  77. package/source/css/_components/partial/article-footer.styl +7 -4
  78. package/source/css/_components/partial/cover.styl +280 -1
  79. package/source/css/_components/partial/related.styl +10 -6
  80. package/source/css/_components/sidebar/footer.styl +65 -12
  81. package/source/css/_components/sidebar/nav-area.styl +6 -1
  82. package/source/css/_components/sidebar/search.styl +6 -2
  83. package/source/css/_components/tag-plugins/chat.styl +1 -1
  84. package/source/css/_components/tag-plugins/folders.styl +4 -4
  85. package/source/css/_components/tag-plugins/folding.styl +3 -3
  86. package/source/css/_components/tag-plugins/hashtag.styl +2 -3
  87. package/source/css/_components/tag-plugins/mdrender.styl +1 -1
  88. package/source/css/_components/tag-plugins/note.styl +4 -4
  89. package/source/css/_components/tag-plugins/poetry.styl +5 -5
  90. package/source/css/_components/tag-plugins/quot.styl +1 -2
  91. package/source/css/_components/tag-plugins/quote.styl +1 -1
  92. package/source/css/_components/tag-plugins/tabs.styl +2 -2
  93. package/source/css/_components/tag-plugins/timeline.styl +4 -4
  94. package/source/css/_components/widgets/ghrepo.styl +4 -0
  95. package/source/css/_components/widgets/markdown.styl +1 -14
  96. package/source/css/_components/widgets/timeline.styl +2 -2
  97. package/source/css/_components/widgets/toc.styl +22 -20
  98. package/source/css/_components/widgets/widgets.styl +13 -1
  99. package/source/css/_components/wiki-card.styl +209 -0
  100. package/source/css/_custom.styl +22 -20
  101. package/source/css/_plugins/lazyload.styl +8 -5
  102. package/source/css/comments/utterances.styl +2 -2
  103. package/source/css/comments/waline.styl +24 -0
  104. package/source/css/plugins/mermaid.styl +31 -12
  105. package/source/js/icons.js +1 -1
  106. package/source/js/main.js +62 -6
  107. package/source/js/plugins/adaptive-text.js +46 -2
  108. package/source/js/plugins/dropdown.js +435 -0
  109. package/source/js/plugins/galaxy.js +423 -0
  110. package/source/js/search/algolia-search.js +1 -1
  111. package/source/js/search/local-search.js +1 -1
  112. package/source/js/services/ghinfo.js +31 -3
  113. package/source/js/services/siteinfo.js +26 -0
  114. package/source/js/services/sites.js +6 -2
  115. package/source/js/services.js +5 -2
  116. package/source/js/tagtree.js +7 -7
  117. package/source/js/utils.js +1 -1
  118. package/layout/_partial/widgets/components/link.ejs +0 -21
  119. package/source/css/_components/sidebar/menu.styl +0 -77
  120. package/source/css/_components/widgets/components.styl +0 -68
  121. package/source/css/_components/widgets/list.styl +0 -46
  122. package/source/css/_components/widgets/related.styl +0 -40
@@ -24,34 +24,25 @@ function layoutArchiveList() {
24
24
  }
25
25
  el += `<div class="post-list${cls} archives">`
26
26
  var years = []
27
- const posts = page.author != null ? site.posts.filter(p => (p.author || theme.default_author.id) == page.author.id) : site.posts
27
+ var posts = page.author != null ? site.posts.filter(function(post) {
28
+ return (post.author || theme.default_author.id) == page.author.id
29
+ }) : site.posts
28
30
  posts.sort('date', -1).each(function(post) {
29
- post.year = date(post.date, 'YYYY')
30
- if (post.year && (years.includes(post.year) == false) && (post.title || post.date)) {
31
- years.push(post.year)
31
+ post.year = date(post.date, 'YYYY')
32
+ if (post.year && (years.includes(post.year) == false) && (post.title || post.date)) {
33
+ years.push(post.year)
32
34
  }
33
35
  })
34
- for (let year of years) {
36
+ for (var year of years) {
35
37
  el += `<article class="${scrollreveal('')}" id="archive">`
36
- el += `<div class='archive-header h4'>${year}</div>`
38
+ el += `<div class='archive-header h4'>${year}</div>`
37
39
  posts.sort('date', -1).filter(function (post) {
38
40
  post.year = date(post.date, 'YYYY')
39
41
  return post.year == year
40
42
  }).each(function(post) {
41
- el += `<div class="archive-list">`
42
- el +=`<a class='post fs14' href="${pretty_url(post.link || post.path)}">`
43
- el += `<time>${date(post.date, 'MM-DD')}</time>`
44
- el += `<span>`
45
- if (post.title) {
46
- el += post.title
47
- } else if (post.date) {
48
- el += date(post.date, config.date_format)
49
- }
50
- el += `</span>`
51
- el += `</a>`
52
- el += `</div>`
43
+ el += partial('_partial/main/post_list/archive_item', {post: post})
53
44
  })
54
- el += `</article>`
45
+ el += `</article>`
55
46
  }
56
47
  el += `</div>`
57
48
  return el
@@ -65,4 +56,4 @@ function layoutDiv() {
65
56
  }
66
57
  %>
67
58
 
68
- <%- layoutDiv() %>
59
+ <%- layoutDiv() %>
@@ -0,0 +1,60 @@
1
+ <%
2
+ function renderCollection(options) {
3
+ return partial('_partial/components/collection', {options: options})
4
+ }
5
+
6
+ function renderWidget(title, options) {
7
+ return partial('_partial/components/widget-frame', {
8
+ title: title,
9
+ body: renderCollection(options),
10
+ reveal: false
11
+ })
12
+ }
13
+
14
+ function renderSurface(name, surface, width) {
15
+ var navItems = [
16
+ {href: '#active', title: '当前页面', icon: 'default:documents', active: true},
17
+ {href: '#long', title: '这是一个用于检查单行省略和容器适应性的很长标题', icon: 'default:rightbar'},
18
+ {href: '#plain', title: '无图标条目'}
19
+ ]
20
+ var gridItems = [
21
+ {href: '#one', title: '项目一', icon: 'default:documents', active: true},
22
+ {href: '#two', title: '项目二', icon: 'default:rightbar'},
23
+ {href: '#three', title: '项目三', icon: 'default:github'},
24
+ {href: '#four', title: '项目四', icon: 'default:rss'}
25
+ ]
26
+ var summaryItems = [
27
+ {href: '#summary-one', title: '摘要条目', description: '两行说明用于检查文字层级、截断和不同 surface 下的卡片填充。'},
28
+ {href: '#summary-two', title: '第二个摘要条目', description: '正文宽度下仍复用同一 DOM 与状态变量。'}
29
+ ]
30
+ var treeItems = [
31
+ {href: '#root', title: '一级节点', active: true},
32
+ {href: '#child', title: '二级节点', depth: 1, indicator: false},
33
+ {href: '#leaf', title: '三级节点', depth: 2, indicator: false}
34
+ ]
35
+ var html = '<section class="collection-preview__surface" data-ui-surface="' + surface + '" data-preview-width="' + width + '">'
36
+ html += '<header><strong>' + escape_html(name) + '</strong><code>' + escape_html(surface) + ' · ' + width + 'px</code></header>'
37
+ html += renderWidget('紧凑列表', {layout: 'list', variant: 'nav', density: 'compact', items: navItems})
38
+ html += renderWidget('响应式网格', {layout: 'grid', variant: 'nav', columns: 2, items: gridItems})
39
+ html += renderWidget('摘要列表', {layout: 'list', variant: 'summary', items: summaryItems})
40
+ html += renderWidget('层级列表', {layout: 'list', variant: 'nav', density: 'compact', items: treeItems})
41
+ html += '</section>'
42
+ return html
43
+ }
44
+ %>
45
+ <article class="md-text collection-preview">
46
+ <h1>侧边栏集合组件预览</h1>
47
+ <p>此开发页面用于同时检查列表、网格和摘要条目在四种 surface、不同容器宽度及明暗主题下的表现。</p>
48
+ <div class="collection-preview__theme" aria-label="预览主题">
49
+ <span>预览主题</span>
50
+ <button type="button" onclick="document.documentElement.setAttribute('data-theme','light')">Light</button>
51
+ <button type="button" onclick="document.documentElement.setAttribute('data-theme','dark')">Dark</button>
52
+ <button type="button" onclick="document.documentElement.removeAttribute('data-theme')">Auto</button>
53
+ </div>
54
+ <div class="collection-preview__grid">
55
+ <%- renderSurface('左栏 Glass', 'glass', 240) %>
56
+ <%- renderSurface('左栏 Card', 'card', 240) %>
57
+ <%- renderSurface('右侧栏', 'sidebar', 320) %>
58
+ <%- renderSurface('正文区域', 'content', 720) %>
59
+ </div>
60
+ </article>
@@ -1,12 +1,18 @@
1
1
  <%
2
2
  function layout_topic_list(partial) {
3
3
  var el = ''
4
- const { publish_list, tree } = theme.topic
5
- for (let id of publish_list) {
6
- const topic = tree[id]
7
- if (topic == null) {
8
- continue
9
- }
4
+ var publish_list = theme.topic.publish_list
5
+ var tree = theme.topic.tree
6
+ var topics = publish_list.map(function(id) {
7
+ return tree[id]
8
+ }).filter(function(topic) {
9
+ return topic != null
10
+ }).sort(function(a, b) {
11
+ var aDate = a.homepage && a.homepage.date ? new Date(a.homepage.date).getTime() : 0
12
+ var bDate = b.homepage && b.homepage.date ? new Date(b.homepage.date).getTime() : 0
13
+ return bDate - aDate
14
+ })
15
+ for (var topic of topics) {
10
16
  el += `<div class="post-card-wrap${scrollreveal(' ')}">`
11
17
  el += `<div class="post-card topic">`
12
18
  el += partial(topic)
@@ -13,14 +13,22 @@ function layout_wiki_list(partial) {
13
13
  if (page.filter === false) {
14
14
  // wikiList
15
15
  el += `<div class="post-card-wrap${scrollreveal(' ')}">`
16
- el += `<a class="post-card wiki" href="${pretty_url(proj.homepage?.path || '/')}">`
16
+ var coverStyle = ''
17
+ if (proj.cover) {
18
+ coverStyle = ` style="--cover-url:url('${escape_html(proj.cover.replace(/'/g, '%27'))}')"`
19
+ }
20
+ el += `<a class="wiki-card${proj.cover ? '' : ' no-cover'}"${coverStyle} href="${pretty_url(proj.homepage?.path || '/')}">`
17
21
  el += partial(proj)
18
22
  el += `</a>`
19
23
  el += `</div>`
20
24
  } else if (proj.tags && proj.tags.includes(page.tagName) === true) {
21
25
  // filtered wikiList
22
26
  el += `<div class="post-card-wrap${scrollreveal(' ')} filter">`
23
- el += `<a class="post-card wiki" href="${pretty_url(proj.homepage?.path || '/')}">`
27
+ var coverStyle = ''
28
+ if (proj.cover) {
29
+ coverStyle = ` style="--cover-url:url('${escape_html(proj.cover.replace(/'/g, '%27'))}')"`
30
+ }
31
+ el += `<a class="wiki-card${proj.cover ? '' : ' no-cover'}"${coverStyle} href="${pretty_url(proj.homepage?.path || '/')}">`
24
32
  el += partial(proj)
25
33
  el += `</a>`
26
34
  el += `</div>`
@@ -41,4 +49,42 @@ function layoutDiv() {
41
49
  }
42
50
  %>
43
51
 
44
- <%- layoutDiv() %>
52
+ <%- layoutDiv() %>
53
+ <script>
54
+ // Wiki 覆盖层须同时等待封面原图和平均主题色完成,避免默认主题色短暂闪现。
55
+ function revealWikiCover(card) {
56
+ if (card._wikiImageReady && card._wikiOverlayReady && !card.classList.contains('no-cover')) {
57
+ card.classList.add('cover-loaded');
58
+ }
59
+ }
60
+ document.addEventListener('wiki-overlay-ready', function(event) {
61
+ var card = event.target.closest('.wiki-card');
62
+ if (!card) return;
63
+ card._wikiOverlayReady = true;
64
+ revealWikiCover(card);
65
+ });
66
+ document.querySelectorAll('.wiki-card-cover:not(.no-cover) > img').forEach(function(img) {
67
+ var cover = img.parentElement;
68
+ var card = img.closest('.wiki-card');
69
+ if (!cover || !card) return;
70
+ var loaded = function() {
71
+ card._wikiImageReady = true;
72
+ revealWikiCover(card);
73
+ };
74
+ var failed = function() {
75
+ card._wikiImageReady = false;
76
+ card.classList.remove('cover-loaded');
77
+ card.classList.add('no-cover');
78
+ cover.classList.add('no-cover', 'cover-error');
79
+ };
80
+ img.addEventListener('load', loaded, { once: true });
81
+ img.addEventListener('error', failed, { once: true });
82
+ if (img.complete) {
83
+ if (img.naturalWidth > 0) {
84
+ loaded();
85
+ } else {
86
+ failed();
87
+ }
88
+ }
89
+ });
90
+ </script>
package/layout/layout.ejs CHANGED
@@ -49,19 +49,20 @@ if (theme.style.prefers_theme === 'auto') {
49
49
  html += site_background
50
50
  html += `<div id="l_cover">${partial('_partial/cover/index').trim()}</div>`
51
51
  html += `<div class="l_body ${page_type}" id="start" layout="${page.layout}" type="${article_type}" ${indent ? 'text-indent' : ''}>`
52
- html += `<aside class="l_left${theme.style.leftbar?.['ui-style'] === 'card' ? ' leftbar-card' : ''}">`
52
+ var leftbarSurface = theme.style.leftbar?.['ui-style'] === 'card' ? 'card' : 'glass'
53
+ html += `<aside class="l_left${leftbarSurface === 'card' ? ' leftbar-card' : ''}" data-ui-surface="${leftbarSurface}">`
53
54
  html += `<div class="sidebg"></div>`
54
55
  html += `<div class="leftbar-container${theme.style.leftbar?.blur ? ' leftbar-blur' : ''}">`
55
56
  html += partial('_partial/sidebar/index_leftbar')
56
57
  html += `</div>`
57
58
  html += `</aside>`
58
- html += `<div class="l_main" id="main">`
59
+ html += `<div class="l_main" id="main" data-ui-surface="content">`
59
60
  html += partial('_partial/sidebar/logo', {where: 'main'})
60
61
  html += body
61
62
  html += partial('_partial/main/footer')
62
63
  html += `<div class="main-mask" onclick="sidebar.dismiss()"></div>`
63
64
  html += `</div>`
64
- html += `<aside class="l_right">`
65
+ html += `<aside class="l_right" data-ui-surface="sidebar">`
65
66
  html += partial('_partial/sidebar/index_rightbar').trim()
66
67
  html += `</aside>`
67
68
  html += partial('_partial/menubtn')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hexo-theme-stellar",
3
- "version": "1.42.0",
3
+ "version": "1.43.0",
4
4
  "description": "Elegant and powerful theme for Hexo.",
5
5
  "main": "package.json",
6
6
  "scripts": {
@@ -41,7 +41,6 @@ hexo.on('ready', () => {
41
41
  checkVersion(hexo, { useCache: true });
42
42
  });
43
43
 
44
-
45
44
  // 防止重复注册
46
45
  let hasRun = false;
47
46
 
@@ -66,4 +65,3 @@ hexo.extend.filter.register('before_generate', async () => {
66
65
  fixMarkdownImages(hexo); // 不用 await,因为是同步的
67
66
  }
68
67
  });
69
-
@@ -1,56 +1,200 @@
1
1
  /**
2
- * img_lazyload.js v1 | https://github.com/xaoxuu/hexo-theme-stellar/
2
+ * img_lazyload.js v2 | https://github.com/xaoxuu/hexo-theme-stellar/
3
3
  *
4
+ * 懒加载强制开启:不再读取 enable 配置,no-lazy 是唯一例外。
5
+ * v2:改为属性感知的 <img> 标签解析,仅在标签自身范围内读取 src,
6
+ * 并跳过 <script>/<style>/<!-- --> 区域,避免与「去属性引号」的 HTML
7
+ * 压缩器(如 hexo-minify removeAttributeQuotes)组合时正则跨标签越界,
8
+ * 误改写页内内联脚本中的 `s.src = "..."`。
4
9
  */
5
10
 
6
- 'use strict';
11
+ "use strict";
7
12
 
8
- // 懒加载强制开启:不再读取 enable 配置,no-lazy 是唯一例外
9
- function lazyProcess(htmlContent) {
10
- // 短路:无 <img 的页面(如 404)无需正则扫描
11
- if (typeof htmlContent !== 'string' || !/<img/i.test(htmlContent)) {
12
- return htmlContent;
13
+ // 1×1 透明 PNG:懒加载占位图
14
+ const LOADING_IMG =
15
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAABGdBTUEAALGPC/xhBQAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAAaADAAQAAAABAAAAAQAAAADa6r/EAAAAC0lEQVQIHWNgAAIAAAUAAY27m/MAAAAASUVORK5CYII=";
16
+
17
+ // 解析 <img ...> 标签内部属性,返回 [{name, value, raw, start, end}]
18
+ function parseAttrs(inner) {
19
+ const attrs = [];
20
+ const re = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
21
+ let m;
22
+ while ((m = re.exec(inner)) !== null) {
23
+ attrs.push({
24
+ name: m[1].toLowerCase(),
25
+ value: m[2] !== undefined ? m[2] : m[3] !== undefined ? m[3] : m[4] !== undefined ? m[4] : null,
26
+ raw: m[0],
27
+ start: m.index,
28
+ end: m.index + m[0].length,
29
+ });
30
+ }
31
+ return attrs;
32
+ }
33
+
34
+ // 处理单个 <img> 标签:加 lazy 类并替换 src 为占位图 + data-src
35
+ function processImgTag(imgTag) {
36
+ // 快速跳过:整标签已含懒加载标记 / 原生 srcset / no-lazy(兼容压缩前后写法)
37
+ if (/data-src/gi.test(imgTag)) {
38
+ return imgTag;
39
+ }
40
+ if (/srcset=/gi.test(imgTag)) {
41
+ return imgTag;
42
+ }
43
+ if (/\bno-lazy\b/gi.test(imgTag)) {
44
+ return imgTag;
45
+ }
46
+
47
+ const selfClosing = /\/>$/.test(imgTag);
48
+ const inner = imgTag.slice(4, selfClosing ? imgTag.length - 2 : imgTag.length - 1);
49
+ const attrs = parseAttrs(inner);
50
+ const src = attrs.find((a) => a.name === "src");
51
+ // 无 src / 空 src / 已是占位图(data:image)时不处理
52
+ if (!src || src.value == null || src.value === "" || /^data:image/i.test(src.value)) {
53
+ return imgTag;
13
54
  }
14
- return htmlContent.replace(/<img(.*?)src="(.*?)"(.*?)>/gi, function(imgTag, src_before, src_value, src_after) {
15
- // 已由 tag 插件输出懒加载标记(data-src / data-srcset)的图片不重复处理
16
- if (/data-src/gi.test(imgTag)) {
17
- return imgTag;
55
+
56
+ const classAttr = attrs.find((a) => a.name === "class");
57
+ const oldClass = classAttr && classAttr.value != null ? classAttr.value : "";
58
+ const newClass = (oldClass ? oldClass + " " : "") + "lazy";
59
+
60
+ let out = "<img";
61
+ if (!classAttr) {
62
+ out += ' class="' + newClass + '"';
63
+ }
64
+ let pos = 0;
65
+ for (const a of attrs) {
66
+ out += inner.slice(pos, a.start);
67
+ if (a.name === "src") {
68
+ out += 'src="' + LOADING_IMG + '" data-src="' + src.value + '"';
69
+ } else if (a.name === "class") {
70
+ out += 'class="' + newClass + '"';
71
+ } else {
72
+ out += a.raw;
18
73
  }
19
- // 使用 srcset 的图片交给浏览器原生处理,避免占位图与 srcset 冲突
20
- if (/srcset=/gi.test(imgTag)) {
21
- return imgTag;
74
+ pos = a.end;
75
+ }
76
+ out += inner.slice(pos);
77
+ out += selfClosing ? "/>" : ">";
78
+ return out;
79
+ }
80
+
81
+ // 查找标签结束位置(尊重属性值内的引号,避免把 > 截断进属性值)
82
+ function findTagEnd(html, start) {
83
+ let j = start;
84
+ let quote = null;
85
+ while (j < html.length) {
86
+ const ch = html[j];
87
+ if (quote) {
88
+ if (ch === quote) {
89
+ quote = null;
90
+ }
91
+ } else if (ch === '"' || ch === "'") {
92
+ quote = ch;
93
+ } else if (ch === ">") {
94
+ return j;
22
95
  }
23
- if (/src="data:image(.*?)/gi.test(imgTag)) {
24
- return imgTag;
96
+ j++;
97
+ }
98
+ return -1;
99
+ }
100
+
101
+ function isTagBoundary(html, pos) {
102
+ if (pos >= html.length) {
103
+ return true;
104
+ }
105
+ const ch = html[pos];
106
+ return ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch === ">" || ch === "/";
107
+ }
108
+
109
+ // 全页扫描:只在真实 <img> 标签上做懒加载,跳过脚本/样式/注释内容
110
+ function processHtml(html) {
111
+ const parts = [];
112
+ let i = 0;
113
+ const n = html.length;
114
+ const lower = html.toLowerCase();
115
+ while (i < n) {
116
+ const ch = html[i];
117
+ if (ch !== "<") {
118
+ parts.push(ch);
119
+ i++;
120
+ continue;
25
121
  }
26
- // no-lazy 兼容 `no-lazy` / `no-lazy=""` 两种写法
27
- if (/\bno-lazy\b/gi.test(imgTag)) {
28
- return imgTag;
122
+ // HTML 注释
123
+ if (html.startsWith("<!--", i)) {
124
+ const end = html.indexOf("-->", i);
125
+ if (end === -1) {
126
+ break;
127
+ }
128
+ parts.push(html.slice(i, end + 3));
129
+ i = end + 3;
130
+ continue;
29
131
  }
30
- var newImgTag = imgTag;
31
- if (newImgTag.includes(' class="') == false) {
32
- newImgTag = newImgTag.slice(0,4) + ' class=""' + newImgTag.slice(4);
132
+ // <script>…</script>:整段跳过,避免误处理模板字符串/字符串里的 <img
133
+ if (lower.startsWith("<script", i) && isTagBoundary(html, i + 7)) {
134
+ const end = findTagEnd(html, i);
135
+ if (end === -1) {
136
+ break;
137
+ }
138
+ parts.push(html.slice(i, end + 1));
139
+ i = end + 1;
140
+ const close = lower.indexOf("</script", i);
141
+ if (close === -1) {
142
+ break;
143
+ }
144
+ const closeEnd = html.indexOf(">", close);
145
+ if (closeEnd === -1) {
146
+ break;
147
+ }
148
+ parts.push(html.slice(i, closeEnd + 1));
149
+ i = closeEnd + 1;
150
+ continue;
33
151
  }
34
- // class 中增加 lazy
35
- newImgTag = newImgTag.replace(/(.*?) class="(.*?)" (.*?)>/gi, function(ori, before, value, after){
36
- var newClass = value;
37
- if (newClass.length > 0) {
38
- newClass += ' ';
39
- }
40
- newClass += 'lazy';
41
- if (value) {
42
- return ori.replace('class="' + value, 'class="' + newClass);
43
- } else {
44
- return ori.replace('class="', 'class="' + newClass);
152
+ // <style>…</style>:整段跳过
153
+ if (lower.startsWith("<style", i) && isTagBoundary(html, i + 6)) {
154
+ const end = findTagEnd(html, i);
155
+ if (end === -1) {
156
+ break;
45
157
  }
46
- });
47
- // 加载图
48
- const loadingImg = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAABGdBTUEAALGPC/xhBQAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAAaADAAQAAAABAAAAAQAAAADa6r/EAAAAC0lEQVQIHWNgAAIAAAUAAY27m/MAAAAASUVORK5CYII=';
49
- newImgTag = newImgTag.replace(src_value, loadingImg + '" data-src="' + src_value);
50
- return newImgTag;
51
- });
158
+ parts.push(html.slice(i, end + 1));
159
+ i = end + 1;
160
+ const close = lower.indexOf("</style", i);
161
+ if (close === -1) {
162
+ break;
163
+ }
164
+ const closeEnd = html.indexOf(">", close);
165
+ if (closeEnd === -1) {
166
+ break;
167
+ }
168
+ parts.push(html.slice(i, closeEnd + 1));
169
+ i = closeEnd + 1;
170
+ continue;
171
+ }
172
+ // <img …>
173
+ if (lower.startsWith("<img", i) && isTagBoundary(html, i + 4)) {
174
+ const end = findTagEnd(html, i);
175
+ if (end === -1) {
176
+ break;
177
+ }
178
+ parts.push(processImgTag(html.slice(i, end + 1)));
179
+ i = end + 1;
180
+ continue;
181
+ }
182
+ parts.push(ch);
183
+ i++;
184
+ }
185
+ return parts.join("");
186
+ }
187
+
188
+ function lazyProcess(htmlContent) {
189
+ // 短路:无 <img 的页面(如 404)无需扫描
190
+ if (typeof htmlContent !== "string" || !/<img/i.test(htmlContent)) {
191
+ return htmlContent;
192
+ }
193
+ return processHtml(htmlContent);
52
194
  }
53
195
 
54
- module.exports.processSite = function(htmlContent) {
196
+ module.exports.processSite = function (htmlContent) {
55
197
  return lazyProcess.call(this, htmlContent);
56
198
  };
199
+
200
+ module.exports.lazyProcess = lazyProcess;
@@ -5,7 +5,7 @@
5
5
  // 与 layout/_partial/scripts/defines.ejs 中旧的白名单保持一致(客户端通过 ctx.icons 读取)。
6
6
  hexo.extend.generator.register('stellar_icons', function () {
7
7
  const icons = (this.theme.config && this.theme.config.icons) || {};
8
- const keys = ['default:tocomment', 'default:loading-spinner', 'default:warning', 'weibo:repeat', 'weibo:like'];
8
+ const keys = ['default:tocomment', 'default:warning', 'weibo:repeat', 'weibo:like'];
9
9
  const out = {};
10
10
  // 去除 SVG 注释(避免 <!-- / </script> 解析风险),再转义 <
11
11
  for (const k of keys) {
@@ -3,12 +3,16 @@
3
3
  const { stripHTML } = require('hexo-util');
4
4
 
5
5
  // 文章一行小字取值(hero 卡片与置顶轮播共用,未来可复用于其它场景):
6
- // subtitle > description > excerpt/content 前 length 字(去 HTML、压缩空白、截断;省略号由 CSS 单行处理),空则返回空串。
6
+ // subtitle(含 " | " 时取非空左侧)> description > excerpt/content 前 length 字(去 HTML、压缩空白、截断;省略号由 CSS 单行处理),空则返回空串。
7
7
  function subtitle(post, length = 50) {
8
8
  if (post == null) {
9
9
  return '';
10
10
  }
11
11
  if (post.subtitle != null && String(post.subtitle).length > 0) {
12
+ const separator = String(post.subtitle).indexOf(' | ');
13
+ if (separator > 0) {
14
+ return String(post.subtitle).slice(0, separator);
15
+ }
12
16
  return post.subtitle;
13
17
  }
14
18
  if (post.description != null && String(post.description).length > 0) {
@@ -12,6 +12,7 @@ hexo.extend.tag.register('grid', require('./lib/grid')(hexo), true)
12
12
  hexo.extend.tag.register('swiper', require('./lib/swiper')(hexo), true)
13
13
  hexo.extend.tag.register('gallery', require('./lib/gallery')(hexo), {ends: true})
14
14
  hexo.extend.tag.register('banner', require('./lib/banner')(hexo), true)
15
+ hexo.extend.tag.register('dropdown', require('./lib/dropdown')(hexo), true)
15
16
 
16
17
  // data
17
18
  hexo.extend.tag.register('users', require('./lib/friends')(hexo))
@@ -18,7 +18,7 @@ module.exports = ctx => function(args, content) {
18
18
  el += '<div class="tag-plugin about">'
19
19
  if (args.back) {
20
20
  el += '<a class="nav-back cap" href="' + url_for(ctx.config.root) + '">'
21
- el += ctx.utils.icon('default:goback')
21
+ el += ctx.utils.icon('default:arrow-left')
22
22
  el += '</a>'
23
23
  }
24
24
  // avatar
@@ -32,7 +32,7 @@ module.exports = ctx => function(args) {
32
32
  el += `<div class="grid-cell album-card">`
33
33
  el += `<a class="card-link lazy-box" target="_blank" rel="external nofollow noopener noreferrer" href="${item.url}">`
34
34
  el += `<img class="lazy" data-src="${item.cover || item.icon || item.avatar || ctx.theme.config.default.cover}" onerror="javascript:this.removeAttribute(&quot;data-src&quot;);this.src=&quot;${ctx.theme.config.default.cover}&quot;;"/>`
35
- el += `<div class="lazy-icon" style="background-image:url(${ctx.theme.config.default.loading || ctx.utils.iconData('default:loading-placeholder')});"></div>`
35
+ el += `<div class="lazy-icon"></div>`
36
36
  el += `<div class="image-meta">`
37
37
  if (item.title) {
38
38
  el += `<span class="image-caption">${item.title}</span>`
@@ -26,7 +26,7 @@ module.exports = ctx => function(args, content) {
26
26
  } else {
27
27
  el += `
28
28
  <button class="back cap" onclick="window.history.back()">
29
- ${ctx.utils.icon('default:goback')}
29
+ ${ctx.utils.icon('default:arrow-left')}
30
30
  </button>
31
31
  `
32
32
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * dropdown.js v1 | https://github.com/xaoxuu/hexo-theme-stellar/
3
+ *
4
+ * {% dropdown [direction:up/down] [align:left/right] title %}
5
+ * - icon:key [title](url)
6
+ * {% enddropdown %}
7
+ */
8
+
9
+ 'use strict'
10
+
11
+ function parseItem(line) {
12
+ const matches = line.match(/^\s*(?:[-*]\s+)?(?:icon:([^\s]+)\s+)?\[([^\]]+)\]\(([^)\s]+)\)(?:\s+icon:([^\s]+))?\s*$/)
13
+ if (!matches) {
14
+ return null
15
+ }
16
+ return {
17
+ icon: matches[1] || matches[4],
18
+ title: matches[2],
19
+ url: matches[3]
20
+ }
21
+ }
22
+
23
+ module.exports = ctx => function(args, content = '') {
24
+ const { escapeHTML, url_for } = require('hexo-util')
25
+ args = ctx.args.map(args, ['icon', 'direction', 'align', 'open'], ['title'])
26
+ if (!args.title) {
27
+ return ''
28
+ }
29
+
30
+ const items = content.split(/\r?\n/).map(parseItem).filter(Boolean)
31
+ if (items.length === 0) {
32
+ return ''
33
+ }
34
+
35
+ const direction = ['up', 'down'].includes(args.direction) ? args.direction : 'auto'
36
+ const align = ['left', 'right'].includes(args.align) ? args.align : 'auto'
37
+ const title = escapeHTML(args.title)
38
+ const toUrl = url_for.bind(ctx)
39
+ var el = `<details class="tag-plugin dropdown" direction="${direction}" align="${align}"`
40
+ if (args.open === 'true') {
41
+ el += ' open'
42
+ }
43
+ el += '>'
44
+ el += `<summary class="dropdown-trigger" title="${title}" aria-label="${title}">`
45
+ el += `<span>${title}</span>`
46
+ el += '<svg class="dropdown-arrow" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 9l6 6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>'
47
+ el += '</summary>'
48
+ el += '<div class="dropdown-menu ui-collection" data-ui-surface="glass" data-layout="list" data-variant="nav" data-density="compact">'
49
+ for (const item of items) {
50
+ const url = String(item.url)
51
+ el += `<a class="dropdown-item ui-collection__item" href="${escapeHTML(toUrl(url))}`
52
+ if (url.includes('://')) {
53
+ el += '" target="_blank" rel="external nofollow noopener noreferrer">'
54
+ } else {
55
+ el += '" rel="noopener noreferrer">'
56
+ }
57
+ if (item.icon) {
58
+ el += `<span class="ui-collection__leading">${ctx.utils.icon(item.icon, 'no-lazy', true)}</span>`
59
+ }
60
+ el += `<span class="ui-collection__content"><span class="ui-collection__title">${escapeHTML(item.title)}</span></span></a>`
61
+ }
62
+ el += '</div></details>'
63
+ return el
64
+ }
@@ -29,7 +29,7 @@ module.exports = ctx => function(args) {
29
29
  el += `<a class="card-link" target="_blank" rel="external nofollow noopener noreferrer" href="${item.url}">`
30
30
  el += `<div class="lazy-box icon">`
31
31
  el += `<img class="lazy" data-src="${item.icon || item.avatar || ctx.theme.config.default.avatar}" onerror="javascript:this.removeAttribute(&quot;data-src&quot;);this.src=&quot;${ctx.theme.config.default.avatar}&quot;;"/>`
32
- el += `<div class="lazy-icon" style="background-image:url(&quot;${ctx.theme.config.default.loading || ctx.utils.iconData('default:loading-placeholder')}&quot;);"></div>`
32
+ el += `<div class="lazy-icon"></div>`
33
33
  el += `</div>`
34
34
  el += `<div class="name">`
35
35
  el += `<span>${item.title}</span>`