hexo-theme-stellar 1.39.1 → 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 (45) hide show
  1. package/.claude/skills/stellar-theme-dev/SKILL.md +2 -2
  2. package/CHANGELOG.md +29 -0
  3. package/_config.yml +6 -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/head.ejs +11 -2
  9. package/layout/_partial/main/navbar/article_banner.ejs +7 -0
  10. package/layout/_partial/main/navbar/dateinfo.ejs +1 -1
  11. package/layout/_partial/main/post_list/post_card.ejs +11 -0
  12. package/layout/_partial/widgets/markdown.ejs +1 -1
  13. package/layout/_partial/widgets/toc.ejs +8 -1
  14. package/layout/categories.ejs +18 -8
  15. package/layout/page.ejs +5 -1
  16. package/package.json +5 -3
  17. package/scripts/commands/new-note.js +2 -2
  18. package/scripts/helpers/category_tree.js +11 -0
  19. package/scripts/helpers/json_ld.js +5 -0
  20. package/scripts/helpers/mdrender.js +29 -0
  21. package/scripts/helpers/reading_time.js +12 -0
  22. package/scripts/lib/category_tree.js +26 -0
  23. package/scripts/lib/mdrender_html.js +97 -0
  24. package/scripts/lib/reading_time.js +16 -0
  25. package/scripts/lib/wiki_readme.js +57 -0
  26. package/scripts/tags/index.js +2 -0
  27. package/scripts/tags/lib/md.js +17 -9
  28. package/scripts/tags/lib/table.js +22 -0
  29. package/scripts/tags/lib/timeline.js +9 -9
  30. package/scripts/tags/lib/tip.js +23 -0
  31. package/source/css/_common/device.styl +3 -0
  32. package/source/css/_components/list.styl +11 -5
  33. package/source/css/_components/pages/archives.styl +7 -3
  34. package/source/css/_components/partial/bread-nav.styl +9 -1
  35. package/source/css/_components/sidebar/search.styl +6 -4
  36. package/source/css/_components/sidebar/sidebar.styl +10 -7
  37. package/source/css/_components/tag-plugins/table.styl +37 -0
  38. package/source/css/_components/tag-plugins/tip.styl +27 -0
  39. package/source/css/_custom.styl +1 -1
  40. package/source/css/_defines/func.styl +19 -13
  41. package/source/css/_defines/theme_base.styl +3 -0
  42. package/source/js/main.js +92 -1
  43. package/source/js/services/mdrender.js +116 -7
  44. package/source/js/services/memos.js +60 -8
  45. 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,34 @@
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
+
3
32
  ## 1.39.1
4
33
 
5
34
  > 发布日期:2026-08-13
package/_config.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  ######## Stellar info ########
2
2
  stellar:
3
- version: '1.39.1'
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
@@ -206,6 +206,10 @@ article:
206
206
  related_posts:
207
207
  enable: false
208
208
  max_count: 5
209
+ # 文章页是否显示字数和预计阅读时长(#302)
210
+ reading_time: false
211
+ # 文章卡片是否显示标签(最多 5 个,#580)
212
+ card_tags: false
209
213
 
210
214
  search:
211
215
  service: local_search # local_search, algolia_search, todo...
@@ -676,7 +680,7 @@ style:
676
680
  image-l: 24px # 非技术类文章插图圆角
677
681
  image: 16px # 技术类文章插图圆角
678
682
  image-s: 8px # 评论区图片圆角(部分评论支持)
679
- corner-shape: superellipse(1.5) # 连续曲率圆角(仅 Chromium 139+ 支持,其余浏览器自动回退普通圆角):superellipse(1.x) 开启,round 关闭
683
+ corner-shape: superellipse(1.25) # 连续曲率圆角(仅 Chromium 139+ 支持,其余浏览器自动回退普通圆角):superellipse(1.x) 开启,round 关闭
680
684
  color:
681
685
  # 主题色配置(不会根据明暗动态调整,请设置为通用的颜色)
682
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
 
@@ -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
  }
@@ -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() %>
@@ -93,6 +93,17 @@ function div_default() {
93
93
  }
94
94
  }
95
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;
@@ -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/page.ejs CHANGED
@@ -44,8 +44,12 @@ function layoutDiv() {
44
44
  el += partial('_partial/main/navbar/article_banner')
45
45
  }
46
46
  el += `<article class="${articleClass()}">`
47
- if (page.content && page.content.length > 0) {
47
+ if (page.content && String(page.content).trim().length > 0) {
48
48
  el += page.content
49
+ } else {
50
+ // wiki 项目首页正文为空(剪裁空白后)且配置了 repo 时,
51
+ // 以该仓库 README.md 作为主页正文(应用层 helper,复用底层远程 md 渲染组件)
52
+ el += wiki_readme_html(theme.wiki.tree[page.wiki], page)
49
53
  }
50
54
  if (notebook) {
51
55
  el += partial('_partial/main/notebook/note_tags', { notebook: notebook })
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "hexo-theme-stellar",
3
- "version": "1.39.1",
3
+ "version": "1.40.0",
4
4
  "description": "Elegant and powerful theme for Hexo.",
5
5
  "main": "package.json",
6
6
  "scripts": {
7
7
  "lint": "eslint .",
8
- "test": "node --test",
8
+ "test": "node ci/check-require-decls.js && node --test",
9
9
  "check": "npm run lint && npm test && python3 docs/knowledge/tools/verify.py",
10
10
  "release": "node release.js",
11
11
  "release:dry": "node release.js --dry-run"
@@ -29,10 +29,12 @@
29
29
  "glob": "^10.4.0",
30
30
  "hexo-renderer-ejs": "^2.0.0",
31
31
  "hexo-renderer-stylus": "^3.0.1",
32
+ "hexo-pagination": "^3.0.0",
32
33
  "probe-image-size": "^7.2.3"
33
34
  },
34
35
  "homepage": "https://xaoxuu.com/wiki/stellar/",
35
36
  "devDependencies": {
36
- "eslint": "^9.39.5"
37
+ "eslint": "^9.39.5",
38
+ "hexo-util": "^4.0.0"
37
39
  }
38
40
  }
@@ -36,7 +36,7 @@ hexo.extend.console.register('new-note', 'Create a new note', {
36
36
  const frontMatter = [
37
37
  '---',
38
38
  `title: ${name}`,
39
- `date: ${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')} ${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}:${String(date.getSeconds()).padStart(2,'0')}`,
39
+ `date: ${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')} ${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`,
40
40
  `tags: [${tags.map(t => '"'+t+'"').join(', ')}]`,
41
41
  `notebook: ${book}`,
42
42
  '---',
@@ -46,4 +46,4 @@ hexo.extend.console.register('new-note', 'Create a new note', {
46
46
  fs.writeFileSync(filePath, frontMatter);
47
47
  console.log(`Created: ${filePath}`);
48
48
  callback();
49
- });
49
+ });
@@ -0,0 +1,11 @@
1
+ /* global hexo */
2
+ 'use strict';
3
+
4
+ const { buildCategoryTree } = require('../lib/category_tree');
5
+
6
+ hexo.extend.helper.register('category_tree', function(categories) {
7
+ const list = categories && typeof categories.toArray === 'function'
8
+ ? categories.toArray()
9
+ : (categories || []);
10
+ return buildCategoryTree(list);
11
+ });
@@ -98,6 +98,11 @@ hexo.extend.helper.register('json_ld', function(args) {
98
98
 
99
99
  if (page.excerpt || page.description) {
100
100
  schema.description = this.strip_html(page.description || page.excerpt);
101
+ } else if (page.wiki) {
102
+ const proj = this.theme.wiki.tree[page.wiki];
103
+ if (proj && proj.description) {
104
+ schema.description = proj.description;
105
+ }
101
106
  } else {
102
107
  schema.description = util.truncate(this.strip_html(page.content), {length: 200});
103
108
  }
@@ -0,0 +1,29 @@
1
+ /* global hexo */
2
+ 'use strict';
3
+
4
+ const { mdrenderHtml, PLACEHOLDER_CLASS } = require('../lib/mdrender_html');
5
+ const { wikiReadmeHtml, isWikiReadmePage } = require('../lib/wiki_readme');
6
+
7
+ // 远程 Markdown 渲染占位标记(底层组件服务端入口,供 EJS 模板复用)
8
+ hexo.extend.helper.register('mdrender_html', function (src, options = {}) {
9
+ if (options.ghraw == null) {
10
+ options.ghraw = hexo.theme.config.api_host.ghraw;
11
+ }
12
+ return mdrenderHtml(src, options);
13
+ });
14
+
15
+ // wiki 项目 README 主页占位(首页正文为空且配置 repo 时输出,否则空串)
16
+ hexo.extend.helper.register('wiki_readme_html', function (proj, page) {
17
+ return wikiReadmeHtml(proj, page, { ghraw: hexo.theme.config.api_host.ghraw });
18
+ });
19
+
20
+ // 页面是否包含客户端渲染的远程 md(供 TOC 预留空容器)
21
+ hexo.extend.helper.register('has_remote_md', function (page) {
22
+ if (!page) {
23
+ return false;
24
+ }
25
+ if (page.content && page.content.indexOf(PLACEHOLDER_CLASS) >= 0) {
26
+ return true;
27
+ }
28
+ return isWikiReadmePage(hexo.theme.config.wiki.tree[page.wiki], page);
29
+ });
@@ -0,0 +1,12 @@
1
+ /* global hexo */
2
+ 'use strict';
3
+
4
+ const { countWords, readingMinutes } = require('../lib/reading_time');
5
+
6
+ hexo.extend.helper.register('word_count', function(page) {
7
+ return countWords(page && (page.content || page._content || ''));
8
+ });
9
+
10
+ hexo.extend.helper.register('reading_time', function(page) {
11
+ return readingMinutes(page && (page.content || page._content || ''));
12
+ });
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ // 分类嵌套树构建(#564):基于 parent 关系递归组装,兼容 2/3/4 级
4
+ function buildCategoryTree(list) {
5
+ const items = (list || []).map(c => ({
6
+ _id: c._id,
7
+ name: c.name,
8
+ path: c.path,
9
+ parentId: c.parent ? (typeof c.parent === 'object' ? c.parent._id : c.parent) : null,
10
+ count: (c.posts && c.posts.length) || 0,
11
+ children: []
12
+ }));
13
+ const byId = {};
14
+ items.forEach(item => { byId[item._id] = item; });
15
+ const roots = [];
16
+ items.forEach(item => {
17
+ if (item.parentId != null && byId[item.parentId]) {
18
+ byId[item.parentId].children.push(item);
19
+ } else {
20
+ roots.push(item);
21
+ }
22
+ });
23
+ return roots;
24
+ }
25
+
26
+ module.exports = { buildCategoryTree };
@@ -0,0 +1,97 @@
1
+ /**
2
+ * mdrender_html.js v2 | https://github.com/xaoxuu/hexo-theme-stellar/
3
+ *
4
+ * 远程 Markdown 渲染底层组件(通用能力,不包含应用场景逻辑):
5
+ * 统一生成 .data-service.ds-mdrender 占位标记。
6
+ *
7
+ * 行为:
8
+ * - src 为 GitHub raw 类地址时,host 替换为调用方传入的 ghraw
9
+ * (主题配置 api_host.ghraw,代码不设默认值);未传 ghraw 时保留 src 原 host;
10
+ * - 识别后输出 data-base(该 md 所在 raw 目录);
11
+ * - 非 GitHub src 原样输出、不带 data-base;
12
+ * - replace=true 输出 data-replace(客户端原地替换,无外部容器);
13
+ * - heading 缺省 true,输出 data-heading(客户端标题适配本地文章格式)。
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ // GitHub raw 规范 host:用于识别用户 src 中的规范 raw 地址(GitHub 固有事实,非配置默认值)
19
+ const GITHUB_RAW_HOST = 'raw.githubusercontent.com';
20
+
21
+ // 占位标记类名(组件契约:服务端生成、services 按需加载、客户端渲染共用)
22
+ const PLACEHOLDER_CLASS = 'ds-mdrender';
23
+
24
+ /**
25
+ * 解析 GitHub raw 地址(规范 raw host 或调用方传入的 ghraw 镜像 host)。
26
+ * 返回 { host, owner, repo, ref, rest };无法识别时返回 null。
27
+ */
28
+ function parseGitHubRaw(url, ghrawHost) {
29
+ if (typeof url !== 'string' || url.length === 0) {
30
+ return null;
31
+ }
32
+ let u;
33
+ try {
34
+ u = new URL(url);
35
+ } catch (e) {
36
+ return null;
37
+ }
38
+ const host = u.hostname || '';
39
+ if (host !== GITHUB_RAW_HOST && host !== ghrawHost) {
40
+ return null;
41
+ }
42
+ const segs = u.pathname.split('/').filter(s => s.length > 0);
43
+ if (segs.length < 3) {
44
+ return null;
45
+ }
46
+ return {
47
+ host: host,
48
+ owner: segs[0],
49
+ repo: segs[1],
50
+ ref: segs[2],
51
+ rest: segs.slice(3).join('/')
52
+ };
53
+ }
54
+
55
+ function escapeAttr(value) {
56
+ return String(value).replace(/&/g, '&amp;').replace(/"/g, '&quot;');
57
+ }
58
+
59
+ /**
60
+ * 生成远程 Markdown 渲染占位标记。
61
+ * options: { id, replace, heading, ghraw }
62
+ */
63
+ function mdrenderHtml(src, options = {}) {
64
+ const parsed = parseGitHubRaw(src, options.ghraw);
65
+ let url = src;
66
+ let base = null;
67
+ if (parsed) {
68
+ // 未传 ghraw 时保留 src 原 host,不硬编码默认值
69
+ const ghraw = options.ghraw || parsed.host;
70
+ const dir = `${parsed.owner}/${parsed.repo}/${parsed.ref}/`;
71
+ url = `https://${ghraw}/${dir}${parsed.rest}`;
72
+ base = `https://${ghraw}/${dir}`;
73
+ }
74
+ let el = '<div class="data-service ds-mdrender"';
75
+ el += ` src="${escapeAttr(url)}"`;
76
+ if (base) {
77
+ el += ` data-base="${escapeAttr(base)}"`;
78
+ }
79
+ if (options.replace === true) {
80
+ el += ' data-replace="true"';
81
+ }
82
+ if (options.heading !== false) {
83
+ el += ' data-heading="true"';
84
+ }
85
+ if (options.id) {
86
+ el += ` id="${escapeAttr(options.id)}"`;
87
+ }
88
+ el += '></div>';
89
+ return el;
90
+ }
91
+
92
+ module.exports = {
93
+ GITHUB_RAW_HOST,
94
+ PLACEHOLDER_CLASS,
95
+ parseGitHubRaw,
96
+ mdrenderHtml
97
+ };
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+
3
+ // 阅读信息统计(#302):中文字符按 1 字计,英文/数字按空格分词计词
4
+ function countWords(content) {
5
+ const text = String(content || '').replace(/<[^>]*>/g, ' ');
6
+ const cjk = (text.match(/[\u4e00-\u9fff]/g) || []).length;
7
+ const latin = (text.replace(/[\u4e00-\u9fff]/g, ' ').match(/[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)*/g) || []).length;
8
+ return cjk + latin;
9
+ }
10
+
11
+ // 混合阅读速度约 300 字/分钟,最少 1 分钟
12
+ function readingMinutes(content, speed = 300) {
13
+ return Math.max(1, Math.ceil(countWords(content) / speed));
14
+ }
15
+
16
+ module.exports = { countWords, readingMinutes };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * wiki_readme.js v1 | https://github.com/xaoxuu/hexo-theme-stellar/
3
+ *
4
+ * Wiki 应用层:项目首页正文为空(剪裁空白后)且配置 repo 时,
5
+ * 以该仓库 README.md 作为主页正文(复用底层远程 md 渲染组件)。
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const { mdrenderHtml } = require('./mdrender_html');
11
+
12
+ /**
13
+ * README 地址。host 由调用方从主题配置 api_host.ghraw 传入;
14
+ * branch 缺省用 GitHub HEAD(自动指向默认分支)。
15
+ */
16
+ function readmeUrl(repo, branch, host) {
17
+ return `https://${host}/${repo}/${branch || 'HEAD'}/README.md`;
18
+ }
19
+
20
+ // 页面正文是否为空(剪裁多余空行、空格后判断)
21
+ function isEmptyContent(page) {
22
+ return !page || !String(page.content || '').trim();
23
+ }
24
+
25
+ /**
26
+ * 判定是否 README 主页:配置了 repo 的项目首页且正文为空。
27
+ */
28
+ function isWikiReadmePage(proj, page) {
29
+ if (!proj || !proj.repo || !page || !proj.homepage) {
30
+ return false;
31
+ }
32
+ if (!isEmptyContent(page)) {
33
+ return false;
34
+ }
35
+ return proj.homepage.path === page.path;
36
+ }
37
+
38
+ /**
39
+ * 生成 README 主页占位(原地替换模式);不适用时返回空串。
40
+ * options: { ghraw }
41
+ */
42
+ function wikiReadmeHtml(proj, page, options = {}) {
43
+ if (!isWikiReadmePage(proj, page)) {
44
+ return '';
45
+ }
46
+ return mdrenderHtml(readmeUrl(proj.repo, proj.branch, options.ghraw), {
47
+ ghraw: options.ghraw,
48
+ replace: true
49
+ });
50
+ }
51
+
52
+ module.exports = {
53
+ readmeUrl,
54
+ isEmptyContent,
55
+ isWikiReadmePage,
56
+ wikiReadmeHtml
57
+ };
@@ -48,6 +48,8 @@ hexo.extend.tag.register('audio', require('./lib/audio')(hexo))
48
48
  hexo.extend.tag.register('video', require('./lib/video')(hexo))
49
49
  hexo.extend.tag.register('rating', require('./lib/rating')(hexo))
50
50
  hexo.extend.tag.register('vote', require('./lib/vote')(hexo))
51
+ hexo.extend.tag.register('table', require('./lib/table')(hexo), {ends: true})
52
+ hexo.extend.tag.register('tip', require('./lib/tip')(hexo), {ends: true})
51
53
 
52
54
  // read 阅读类
53
55
  hexo.extend.tag.register('reel', require('./lib/read/reel')(hexo), true)
@@ -1,20 +1,28 @@
1
1
  /**
2
- * md.js v1.0 | https://github.com/volantis-x/hexo-theme-volantis/
3
- * contributor: @MHuiG
4
- *
2
+ * md.js v2 | https://github.com/xaoxuu/hexo-theme-stellar/
3
+ *
5
4
  * 格式与官方标签插件一致使用空格分隔,中括号内的是可选参数(中括号不需要写出来)
6
5
  *
7
- * {% md src %}
6
+ * {% md src [wrap:true|false] %}
7
+ *
8
+ * wrap 默认 true:渲染结果保留在 .data-service.ds-mdrender 容器内(现状);
9
+ * wrap:false:渲染后占位元素被原地替换,无外部容器(复用底层 data-replace)。
8
10
  *
11
+ * 复用底层组件(scripts/lib/mdrender_html.js)输出渲染占位;
12
+ * 容器结构与现状一致,标题默认适配本地文章格式(id + headerlink,h1 隐藏)。
9
13
  */
10
14
  'use strict'
11
15
 
16
+ const { mdrenderHtml } = require('../../lib/mdrender_html')
17
+
12
18
  var md_index = 0
13
19
 
14
20
  module.exports = ctx => function(args) {
15
- args = ctx.args.map(args, [''], ['src'])
21
+ args = ctx.args.map(args, ['wrap'], ['src'])
16
22
  const md_id = "md_" + ++md_index
17
- return `
18
- <div class="data-service ds-mdrender" src="${args.src}" id="${md_id}"></div>
19
- `
20
- }
23
+ return mdrenderHtml(args.src, {
24
+ id: md_id,
25
+ ghraw: ctx.theme.config.api_host.ghraw,
26
+ replace: args.wrap === 'false'
27
+ })
28
+ }