4571 words
23 minutes
Hugo | Blowfish短代码及友链样式

前言#

杂物间从Mogege主题迁移到Blowfish之后稍微做了一下CSS样式魔改:Hugo | 记录Blowfish主题美化过程。本篇文章用来记录拖了一个月没更新的短代码,以及友链样式。

功能在于精不在于多,我满意就行啦。

文章(代码)较长,按需拿取食用。

Shortcode 短代码#

防剧透的文本高斯模糊样式#

layouts/shortcodes新建blur.html

blur.html
<span class="blur">{{.Inner | markdownify}}</span>
<style>
/* 文本高斯模糊 */
.blur {
filter: blur(4px);
transition: filter 0.3s ease;
}
.blur:hover {
filter: blur(0);
}
</style>

不受任何主题限制,鼠标悬停或屏幕点击后文字正常显示,且内文本支持 markdown 格式。小心眼睛会晕…

使用方法:

{{< blur >}}想要高斯模糊的文本{{</ blur >}}

NeoDB 书影音页#

参考:将 NeoDB 记录整合到 Hugo 中

想要简单地实现一个书影音页,不想添加太多依赖,所以使用了短代码方式实现。缺点是只有在本地浏览和push构建的时候才能刷新即时数据。所以要经常更新博客呀~

通过 API 获取 NeoDB 数据#

NeoDB 提供了开放的 API,可以通过 API 获取用户的书影音记录。

该 API 的用法:

  • URL 为 https://neodb.social/api/me/shelf
  • 需要 Bearer Token
  • 指定 Path Variable typewishlist, progress, complete 中的一个
  • (可选) 指定 Query Param categorybook, movie, tv, podcast, music, game, performance 中的一个
  • (可选) 指定 Query Param page 为页码 例如 https://neodb.social/api/me/shelf/complete?category=book&page=1

获取 Access Token 的方法:NeoDB Developer#How to authorize, 也可以生成 Test Access Token 用于测试。

为了方便使用、封装 TOKEN 信息,我们可以将获取 API 独立部署成一个 Cloudflare Worker。

在 Cloudflare Workers 中创建一个新的 Worker,设置环境变量 NEODB_TOKEN,值为刚刚获取的 Access Tokenworker.js 内容如下:

worker.js
const myBearer = NEODB_TOKEN; // Assuming 'NEODB_TOKEN' is set in your Cloudflare Worker's environment variables
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request))
})
async function handleRequest(request) {
try {
console.log(myBearer)
const url = new URL(request.url);
const category = url.pathname.substring(1);
// Optionally, handle query parameters (e.g., page number)
const page = url.searchParams.get('page') || '1';
// Available values : wishlist, progress, complete
const type = url.searchParams.get('type') || 'complete';
let dbApiUrl = `https://neodb.social/api/me/shelf/${type}?category=${category}&page=${page}`;
const response = await fetch(dbApiUrl, {
method: 'get',
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${myBearer}`
}
});
// Check if the response from the API is OK (status code 200-299)
if (!response.ok) {
throw new Error(`API returned status ${response.status}`);
}
// Optionally, modify or just forward the API's response
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' },
status: response.status
});
} catch (error) {
// Handle any errors that occurred during the fetch
return new Response(error.message, { status: 500 });
}
}

通过 Hugo Shortcode 将数据整合到 Hugo 中#

/layouts/shortcodes 中创建 neodb-page.html,注意替换你的 Cloudflare Worker URL:

neodb-page.html
<!--Available categories: book, movie, tv, podcast, music, game, performance-->
{{ $category := .Get 0 }}
<!--Available types: wishlist, progress, complete-->
{{ $type := .Get 1 }}
{{ if eq $type "" }}
{{ $type = "complete" }}
{{ end }}
{{ $now := now.Unix }}
{{ $url := printf "https://your.workers.dev/%s?type=%s&t=%d" $category $type $now }}
{{ $remote := resources.GetRemote $url }}
{{ $json := $remote | transform.Unmarshal }}
<div class="item-gallery">
{{ range $value := first 10 $json.data }}
{{ $item := $value.item }}
<div class="item-card">
<a class="item-card-upper" href="{{ $item.id }}" target="_blank" rel="noreferrer">
<img class="item-cover" src="{{ .item.cover_image_url }}" alt="{{ .item.display_title }}">
</a>
{{ if .item.rating }}
<div class="rate">
<span><b>{{ .item.rating }}</b>🌟</span>
<br>
<span class="rating-count"> {{.item.rating_count}}人评分</span>
</div>
{{ else}}
<div class="rate">
<span>暂无🌟</span>
<br>
<span class="rating-count"> {{.item.rating_count}}人评分</span>
</div>
{{ end }}
<h3 class="item-title">{{ .item.display_title }}</h3>
</div>
{{ end }}
</div>
<style>
.item-gallery {
display: flex;
padding: 0 1rem;
overflow-x: scroll;
align-items: baseline;
}
.item-card {
display: flex;
flex-direction: column;
flex: 0 0 17%;
margin: 0 0.5rem 1rem;
border-radius: 5px;
transition: transform 0.2s;
width: 8rem;
}
.item-card:hover {
transform: translateY(-5px);
}
.rate {
text-align: center;
}
.rating-count {
font-size: 0.8rem;
color: grey;
}
.item-cover {
width: 100%;
min-height: 3rem;
border: 2px solid transparent;
}
.item-title {
font-size: 1rem;
text-align: center;
margin: 0;
}
</style>

我稍微修改了里面关于即时获取数据的方法,每次在本地hugo server预览都可以实时抓取到最新数据,反正本地预览也不怕慢就是了。

然后,在页面中使用 shortcode 引用即可:

## 想读的书
{{< neodb-page book wishlist >}}
## 读过的书
{{< neodb-page book complete >}}

示例:Archive

NeoDB 短代码#

其实一共做了三版,v1 是抓取个人标记条目的标题,类别,封面,时间,个人评星和个人评价,API是https://neodb.social/api/me/shelf,需要token

v2 是抓取与个人数据完全无关的公共数据,包含标题,封面,类别,总评分,评分人数和条目描述,API是https://neodb.social/api/,数据是公开的,不需要token

我感觉太多余,本着能少则少的想法做了一个整合版 v3,对v1和v2直接进行整合,逻辑比较复杂:

  • 主要抓取条目来源:API是https://neodb.social/api/,包含标题,封面,总评分(1-10),评分人数,条目描述。

  • 当总评分显示为null时,将总评分和评分人数更换为个人评星(1-5星,可半星),API是https://neodb.social/api/me/shelf

  • 如果条目被标记,则显示标记时间和标记类别,比如“在玩”“想读”。如果没有被标记,则不显示。

v3 满足了我的所有需求,无论什么条目都不会报错。layouts/shortcodes/neodb.html内容:

neodb.html
{{/* Neodb 综合展示短代码(默认公共评分 + 用户状态 + fallback用户评分) */}}
{{ $dbUrl := .Get 0 }}
{{ $authToken := "your_token" }}
{{ $match := findRE `neodb\.social\/(book|movie|tv|game|podcast)\/([^\/]+)` $dbUrl }}
{{ if not (len $match) }}
<p style="text-align:center"><small>无效的 Neodb 链接格式</small></p>
{{ return }}
{{ end }}
{{ $segments := split (index $match 0) "/" }}
{{ $type := index $segments 1 }}
{{ $uuid := index $segments 2 }}
{{ $publicApiUrl := printf "https://neodb.social/api/%s/%s?t=%d" $type $uuid (now.Unix) }}
{{ $userApiUrl := printf "https://neodb.social/api/me/shelf/item/%s?t=%d" $uuid (now.Unix) }}
{{ $publicData := (resources.GetRemote $publicApiUrl) | transform.Unmarshal }}
{{ $category := $publicData.category }}
{{/* 公共评分 */}}
{{ $hasPublicRating := false }}
{{ $rating := 0.0 }}
{{ $ratingCount := 0 }}
{{ with $publicData.rating }}
{{ $rating = float . }}
{{ $hasPublicRating = true }}
{{ end }}
{{ with $publicData.rating_count }}
{{ $ratingCount = . }}
{{ end }}
{{/* 用户数据(用于状态标记 和 fallback 星级) */}}
{{ $userShelf := dict }}
{{ $userRating := 0 }}
{{ $useUserStars := false }}
{{ $statusText := "" }}
{{ $userOpts := dict "headers" (dict "Authorization" (print "Bearer " $authToken)) }}
{{ $userData := (resources.GetRemote $userApiUrl $userOpts) | transform.Unmarshal }}
{{ if and (isset $userData "shelf_type") (ne ($userData.shelf_type) "") }}
{{ $userShelf = $userData }}
{{/* 生成状态文本 */}}
{{ $prefix := "" }} {{ $suffix := "" }} {{ $action := "" }}
{{ if eq $category "book" }} {{ $action = "读" }}
{{ else if or (eq $category "tv") (eq $category "movie") }} {{ $action = "看" }}
{{ else if or (eq $category "podcast") (eq $category "album") }} {{ $action = "听" }}
{{ else if eq $category "game" }} {{ $action = "玩" }} {{ end }}
{{ $shelfType := $userShelf.shelf_type }}
{{ if eq $shelfType "wishlist" }} {{ $prefix = "想" }}
{{ else if eq $shelfType "complete" }}{{ $suffix = "过" }}
{{ else if eq $shelfType "progress" }}{{ $prefix = "在" }}
{{ else if eq $shelfType "dropped" }} {{ $prefix = "不" }}{{ $suffix = "了" }}
{{ end }}
{{ $statusText = print $prefix $action $suffix }}
{{ end }}
{{ with $userData.rating_grade }}
{{ $userRating = float . }}
{{ if not $hasPublicRating }}
{{ $rating = $userRating }}
{{ $useUserStars = true }}
{{ end }}
{{ end }}
<div class="db-card">
<div class="db-card-subject">
<div class="db-card-post">
<img loading="lazy" src="{{ $publicData.cover_image_url }}" alt="{{ $publicData.title }}" style="max-width: 100%; height: auto;">
</div>
<div class="db-card-content">
<div class="db-card-title">
<a href="{{ $dbUrl }}" class="cute" target="_blank" rel="noreferrer">{{ $publicData.title }}</a>
</div>
<div class="db-card-rating">
<div class="db-card-rating-meta">
{{ if $useUserStars }}
{{ $starCount := div (mul $rating 5) 10 }}
{{ $fullStars := int $starCount }}
{{ $halfStar := 0 }}
{{ if (mod $rating 2) }}{{ $halfStar = 1 }}{{ end }}
{{ $emptyStars := sub 5 (add $fullStars $halfStar) }}
<span class="db-card-rating-stars">
{{- range $i := (seq 1 $fullStars) -}}<i class="fas fa-star"></i>{{- end -}}
{{- if eq $halfStar 1 -}}<i class="fa fa-star-half-stroke"></i>{{- end -}}
{{- range $i := (seq 1 $emptyStars) -}}<i class="far fa-star"></i>{{- end -}}
</span>
{{ else }}
<span class="db-card-rating-stars">
<span class="allstardark">
<span class="allstarlight" style="width:{{ mul 10 $rating }}%"></span>
</span>
{{ printf "%.1f" $rating }} <i class="fa-solid fa-ranking-star"></i>
</span>
<!-- <span class="rating_nums">{{ printf "%.1f" $rating }} <i class="fa-solid fa-ranking-star"></i></span> -->
{{ if gt $ratingCount 0 }}
<span class="rating_count">{{ $ratingCount }} 个评分</span>
{{ end }}
{{ end }}
{{ with $userShelf.created_time }}
{{ $markTime := . | time.Format "2006年01月02日" }}
<span class="rating_status">{{ $markTime }} {{ $statusText }}</span>
{{ end }}
</div>
</div>
<div class="db-card-description scrollable-description">
{{ $publicData.brief }}
</div>
</div>
</div>
</div>
<style>
.db-card-rating-meta {
display: flex;
flex-wrap: wrap;
gap: 0.8em;
align-items: center;
font-size: 0.95em;
}
.db-card-rating-stars {
display: inline-flex;
gap: 0.15em;
}
.scrollable-description {
max-height: 100px;
overflow-y: auto;
padding-right: 0.5em;
line-height: 1.5;
margin-top: 0.5em;
}
.scrollable-description::-webkit-scrollbar {
width: 6px;
}
.scrollable-description::-webkit-scrollbar-thumb {
background-color: rgba(150, 150, 150, 0.4);
border-radius: 4px;
}
</style>

这里的icon我是用的是免费的Awesome icon v6版本,需要到Awesome官网注册账号。

<script src="https://kit.fontawesome.com/xxxxxxxxx.js" crossorigin="anonymous"></script>

然后将以上内容放入layouts/partials/extend-footer.html引用图标,js序列号填写自己获取的。

CSS样式static/css/neodb.css

neodb.css
/* Neodb card style */
.db-card {
margin: 0;
background: var(--color-codebg);
border-radius: 7px;
box-shadow: none;
font-size: 14px;
padding-top: 10px;
}
.db-card-subject {
display: flex;
align-items: flex-start;
line-height: 1.6;
position: relative;
font-size: inherit; /* 继承全局字体大小 */
}
.dark .db-card {
background: var(--color-codebg);
}
.db-card-content {
flex: 1 1 auto;
overflow: auto;
margin-top: 8px;
}
.db-card-post {
width: 100px;
margin-right: 20px;
margin-top: 0px;
display: flex;
flex: 0 0 auto;
}
.db-card-title {
padding-top: 8px; /* 轻微调整标题的高度 */
}
.db-card-rating,
.db-card-comment,
.db-card-cate {
font-size: inherit; /* 继承全局字体大小 */
}
.db-card-title {
margin-bottom: 3px;
color: #fff;
font-weight: bold;
}
.db-card-title a {
text-decoration: none !important;
}
.db-card-comment {
margin-top: 0px;
color: var(--card-text-color-main);
max-height: none;
overflow: visible;
}
.db-card-description {
margin-top: 0px;
color: var(--card-text-color-main);
max-height: none;
overflow: visible;
}
.db-card-cate {
position: absolute;
top: 0;
right: 0;
padding: 1px 8px;
font-style: italic;
border-radius: 0 8px 0 8px;
text-transform: capitalize;
background: #6b0f0f;
color: #fff;
}
.db-card-post img {
width: 100px !important;
height: 150px !important;
border-radius: 4px;
object-fit: cover;
}
@media (max-width: 600px) {
.db-card {
margin: 0.8rem 0.5rem;
}
}

css没有做更改,里面有一些v3版已经不用的元素但是前两版还在用,所以就放着。

使用方法:

{{< neodb "https://neodb.social/game/72fWr2UHNBhusgSXhEKoio" >}}

示例:【乙女】薄桜鬼 真改 風華伝 全通关心得

适配了PC和手机端显示,条目描述也做了滑动显示。完美!

友情链接样式#

借鉴了eallion的代码,缝缝改改自己写了个友链样式,适用于blowfish主题。

layouts/_default/links.html内容:

links.html
{{ define "main" }}
{{ .Scratch.Set "scope" "single" }}
<article>
{{ if .Params.showHero | default (.Site.Params.article.showHero | default false) }}
{{ $heroStyle := .Params.heroStyle }}
{{ if not $heroStyle }}{{ $heroStyle = .Site.Params.article.heroStyle }}{{ end }}
{{ $heroStyle := print "hero/" $heroStyle ".html" }}
{{ if templates.Exists ( printf "partials/%s" $heroStyle ) }}
{{ partial $heroStyle . }}
{{ else }}
{{ partial "hero/basic.html" . }}
{{ end }}
{{ end }}
<header id="single_header" class="mt-5 max-w-prose">
{{ if .Params.showBreadcrumbs | default (.Site.Params.article.showBreadcrumbs | default false) }}
{{ partial "breadcrumbs.html" . }}
{{ end }}
<h1 class="mt-0 text-4xl font-extrabold text-neutral-900 dark:text-neutral">
{{ .Title }}
</h1>
<div class="mt-1 mb-6 text-base text-neutral-500 dark:text-neutral-400 print:hidden">
{{ partial "article-meta/basic.html" (dict "context" . "scope" "single") }}
</div>
</header>
<section class="flex flex-col max-w-full mt-0 prose dark:prose-invert lg:flex-row">
<div class="min-w-0 min-h-0 max-w-fit">
<div class="article-content max-w-full mb-20 break-words">
{{ .Content }}
<!-- 添加 not-prose 类来避免 prose 样式的影响 -->
<div class="not-prose">
<div class="friends-links grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{{ $linksResource := resources.Get "data/links.json" }}
{{ $links := $linksResource | transform.Unmarshal }}
{{ $linksResource := resources.Get "data/links.json" }}
{{ $links := $linksResource | transform.Unmarshal }}
{{ range $links.links }}
{{ $name := .name }}
{{ $bio := .bio }}
{{ $url := .url }}
{{ $avatar := .avatar }}
<a target="_blank" href="{{ $url }}" title="{{ $name }}" class="block" rel="noopener noreferrer">
<div class="group flex items-center p-4 bg-gray-100 dark:bg-gray-900 rounded-lg hover:scale-105 transition-all duration-300 ease-in-out">
<div class="flex-shrink-0 mr-4">
<div class="friend-avatar-wrapper">
<img class="friend-avatar lazy nozoom w-16 h-16 object-cover rounded-full block" loading="lazy"
src="{{ $avatar }}"
alt="{{ $name }}"
onerror="this.onerror=null; this.src='/img/Transparent_Akkarin.th.jpg';"
style="width: 64px; height: 64px;">
</div>
</div>
<span class="inline-block w-4 h-4 mr-3"></span>
<div class="flex-grow min-w-0">
<div class="flex items-center mb-2">
<span class="font-medium text-neutral-900 dark:text-neutral-100 truncate" style="strong">{{ $name }}</span>
</div>
<p class="text-sm text-neutral-700 dark:text-neutral-300 line-clamp-2 m-0">{{ $bio }}</p>
</div>
</div>
</a>
{{ end }}
</div>
</div>
</div>
</div>
</section>
</article>
{{ $lazyloadJS := resources.Get "js/lazyload.iife.min.js" | fingerprint "sha256" }}
<script type="text/javascript" src="{{ $lazyloadJS.RelPermalink }}" integrity="{{ $lazyloadJS.Data.Integrity }}"></script>
<script>
// Random links
function shuffleArray(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
function randomizeLinks() {
const linksContainer = document.querySelector('.friends-links');
const links = Array.from(linksContainer.querySelectorAll('a'));
const shuffledLinks = shuffleArray(links);
links.forEach(link => link.remove());
shuffledLinks.forEach(link => linksContainer.appendChild(link));
}
randomizeLinks();
</script>
<script>
var lazyLoadInstance = new LazyLoad({
// Your custom settings go here
});
</script>
<style>
.friend-avatar-wrapper {
width: 64px;
aspect-ratio: 1 / 1; /* 保证是正方形,避免压扁 */
border-radius: 50%; /* 圆形裁切 */
overflow: hidden;
border: 2px solid #9ca3af;
display: flex;
align-items: center;
justify-content: center;
background-color: white;
box-sizing: border-box;
transition: transform 0.6s ease;
}
/* 鼠标悬停时旋转 */
.friend-avatar-wrapper:hover {
transform: rotate(360deg);
}
/* dark 模式下边框稍调亮 */
html.dark .friend-avatar-wrapper {
border-color: #d1d5db; /* light gray for dark mode */
}
.friend-avatar {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
</style>
{{ end }}

assets/js/lazyload.iife.min.js,作用是图片懒加载:

lazyload.iife.min.js
var LazyLoad = (function () {
"use strict";
const e = "undefined" != typeof window,
t =
(e && !("onscroll" in window)) ||
("undefined" != typeof navigator &&
/(gle|ing|ro)bot|crawl|spider/i.test(navigator.userAgent)),
a = e && window.devicePixelRatio > 1,
s = {
elements_selector: ".lazy",
container: t || e ? document : null,
threshold: 300,
thresholds: null,
data_src: "src",
data_srcset: "srcset",
data_sizes: "sizes",
data_bg: "bg",
data_bg_hidpi: "bg-hidpi",
data_bg_multi: "bg-multi",
data_bg_multi_hidpi: "bg-multi-hidpi",
data_bg_set: "bg-set",
data_poster: "poster",
class_applied: "applied",
class_loading: "loading",
class_loaded: "loaded",
class_error: "error",
class_entered: "entered",
class_exited: "exited",
unobserve_completed: !0,
unobserve_entered: !1,
cancel_on_exit: !0,
callback_enter: null,
callback_exit: null,
callback_applied: null,
callback_loading: null,
callback_loaded: null,
callback_error: null,
callback_finish: null,
callback_cancel: null,
use_native: !1,
restore_on_error: !1,
},
n = (e) => Object.assign({}, s, e),
l = function (e, t) {
let a;
const s = "LazyLoad::Initialized",
n = new e(t);
try {
a = new CustomEvent(s, { detail: { instance: n } });
} catch (e) {
(a = document.createEvent("CustomEvent")),
a.initCustomEvent(s, !1, !1, { instance: n });
}
window.dispatchEvent(a);
},
o = "src",
r = "srcset",
i = "sizes",
c = "poster",
d = "llOriginalAttrs",
_ = "data",
u = "loading",
g = "loaded",
b = "applied",
h = "error",
m = "native",
p = "data-",
v = "ll-status",
f = (e, t) => e.getAttribute(p + t),
E = (e) => f(e, v),
I = (e, t) =>
((e, t, a) => {
const s = p + t;
null !== a ? e.setAttribute(s, a) : e.removeAttribute(s);
})(e, v, t),
k = (e) => I(e, null),
A = (e) => null === E(e),
L = (e) => E(e) === m,
y = [u, g, b, h],
w = (e, t, a, s) => {
e &&
"function" == typeof e &&
(void 0 === s ? (void 0 === a ? e(t) : e(t, a)) : e(t, a, s));
},
C = (t, a) => {
e && "" !== a && t.classList.add(a);
},
O = (t, a) => {
e && "" !== a && t.classList.remove(a);
},
x = (e) => e.llTempImage,
M = (e, t) => {
if (!t) return;
const a = t._observer;
a && a.unobserve(e);
},
z = (e, t) => {
e && (e.loadingCount += t);
},
N = (e, t) => {
e && (e.toLoadCount = t);
},
R = (e) => {
let t = [];
for (let a, s = 0; (a = e.children[s]); s += 1)
"SOURCE" === a.tagName && t.push(a);
return t;
},
T = (e, t) => {
const a = e.parentNode;
a && "PICTURE" === a.tagName && R(a).forEach(t);
},
G = (e, t) => {
R(e).forEach(t);
},
D = [o],
H = [o, c],
V = [o, r, i],
F = [_],
B = (e) => !!e[d],
J = (e) => e[d],
S = (e) => delete e[d],
j = (e, t) => {
if (B(e)) return;
const a = {};
t.forEach((t) => {
a[t] = e.getAttribute(t);
}),
(e[d] = a);
},
P = (e, t) => {
if (!B(e)) return;
const a = J(e);
t.forEach((t) => {
((e, t, a) => {
a ? e.setAttribute(t, a) : e.removeAttribute(t);
})(e, t, a[t]);
});
},
U = (e, t, a) => {
C(e, t.class_applied),
I(e, b),
a && (t.unobserve_completed && M(e, t), w(t.callback_applied, e, a));
},
$ = (e, t, a) => {
C(e, t.class_loading),
I(e, u),
a && (z(a, 1), w(t.callback_loading, e, a));
},
q = (e, t, a) => {
a && e.setAttribute(t, a);
},
K = (e, t) => {
q(e, i, f(e, t.data_sizes)),
q(e, r, f(e, t.data_srcset)),
q(e, o, f(e, t.data_src));
},
Q = {
IMG: (e, t) => {
T(e, (e) => {
j(e, V), K(e, t);
}),
j(e, V),
K(e, t);
},
IFRAME: (e, t) => {
j(e, D), q(e, o, f(e, t.data_src));
},
VIDEO: (e, t) => {
G(e, (e) => {
j(e, D), q(e, o, f(e, t.data_src));
}),
j(e, H),
q(e, c, f(e, t.data_poster)),
q(e, o, f(e, t.data_src)),
e.load();
},
OBJECT: (e, t) => {
j(e, F), q(e, _, f(e, t.data_src));
},
},
W = ["IMG", "IFRAME", "VIDEO", "OBJECT"],
X = (e, t) => {
!t ||
((e) => e.loadingCount > 0)(t) ||
((e) => e.toLoadCount > 0)(t) ||
w(e.callback_finish, t);
},
Y = (e, t, a) => {
e.addEventListener(t, a), (e.llEvLisnrs[t] = a);
},
Z = (e, t, a) => {
e.removeEventListener(t, a);
},
ee = (e) => !!e.llEvLisnrs,
te = (e) => {
if (!ee(e)) return;
const t = e.llEvLisnrs;
for (let a in t) {
const s = t[a];
Z(e, a, s);
}
delete e.llEvLisnrs;
},
ae = (e, t, a) => {
((e) => {
delete e.llTempImage;
})(e),
z(a, -1),
((e) => {
e && (e.toLoadCount -= 1);
})(a),
O(e, t.class_loading),
t.unobserve_completed && M(e, a);
},
se = (e, t, a) => {
const s = x(e) || e;
ee(s) ||
((e, t, a) => {
ee(e) || (e.llEvLisnrs = {});
const s = "VIDEO" === e.tagName ? "loadeddata" : "load";
Y(e, s, t), Y(e, "error", a);
})(
s,
(n) => {
((e, t, a, s) => {
const n = L(t);
ae(t, a, s),
C(t, a.class_loaded),
I(t, g),
w(a.callback_loaded, t, s),
n || X(a, s);
})(0, e, t, a),
te(s);
},
(n) => {
((e, t, a, s) => {
const n = L(t);
ae(t, a, s),
C(t, a.class_error),
I(t, h),
w(a.callback_error, t, s),
a.restore_on_error && P(t, V),
n || X(a, s);
})(0, e, t, a),
te(s);
}
);
},
ne = (e, t, s) => {
((e) => W.indexOf(e.tagName) > -1)(e)
? ((e, t, a) => {
se(e, t, a),
((e, t, a) => {
const s = Q[e.tagName];
s && (s(e, t), $(e, t, a));
})(e, t, a);
})(e, t, s)
: ((e, t, s) => {
((e) => {
e.llTempImage = document.createElement("IMG");
})(e),
se(e, t, s),
((e) => {
B(e) || (e[d] = { backgroundImage: e.style.backgroundImage });
})(e),
((e, t, s) => {
const n = f(e, t.data_bg),
l = f(e, t.data_bg_hidpi),
r = a && l ? l : n;
r &&
((e.style.backgroundImage = `url("${r}")`),
x(e).setAttribute(o, r),
$(e, t, s));
})(e, t, s),
((e, t, s) => {
const n = f(e, t.data_bg_multi),
l = f(e, t.data_bg_multi_hidpi),
o = a && l ? l : n;
o && ((e.style.backgroundImage = o), U(e, t, s));
})(e, t, s),
((e, t, a) => {
const s = f(e, t.data_bg_set);
if (!s) return;
let n = s.split("|").map((e) => `image-set(${e})`);
(e.style.backgroundImage = n.join()), U(e, t, a);
})(e, t, s);
})(e, t, s);
},
le = (e) => {
e.removeAttribute(o), e.removeAttribute(r), e.removeAttribute(i);
},
oe = (e) => {
T(e, (e) => {
P(e, V);
}),
P(e, V);
},
re = {
IMG: oe,
IFRAME: (e) => {
P(e, D);
},
VIDEO: (e) => {
G(e, (e) => {
P(e, D);
}),
P(e, H),
e.load();
},
OBJECT: (e) => {
P(e, F);
},
},
ie = (e, t) => {
((e) => {
const t = re[e.tagName];
t
? t(e)
: ((e) => {
if (!B(e)) return;
const t = J(e);
e.style.backgroundImage = t.backgroundImage;
})(e);
})(e),
((e, t) => {
A(e) ||
L(e) ||
(O(e, t.class_entered),
O(e, t.class_exited),
O(e, t.class_applied),
O(e, t.class_loading),
O(e, t.class_loaded),
O(e, t.class_error));
})(e, t),
k(e),
S(e);
},
ce = ["IMG", "IFRAME", "VIDEO"],
de = (e) => e.use_native && "loading" in HTMLImageElement.prototype,
_e = (e, t, a) => {
e.forEach((e) =>
((e) => e.isIntersecting || e.intersectionRatio > 0)(e)
? ((e, t, a, s) => {
const n = ((e) => y.indexOf(E(e)) >= 0)(e);
I(e, "entered"),
C(e, a.class_entered),
O(e, a.class_exited),
((e, t, a) => {
t.unobserve_entered && M(e, a);
})(e, a, s),
w(a.callback_enter, e, t, s),
n || ne(e, a, s);
})(e.target, e, t, a)
: ((e, t, a, s) => {
A(e) ||
(C(e, a.class_exited),
((e, t, a, s) => {
a.cancel_on_exit &&
((e) => E(e) === u)(e) &&
"IMG" === e.tagName &&
(te(e),
((e) => {
T(e, (e) => {
le(e);
}),
le(e);
})(e),
oe(e),
O(e, a.class_loading),
z(s, -1),
k(e),
w(a.callback_cancel, e, t, s));
})(e, t, a, s),
w(a.callback_exit, e, t, s));
})(e.target, e, t, a)
);
},
ue = (e) => Array.prototype.slice.call(e),
ge = (e) => e.container.querySelectorAll(e.elements_selector),
be = (e) => ((e) => E(e) === h)(e),
he = (e, t) => ((e) => ue(e).filter(A))(e || ge(t)),
me = function (t, a) {
const s = n(t);
(this._settings = s),
(this.loadingCount = 0),
((e, t) => {
de(e) ||
(t._observer = new IntersectionObserver((a) => {
_e(a, e, t);
}, ((e) => ({ root: e.container === document ? null : e.container, rootMargin: e.thresholds || e.threshold + "px" }))(e)));
})(s, this),
((t, a) => {
e &&
((a._onlineHandler = () => {
((e, t) => {
var a;
((a = ge(e)), ue(a).filter(be)).forEach((t) => {
O(t, e.class_error), k(t);
}),
t.update();
})(t, a);
}),
window.addEventListener("online", a._onlineHandler));
})(s, this),
this.update(a);
};
return (
(me.prototype = {
update: function (e) {
const a = this._settings,
s = he(e, a);
var n, l;
N(this, s.length),
t
? this.loadAll(s)
: de(a)
? ((e, t, a) => {
e.forEach((e) => {
-1 !== ce.indexOf(e.tagName) &&
((e, t, a) => {
e.setAttribute("loading", "lazy"),
se(e, t, a),
((e, t) => {
const a = Q[e.tagName];
a && a(e, t);
})(e, t),
I(e, m);
})(e, t, a);
}),
N(a, 0);
})(s, a, this)
: ((l = s),
((e) => {
e.disconnect();
})((n = this._observer)),
((e, t) => {
t.forEach((t) => {
e.observe(t);
});
})(n, l));
},
destroy: function () {
this._observer && this._observer.disconnect(),
e && window.removeEventListener("online", this._onlineHandler),
ge(this._settings).forEach((e) => {
S(e);
}),
delete this._observer,
delete this._settings,
delete this._onlineHandler,
delete this.loadingCount,
delete this.toLoadCount;
},
loadAll: function (e) {
const t = this._settings;
he(e, t).forEach((e) => {
M(e, this), ne(e, t, this);
});
},
restoreAll: function () {
const e = this._settings;
ge(e).forEach((t) => {
ie(t, e);
});
},
}),
(me.load = (e, t) => {
const a = n(t);
ne(e, a);
}),
(me.resetStatus = (e) => {
k(e);
}),
e &&
((e, t) => {
if (t)
if (t.length) for (let a, s = 0; (a = t[s]); s += 1) l(e, a);
else l(e, t);
})(me, window.lazyLoadOptions),
me
);
})();

存放友链的地方assets/data/links.json

links.json
{
"links": [
{
"name": "xxx’s Blog",
"bio": "个人简介。",
"url": "https://example.com/",
"avatar": "/img/friends/test.jpg"
},
...
]
}

示例:Links

指定了友链头像获取失败时显示的图片:/static/img/Transparent_Akkarin.th.jpg,选择自己喜欢的图片就好。

在改样式的时候我设置了鼠标悬浮头像旋转,头像宽度高度强制为64px,圆形头像灰色外框(夜间模式更亮),以及白色为底占位,防止一些矢量图如ico以及不规则形状的图片显示变形。这样无论图片来自远程还是本地,格式大小如何,都能够正常显示。以及友链显示顺序是随机的,看起来会比较有意思吧。

Hugo | Blowfish短代码及友链样式
https://blog.tantalum.life/posts/shortcode-and-links-for-blowfish/
Author
Zokiio
Published at
2025-06-24
License
CC BY-NC-SA 4.0