add theme

This commit is contained in:
2025-08-12 12:19:25 +08:00
parent 8c06923a46
commit ac0d1944ab
227 changed files with 18962 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
'use strict'
const { stripHTML, truncate } = require('hexo-util')
// Truncates the given content to a specified length, removing HTML tags and replacing newlines with spaces.
const truncateContent = (content, length, encrypt = false) => {
if (!content || encrypt) return ''
return truncate(stripHTML(content).replace(/\n/g, ' '), { length })
}
// Generates a post description based on the provided data and theme configuration.
const postDesc = (data, hexo) => {
const { description, content, postDesc, encrypt } = data
if (postDesc) return postDesc
const { length, method } = hexo.theme.config.index_post_content
if (method === false) return
let result
switch (method) {
case 1:
result = description
break
case 2:
result = description || truncateContent(content, length, encrypt)
break
default:
result = truncateContent(content, length, encrypt)
}
data.postDesc = result
return result
}
module.exports = { truncateContent, postDesc }

View File

@@ -0,0 +1,20 @@
/**
* Butterfly
* 404 error page
*/
'use strict'
hexo.extend.generator.register('404', function (locals) {
if (!hexo.theme.config.error_404.enable) return
return {
path: '404.html',
layout: ['page'],
data: {
type: '404',
top_img: false,
comments: false,
aside: false
}
}
})

View File

@@ -0,0 +1,97 @@
/**
* Butterfly
* Merge CDN
*/
'use strict'
const { version } = require('../../package.json')
const path = require('path')
hexo.extend.filter.register('before_generate', () => {
const themeConfig = hexo.theme.config
const { CDN } = themeConfig
const thirdPartySrc = hexo.render.renderSync({ path: path.join(hexo.theme_dir, '/plugins.yml'), engine: 'yaml' })
const internalSrc = {
main: {
name: 'hexo-theme-butterfly',
file: 'js/main.js',
version
},
utils: {
name: 'hexo-theme-butterfly',
file: 'js/utils.js',
version
},
translate: {
name: 'hexo-theme-butterfly',
file: 'js/tw_cn.js',
version
},
local_search: {
name: 'hexo-theme-butterfly',
file: 'js/search/local-search.js',
version
},
algolia_js: {
name: 'hexo-theme-butterfly',
file: 'js/search/algolia.js',
version
}
}
const minFile = file => {
return file.replace(/(?<!\.min)\.(js|css)$/g, ext => '.min' + ext)
}
const createCDNLink = (data, type, cond = '') => {
Object.keys(data).forEach(key => {
let { name, version, file, other_name } = data[key]
const cdnjs_name = other_name || name
const cdnjs_file = file.replace(/^[lib|dist]*\/|browser\//g, '')
const min_cdnjs_file = minFile(cdnjs_file)
if (cond === 'internal') file = `source/${file}`
const min_file = minFile(file)
const verType = CDN.version ? (type === 'local' ? `?v=${version}` : `@${version}`) : ''
const value = {
version,
name,
file,
cdnjs_file,
min_file,
min_cdnjs_file,
cdnjs_name
}
const cdnSource = {
local: cond === 'internal' ? `${cdnjs_file + verType}` : `/pluginsSrc/${name}/${file + verType}`,
jsdelivr: `https://cdn.jsdelivr.net/npm/${name}${verType}/${min_file}`,
unpkg: `https://unpkg.com/${name}${verType}/${file}`,
cdnjs: `https://cdnjs.cloudflare.com/ajax/libs/${cdnjs_name}/${version}/${min_cdnjs_file}`,
custom: (CDN.custom_format || '').replace(/\$\{(.+?)\}/g, (match, $1) => value[$1])
}
data[key] = cdnSource[type]
})
if (cond === 'internal') data.main_css = 'css/index.css' + (CDN.version ? `?v=${version}` : '')
return data
}
// delete null value
const deleteNullValue = obj => {
if (!obj) return
for (const i in obj) {
obj[i] === null && delete obj[i]
}
return obj
}
themeConfig.asset = Object.assign(
createCDNLink(internalSrc, CDN.internal_provider, 'internal'),
createCDNLink(thirdPartySrc, CDN.third_party_provider),
deleteNullValue(CDN.option)
)
})

View File

@@ -0,0 +1,24 @@
/**
* Capitalize the first letter of comment name
*/
hexo.extend.filter.register('before_generate', () => {
const themeConfig = hexo.theme.config
let { use } = themeConfig.comments
if (!use) return
// Make sure use is an array
use = Array.isArray(use) ? use : use.split(',')
// Capitalize the first letter of each comment name
use = use.map(item =>
item.trim().toLowerCase().replace(/\b[a-z]/g, s => s.toUpperCase())
)
// Disqus and Disqusjs conflict, only keep the first one
if (use.includes('Disqus') && use.includes('Disqusjs')) {
use = [use[0]]
}
themeConfig.comments.use = use
})

View File

@@ -0,0 +1,20 @@
hexo.extend.filter.register('before_generate', () => {
// Get first two digits of the Hexo version number
const { version, log, locals } = hexo
const hexoVer = version.replace(/(^.*\..*)\..*/, '$1')
if (hexoVer < 5.3) {
log.error('Please update Hexo to V5.3.0 or higher!')
log.error('請把 Hexo 升級到 V5.3.0 或更高的版本!')
process.exit(-1)
}
if (locals.get) {
const data = locals.get('data')
if (data && data.butterfly) {
log.error("'butterfly.yml' is deprecated. Please use '_config.butterfly.yml'")
log.error("'butterfly.yml' 已經棄用,請使用 '_config.butterfly.yml'")
process.exit(-1)
}
}
})

View File

@@ -0,0 +1,593 @@
const { deepMerge } = require('hexo-util')
hexo.extend.filter.register('before_generate', () => {
const defaultConfig = {
nav: {
logo: null,
display_title: true,
display_post_title: true,
fixed: false
},
menu: null,
code_blocks: {
theme: 'light',
macStyle: false,
height_limit: false,
word_wrap: false,
copy: true,
language: true,
shrink: false,
fullpage: false
},
social: null,
favicon: '/img/favicon.png',
avatar: {
img: '/img/butterfly-icon.png',
effect: false
},
disable_top_img: false,
default_top_img: null,
index_img: null,
archive_img: null,
tag_img: null,
tag_per_img: null,
category_img: null,
category_per_img: null,
footer_img: false,
background: null,
cover: {
index_enable: true,
aside_enable: true,
archives_enable: true,
default_cover: null
},
error_img: {
flink: '/img/friend_404.gif',
post_page: '/img/404.jpg'
},
error_404: {
enable: false,
subtitle: 'Page Not Found',
background: '/img/error-page.png'
},
post_meta: {
page: {
date_type: 'created',
date_format: 'date',
categories: true,
tags: false,
label: true
},
post: {
position: 'left',
date_type: 'both',
date_format: 'date',
categories: true,
tags: true,
label: true
}
},
index_site_info_top: null,
index_top_img_height: null,
subtitle: {
enable: false,
effect: true,
typed_option: null,
source: false,
sub: null
},
index_layout: 3,
index_post_content: {
method: 3,
length: 500
},
toc: {
post: true,
page: false,
number: true,
expand: false,
style_simple: false,
scroll_percent: true
},
post_copyright: {
enable: true,
decode: false,
author_href: null,
license: 'CC BY-NC-SA 4.0',
license_url: 'https://creativecommons.org/licenses/by-nc-sa/4.0/'
},
reward: {
enable: false,
text: null,
QR_code: null
},
post_edit: {
enable: false,
url: null
},
related_post: {
enable: true,
limit: 6,
date_type: 'created'
},
post_pagination: 1,
noticeOutdate: {
enable: false,
style: 'flat',
limit_day: 365,
position: 'top',
message_prev: 'It has been',
message_next: 'days since the last update, the content of the article may be outdated.'
},
footer: {
nav: null,
owner: {
enable: true,
since: 2024
},
copyright: {
enable: true,
version: true
},
custom_text: null
},
aside: {
enable: true,
hide: false,
button: true,
mobile: true,
position: 'right',
display: {
archive: true,
tag: true,
category: true
},
card_author: {
enable: true,
description: null,
button: {
enable: true,
icon: 'fab fa-github',
text: 'Follow Me',
link: 'https://github.com/xxxxxx'
}
},
card_announcement: {
enable: true,
content: 'This is my Blog'
},
card_recent_post: {
enable: true,
limit: 5,
sort: 'date',
sort_order: null
},
card_newest_comments: {
enable: false,
sort_order: null,
limit: 6,
storage: 10,
avatar: true
},
card_categories: {
enable: true,
limit: 8,
expand: 'none',
sort_order: null
},
card_tags: {
enable: true,
limit: 40,
color: false,
orderby: 'random',
order: 1,
sort_order: null
},
card_archives: {
enable: true,
type: 'monthly',
format: 'MMMM YYYY',
order: -1,
limit: 8,
sort_order: null
},
card_post_series: {
enable: true,
series_title: false,
orderBy: 'date',
order: -1
},
card_webinfo: {
enable: true,
post_count: true,
last_push_date: true,
sort_order: null,
runtime_date: null
}
},
rightside_bottom: null,
translate: {
enable: false,
default: '繁',
defaultEncoding: 2,
translateDelay: 0,
msgToTraditionalChinese: '繁',
msgToSimplifiedChinese: '簡'
},
readmode: true,
darkmode: {
enable: true,
button: true,
autoChangeMode: false,
start: null,
end: null
},
rightside_scroll_percent: false,
rightside_item_order: {
enable: false,
hide: null,
show: null
},
rightside_config_animation: true,
anchor: {
auto_update: false,
click_to_scroll: false
},
photofigcaption: false,
copy: {
enable: true,
copyright: {
enable: false,
limit_count: 150
}
},
wordcount: {
enable: false,
post_wordcount: true,
min2read: true,
total_wordcount: true
},
busuanzi: {
site_uv: true,
site_pv: true,
page_pv: true
},
math: {
use: null,
per_page: true,
hide_scrollbar: false,
mathjax: {
enableMenu: true,
tags: 'none'
},
katex: {
copy_tex: false
}
},
search: {
use: null,
placeholder: null,
algolia_search: {
hitsPerPage: 6
},
local_search: {
preload: false,
top_n_per_article: 1,
unescape: false,
CDN: null
},
docsearch: {
appId: null,
apiKey: null,
indexName: null,
option: null
}
},
share: {
use: 'sharejs',
sharejs: {
sites: 'facebook,twitter,wechat,weibo,qq'
},
addtoany: {
item: 'facebook,twitter,wechat,sina_weibo,facebook_messenger,email,copy_link'
}
},
comments: {
use: null,
text: true,
lazyload: false,
count: false,
card_post_count: false
},
disqus: {
shortname: null,
apikey: null
},
disqusjs: {
shortname: null,
apikey: null,
option: null
},
livere: {
uid: null
},
gitalk: {
client_id: null,
client_secret: null,
repo: null,
owner: null,
admin: null,
option: null
},
valine: {
appId: null,
appKey: null,
avatar: 'monsterid',
serverURLs: null,
bg: null,
visitor: false,
option: null
},
waline: {
serverURL: null,
bg: null,
pageview: false,
option: null
},
utterances: {
repo: null,
issue_term: 'pathname',
light_theme: 'github-light',
dark_theme: 'photon-dark',
js: null,
option: null
},
facebook_comments: {
app_id: null,
user_id: null,
pageSize: 10,
order_by: 'social',
lang: 'en_US'
},
twikoo: {
envId: null,
region: null,
visitor: false,
option: null
},
giscus: {
repo: null,
repo_id: null,
category_id: null,
light_theme: 'light',
dark_theme: 'dark',
js: null,
option: null
},
remark42: {
host: null,
siteId: null,
option: null
},
artalk: {
server: null,
site: null,
visitor: false,
option: null
},
chat: {
use: null,
rightside_button: false,
button_hide_show: false
},
chatra: {
id: null
},
tidio: {
public_key: null
},
crisp: {
website_id: null
},
google_tag_manager: {
tag_id: null,
domain: 'https://www.googletagmanager.com'
},
baidu_analytics: null,
google_analytics: null,
cloudflare_analytics: null,
microsoft_clarity: null,
umami_analytics: {
enable: false,
serverURL: null,
website_id: null,
option: null,
UV_PV: {
site_uv: false,
site_pv: false,
page_pv: false,
token: null
}
},
google_adsense: {
enable: false,
auto_ads: true,
js: 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js',
client: null,
enable_page_level_ads: true
},
ad: {
index: null,
aside: null,
post: null
},
site_verification: null,
category_ui: null,
tag_ui: null,
rounded_corners_ui: true,
text_align_justify: false,
mask: {
header: true,
footer: true
},
preloader: {
enable: false,
source: 1,
pace_css_url: null
},
enter_transitions: true,
display_mode: 'light',
beautify: {
enable: false,
field: 'post',
title_prefix_icon: null,
title_prefix_icon_color: null
},
font: {
global_font_size: null,
code_font_size: null,
font_family: null,
code_font_family: null
},
blog_title_font: {
font_link: null,
font_family: null
},
hr_icon: {
enable: true,
icon: null,
icon_top: null
},
activate_power_mode: {
enable: false,
colorful: true,
shake: true,
mobile: false
},
canvas_ribbon: {
enable: false,
size: 150,
alpha: 0.6,
zIndex: -1,
click_to_change: false,
mobile: false
},
canvas_fluttering_ribbon: {
enable: false,
mobile: false
},
canvas_nest: {
enable: false,
color: '0,0,255',
opacity: 0.7,
zIndex: -1,
count: 99,
mobile: false
},
fireworks: {
enable: false,
zIndex: 9999,
mobile: false
},
click_heart: {
enable: false,
mobile: false
},
clickShowText: {
enable: false,
text: null,
fontSize: '15px',
random: false,
mobile: false
},
lightbox: null,
series: {
enable: false,
orderBy: 'title',
order: 1,
number: true
},
abcjs: {
enable: false,
per_page: true
},
mermaid: {
enable: false,
code_write: false,
theme: {
light: 'default',
dark: 'dark'
}
},
chartjs: {
enable: false,
fontColor: {
light: 'rgba(0, 0, 0, 0.8)',
dark: 'rgba(255, 255, 255, 0.8)'
},
borderColor: {
light: 'rgba(0, 0, 0, 0.1)',
dark: 'rgba(255, 255, 255, 0.2)'
},
scale_ticks_backdropColor: {
light: 'transparent',
dark: 'transparent'
}
},
note: {
style: 'flat',
icons: true,
border_radius: 3,
light_bg_offset: 0
},
pjax: {
enable: false,
exclude: null
},
aplayerInject: {
enable: false,
per_page: true
},
snackbar: {
enable: false,
position: 'bottom-left',
bg_light: '#49b1f5',
bg_dark: '#1f1f1f'
},
instantpage: false,
lazyload: {
enable: false,
native: false,
field: 'site',
placeholder: null,
blur: false
},
pwa: {
enable: false,
manifest: null,
apple_touch_icon: null,
favicon_32_32: null,
favicon_16_16: null,
mask_icon: null
},
Open_Graph_meta: {
enable: true,
option: null
},
structured_data: true,
css_prefix: true,
inject: {
head: null,
bottom: null
},
CDN: {
internal_provider: 'local',
third_party_provider: 'jsdelivr',
version: false,
custom_format: null,
option: null
}
}
hexo.theme.config = deepMerge(defaultConfig, hexo.theme.config)
}, 1)

View File

@@ -0,0 +1,24 @@
/**
* Stylus renderer
*/
'use strict'
hexo.extend.filter.register('stylus:renderer', style => {
const { syntax_highlighter: syntaxHighlighter, highlight, prismjs } = hexo.config
let { enable: highlightEnable, line_number: highlightLineNumber } = highlight
let { enable: prismjsEnable, line_number: prismjsLineNumber } = prismjs
// for hexo > 7.0
if (syntaxHighlighter) {
highlightEnable = syntaxHighlighter === 'highlight.js'
prismjsEnable = syntaxHighlighter === 'prismjs'
}
style.define('$highlight_enable', highlightEnable)
.define('$highlight_line_number', highlightLineNumber)
.define('$prismjs_enable', prismjsEnable)
.define('$prismjs_line_number', prismjsLineNumber)
.define('$language', hexo.config.language)
// .import(`${this.source_dir.replace(/\\/g, '/')}_data/css/*`)
})

View File

@@ -0,0 +1,13 @@
hexo.on('ready', () => {
const { version } = require('../../package.json')
hexo.log.info(`
===================================================================
##### # # ##### ##### ###### ##### ###### # # #
# # # # # # # # # # # # #
##### # # # # ##### # # ##### # #
# # # # # # # ##### # # #
# # # # # # # # # # # #
##### #### # # ###### # # # ###### #
${version}
===================================================================`)
})

View File

@@ -0,0 +1,31 @@
/**
* Butterfly
* lazyload
* replace src to data-lazy-src
*/
'use strict'
const urlFor = require('hexo-util').url_for.bind(hexo)
const lazyload = htmlContent => {
if (hexo.theme.config.lazyload.native) {
return htmlContent.replace(/(<img.*?)(>)/ig, '$1 loading=\'lazy\'$2')
}
const bg = hexo.theme.config.lazyload.placeholder ? urlFor(hexo.theme.config.lazyload.placeholder) : 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
return htmlContent.replace(/(<img.*? src=)/ig, `$1 "${bg}" data-lazy-src=`)
}
hexo.extend.filter.register('after_render:html', data => {
const { enable, field } = hexo.theme.config.lazyload
if (!enable || field !== 'site') return
return lazyload(data)
})
hexo.extend.filter.register('after_post_render', data => {
const { enable, field } = hexo.theme.config.lazyload
if (!enable || field !== 'post') return
data.content = lazyload(data.content)
return data
})

View File

@@ -0,0 +1,82 @@
/**
* Random cover for posts
*/
'use strict'
hexo.extend.generator.register('post', locals => {
const previousIndexes = []
const getRandomCover = defaultCover => {
if (!defaultCover) return false
if (!Array.isArray(defaultCover)) return defaultCover
const coverCount = defaultCover.length
if (coverCount === 1) {
return defaultCover[0]
}
const maxPreviousIndexes = coverCount === 2 ? 1 : (coverCount === 3 ? 2 : 3)
let index
do {
index = Math.floor(Math.random() * coverCount)
} while (previousIndexes.includes(index) && previousIndexes.length < coverCount)
previousIndexes.push(index)
if (previousIndexes.length > maxPreviousIndexes) {
previousIndexes.shift()
}
return defaultCover[index]
}
const handleImg = data => {
const imgTestReg = /\.(png|jpe?g|gif|svg|webp|avif)(\?.*)?$/i
let { cover: coverVal, top_img: topImg } = data
// Add path to top_img and cover if post_asset_folder is enabled
if (hexo.config.post_asset_folder) {
if (topImg && topImg.indexOf('/') === -1 && imgTestReg.test(topImg)) {
data.top_img = `${data.path}${topImg}`
}
if (coverVal && coverVal.indexOf('/') === -1 && imgTestReg.test(coverVal)) {
data.cover = `${data.path}${coverVal}`
}
}
if (coverVal === false) return data
// If cover is not set, use random cover
if (!coverVal) {
const { cover: { default_cover: defaultCover } } = hexo.theme.config
const randomCover = getRandomCover(defaultCover)
data.cover = randomCover
coverVal = randomCover // update coverVal
}
if (coverVal && (coverVal.indexOf('//') !== -1 || imgTestReg.test(coverVal))) {
data.cover_type = 'img'
}
return data
}
// https://github.com/hexojs/hexo/blob/master/lib%2Fplugins%2Fgenerator%2Fpost.ts
const posts = locals.posts.sort('date').toArray()
const { length } = posts
return posts.map((post, i) => {
if (i) post.prev = posts[i - 1]
if (i < length - 1) post.next = posts[i + 1]
post.__post = true
return {
data: handleImg(post),
layout: 'post',
path: post.path
}
})
})

View File

@@ -0,0 +1,126 @@
'use strict'
hexo.extend.helper.register('aside_archives', function (options = {}) {
const { config, page, site, url_for, _p } = this
const { archive_dir: archiveDir, timezone, language } = config
// Destructure and set default options with object destructuring
const {
type = 'monthly',
format = type === 'monthly' ? 'MMMM YYYY' : 'YYYY',
show_count: showCount = true,
order = -1,
limit,
transform
} = options
// Optimize locale handling
const lang = toMomentLocale(page.lang || page.language || language)
// Memoize comparison function to improve performance
const compareFunc =
type === 'monthly'
? (yearA, monthA, yearB, monthB) => yearA === yearB && monthA === monthB
: (yearA, yearB) => yearA === yearB
// Early return if no posts
if (!site.posts.length) return ''
// Use reduce for more efficient data processing
const data = site.posts.sort('date', order).reduce((acc, post) => {
let date = post.date.clone()
if (timezone) date = date.tz(timezone)
const year = date.year()
const month = date.month() + 1
if (lang) date = date.locale(lang)
// Find or create archive entry
const lastEntry = acc[acc.length - 1]
if (type === 'yearly') {
const existingYearIndex = acc.findIndex(entry => entry.year === year)
if (existingYearIndex !== -1) {
acc[existingYearIndex].count++
} else {
// 否則創建新條目
acc.push({
name: date.format(format),
year,
month,
count: 1
})
}
} else {
if (!lastEntry || !compareFunc(lastEntry.year, lastEntry.month, year, month)) {
acc.push({
name: date.format(format),
year,
month,
count: 1
})
} else {
lastEntry.count++
}
}
return acc
}, [])
// Create link generator function
const createArchiveLink = item => {
let url = `${archiveDir}/${item.year}/`
if (type === 'monthly') {
url += item.month < 10 ? `0${item.month}/` : `${item.month}/`
}
return url_for(url)
}
// Limit results efficiently
const limitedData = limit > 0 ? data.slice(0, Math.min(data.length, limit)) : data
// Use template literal for better readability
const archiveHeader = `
<div class="item-headline">
<i class="fas fa-archive"></i>
<span>${_p('aside.card_archives')}</span>
${
data.length > limitedData.length
? `<a class="card-more-btn" href="${url_for(archiveDir)}/"
title="${_p('aside.more_button')}">
<i class="fas fa-angle-right"></i>
</a>`
: ''
}
</div>
`
// Use map for generating list items, join for performance
const archiveList = `
<ul class="card-archive-list">
${limitedData
.map(
item => `
<li class="card-archive-list-item">
<a class="card-archive-list-link" href="${createArchiveLink(item)}">
<span class="card-archive-list-date">
${transform ? transform(item.name) : item.name}
</span>
${showCount ? `<span class="card-archive-list-count">${item.count}</span>` : ''}
</a>
</li>
`
)
.join('')}
</ul>
`
return archiveHeader + archiveList
})
// Improved locale conversion function
const toMomentLocale = lang => {
if (!lang || ['en', 'default'].includes(lang)) return 'en'
return lang.toLowerCase().replace('_', '-')
}

View File

@@ -0,0 +1,81 @@
'use strict'
hexo.extend.helper.register('aside_categories', function (categories, options = {}) {
if (!categories || !Object.prototype.hasOwnProperty.call(categories, 'length')) {
options = categories || {}
categories = this.site.categories
}
if (!categories || !categories.length) return ''
const { config } = this
const showCount = Object.prototype.hasOwnProperty.call(options, 'show_count') ? options.show_count : true
const depth = options.depth ? parseInt(options.depth, 10) : 0
const orderby = options.orderby || 'name'
const order = options.order || 1
const categoryDir = this.url_for(config.category_dir)
const limit = options.limit === 0 ? categories.length : (options.limit || categories.length)
const isExpand = options.expand !== 'none'
const expandClass = isExpand && options.expand === true ? 'expand' : ''
const buttonLabel = this._p('aside.more_button')
const prepareQuery = parent => {
const query = parent ? { parent } : { parent: { $exists: false } }
return categories.find(query).sort(orderby, order).filter(cat => cat.length)
}
const hierarchicalList = (remaining, level = 0, parent) => {
let result = ''
if (remaining > 0) {
prepareQuery(parent).forEach(cat => {
if (remaining > 0) {
remaining -= 1
let child = ''
if (!depth || level + 1 < depth) {
const childList = hierarchicalList(remaining, level + 1, cat._id)
child = childList.result
remaining = childList.remaining
}
const parentClass = isExpand && !parent && child ? 'parent' : ''
result += `<li class="card-category-list-item ${parentClass}">`
result += `<a class="card-category-list-link" href="${this.url_for(cat.path)}">`
result += `<span class="card-category-list-name">${cat.name}</span>`
if (showCount) {
result += `<span class="card-category-list-count">${cat.length}</span>`
}
if (isExpand && !parent && child) {
result += `<i class="fas fa-caret-left ${expandClass}"></i>`
}
result += '</a>'
if (child) {
result += `<ul class="card-category-list child">${child}</ul>`
}
result += '</li>'
}
})
}
return { result, remaining }
}
const list = hierarchicalList(limit)
const moreButton = categories.length > limit
? `<a class="card-more-btn" href="${categoryDir}/" title="${buttonLabel}">
<i class="fas fa-angle-right"></i></a>`
: ''
return `<div class="item-headline">
<i class="fas fa-folder-open"></i>
<span>${this._p('aside.card_categories')}</span>
${moreButton}
</div>
<ul class="card-category-list${isExpand && list.result ? ' expandBtn' : ''}" id="aside-cat-list">
${list.result}
</ul>`
})

View File

@@ -0,0 +1,45 @@
hexo.extend.helper.register('getArchiveLength', function () {
const archiveGenerator = hexo.config.archive_generator
const posts = this.site.posts
const { yearly, monthly, daily } = archiveGenerator
const { year, month, day } = this.page
// Archives Page
if (!year) return posts.length
// Function to generate a unique key based on the granularity
const getKey = (post, type) => {
const date = post.date.clone()
const y = date.year()
const m = date.month() + 1
const d = date.date()
if (type === 'year') return `${y}`
if (type === 'month') return `${y}-${m}`
if (type === 'day') return `${y}-${m}-${d}`
}
// Create a map to count posts per period
const mapData = this.fragment_cache('createArchiveObj', () => {
const map = new Map()
posts.forEach(post => {
const keyYear = getKey(post, 'year')
const keyMonth = getKey(post, 'month')
const keyDay = getKey(post, 'day')
if (yearly) map.set(keyYear, (map.get(keyYear) || 0) + 1)
if (monthly) map.set(keyMonth, (map.get(keyMonth) || 0) + 1)
if (daily) map.set(keyDay, (map.get(keyDay) || 0) + 1)
})
return map
})
// Determine the appropriate key to fetch based on current page context
let key
if (yearly && year) key = `${year}`
if (monthly && month) key = `${year}-${month}`
if (daily && day) key = `${year}-${month}-${day}`
// Return the count for the current period or default to the total posts
return mapData.get(key) || posts.length
})

View File

@@ -0,0 +1,155 @@
'use strict'
hexo.extend.helper.register('inject_head_js', function () {
const { darkmode, aside, pjax } = this.theme
const start = darkmode.start || 6
const end = darkmode.end || 18
const { theme_color } = hexo.theme.config
const themeColorLight = theme_color && theme_color.enable ? theme_color.meta_theme_color_light : '#ffffff'
const themeColorDark = theme_color && theme_color.enable ? theme_color.meta_theme_color_dark : '#0d0d0d'
const createCustomJs = () => `
const saveToLocal = {
set: (key, value, ttl) => {
if (!ttl) return
const expiry = Date.now() + ttl * 86400000
localStorage.setItem(key, JSON.stringify({ value, expiry }))
},
get: key => {
const itemStr = localStorage.getItem(key)
if (!itemStr) return undefined
const { value, expiry } = JSON.parse(itemStr)
if (Date.now() > expiry) {
localStorage.removeItem(key)
return undefined
}
return value
}
}
window.btf = {
saveToLocal,
getScript: (url, attr = {}) => new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = url
script.async = true
Object.entries(attr).forEach(([key, val]) => script.setAttribute(key, val))
script.onload = script.onreadystatechange = () => {
if (!script.readyState || /loaded|complete/.test(script.readyState)) resolve()
}
script.onerror = reject
document.head.appendChild(script)
}),
getCSS: (url, id) => new Promise((resolve, reject) => {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = url
if (id) link.id = id
link.onload = link.onreadystatechange = () => {
if (!link.readyState || /loaded|complete/.test(link.readyState)) resolve()
}
link.onerror = reject
document.head.appendChild(link)
}),
addGlobalFn: (key, fn, name = false, parent = window) => {
if (!${pjax.enable} && key.startsWith('pjax')) return
const globalFn = parent.globalFn || {}
globalFn[key] = globalFn[key] || {}
globalFn[key][name || Object.keys(globalFn[key]).length] = fn
parent.globalFn = globalFn
}
}
`
const createDarkmodeJs = () => {
if (!darkmode.enable) return ''
let darkmodeJs = `
const activateDarkMode = () => {
document.documentElement.setAttribute('data-theme', 'dark')
if (document.querySelector('meta[name="theme-color"]') !== null) {
document.querySelector('meta[name="theme-color"]').setAttribute('content', '${themeColorDark}')
}
}
const activateLightMode = () => {
document.documentElement.setAttribute('data-theme', 'light')
if (document.querySelector('meta[name="theme-color"]') !== null) {
document.querySelector('meta[name="theme-color"]').setAttribute('content', '${themeColorLight}')
}
}
btf.activateDarkMode = activateDarkMode
btf.activateLightMode = activateLightMode
const theme = saveToLocal.get('theme')
`
switch (darkmode.autoChangeMode) {
case 1:
darkmodeJs += `
const mediaQueryDark = window.matchMedia('(prefers-color-scheme: dark)')
const mediaQueryLight = window.matchMedia('(prefers-color-scheme: light)')
if (theme === undefined) {
if (mediaQueryLight.matches) activateLightMode()
else if (mediaQueryDark.matches) activateDarkMode()
else {
const hour = new Date().getHours()
const isNight = hour <= ${start} || hour >= ${end}
isNight ? activateDarkMode() : activateLightMode()
}
mediaQueryDark.addEventListener('change', () => {
if (saveToLocal.get('theme') === undefined) {
e.matches ? activateDarkMode() : activateLightMode()
}
})
} else {
theme === 'light' ? activateLightMode() : activateDarkMode()
}
`
break
case 2:
darkmodeJs += `
const hour = new Date().getHours()
const isNight = hour <= ${start} || hour >= ${end}
if (theme === undefined) isNight ? activateDarkMode() : activateLightMode()
else theme === 'light' ? activateLightMode() : activateDarkMode()
`
break
default:
darkmodeJs += `
theme === 'dark' ? activateDarkMode() : theme === 'light' ? activateLightMode() : null
`
}
return darkmodeJs
}
const createAsideStatusJs = () => {
if (!aside.enable || !aside.button) return ''
return `
const asideStatus = saveToLocal.get('aside-status')
if (asideStatus !== undefined) {
document.documentElement.classList.toggle('hide-aside', asideStatus === 'hide')
}
`
}
const createDetectAppleJs = () => `
const detectApple = () => {
if (/iPad|iPhone|iPod|Macintosh/.test(navigator.userAgent)) {
document.documentElement.classList.add('apple')
}
}
detectApple()
`
return `<script>
(() => {
${createCustomJs()}
${createDarkmodeJs()}
${createAsideStatusJs()}
${createDetectAppleJs()}
})()
</script>`
})

View File

@@ -0,0 +1,152 @@
'use strict'
const { truncateContent, postDesc } = require('../common/postDesc')
const { prettyUrls } = require('hexo-util')
const crypto = require('crypto')
const moment = require('moment-timezone')
hexo.extend.helper.register('truncate', truncateContent)
hexo.extend.helper.register('postDesc', data => {
return postDesc(data, hexo)
})
hexo.extend.helper.register('cloudTags', function (options = {}) {
const env = this
let { source, minfontsize, maxfontsize, limit, unit = 'px', orderby, order } = options
if (limit > 0) {
source = source.limit(limit)
}
const sizes = [...new Set(source.map(tag => tag.length).sort((a, b) => a - b))]
const getRandomColor = () => {
const randomColor = () => Math.floor(Math.random() * 201)
const r = randomColor()
const g = randomColor()
const b = randomColor()
return `rgb(${Math.max(r, 50)}, ${Math.max(g, 50)}, ${Math.max(b, 50)})`
}
const generateStyle = (size, unit) =>
`font-size: ${parseFloat(size.toFixed(2)) + unit}; color: ${getRandomColor()};`
const length = sizes.length - 1
const result = source.sort(orderby, order).map(tag => {
const ratio = length ? sizes.indexOf(tag.length) / length : 0
const size = minfontsize + ((maxfontsize - minfontsize) * ratio)
const style = generateStyle(size, unit)
return `<a href="${env.url_for(tag.path)}" style="${style}">${tag.name}</a>`
}).join('')
return result
})
hexo.extend.helper.register('urlNoIndex', function (url = null, trailingIndex = false, trailingHtml = false) {
return prettyUrls(url || this.url, { trailing_index: trailingIndex, trailing_html: trailingHtml })
})
hexo.extend.helper.register('md5', function (path) {
return crypto.createHash('md5').update(decodeURI(this.url_for(path))).digest('hex')
})
hexo.extend.helper.register('injectHtml', data => {
return data ? data.join('') : ''
})
hexo.extend.helper.register('findArchivesTitle', function (page, menu, date) {
if (page.year) {
const dateStr = page.month ? `${page.year}-${page.month}` : `${page.year}`
const dateFormat = page.month ? hexo.theme.config.aside.card_archives.format : 'YYYY'
return date(dateStr, dateFormat)
}
const defaultTitle = this._p('page.archives')
if (!menu) return defaultTitle
const loop = (m) => {
for (const key in m) {
if (typeof m[key] === 'object') {
const result = loop(m[key])
if (result) return result
}
if (/\/archives\//.test(m[key])) {
return key
}
}
}
return loop(menu) || defaultTitle
})
hexo.extend.helper.register('getBgPath', function(path) {
if (!path) return ''
const absoluteUrlPattern = /^(?:[a-z][a-z\d+.-]*:)?\/\//i
const relativeUrlPattern = /^(\.\/|\.\.\/|\/|[^/]+\/).*$/
const colorPattern = /^(#|rgb|rgba|hsl|hsla)/i
if (colorPattern.test(path)) {
return `background-color: ${path};`
} else if (absoluteUrlPattern.test(path) || relativeUrlPattern.test(path)) {
return `background-image: url(${this.url_for(path)});`
} else {
return `background: ${path};`
}
})
hexo.extend.helper.register('shuoshuoFN', (data, page) => {
const { limit } = page
let finalResult = ''
// Check if limit.value is a valid date
const isValidDate = date => !isNaN(Date.parse(date))
// order by date
const orderByDate = data => data.sort((a, b) => Date.parse(b.date) - Date.parse(a.date))
// Apply number limit or time limit conditionally
const limitData = data => {
if (limit && limit.type === 'num' && limit.value > 0) {
return data.slice(0, limit.value)
} else if (limit && limit.type === 'date' && isValidDate(limit.value)) {
const limitDate = Date.parse(limit.value)
return data.filter(item => Date.parse(item.date) >= limitDate)
}
return data
}
orderByDate(data)
finalResult = limitData(data)
// This is a hack method, because hexo treats time as UTC time
// so you need to manually convert the time zone
finalResult.forEach(item => {
const utcDate = moment.utc(item.date).format('YYYY-MM-DD HH:mm:ss')
item.date = moment.tz(utcDate, hexo.config.timezone).format('YYYY-MM-DD HH:mm:ss')
})
return finalResult
})
hexo.extend.helper.register('getPageType', (page, isHome) => {
const { layout, tag, category, type, archive } = page
if (layout) return layout
if (tag) return 'tag'
if (category) return 'category'
if (archive) return 'archive'
if (type) {
if (type === 'tags' || type === 'categories') return type
else return 'page'
}
if (isHome) return 'home'
return 'post'
})
hexo.extend.helper.register('getVersion', () => {
const { version } = require('../../package.json')
return { hexo: hexo.version, theme: version }
})

View File

@@ -0,0 +1,97 @@
/* eslint-disable camelcase */
/**
* Butterfly
* Related Posts
* According the tag
*/
'use strict'
const { postDesc } = require('../common/postDesc')
hexo.extend.helper.register('related_posts', function (currentPost, allPosts) {
let relatedPosts = []
const tagsData = currentPost.tags
tagsData.length && tagsData.forEach(function (tag) {
allPosts.forEach(function (post) {
if (currentPost.path !== post.path && isTagRelated(tag.name, post.tags)) {
const getPostDesc = post.postDesc || postDesc(post, hexo)
const relatedPost = {
title: post.title,
path: post.path,
cover: post.cover,
cover_type: post.cover_type,
weight: 1,
updated: post.updated,
created: post.date,
postDesc: getPostDesc
}
const index = findItem(relatedPosts, 'path', post.path)
if (index !== -1) {
relatedPosts[index].weight += 1
} else {
relatedPosts.push(relatedPost)
}
}
})
})
if (relatedPosts.length === 0) {
return ''
}
let result = ''
const hexoConfig = hexo.config
const config = hexo.theme.config
const limitNum = config.related_post.limit || 6
const dateType = config.related_post.date_type || 'created'
const headlineLang = this._p('post.recommend')
relatedPosts = relatedPosts.sort(compare('weight'))
if (relatedPosts.length > 0) {
result += '<div class="relatedPosts">'
result += `<div class="headline"><i class="fas fa-thumbs-up fa-fw"></i><span>${headlineLang}</span></div>`
result += '<div class="relatedPosts-list">'
for (let i = 0; i < Math.min(relatedPosts.length, limitNum); i++) {
let { cover, title, path, cover_type, created, updated, postDesc } = relatedPosts[i]
const { escape_html, url_for, date } = this
cover = cover || 'var(--default-bg-color)'
title = escape_html(title)
const className = postDesc ? 'pagination-related' : 'pagination-related no-desc'
result += `<a class="${className}" href="${url_for(path)}" title="${title}">`
if (cover_type === 'img') {
result += `<img class="cover" src="${url_for(cover)}" alt="cover">`
} else {
result += `<div class="cover" style="background: ${cover}"></div>`
}
if (dateType === 'created') {
result += `<div class="info text-center"><div class="info-1"><div class="info-item-1"><i class="far fa-calendar-alt fa-fw"></i> ${date(created, hexoConfig.date_format)}</div>`
} else {
result += `<div class="info text-center"><div class="info-1"><div class="info-item-1"><i class="fas fa-history fa-fw"></i> ${date(updated, hexoConfig.date_format)}</div>`
}
result += `<div class="info-item-2">${title}</div></div>`
if (postDesc) {
result += `<div class="info-2"><div class="info-item-1">${postDesc}</div></div>`
}
result += '</div></a>'
}
result += '</div></div>'
return result
}
})
function isTagRelated (tagName, tags) {
return tags.some(tag => tag.name === tagName)
}
function findItem (arrayToSearch, attr, val) {
return arrayToSearch.findIndex(item => item[attr] === val)
}
function compare (attr) {
return (a, b) => b[attr] - a[attr]
}

View File

@@ -0,0 +1,22 @@
'use strict'
hexo.extend.helper.register('groupPosts', function () {
const getGroupArray = array => {
return array.reduce((groups, item) => {
const key = item.series
if (key) {
groups[key] = groups[key] || []
groups[key].push(item)
}
return groups
}, {})
}
const sortPosts = posts => {
const { orderBy = 'date', order = 1 } = this.theme.aside.card_post_series
if (orderBy === 'title') return posts.sort('title', order)
return posts.sort('date', order)
}
return getGroupArray(sortPosts(this.site.posts))
})

View File

@@ -0,0 +1,21 @@
/**
* Button
* {% btn url text icon option %}
* option: color outline center block larger
* color : default/blue/pink/red/purple/orange/green
*/
'use strict'
const urlFor = require('hexo-util').url_for.bind(hexo)
const btn = args => {
const [url = '', text = '', icon = '', option = ''] = args.join(' ').split(',').map(arg => arg.trim())
const iconHTML = icon ? `<i class="${icon}"></i>` : ''
const textHTML = text ? `<span>${text}</span>` : ''
return `<a class="btn-beautify ${option}" href="${urlFor(url)}" title="${text}">${iconHTML}${textHTML}</a>`
}
hexo.extend.tag.register('btn', btn, { ends: false })

View File

@@ -0,0 +1,49 @@
/**
* Butterfly
* chartjs
* https://www.chartjs.org/
* {% chartjs [width, abreast, chartId] %}
* <!-- chart -->
* <!-- endchart -->
* <!-- desc -->
* <!-- enddesc -->
* {% endchartjs %}
*/
'use strict'
const { escapeHTML } = require('hexo-util')
const chartjs = (args, content) => {
if (!content) return
const chartRegex = /<!--\s*chart\s*-->\n([\w\W\s\S]*?)<!--\s*endchart\s*-->/
const descRegex = /<!--\s*desc\s*-->\n([\w\W\s\S]*?)<!--\s*enddesc\s*-->/
const selfConfig = args.join(' ').trim()
const [width = '', layout = false, chartId = ''] = selfConfig.split(',').map(s => s.trim())
const chartMatch = content.match(chartRegex)
const descMatch = content.match(descRegex)
if (!chartMatch) {
hexo.log.warn('chartjs tag: chart content is required!')
return
}
const chartConfig = chartMatch && chartMatch[1] ? chartMatch[1] : ''
const descContent = descMatch && descMatch[1] ? descMatch[1] : ''
const renderedDesc = descContent ? hexo.render.renderSync({ text: descContent, engine: 'markdown' }).trim() : ''
const descDOM = renderedDesc ? `<div class="chartjs-desc">${renderedDesc}</div>` : ''
const abreastClass = layout ? ' chartjs-abreast' : ''
const widthStyle = width ? `data-width="${width}%"` : ''
return `<div class="chartjs-container${abreastClass}" data-chartjs-id="${chartId}" ${widthStyle}>
<pre class="chartjs-src" hidden>${escapeHTML(chartConfig)}</pre>
${descDOM}
</div>`
}
hexo.extend.tag.register('chartjs', chartjs, { ends: true })

View File

@@ -0,0 +1,34 @@
/**
* flink
*/
'use strict'
const urlFor = require('hexo-util').url_for.bind(hexo)
const flinkFn = (args, content) => {
const data = hexo.render.renderSync({ text: content, engine: 'yaml' })
let result = ''
data.forEach(item => {
const className = item.class_name ? `<div class="flink-name">${item.class_name}</div>` : ''
const classDesc = item.class_desc ? `<div class="flink-desc">${item.class_desc}</div>` : ''
const listResult = item.link_list.map(link => `
<div class="flink-list-item">
<a href="${link.link}" title="${link.name}" target="_blank">
<div class="flink-item-icon">
<img class="no-lightbox" src="${link.avatar}" onerror='this.onerror=null;this.src="${urlFor(hexo.theme.config.error_img.flink)}"' alt="${link.name}" />
</div>
<div class="flink-item-name">${link.name}</div>
<div class="flink-item-desc" title="${link.descr}">${link.descr}</div>
</a>
</div>`).join('')
result += `${className}${classDesc}<div class="flink-list">${listResult}</div>`
})
return `<div class="flink">${result}</div>`
}
hexo.extend.tag.register('flink', flinkFn, { ends: true })

View File

@@ -0,0 +1,76 @@
/**
* Butterfly
* galleryGroup and gallery
* {% galleryGroup [name] [descr] [url] [img] %}
*
* {% gallery [button],[limit],[firstLimit] %}
* {% gallery url,[url],[button] %}
*/
'use strict'
const urlFor = require('hexo-util').url_for.bind(hexo)
const DEFAULT_LIMIT = 10
const DEFAULT_FIRST_LIMIT = 10
const IMAGE_REGEX = /!\[(.*?)\]\(([^\s]*)\s*(?:["'](.*?)["']?)?\s*\)/g
// Helper functions
const parseGalleryArgs = args => {
const [type, ...rest] = args.join(' ').split(',').map(arg => arg.trim())
return {
isUrl: type === 'url',
params: type === 'url' ? rest : [type, ...rest]
}
}
const parseImageContent = content => {
const images = []
let match
while ((match = IMAGE_REGEX.exec(content)) !== null) {
images.push({
url: match[2],
alt: match[1] || '',
title: match[3] || ''
})
}
return images
}
const createGalleryHTML = (type, dataStr, button, limit, firstLimit) => {
return `<div class="gallery-container" data-type="${type}" data-button="${button}" data-limit="${limit}" data-first="${firstLimit}">
<div class="gallery-items">${dataStr}</div>
</div>`
}
const gallery = (args, content) => {
const { isUrl, params } = parseGalleryArgs(args)
if (isUrl) {
const [dataStr, button = false, limit = DEFAULT_LIMIT, firstLimit = DEFAULT_FIRST_LIMIT] = params
return createGalleryHTML('url', urlFor(dataStr), button, limit, firstLimit)
}
const [button = false, limit = DEFAULT_LIMIT, firstLimit = DEFAULT_FIRST_LIMIT] = params
const images = parseImageContent(content)
return createGalleryHTML('data', JSON.stringify(images), button, limit, firstLimit)
}
const galleryGroup = args => {
const [name = '', descr = '', url = '', img = ''] = args.map(arg => arg.trim())
return `<figure class="gallery-group">
<img class="gallery-group-img no-lightbox" src='${urlFor(img)}' alt="Group Image Gallery">
<figcaption>
<div class="gallery-group-name">${name}</div>
<p>${descr}</p>
<a href='${urlFor(url)}'></a>
</figcaption>
</figure>`
}
// Register tags
hexo.extend.tag.register('gallery', gallery, { ends: true })
hexo.extend.tag.register('galleryGroup', galleryGroup)

View File

@@ -0,0 +1,52 @@
/**
* Butterfly
* @example
* hideInline
* {% hideInline content,display,bg,color %}
* content不能包含當引號可用 &apos;
* hideBlock
* {% hideBlock display,bg,color %}
* content
* {% endhideBlock %}
* hideToggle
* {% hideToggle display,bg,color %}
* content
* {% endhideToggle %}
*/
'use strict'
const parseArgs = args => args.join(' ').split(',')
const generateStyle = (bg, color) => {
let style = 'style="'
if (bg) style += `background-color: ${bg};`
if (color) style += `color: ${color}`
style += '"'
return style
}
const hideInline = args => {
const [content, display = 'Click', bg = false, color = false] = parseArgs(args)
const style = generateStyle(bg, color)
return `<span class="hide-inline"><button type="button" class="hide-button" ${style}>${display}</button><span class="hide-content">${content}</span></span>`
}
const hideBlock = (args, content) => {
const [display = 'Click', bg = false, color = false] = parseArgs(args)
const style = generateStyle(bg, color)
const renderedContent = hexo.render.renderSync({ text: content, engine: 'markdown' })
return `<div class="hide-block"><button type="button" class="hide-button" ${style}>${display}</button><div class="hide-content">${renderedContent}</div></div>`
}
const hideToggle = (args, content) => {
const [display, bg = false, color = false] = parseArgs(args)
const style = generateStyle(bg, color)
const border = bg ? `style="border: 1px solid ${bg}"` : ''
const renderedContent = hexo.render.renderSync({ text: content, engine: 'markdown' })
return `<details class="toggle" ${border}><summary class="toggle-button" ${style}>${display}</summary><div class="toggle-content">${renderedContent}</div></details>`
}
hexo.extend.tag.register('hideInline', hideInline)
hexo.extend.tag.register('hideBlock', hideBlock, { ends: true })
hexo.extend.tag.register('hideToggle', hideToggle, { ends: true })

View File

@@ -0,0 +1,19 @@
/**
* inlineImg
* @param {Array} args - Image name and height
* @param {string} args[0] - Image name
* @param {number} args[1] - Image height
* @returns {string} - Image tag
*/
'use strict'
const urlFor = require('hexo-util').url_for.bind(hexo)
const inlineImg = ([img, height = '']) => {
const heightStyle = height ? `style="height:${height}"` : ''
const src = urlFor(img)
return `<img class="inline-img" src="${src}" ${heightStyle} />`
}
hexo.extend.tag.register('inlineImg', inlineImg, { ends: false })

View File

@@ -0,0 +1,14 @@
/**
* Butterfly
* label
* {% label text color %}
*/
'use strict'
const addLabel = args => {
const [text, className = 'default'] = args
return `<mark class="hl-label ${className}">${text}</mark>`
}
hexo.extend.tag.register('label', addLabel, { ends: false })

View File

@@ -0,0 +1,70 @@
function link(args) {
args = args.join(' ').split(',');
let title = args[0];
let sitename = args[1];
let link = args[2];
// 定义不同域名对应的头像URL
const avatarUrls = {
};
// 定义白名单域名
const whitelistDomains = [
'biss.click'
];
// 获取URL的根域名
function getRootDomain(url) {
const hostname = new URL(url).hostname;
const domainParts = hostname.split('.').reverse();
if (domainParts.length > 1) {
return domainParts[1] + '.' + domainParts[0];
}
return hostname;
}
// 根据URL获取对应的头像URL
function getAvatarUrl(url) {
const rootDomain = getRootDomain(url);
for (const domain in avatarUrls) {
if (domain.endsWith(rootDomain)) {
return avatarUrls[domain];
}
}
return 'https://free.picui.cn/free/2025/08/10/689845496a283.png'; // 默认头像URL
}
// 检查是否在白名单中
function isWhitelisted(url) {
const rootDomain = getRootDomain(url);
for (const domain of whitelistDomains) {
if (rootDomain.endsWith(domain)) {
return true;
}
}
return false;
}
// 获取对应的头像URL
let imgUrl = getAvatarUrl(link);
// 判断并生成提示信息
// 判断并生成提示信息
let tipMessage = isWhitelisted(link)
? "✅来自本站,本站可确保其安全性,请放心点击跳转"
: "🪧引用站外地址,不保证站点的可用性和安全性";
return `<div class='liushen-tag-link'><a class="tag-Link" target="_blank" href="${link}">
<div class="tag-link-tips">${tipMessage}</div>
<div class="tag-link-bottom">
<div class="tag-link-left" style="background-image: url(${imgUrl});"></div>
<div class="tag-link-right">
<div class="tag-link-title">${title}</div>
<div class="tag-link-sitename">${sitename}</div>
</div>
<i class="fa-solid fa-angle-right"></i>
</div>
</a></div>`;
}
hexo.extend.tag.register('link', link, { ends: false });

View File

@@ -0,0 +1,17 @@
/**
* Butterfly
* mermaid
* https://github.com/mermaid-js/mermaid
*/
'use strict'
const { escapeHTML } = require('hexo-util')
const mermaid = (args, content) => {
return `<div class="mermaid-wrap"><pre class="mermaid-src" hidden>
${escapeHTML(content)}
</pre></div>`
}
hexo.extend.tag.register('mermaid', mermaid, { ends: true })

View File

@@ -0,0 +1,27 @@
/**
* note.js
* transplant from hexo-theme-next
* Modify by Jerry
*/
'use strict'
const postNote = (args, content) => {
const styleConfig = hexo.theme.config.note.style
const noteTag = ['flat', 'modern', 'simple', 'disabled']
if (!noteTag.includes(args[args.length - 1])) {
args.push(styleConfig)
}
let icon = ''
const iconArray = args[args.length - 2]
if (iconArray && iconArray.startsWith('fa')) {
icon = `<i class="note-icon ${iconArray}"></i>`
args[args.length - 2] = 'icon-padding'
}
return `<div class="note ${args.join(' ')}">${icon + hexo.render.renderSync({ text: content, engine: 'markdown' })}</div>`
}
hexo.extend.tag.register('note', postNote, { ends: true })
hexo.extend.tag.register('subnote', postNote, { ends: true })

View File

@@ -0,0 +1,50 @@
/**
* Music Score
* {% score %}
*/
'use strict'
const score = (args, content) => {
// Escape HTML tags and some special characters, including curly braces
const escapeHtmlTags = s => {
const lookup = {
'&': '&amp;',
'"': '&quot;',
"'": '&apos;',
'<': '&lt;',
'>': '&gt;',
'{': '&#123;',
'}': '&#125;'
}
return s.replace(/[&"'<>{}]/g, c => lookup[c])
}
const trimmed = content.trim()
// Split content using six dashes as a delimiter
const parts = trimmed.split('------')
if (parts.length < 2) {
// If no delimiter is found, treat the entire content as the score
return `<div class="abc-music-sheet">${escapeHtmlTags(trimmed)}</div>`
}
// First part is parameters (JSON string), the rest is the score content
const paramPart = parts[0].trim()
const scorePart = parts.slice(1).join('------').trim()
let paramsObj = {}
try {
paramsObj = JSON.parse(paramPart)
} catch (e) {
console.error("Failed to parse JSON in score tag:", e)
}
// Use double quotes for data-params attribute value,
// ensuring JSON internal double quotes are escaped
return `<div class="abc-music-sheet" data-params="${escapeHtmlTags(JSON.stringify(paramsObj))}">
${escapeHtmlTags(scorePart)}
</div>`
}
hexo.extend.tag.register("score", score, { ends: true })

View File

@@ -0,0 +1,63 @@
/**
* series plugin
* Syntax:
* {% series [series name] %}
* Usage:
* {% series %}
* {% series series1 %}
*/
'use strict'
const urlFor = require('hexo-util').url_for.bind(hexo)
const groups = {}
hexo.extend.filter.register('before_post_render', data => {
if (!hexo.theme.config.series.enable) return data
const { layout, series } = data
if (layout === 'post' && series) {
if (!groups[series]) groups[series] = []
groups[series].push({
title: data.title,
path: data.path,
date: data.date.unix()
})
}
return data
})
function series (args) {
const { series } = hexo.theme.config
if (!series.enable) {
hexo.log.warn('Series plugin is disabled in the theme config')
return ''
}
const seriesArr = args.length ? groups[args[0]] : groups[this.series]
if (!seriesArr) {
hexo.log.warn(`There is no series named "${args[0]}"`)
return ''
}
const isAsc = (series.order || 1) === 1 // 1: asc, -1: desc
const isSortByTitle = series.orderBy === 'title'
const compareFn = (a, b) => {
const itemA = isSortByTitle ? a.title.toUpperCase() : a.date
const itemB = isSortByTitle ? b.title.toUpperCase() : b.date
return itemA < itemB ? (isAsc ? -1 : 1) : itemA > itemB ? (isAsc ? 1 : -1) : 0
}
seriesArr.sort(compareFn)
const listItems = seriesArr.map(ele =>
`<li><a href="${urlFor(ele.path)}" title="${ele.title}">${ele.title}</a></li>`
).join('')
return series.number ? `<ol class="series-items">${listItems}</ol>` : `<ul class="series-items">${listItems}</ul>`
}
hexo.extend.tag.register('series', series, { ends: false })

View File

@@ -0,0 +1,51 @@
/**
* Tabs
* transplant from hexo-theme-next
* modify by Jerry
*/
'use strict'
const postTabs = (args, content) => {
const tabBlock = /<!--\s*tab (.*?)\s*-->\n([\w\W\s\S]*?)<!--\s*endtab\s*-->/g
args = args.join(' ').split(',')
const tabName = args[0] || 'tab'
const tabActive = Number(args[1]) || 0
const matches = []
let match
let tabId = 0
let tabNav = ''
let tabContent = ''
let noDefault = true
while ((match = tabBlock.exec(content)) !== null) {
matches.push(match[1], match[2])
}
for (let i = 0; i < matches.length; i += 2) {
const [tabCaption = '', tabIcon = ''] = matches[i].split('@')
let postContent = matches[i + 1]
postContent = hexo.render.renderSync({ text: postContent, engine: 'markdown' }).trim()
tabId += 1
const caption = (tabCaption || tabIcon) ? tabCaption : `${tabName} ${tabId}`
const iconHtml = tabIcon ? `<i class="${tabIcon.trim()}"></i>` : ''
const isActive = (tabActive > 0 && tabActive === tabId) || (tabActive === 0 && tabId === 1) ? ' active' : ''
if (isActive) noDefault = false
tabNav += `<button type="button" class="tab${isActive}">${iconHtml}${caption.trim()}</button>`
tabContent += `<div class="tab-item-content${isActive}">${postContent}</div>`
}
const toTop = '<div class="tab-to-top"><button type="button" aria-label="scroll to top"><i class="fas fa-arrow-up"></i></button></div>'
tabNav = `<div class="nav-tabs${noDefault ? ' no-default' : ''}">${tabNav}</div>`
tabContent = `<div class="tab-contents">${tabContent}</div>`
return `<div class="tabs">${tabNav}${tabContent}${toTop}</div>`
}
hexo.extend.tag.register('tabs', postTabs, { ends: true })
hexo.extend.tag.register('subtabs', postTabs, { ends: true })
hexo.extend.tag.register('subsubtabs', postTabs, { ends: true })

View File

@@ -0,0 +1,50 @@
/**
* Timeline tag for Hexo
* Syntax:
* {% timeline [headline],[color] %}
* <!-- timeline [title] -->
* [content]
* <!-- endtimeline -->
* <!-- timeline [title] -->
* [content]
* <!-- endtimeline -->
* {% endtimeline %}
*/
'use strict'
const timeLineFn = (args, content) => {
// Use named capture groups for better readability
const tlBlock = /<!--\s*timeline\s*(?<title>.*?)\s*-->\n(?<content>[\s\S]*?)<!--\s*endtimeline\s*-->/g
// Pre-compile markdown render function
const renderMd = text => hexo.render.renderSync({ text, engine: 'markdown' })
// Parse arguments more efficiently
const [text, color = ''] = args.length ? args.join(' ').split(',') : []
// Build initial headline if text exists
const headline = text
? `<div class='timeline-item headline'>
<div class='timeline-item-title'>
<div class='item-circle'>${renderMd(text)}</div>
</div>
</div>`
: ''
// Match all timeline blocks in one pass and transform
const items = Array.from(content.matchAll(tlBlock))
.map(({ groups: { title, content } }) =>
`<div class='timeline-item'>
<div class='timeline-item-title'>
<div class='item-circle'>${renderMd(title)}</div>
</div>
<div class='timeline-item-content'>${renderMd(content)}</div>
</div>`
)
.join('')
return `<div class="timeline ${color}">${headline}${items}</div>`
}
hexo.extend.tag.register('timeline', timeLineFn, { ends: true })