hexo-theme-stellar 1.39.0 → 1.40.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 (54) hide show
  1. package/.claude/skills/stellar-theme-dev/SKILL.md +2 -2
  2. package/CHANGELOG.md +44 -0
  3. package/_config.yml +8 -2
  4. package/languages/en.yml +2 -0
  5. package/languages/zh-CN.yml +2 -0
  6. package/languages/zh-TW.yml +2 -0
  7. package/layout/_partial/comments/artalk/script.ejs +8 -0
  8. package/layout/_partial/cover/post_cover.ejs +1 -1
  9. package/layout/_partial/head.ejs +11 -2
  10. package/layout/_partial/main/article/article_footer.ejs +13 -10
  11. package/layout/_partial/main/navbar/article_banner.ejs +7 -0
  12. package/layout/_partial/main/navbar/dateinfo.ejs +1 -1
  13. package/layout/_partial/main/navbar/nav_tabs_blog.ejs +6 -3
  14. package/layout/_partial/main/navbar/nav_tabs_wiki.ejs +1 -1
  15. package/layout/_partial/main/pin_slider.ejs +11 -12
  16. package/layout/_partial/main/post_list/post_card.ejs +24 -13
  17. package/layout/_partial/sidebar/index_leftbar.ejs +2 -2
  18. package/layout/_partial/widgets/markdown.ejs +1 -1
  19. package/layout/_partial/widgets/toc.ejs +8 -1
  20. package/layout/categories.ejs +18 -8
  21. package/layout/index.ejs +67 -11
  22. package/layout/page.ejs +5 -1
  23. package/package.json +5 -3
  24. package/scripts/commands/new-note.js +2 -2
  25. package/scripts/helpers/category_tree.js +11 -0
  26. package/scripts/helpers/escape_html.js +9 -0
  27. package/scripts/helpers/json_ld.js +5 -0
  28. package/scripts/helpers/mdrender.js +29 -0
  29. package/scripts/helpers/reading_time.js +12 -0
  30. package/scripts/helpers/related_posts.js +9 -5
  31. package/scripts/lib/category_tree.js +26 -0
  32. package/scripts/lib/mdrender_html.js +97 -0
  33. package/scripts/lib/reading_time.js +16 -0
  34. package/scripts/lib/wiki_readme.js +57 -0
  35. package/scripts/tags/index.js +2 -0
  36. package/scripts/tags/lib/md.js +17 -9
  37. package/scripts/tags/lib/table.js +22 -0
  38. package/scripts/tags/lib/timeline.js +9 -9
  39. package/scripts/tags/lib/tip.js +23 -0
  40. package/source/css/_common/device.styl +3 -0
  41. package/source/css/_components/list.styl +11 -5
  42. package/source/css/_components/pages/archives.styl +7 -3
  43. package/source/css/_components/partial/bread-nav.styl +9 -1
  44. package/source/css/_components/sidebar/search.styl +6 -4
  45. package/source/css/_components/sidebar/sidebar.styl +10 -7
  46. package/source/css/_components/tag-plugins/table.styl +37 -0
  47. package/source/css/_components/tag-plugins/tip.styl +27 -0
  48. package/source/css/_custom.styl +1 -1
  49. package/source/css/_defines/func.styl +19 -13
  50. package/source/css/_defines/theme_base.styl +3 -0
  51. package/source/js/main.js +92 -1
  52. package/source/js/services/mdrender.js +116 -7
  53. package/source/js/services/memos.js +60 -8
  54. package/source/js/services/waline_latest_comment.js +3 -1
@@ -29,11 +29,11 @@ description: 主题仓库(hexo-theme-stellar)开发全流程:方案 →
29
29
  命令按需执行:
30
30
 
31
31
  - `scripts/` 有改动 → 必须在主工程(xaoxuu.com)执行 `npm run g` 全量验证(已含 `hexo clean && hexo generate && npx gulp minify`);`npm run s` 是按需渲染,不能替代。
32
- - 新增/修改纯函数 → 补充单测并跑 `npm run check`(lint + 单测 + 知识库硬事实核查)。
32
+ - 新增/修改纯函数 → 补充单测并跑 `npm run check`(lint + 单测 + 依赖声明检查 + 知识库硬事实核查)。
33
33
  - 知识库有改动 → `python3 docs/knowledge/tools/verify.py` 硬事实核查。
34
34
  - UI 方面(样式、模板、前端交互等)改动量不大时无需自检流程,除非用户明确要求。
35
35
  - 检查所有受影响页面类型(首页、文章页、Wiki 页等),验证结果记录在方案目录 `checklist.md`。
36
- - 完成条件:应执行的命令全部通过;首页、文章页、Wiki 页等受影响页面类型均检查通过。
36
+ - 完成条件:应执行的命令全部通过;新增 `require` 均已声明或为 Node 内置模块(test/ 禁止幽灵依赖);首页、文章页、Wiki 页等受影响页面类型均检查通过。
37
37
 
38
38
  ### 4. 提交
39
39
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.40.0
4
+
5
+ > 发布日期:2026-08-14
6
+
7
+ ### 新功能
8
+ - 文章页新增字数与预计阅读时长显示(`article.reading_time`,默认关闭)
9
+ - 文章卡片新增标签展示(最多 5 个,`article.card_tags`,默认关闭)
10
+ - 新增表格标签 `{% table style:scroll|wrap|compact %}`,支持滚动/换行/紧凑三种表格样式
11
+ - 新增气泡注解标签 `{% tip text:... %}词句{% endtip %}`,桌面 hover、移动端点击显示
12
+ - 远程 Markdown 渲染抽象:`{% md %}` 标签新增可选 `wrap` 参数;wiki 项目配置 `repo` 后可直接以仓库 README 作为首页正文
13
+
14
+ ### 修复
15
+ - wiki 项目描述作为页面描述(OG / JSON-LD)的兜底,修复 SEO 描述缺失的问题
16
+ - 声明 hexo-pagination / hexo-util 依赖并新增依赖声明检查门禁,修复幽灵依赖导致的构建问题
17
+ - 时间线节点标题(header)不再渲染 markdown 的问题(#401)
18
+ - 分类页三级及以上分类无法正确展示的问题,改为基于 parent 递归构建嵌套树(#564)
19
+ - 亮暗切换后 topbar 与左栏颜色不同步的问题:navbar/sidebar 暗色样式统一跟随 `data-theme` 开关(#593、#663)
20
+ - iOS Safari 下右下角悬浮控件在玻璃椭圆框中不对齐的问题(#599)
21
+ - artalk 邮件通知链接携带 `?atk_*` 参数导致目录定位失效的问题(#598)
22
+ - waline 最近评论接口返回结构兼容(数组 / `{ data: [...] }`)(#630)
23
+ - memos 新版 v1 接口识别与渲染适配,识别失败仍回退 feature 兜底(#668)
24
+ - new-note 命令生成 front-matter 的 date 对齐为 `YYYY-MM-DD HH:mm`(#594)
25
+
26
+ ### 升级注意(配置变更与破坏性改动)
27
+ - 新增 `article.reading_time`(默认 `false`)与 `article.card_tags`(默认 `false`),需要时设为 `true`
28
+ - 新增 `table`、`tip` 两个标签插件;`table` 默认使用 `scroll` 样式
29
+
30
+ Full Changelog: [1.39.1...1.40.0](https://github.com/xaoxuu/hexo-theme-stellar/compare/1.39.1...1.40.0)
31
+
32
+ ## 1.39.1
33
+
34
+ > 发布日期:2026-08-13
35
+
36
+ ### 新功能
37
+ - 置顶文章新增平铺样式配置 `article.pin_style: flat`:首页第一页不再渲染置顶轮播,置顶文章按与轮播一致的排序权重在文章列表靠前平铺展示
38
+
39
+ ### 修复
40
+ - 用户内容统一 HTML 转义并移除圆点 aria-label,修复标题含引号导致构建失败
41
+
42
+ ### 升级注意(配置变更与破坏性改动)
43
+ - 新增 `article.pin_style`(`carousel` / `flat`,默认 `carousel`):默认行为不变;设置 `flat` 后首页置顶文章改为平铺样式展示
44
+
45
+ Full Changelog: [1.39.0...1.39.1](https://github.com/xaoxuu/hexo-theme-stellar/compare/1.39.0...1.39.1)
46
+
3
47
  ## 1.39.0
4
48
 
5
49
  > 发布日期:2026-08-13
package/_config.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  ######## Stellar info ########
2
2
  stellar:
3
- version: '1.39.0'
3
+ version: '1.40.0'
4
4
  homepage: 'https://xaoxuu.com/wiki/stellar/'
5
5
  repo: 'https://github.com/xaoxuu/hexo-theme-stellar'
6
6
  main_css: /css/main.css
@@ -180,6 +180,8 @@ notebook:
180
180
 
181
181
  ######## Article ########
182
182
  article:
183
+ # 置顶文章样式:carousel = 轮播(默认), flat = 平铺(不渲染轮播,置顶文章在首页文章列表靠前展示,排序规则与轮播一致)
184
+ pin_style: carousel
183
185
  # 文章类型,决定布局风格,可以在 front-matter/topic/wiki 中覆盖此参数
184
186
  type: tech # tech: 默认技术类文章, story: 图文类文章,文字和段落间增距大
185
187
  # 缩进两个文字宽度,适合文字类博客,可以在 front-matter/topic/wiki 中覆盖此参数
@@ -204,6 +206,10 @@ article:
204
206
  related_posts:
205
207
  enable: false
206
208
  max_count: 5
209
+ # 文章页是否显示字数和预计阅读时长(#302)
210
+ reading_time: false
211
+ # 文章卡片是否显示标签(最多 5 个,#580)
212
+ card_tags: false
207
213
 
208
214
  search:
209
215
  service: local_search # local_search, algolia_search, todo...
@@ -674,7 +680,7 @@ style:
674
680
  image-l: 24px # 非技术类文章插图圆角
675
681
  image: 16px # 技术类文章插图圆角
676
682
  image-s: 8px # 评论区图片圆角(部分评论支持)
677
- corner-shape: superellipse(1.5) # 连续曲率圆角(仅 Chromium 139+ 支持,其余浏览器自动回退普通圆角):superellipse(1.x) 开启,round 关闭
683
+ corner-shape: superellipse(1.25) # 连续曲率圆角(仅 Chromium 139+ 支持,其余浏览器自动回退普通圆角):superellipse(1.x) 开启,round 关闭
678
684
  color:
679
685
  # 主题色配置(不会根据明暗动态调整,请设置为通用的颜色)
680
686
  theme: 'hsl(192 98% 55%)' # 主题色
package/languages/en.yml CHANGED
@@ -37,6 +37,8 @@ meta:
37
37
  license: License
38
38
  share: Share
39
39
  contributors: Page Contributors
40
+ word_count: '%s words'
41
+ reading_time: '%s min read'
40
42
  date_suffix:
41
43
  just: Just
42
44
  min: minutes ago
@@ -37,6 +37,8 @@ meta:
37
37
  license: 许可协议
38
38
  share: 分享文章
39
39
  contributors: 本文贡献者
40
+ word_count: '%s 字'
41
+ reading_time: '预计阅读 %s 分钟'
40
42
  date_suffix:
41
43
  just: 刚刚
42
44
  min: 分钟前
@@ -37,6 +37,8 @@ meta:
37
37
  license: 授權條款
38
38
  share: 分享文章
39
39
  contributors: 本文貢獻者
40
+ word_count: '%s 字'
41
+ reading_time: '預計閱讀 %s 分鐘'
40
42
  date_suffix:
41
43
  just: 剛剛
42
44
  min: 分鐘前
@@ -39,6 +39,14 @@
39
39
  },
40
40
  <% } %>
41
41
  });
42
+ // artalk 邮件通知链接携带 ?atk_comment=... 等查询参数,
43
+ // 其 hash 监听会与主题目录定位冲突(#598):
44
+ // 初始化并完成评论定位后清理查询参数,避免后续目录/锚点跳转被干扰。
45
+ if (window.location.search.indexOf('atk_') !== -1) {
46
+ window.setTimeout(function () {
47
+ window.history.replaceState(null, '', window.location.pathname + window.location.hash);
48
+ }, 300);
49
+ }
42
50
  });
43
51
  }
44
52
 
@@ -28,7 +28,7 @@ function div() {
28
28
  }
29
29
  el += '<div class="l_cover post' + scrollreveal(' ') + '">';
30
30
  el += '<div class="cover">';
31
- el += '<div class="lazy img bg" data-bg="' + url + '"></div>';
31
+ el += '<div class="lazy img bg" data-bg="' + escape_html(url) + '"></div>';
32
32
  el += '</div>';
33
33
  el += '</div>';
34
34
  return el;
@@ -23,14 +23,17 @@ function generate_description() {
23
23
  if (theme.open_graph && theme.open_graph.enable) {
24
24
  return '';
25
25
  }
26
+ if (page.description) {
27
+ return '<meta name="description" content="' + truncate(strip_html(page.description), {length: 150}) + '">';
28
+ }
26
29
  if (page.wiki) {
27
30
  const proj = theme.wiki.tree[page.wiki]
28
31
  if (proj?.description) {
29
32
  return '<meta name="description" content="' + proj.description + '">'
30
33
  }
31
34
  }
32
- if (page.description || page.excerpt || page.content) {
33
- return '<meta name="description" content="' + truncate(strip_html(page.description || page.excerpt || page.content), {length: 150}) + '">';
35
+ if (page.excerpt || page.content) {
36
+ return '<meta name="description" content="' + truncate(strip_html(page.excerpt || page.content), {length: 150}) + '">';
34
37
  }
35
38
  return '<meta name="description" content="' + config.description + '">';
36
39
  }
@@ -66,6 +69,12 @@ function og_args() {
66
69
  if (page.layout == 'post' && page.cover) {
67
70
  args.twitter_card = 'summary_large_image';
68
71
  }
72
+ if (page.wiki && !page.description) {
73
+ const proj = theme.wiki.tree[page.wiki];
74
+ if (proj && proj.description) {
75
+ args.description = proj.description;
76
+ }
77
+ }
69
78
  args.image = config.avatar || (config.email ? gravatar(config.email) : null);
70
79
  return Object.assign(args, page.open_graph);
71
80
  }
@@ -98,6 +98,9 @@ function layoutDiv() {
98
98
  showSharePlugin = true
99
99
  }
100
100
  if (theme.article.share && showSharePlugin) {
101
+ function urlenc(v) {
102
+ return encodeURIComponent(v == null ? '' : String(v))
103
+ }
101
104
  function socialButtons() {
102
105
  var el = ''
103
106
  theme.article.share.forEach((item, i) => {
@@ -109,20 +112,20 @@ function layoutDiv() {
109
112
  if (item == 'wechat') {
110
113
  el += ' onclick="util.toggle(&quot;qrcode-wechat&quot;)"';
111
114
  } else if (item == 'weibo') {
112
- el += ' href="https://service.weibo.com/share/share.php?url=' + page.permalink;
113
- el += '&title=' + page.title + ' - ' + config.title;
115
+ el += ' href="https://service.weibo.com/share/share.php?url=' + urlenc(page.permalink);
116
+ el += '&title=' + urlenc(page.title + ' - ' + config.title);
114
117
  if (page.layout == 'post' && page.cover) {
115
- el += '&pics=' + page.cover;
118
+ el += '&pics=' + urlenc(page.cover);
116
119
  } else if (page.wiki && page.icon) {
117
- el += '&pics=' + page.icon;
120
+ el += '&pics=' + urlenc(page.icon);
118
121
  }
119
- el += '&summary=' + truncate(page.description || strip_html(page.excerpt || page.content), {length: 120});
122
+ el += '&summary=' + urlenc(truncate(page.description || strip_html(page.excerpt || page.content), {length: 120}));
120
123
  el += '"';
121
124
  } else if (item == 'email') {
122
- el += ' href="mailto:?subject=' + page.title + ' - ' + config.title + '&amp;'
123
- el += 'body=' + page.permalink + '"';
125
+ el += ' href="mailto:?subject=' + urlenc(page.title + ' - ' + config.title) + '&amp;'
126
+ el += 'body=' + urlenc(page.permalink) + '"';
124
127
  } else if (item == 'link') {
125
- el += ' onclick="util.copy(&quot;copy-link&quot;, &quot;' + __('message.copied') + '&quot;)"';
128
+ el += ' onclick="util.copy(&quot;copy-link&quot;, &quot;' + escape_html(__('message.copied')) + '&quot;)"';
126
129
  }
127
130
  el += '>';
128
131
  el += icon(`share:${item}`)
@@ -135,7 +138,7 @@ function layoutDiv() {
135
138
  if (theme.article.share.includes('wechat')) {
136
139
  return `
137
140
  <div class="qrcode" id="qrcode-wechat" style="opacity:0;height:0">
138
- <img src="https://api.qrserver.com/v1/create-qr-code/?size=256x256&data=${page.permalink}"/>
141
+ <img src="https://api.qrserver.com/v1/create-qr-code/?size=256x256&data=${urlenc(page.permalink)}"/>
139
142
  </div>
140
143
  `
141
144
  } else {
@@ -146,7 +149,7 @@ function layoutDiv() {
146
149
  <section id="share">
147
150
  <div class="header"><span>${__('meta.share')}</span></div>
148
151
  <div class="body">
149
- <div class="link"><input class="copy-area" readonly="true" id="copy-link" value="${page.permalink}" /></div>
152
+ <div class="link"><input class="copy-area" readonly="true" id="copy-link" value="${escape_html(page.permalink)}" /></div>
150
153
  <div class="social-wrap dis-select">${socialButtons()}</div>
151
154
  ${qrcode()}
152
155
  </div>
@@ -41,6 +41,13 @@ function layoutBreadcrumb() {
41
41
  } else {
42
42
  el += partial('breadcrumb/page')
43
43
  }
44
+ // 字数和预计阅读时长,置于面包屑行右侧(#302)
45
+ if (page.layout == 'post' && theme.article.reading_time) {
46
+ var _wc = word_count(page)
47
+ if (_wc > 0) {
48
+ el += `<span class="cap reading right">${__("meta.word_count", _wc)} · ${__("meta.reading_time", reading_time(page))}</span>`
49
+ }
50
+ }
44
51
  // end 3.left.top
45
52
  el += `</div>`
46
53
  // 3.left.bottom
@@ -42,4 +42,4 @@ function layoutDiv() {
42
42
  return el
43
43
  }
44
44
  %>
45
- <%- layoutDiv() %>
45
+ <%- layoutDiv() %>
@@ -1,7 +1,10 @@
1
1
  <%
2
2
  function layoutDiv() {
3
3
  var el = '';
4
- el += partial('_partial/main/pin_slider', {type: 'post'});
4
+ // 置顶文章样式为平铺时不渲染轮播,置顶文章改由首页文章列表靠前展示
5
+ if (theme.article?.pin_style !== 'flat') {
6
+ el += partial('_partial/main/pin_slider', {type: 'post'});
7
+ }
5
8
  el += '<div class="navbar top">';
6
9
  el += '<div class="navbar-blur">';
7
10
  el += '<div class="navbar-container">';
@@ -14,7 +17,7 @@ function layoutDiv() {
14
17
 
15
18
  if (site.categories && site.categories.length > 0) {
16
19
  if (page.category) {
17
- el += '<a class="active" href="' + pretty_url(config.category_dir) + '">' + __("btn.category") + __("symbol.colon") + page.category + '</a>';
20
+ el += '<a class="active" href="' + pretty_url(config.category_dir) + '">' + __("btn.category") + __("symbol.colon") + escape_html(page.category) + '</a>';
18
21
  } else if (page.layout == "categories") {
19
22
  el += '<a class="active" href="' + pretty_url(config.category_dir) + '">' + __("btn.categories") + '</a>';
20
23
  } else {
@@ -24,7 +27,7 @@ function layoutDiv() {
24
27
 
25
28
  if (site.tags && site.tags.length > 0) {
26
29
  if (page.tag) {
27
- el += '<a class="active" href="' + pretty_url(config.tag_dir) + '">' + __("btn.tag") + __("symbol.colon") + page.tag + '</a>';
30
+ el += '<a class="active" href="' + pretty_url(config.tag_dir) + '">' + __("btn.tag") + __("symbol.colon") + escape_html(page.tag) + '</a>';
28
31
  } else if (page.layout == "tags") {
29
32
  el += '<a class="active" href="' + pretty_url(config.tag_dir) + '">' + __("btn.tags") + '</a>';
30
33
  } else {
@@ -23,7 +23,7 @@ function layoutDiv() {
23
23
  if (tag.name && tag.name.length > 0 && page.tagName === tag.name) {
24
24
  el += ' class="active"';
25
25
  }
26
- el += ' href="' + pretty_url(tag.path) + '">' + tag.name + '</a>';
26
+ el += ' href="' + escape_html(pretty_url(tag.path)) + '">' + escape_html(tag.name) + '</a>';
27
27
  el += '</a>';
28
28
  }
29
29
  }
@@ -69,18 +69,18 @@ function postSlide(post) {
69
69
  var headline = (post.poster != null && post.poster.headline != null && post.poster.headline.length > 0) ? post.poster.headline : post.title
70
70
  var subtitle = postSubtitle(post)
71
71
  var el = ''
72
- el += '<a class="pin-slide post-slide' + (noCover ? ' no-cover' : '') + '" href="' + pretty_url(post.link || post.path) + '"'
72
+ el += '<a class="pin-slide post-slide' + (noCover ? ' no-cover' : '') + '" href="' + escape_html(pretty_url(post.link || post.path)) + '"'
73
73
  if (!noCover) {
74
- el += ' style="--pin-cover-url:url(\'' + post.cover.replace(/'/g, '%27') + '\')"'
74
+ el += ' style="--pin-cover-url:url(\'' + escape_html(post.cover.replace(/'/g, '%27')) + '\')"'
75
75
  }
76
76
  el += '>'
77
77
  if (!noCover) {
78
- el += '<img class="pin-slide-bg" src="' + post.cover + '" alt=""/>'
78
+ el += '<img class="pin-slide-bg" src="' + escape_html(post.cover) + '" alt=""/>'
79
79
  }
80
80
  el += '<div class="pin-slide-text">'
81
- el += '<div class="pin-slide-headline">' + headline + '</div>'
81
+ el += '<div class="pin-slide-headline">' + escape_html(headline) + '</div>'
82
82
  if (subtitle.length > 0) {
83
- el += '<div class="pin-slide-caption">' + subtitle + '</div>'
83
+ el += '<div class="pin-slide-caption">' + escape_html(subtitle) + '</div>'
84
84
  }
85
85
  el += '</div>'
86
86
  el += '</a>'
@@ -90,7 +90,7 @@ function postSlide(post) {
90
90
  function projectChips(tags) {
91
91
  var caps = ''
92
92
  if (tags && tags.length > 0) {
93
- caps += '<span class="pin-slide-chip">' + tags.join('</span><span class="pin-slide-chip">') + '</span>'
93
+ caps += '<span class="pin-slide-chip">' + tags.map(function(t) { return escape_html(t) }).join('</span><span class="pin-slide-chip">') + '</span>'
94
94
  }
95
95
  return caps
96
96
  }
@@ -100,14 +100,14 @@ function wikiSlide(proj) {
100
100
  var title = proj.title || proj.name || proj.id
101
101
  var excerpt = proj.description || ''
102
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=""/>'
103
+ el += '<a class="pin-slide" href="' + escape_html(pretty_url(proj.homepage?.path || '/')) + '">'
104
+ el += '<img class="pin-slide-bg" src="' + escape_html(img) + '" alt=""/>'
105
105
  el += '<div class="pin-slide-mask"></div>'
106
106
  el += '<div class="pin-slide-info">'
107
107
  el += '<div class="pin-slide-caps">' + projectChips(proj.tags) + '</div>'
108
- el += '<div class="pin-slide-title">' + title + '</div>'
108
+ el += '<div class="pin-slide-title">' + escape_html(title) + '</div>'
109
109
  if (excerpt.length > 0) {
110
- el += '<div class="pin-slide-excerpt">' + excerpt + '</div>'
110
+ el += '<div class="pin-slide-excerpt">' + escape_html(excerpt) + '</div>'
111
111
  }
112
112
  el += '</div>'
113
113
  el += '</a>'
@@ -125,8 +125,7 @@ if (pinItems.length > 0) {
125
125
  slidesHtml += postSlide(item)
126
126
  }
127
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>'
128
+ dotsHtml += '<button type="button" class="pin-slider-dot' + (si == 0 ? ' active' : '') + '" data-index="' + si + '"></button>'
130
129
  }
131
130
  }
132
131
  var groupKey = pinType == 'wiki' ? 'wiki' : 'post'
@@ -39,14 +39,14 @@ function div_default() {
39
39
  }
40
40
  if (cover_url) {
41
41
  el += '<div class="post-cover">';
42
- el += '<img src="' + cover_url + '"/>';
42
+ el += '<img src="' + escape_html(cover_url) + '"/>';
43
43
  el += '</div>';
44
44
  }
45
45
  }
46
46
 
47
47
  // 标题
48
48
  el += '<h2 class="post-title">';
49
- el += post.title ? post.title : date(post.date, config.date_format);
49
+ el += post.title ? escape_html(post.title) : date(post.date, config.date_format);
50
50
  el += '</h2>';
51
51
 
52
52
  // 摘要
@@ -57,11 +57,11 @@ function div_default() {
57
57
  el += '">';
58
58
  el += '<p>';
59
59
  if (post.excerpt) {
60
- el += strip_html(post.excerpt);
60
+ el += escape_html(strip_html(post.excerpt));
61
61
  } else if (post.description) {
62
- el += post.description;
62
+ el += escape_html(post.description);
63
63
  } else if (post.content && theme.article.auto_excerpt > 0) {
64
- el += truncate(strip_html(post.content), {length: theme.article.auto_excerpt});
64
+ el += escape_html(truncate(strip_html(post.content), {length: theme.article.auto_excerpt}));
65
65
  }
66
66
  el += '</p>';
67
67
  el += '</div>';
@@ -87,12 +87,23 @@ function div_default() {
87
87
  let lastCat = cats.pop();
88
88
  el += '<span class="cap breadcrumb"' + category_color(lastCat) + '>';
89
89
  el += icon('default:category')
90
- el += `<span>${str}</span>`
90
+ el += `<span>${escape_html(str)}</span>`
91
91
  el += '</span>';
92
92
  }
93
93
  }
94
94
  }
95
- // 置顶文章已由列表页 navbar 上方的置顶轮播展示,卡片内不再显示 pin 图标。
95
+ // 置顶文章由轮播(默认)或首页列表靠前展示(flat 平铺)承担,卡片内不显示 pin 图标。
96
+ // 文章标签(最多 5 个,#580)
97
+ if (theme.article.card_tags && post.tags && post.tags.length > 0) {
98
+ el += '<span class="card-tags">';
99
+ post.tags.forEach((tag, i) => {
100
+ if (i >= 5) {
101
+ return;
102
+ }
103
+ el += `<span class="cap tag-chip">#${escape_html(tag.name)}</span>`;
104
+ });
105
+ el += '</span>';
106
+ }
96
107
  el += '</div>';
97
108
  el += '</article>';
98
109
  return el;
@@ -107,22 +118,22 @@ function div_photo() {
107
118
  position = 'bottom';
108
119
  }
109
120
  }
110
- el += '<div class="cover" position="' + position + '" style="--cover-url:url(\'' + obj.image.replace(/'/g, '%27') + '\')">';
111
- el += '<img src="' + obj.image + '"/>';
121
+ el += '<div class="cover" position="' + position + '" style="--cover-url:url(\'' + escape_html(obj.image.replace(/'/g, '%27')) + '\')">';
122
+ el += '<img src="' + escape_html(obj.image) + '"/>';
112
123
  if (position.length > 0) {
113
124
  el += '<div class="cover-info" position="' + position + '"';
114
125
  if (obj.color) {
115
- el += 'style="color:' + obj.color + '"';
126
+ el += 'style="color:' + escape_html(obj.color) + '"';
116
127
  }
117
128
  el += '>';
118
129
  if (obj.topic) {
119
- el += '<div class="text topic">' + obj.topic + '</div>';
130
+ el += '<div class="text topic">' + escape_html(obj.topic) + '</div>';
120
131
  }
121
132
  if (obj.headline) {
122
- el += '<div class="text headline">' + obj.headline + '</div>';
133
+ el += '<div class="text headline">' + escape_html(obj.headline) + '</div>';
123
134
  }
124
135
  if (obj.caption) {
125
- el += '<div class="text caption">' + obj.caption + '</div>';
136
+ el += '<div class="text caption">' + escape_html(obj.caption) + '</div>';
126
137
  }
127
138
  el += '</div>';
128
139
 
@@ -131,10 +131,10 @@ function layoutFooterDiv() {
131
131
  if (item.icon && (item.url || item.onclick)) {
132
132
  el += '<a class="social"';
133
133
  if (item.title) {
134
- el += ' title="' + item.title + '"';
134
+ el += ' title="' + escape_html(item.title) + '"';
135
135
  }
136
136
  if (item.url) {
137
- el += ' href="' + url_for(item.url) + '"';
137
+ el += ' href="' + escape_html(url_for(item.url)) + '"';
138
138
  if (item.url.includes('://')) {
139
139
  el += ' target="_blank" rel="external nofollow noopener noreferrer"';
140
140
  } else {
@@ -13,7 +13,7 @@ function layoutDiv() {
13
13
  el += markdown(item.content);
14
14
  }
15
15
  if (item.src) {
16
- el += `<div class="data-service ds-mdrender" src="${item.src}"></div>`
16
+ el += mdrender_html(item.src)
17
17
  }
18
18
  if (item.linklist) {
19
19
  el += partial('components/linklist', {item: item.linklist})
@@ -38,9 +38,12 @@ function layoutToc(fallback) {
38
38
  function layoutDiv(fallback) {
39
39
  const tocBody = layoutTocBody()
40
40
  const hasToc = tocBody.trim().length > 0
41
+ // 远程 md 内容由客户端渲染,构建期正文无标题:
42
+ // 预留空 TOC 容器,渲染完成后由 mdrender.js 填充
43
+ const hasRemoteMd = has_remote_md(page)
41
44
  // 页面是否实际渲染评论区,由 _partial/comments/layout.ejs 标记
42
45
  const hasComments = page.cmt_rendered === true
43
- if (!hasToc && !hasComments) {
46
+ if (!hasToc && !hasRemoteMd && !hasComments) {
44
47
  return ''
45
48
  }
46
49
  var el = ''
@@ -50,6 +53,10 @@ function layoutDiv(fallback) {
50
53
  el += `<div class="widget-body">`
51
54
  el += tocBody
52
55
  el += `</div>`
56
+ } else if (hasRemoteMd) {
57
+ el += layoutTocHeader()
58
+ el += `<div class="widget-body">`
59
+ el += `</div>`
53
60
  }
54
61
  el += `<div class="widget-footer">`
55
62
  el += `<a class="top" onclick="util.scrollTop()">`
@@ -10,14 +10,24 @@ if (page.menu_id == undefined) {
10
10
  <%- partial('_partial/main/navbar/nav_tabs_blog') %>
11
11
  <div class='post-list'>
12
12
  <article class='<%- scrollreveal("") %>' id='cats'>
13
- <% site.categories.sort('path').each(function(category){ %>
14
- <div>
15
- <a class="<%= category.parent ? 'cat child' : 'cat'%>" href="<%= pretty_url(category.path) %>">
16
- <span class='name'><%- icon('default:category') %><%- category.name %></span>
17
- <span class='badge'>(<%- category.posts.length %>)</span>
18
- </a>
19
- </div>
20
- <% }) %>
13
+ <%
14
+ function renderCategoryTree(nodes) {
15
+ var el = ''
16
+ nodes.forEach(function(node) {
17
+ el += `<div class="cat-item">`
18
+ el += `<a class="cat${node.parentId ? ' child' : ''}" href="${pretty_url(node.path)}">`
19
+ el += `<span class="name">${icon('default:category')}${escape_html(node.name)}</span>`
20
+ el += `<span class="badge">(${node.count})</span>`
21
+ el += `</a>`
22
+ if (node.children && node.children.length > 0) {
23
+ el += `<div class="cat-children">${renderCategoryTree(node.children)}</div>`
24
+ }
25
+ el += `</div>`
26
+ })
27
+ return el
28
+ }
29
+ %>
30
+ <%- renderCategoryTree(category_tree(site.categories)) %>
21
31
  </article>
22
32
  </div>
23
33
  <% } %>
package/layout/index.ejs CHANGED
@@ -29,8 +29,49 @@ if (homeCmt && homeCmt !== false && is_home_first_page()) {
29
29
  }
30
30
  }
31
31
 
32
- // 首页第一页渲染置顶轮播时,列表不再重复展示置顶文章
33
- var pinSliderActive = is_home_first_page()
32
+ // 置顶文章样式:carousel = 轮播(默认),flat = 平铺(首页列表靠前展示)
33
+ var pinStyle = theme.article?.pin_style || 'carousel'
34
+ var pinFlat = pinStyle === 'flat'
35
+
36
+ // 与 pin_slider.ejs 保持一致的置顶判定与排序权重
37
+ function pinValue(post) {
38
+ return post.pin != null ? post.pin : post.sticky
39
+ }
40
+ function isPinned(post) {
41
+ var v = pinValue(post)
42
+ return v !== false && v != null
43
+ }
44
+ function pinWeight(post) {
45
+ var v = pinValue(post)
46
+ if (v === true) {
47
+ return 1
48
+ }
49
+ var n = Number(v)
50
+ return isNaN(n) ? 0 : n
51
+ }
52
+
53
+ // 首页第一页渲染置顶轮播时,列表不再重复展示置顶文章(carousel 模式)
54
+ var pinSliderActive = is_home_first_page() && !pinFlat
55
+
56
+ // flat 模式:首页第一页收集全部置顶文章,按轮播顺序置顶展示
57
+ var flatPins = []
58
+ if (pinFlat && is_home_first_page()) {
59
+ var allPosts = site.posts.toArray() || []
60
+ for (var fi = 0; fi < allPosts.length; fi++) {
61
+ var fpost = allPosts[fi]
62
+ if (fpost.indexing === false) {
63
+ continue
64
+ }
65
+ if (isPinned(fpost)) {
66
+ flatPins.push({post: fpost, index: fi})
67
+ }
68
+ }
69
+ flatPins.sort(function(a, b) {
70
+ var d = pinWeight(b.post) - pinWeight(a.post)
71
+ // 权重相同保持原顺序(与轮播一致)
72
+ return d !== 0 ? d : a.index - b.index
73
+ })
74
+ }
34
75
 
35
76
  function layout_post_card(layout, post, content) {
36
77
  var el = '';
@@ -49,17 +90,32 @@ function layout_post_card(layout, post, content) {
49
90
  function layout_post_list(partial) {
50
91
  var el = '';
51
92
  el += `<div class="post-list post">`;
52
- page.posts.each(function(post){
53
- if (post.indexing != false) {
54
- if (pinSliderActive) {
55
- var pinVal = post.pin != null ? post.pin : post.sticky
56
- if (pinVal !== false && pinVal != null) {
57
- return
93
+ if (flatPins.length > 0) {
94
+ var pinnedPaths = {}
95
+ flatPins.forEach(function(item) {
96
+ pinnedPaths[item.post.path] = true
97
+ })
98
+ flatPins.forEach(function(item) {
99
+ el += layout_post_card('post', item.post, partial(item.post));
100
+ })
101
+ page.posts.each(function(post){
102
+ if (post.indexing != false && !pinnedPaths[post.path]) {
103
+ el += layout_post_card('post', post, partial(post));
104
+ }
105
+ })
106
+ } else {
107
+ page.posts.each(function(post){
108
+ if (post.indexing != false) {
109
+ if (pinSliderActive) {
110
+ var pinVal = pinValue(post)
111
+ if (pinVal !== false && pinVal != null) {
112
+ return
113
+ }
58
114
  }
115
+ el += layout_post_card('post', post, partial(post));
59
116
  }
60
- el += layout_post_card('post', post, partial(post));
61
- }
62
- })
117
+ })
118
+ }
63
119
  el += '</div>';
64
120
  return el;
65
121
  }