Featured image of post Hugo Stack 主题装修笔记 Part 3

Hugo Stack 主题装修笔记 Part 3

给赛博小屋点个赞吧

目录

前篇:
Hugo Stack主题装修笔记
Hugo Stack主题装修笔记Part 2

Neodb 自动化短评卡片

2025-01-20 更新:优化了一下代码,现在根据书影音类别和评分状态会显示出对应文字,如“在玩”、“不看了”。唯一的使用要求是neodb链接的条目是要被你标注过的,如果没有的话会报错。

看了好几个版本都不是我想达到的效果,研究了一下 neodb 的 API 后在 GPT 的帮助下搞出了下面这个全自动版,使用方法和最终效果如下:

{{< neodb-review "https://neodb.social/book/1qPRxweiyxXlGqN3azjEy8" >}}

创建 Neodb access token

点击 neodo.social 右上角头像 - 设置 - 更多设置 - 查看已授权的应用程序 - 点击 Create Personal Token - 记下生成的 token。

创建 Neodb 卡片

新建文件 layouts/shortcodes/neodb-review.html 如下,将 neodb_personal_token 的部分替换为上面的 token

点我展开代码
{{ $dbUrl := .Get 0 }}
{{ $apiUrl := "https://neodb.social/api/me/shelf/item/" }}
{{ $itemUuid := "" }}
{{ $authToken := "neodb_personal_token" }} <!-- Replace with your actual API token -->

<!-- Extract item_uuid from the URL -->
{{ if (findRE `.*neodb\.social\/.*\/(.*)` $dbUrl) }}
{{ $itemUuid = replaceRE `.*neodb\.social\/.*\/(.*)` "$1" $dbUrl }}
{{ else }}
<p style="text-align: center;"><small>Invalid URL format.</small></p>
{{ return }}
{{ end }}

<!-- Construct the API URL -->
{{ $dbApiUrl := print $apiUrl $itemUuid }}

<!-- Set up the Authorization header -->
{{ $headers := dict "Authorization" (print "Bearer " $authToken) }}

<!-- Fetch JSON from the API -->
{{ $dbFetch := getJSON $dbApiUrl $headers }}

<!-- Determine shelf status -->
    {{ $shelfType := $dbFetch.shelf_type }}
    {{ $category := $dbFetch.item.category }}
    {{ $action := "" }}
    {{ $prefix := "" }}
    {{ $suffix := "" }}
    {{ $displayText := "" }}

    <!-- Determine the action based on category -->
    {{ 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 }}

    <!-- Determine the prefix and suffix based on shelf type -->
    {{ if eq $shelfType "wishlist" }}
        {{ $prefix = "想" }}
    {{ else if eq $shelfType "complete" }}
        {{ $prefix = "" }}
        {{ $suffix = "过" }}
    {{ else if eq $shelfType "progress" }}
        {{ $prefix = "在" }}
    {{ else if eq $shelfType "dropped" }}
        {{ $prefix = "不" }}
        {{ $suffix = "了" }}
    {{ end }}

    <!-- Combine prefix, action, and suffix -->
    {{ $displayText = print $prefix $action $suffix }}

<!-- Prep for star rating -->
{{ $fullStars := 0 }}
{{ $starCount := 0 }}
{{ $halfStar := 0 }}
{{ $emptyStars := 5 }}
<!-- Calc star rating -->
{{ $rating := $dbFetch.rating_grade }} <!-- Get the rating -->
{{ if $rating }}
    {{ $starCount = div (mul $rating 5) 10 }}
    {{ $fullStars = int $starCount }} <!-- Full stars count -->

    <!-- Determine if there is a half star -->
    {{ if (mod $rating 2) }}
        {{ $halfStar = 1 }}
    {{ end }}

    <!-- Calculate empty stars -->
    {{ $emptyStars = sub 5 (add $fullStars $halfStar) }} <!-- Empty stars count -->
{{ end }}

<!-- Check if data is retrieved -->
{{ if $dbFetch }}
<div class="db-card">
    <div class="db-card-subject">
        <div class="db-card-post"><img src="{{ $dbFetch.item.cover_image_url }}" alt="Cover Image"
                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">{{ $dbFetch.item.title }}</a>
            </div>
            <div class="db-card-rating">
                {{ $dbFetch.created_time | time.Format "2006-01-02T15:04:05Z" | time.Format "2006年01月02日" }} {{ $displayText }}
                <!-- Add the rating as stars -->
                <!-- Full stars -->
                {{ if $fullStars }}
                {{ range $i := (seq 1 $fullStars) }}
                <i class="fa-solid fa-star"></i>
                {{ end }}
                {{ end }}

                <!-- Half star -->
                {{ if $halfStar }}
                <i class="fa-regular fa-star-half-stroke"></i>
                {{ end }}

                <!-- Empty stars -->
                {{ if $emptyStars }}
                {{ range $i := (seq 1 $emptyStars) }}
                <i class="fa-regular fa-star"></i>
                {{ end }}
                {{ end }}
            </div>
            <div class="db-card-comment">{{ $dbFetch.comment_text }}</div>
        </div>
        <div class="db-card-cate">{{ $dbFetch.item.category }}</div>
    </div>
</div>
{{ else }}
<p style="text-align: center;"><small>Failed to fetch content, please check the API validity.</small></p>
{{ end }}

这上面的代码里有一个步骤是将 neodb 评分(1-10 的数字)转换成了星星,其中使用到了 Font Awesome,如果博客没有这个的话的话需要去 Font Awesome 上注册一个账号 - Add a new kit - 进入 kit 界面就能看到如下格式的代码,粘贴在 layouts/partials/head/custom.html 内:

<!-- Font awesome -->
<script src="https://kit.fontawesome.com/xxxxx.js" crossorigin="anonymous"></script>

自定义 css 外观样式

在 assets/scss/custom.scss 里增加如下代码,其中很多 font-size 的部分我引用了 Hugo Stack 主题里的变量,如果是别的主题则需要自行修改。

点我展开代码
// Neodb card style
.db-card {
    margin: 2.5rem 2.5rem;
    background: var(--color-codebg);
    border-radius: 7px;
    box-shadow: 0 6px 10px 0 #00000053;
}

.db-card-subject {
    display: flex;
    align-items: flex-start;
    line-height: 1.6;
    padding: 12px;
    position: relative;
}

.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: 15px;
    margin-top: 20px;
    display: flex;
    flex: 0 0 auto;
}

.db-card-title {
    margin-bottom: 3px;
    font-size: 1.6rem;
    color: var(--card-text-color-main);
    font-weight: bold;
}

.db-card-title a {
    text-decoration: none!important;
}

.db-card-rating {
    font-size: calc(var(--article-font-size) * 0.9);
}

.db-card-comment {
    font-size: calc(var(--article-font-size) * 0.9);
    margin-top: 10px;
    margin-bottom: 15px;
    overflow: auto;
    max-height: 150px!important;
    color: var(--card-text-color-main);
}

.db-card-cate {
    position: absolute;
    top: 0;
    right: 0;
    background: #8aa2d3;
    padding: 1px 8px;
    font-size: small;
    font-style: italic;
    border-radius: 0 8px 0 8px;
    text-transform: capitalize;
}

 .db-card-post img {
    width: 100px!important;
    height: 150px!important;
    border-radius: 4px;
    -o-object-fit: cover;
    object-fit: cover;
}

@media (max-width: 600px) {
    .db-card {
        margin: 0.8rem 0.5rem;
    }
    .db-card-title {
        font-size: calc(var(--article-font-size) * 0.75);
    }
    .db-card-rating {
      font-size: calc(var(--article-font-size) * 0.7);
    }
    .db-card-comment {
      font-size: calc(var(--article-font-size) * 0.7);
    }
}

macOS 风格的代码块

效果如下:

看了博友 Yelle的装修博文 发现的,具体代码来自 L1nSn0w’s Blog ,我调了下样式,比原版教程更紧凑一些。在 assets/scss/partials/layout/article.scss,找到 .highlight 部分并修改成如下:

.highlight {
    background-color: var(--pre-background-color);
    padding: var(--card-padding);
    position: relative;
    border-radius: 10px;
    max-width: 100% !important;
    margin: 0 !important;
    box-shadow: var(--shadow-l1) !important;

创建 static/img/code-header.svg 文件:

点我展开代码
<svg xmlns="http://www.w3.org/2000/svg" version="1.1"  x="0px" y="0px" width="450px" height="130px">
    <ellipse cx="65" cy="65" rx="50" ry="52" stroke="rgb(220,60,54)" stroke-width="2" fill="rgb(237,108,96)"/>
    <ellipse cx="225" cy="65" rx="50" ry="52"  stroke="rgb(218,151,33)" stroke-width="2" fill="rgb(247,193,81)"/>
    <ellipse cx="385" cy="65" rx="50" ry="52"  stroke="rgb(27,161,37)" stroke-width="2" fill="rgb(100,200,86)"/>
</svg>

最后在 assets/scss/custom.scss 添加代码块的样式:

// 为代码块顶部添加macos样式
.article-content {
    .highlight:before {
      content: "";
      display: block;
      background: url(/img/code-header.svg);
      height: 25px;
      width: 100%;
      background-size: 52px;
      background-repeat: no-repeat;
      margin-top: -10px;
      margin-bottom: 0;
    }
  }

首页标签云显示数目

效果:

在前情提要 “在归档页增加标签云tags” 里,已经在归档页增加了标签云及其数目,但首页的标签云还没有显示数量,这里也补充一下。在下述文件增加代码:

assets/scss/partials/widgets.scss

.tagCloud-count-main {
    margin-left: 7px; // Use a separate setting so that it didn't affect the style in archive page
    color: var(--body-text-color);
}

layouts/partials/widget/tag-cloud.html

{{ .Page.Title }}<span class="tagCloud-count-main">{{ .Count }}</span

修改博客运行时间格式成 “x 年 x 月 x 天 “

前情提要:见第一篇的 博客已运行x天x小时x分钟字样 。前篇的时间格式最大单位为天,随着博客变老(?),我决定把单位改为年月日,效果:

在 layouts/partials/footer/custom.html,修改代码如下:

<!-- layouts/partials/footer/custom.html -->
<script>
 let s1 = '2023-3-18'; //website start date
 s1 = new Date(s1.replace(/-/g, "/"));
 let s2 = new Date();

 // Calculate the difference
 let diffInMilliseconds = s2.getTime() - s1.getTime();
 let totalDays = Math.floor(diffInMilliseconds / (1000 * 60 * 60 * 24));

 // Create a new date object starting from the initial date
 let years = s2.getFullYear() - s1.getFullYear();
 let months = s2.getMonth() - s1.getMonth();
 let days = s2.getDate() - s1.getDate();

 // Adjust months and years if necessary
 if (days < 0) {
   months -= 1;
   let prevMonth = new Date(s2.getFullYear(), s2.getMonth(), 0); // Get the last day of the previous month
   days += prevMonth.getDate();
 }
 if (months < 0) {
   years -= 1;
   months += 12;
 }

 // Format the result
 let result = `${years}${months}${days}天`;
 document.getElementById('runningdays').innerHTML = result;
</script>

修改文章统计总数格式为 x 万 x 千字

前情提要:见 总字数统计发表了x篇文章共计x字 。随着博客字数的增加,这里把字数格式单位增加到了万,效果同样见上图。

修改 layouts/partials/footer/footer.html 成如下

    <!-- Add total page and word count time -->
    <section class="totalcount">
        {{$scratch := newScratch}}
        {{ range (where .Site.Pages "Kind" "page" )}}
        {{$scratch.Add "total" .WordCount}}
        {{ end }}
        {{ $totalWords := $scratch.Get "total" }}
        {{ $tenThousands := div $totalWords 10000 }}
        {{ $remainingThousands := mod (div $totalWords 1000) 10 }}

        发表了{{ len (where .Site.RegularPages "Section" "post") }}篇文章 ·
        总计{{ $tenThousands }}万{{ $remainingThousands }}千字
        <br>

    </section>

使图床链接的图片居中

在我的 Hugo Stack 主题版本里,默认只支持本地引用的图片居中,而在使用 url 图片链接时没有居中格式。在 assets/scss/partials/layout/article.scss 里增加以下代码 p > img 的部分,我放在了 figure 的后面:

    figure {
        text-align: center;
	// other code
    }

    // Center image from url source
    p > img {
        display: block;
        margin: 0 auto;
        max-width: 100%;
        height: auto;
    }

给文章增加 emoji 点赞按钮

前几天 竹子的博客发了这个open heart的教程 ,看上去很好玩就立马加上了。这是我第一次用 Cloudflare kv namespace 的服务,中途走了点弯路所以这里详细讲一下,最主要的变动是解决了 api 代码占用很多 kv 资源的问题(太容易超过 Cloudflare 每日限额了)。最终效果如图:

创建 Cloudflare worker

这一步和原教程一样。

  1. 注册 Cloudflare 账号
  2. 安装 node.js和npm
  3. 参照 官方指南 ,在 Terminal 里用以下命令行创建一个 worker project 文件夹。在这个示例里,代码在 username/path 文件夹内新建了一个名为 worker-test 的 worker 文件夹。第一次运行时可能会出现要安装 create-cloudflare 的提示,按 y 回车继续。
cd username/path
npm create cloudflare@latest -- worker-test

Need to install the following packages:
[email protected]
Ok to proceed? (y)
  1. 从命令行的提示里选择模板。逐步选择 Hello World example - Hello World Worker - TypeScript,之后的两个问题 git version control 和 deploy your application 分别选 Yes 和 No。

  2. 用命令行 cd worker-test 定位到刚才新建的文件夹,再 npx wrangler devnpm run start,运行后就能在浏览器的 http://localhost:8787 里看到 Hello world 了。

创建 KV namespace 并更新设置

具体参考 Cloudflare官方创建KV namespace的文档 。开一个新的 Terminal 并确保 cd 位置到同一个文件夹 worker-test,用以下代码新建一个 cloudflare kv namesace,以下是起名为 worker-test-kv 的示例:

npx wrangler kv namespace create worker-test-kv

这个时候会弹出 Cloudflare 的登录页,授权完成后,回到 Terminal 就会有成功的提示了,记下最后的 bindingid 值。

🌀 Creating namespace with title "worker-test-worker-test-kv"
✨ Success!
Add the following to your configuration file in your kv_namespaces array:
[[kv_namespaces]]
binding = "worker_test_kv"
id = "11111222223333333"

在本地的 worker 文件夹根目录找到 wrangler.toml,搜索 kv_namespaces 并 uncomment 掉以下三行,填入上一步的 bindingid 值,示例如下:

[[kv_namespaces]]
binding = "worker_test_kv"
id = "11111222223333333"

创建 emoji script

在 worker 文件夹内新建 src/index.ts,我这里没有完全照抄 open heart protocol提供的api代码 ,因为原代码会使用很多list operations,而 Cloudflare 免费版 KV 资源有限 。我的办法简而言之就是通过直接在 script 里定义 emoji 串、来替代用 list() 方法查找,所以需要在这一行 const emojis = ["❤️", "👍", "😂", "🎉"]; 自定义支持的 emoji 列表。最后,在使用时需要把代码里的 env.KV 全部按照 binding 值替换,如示例中应该替换为 env.worker_test_kv

点我展开代码
const instruction = `.^⋁^.
'. .'
  \`

dddddddddzzzz
OpenHeart protocol API

https://api.oh.dddddddddzzzz.org

Test with example.com as <domain>.

GET /<domain>/<uid> to look up reactions for <uid> under <domain>

POST /<domain>/<uid> to send an emoji

<uid> must not contain a forward slash.
<domain> owner has the right to remove data under its domain scope.

----- Test in CLI -----
Send emoji:
curl -d '<emoji>' -X POST 'https://api.oh.dddddddddzzzz.org/example.com/uid'

Get all emoji counts for /example.com/uid:
curl 'https://api.oh.dddddddddzzzz.org/example.com/uid'
`;

export default {
  async fetch(request, env) {
    if (request.method == 'OPTIONS') {
      return new Response(null, { headers });
    }
    if (request.method === 'GET') {
      if (url(request).pathname === '/') {
        return new Response(instruction, { headers });
      } else {
        return handleGet(request, env);
      }
    }
    if (request.method === 'POST') return handlePost(request, env);
  },
};

const headers = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET,POST",
  "Access-Control-Max-Age": "86400",
};

function error(text, code = 400) {
  return new Response(text, { headers, status: code });
}

async function handleGet(request, env) {
  const [domain, ...uidParts] = url(request).pathname.slice(1).split('/');
  const uid = uidParts ? uidParts.join('/') : null;
  if (!domain || !uid) {
    return error('Domain or UID missing.');
  }

  const list = {};
  const emojis = ["❤️", "👍", "😂", "🎉"]; // Add expected emojis here

  // Fetch counts for each emoji directly
  for (const emoji of emojis) {
    const key = `${domain}:${uid}:${emoji}`;
    const value = await env.KV.get(key);
    if (value) {
      list[emoji] = Number(value);
    }
  }

  return new Response(
    JSON.stringify(list, null, 2), // Return only the found counts
    { headers: { ...headers, "Content-Type": "application/json;charset=UTF-8" } }
  );
}

function url(request) {
  return new URL(request.url);
}

async function handlePost(request, env) {
  const urlObject = url(request);
  const path = urlObject.pathname.slice(1);
  if (path === '') return error('Pathname missing');

  const [domain, ...uidParts] = path.split('/');
  const uid = uidParts ? uidParts.join('/') : '';
  if (uid.length < 1) return error('UID required.');

  const id = [encodeURI(domain), uid].join(':');
  const emoji = ensureEmoji(await request.text());
  if (!emoji) return error('Request body should contain an emoji');

  const key = `${id}:${emoji}`;
  const currentCount = Number(await env.KV.get(key) || 0);
  await env.KV.put(key, (currentCount + 1).toString());

  const redirection = urlObject.searchParams.get('redirect');
  if (redirection !== null) {
    headers['Location'] = redirection || request.headers.get('Referer');
    return new Response('recorded', { headers, status: 303 });
  } else {
    return new Response('recorded', { headers });
  }
}

function ensureEmoji(emoji) {
  const segments = Array.from(
    new Intl.Segmenter({ granularity: 'grapheme' }).segment(emoji.trim())
  );
  const parsedEmoji = segments.length > 0 ? segments[0].segment : null;

  if (/\p{Emoji}/u.test(parsedEmoji)) return parsedEmoji;
}

发布 worker 到 Cloudflare

npm run deploynpx wrangler deploy 将 worker 发布到 Cloudflare 上,命令行末尾会显示 Cloudflare 的 worker 地址,如示例中是 https://worker-test.myusername.workers.dev。发布后,同样能在 Cloudflare 网页端看到这个新的 worker project。

(可选)使用 Custom domain 替代 Cloudflare worker 地址

默认的 worker 地址中含有 Cloudflare 用户名,如果你跟我一样希望隐藏它,可以选择用 Custom domain 替代这个地址,前提是域名已经用 Cloudflare DNS 解析,这个相关教程很多就不展开了。

  1. 进入 Cloudflare 的对应 worker 界面,点击 Setting - 点击 Domains & Routes 右上角的 Add - 选择 Custom domain - 输入合适的 custom domain,比如我的是 open-heart-reaction.thirdshire.com

  2. 回到本地的 worker project 的 wrangler.toml,添加以下代码

    [[routes]]
    pattern = "open-heart-reaction.thirdshire.com"
    custom_domain = true
    
  3. npm run deploy 将更新推送到 Cloudflare 上,这时应该会显示上面的 custom domain 地址而不是原先的默认 workers.dev

在博客页面添加 emoji 按钮

到这里就属于前端和 UI 的部分了,作用是把 emoji 按钮显示在合适的地方。

第一步是载入 emoji 按钮。以我的 Hugo Stack 主题为例,新建 layouts/partials/article/components/reaction.html,其中第一行的链接里是之前显示的默认 worker 地址或 custom domain 地址:

<!-- emoji 可为多个,但必须要在前面的可识别列表里出现 -->
<open-heart href="https://worker-test.myusername.workers.dev/{{ .Permalink }}" emoji="❤️">❤️</open-heart>

<!-- load web component -->
<script src="https://unpkg.com/open-heart-element" type="module"></script>
<!-- when the webcomponent loads, fetch the current counts for that page -->
<script>
window.customElements.whenDefined('open-heart').then(() => {
  for (const oh of document.querySelectorAll('open-heart')) {
    oh.getCount()
  }
})
// refresh component after click
window.addEventListener('open-heart', e => {
	e && e.target && e.target.getCount && e.target.getCount()
})
</script>

第二步是在博客合适的位置插入。找到 layouts/partials/article/article.html,将刚才的 reaction.html 放在 content 和 footer 位置之间:

  {{ partial "article/components/content" . }}

  <!-- Add reaction -->
  {{ partial "article/components/reaction.html" . }}

  {{ partial "article/components/footer" . }}

最后在 assets/scss/custom.scss 增加 css 外观样式:

// Open heart reaction style
open-heart {
  margin: var(--card-padding);
  margin-top: 0;
  display: block;  // Center alignment
  margin-left: auto;
  margin-right: auto;
  width: fit-content;
  border: 1px solid #FFA7B6;
  border-radius: .4em;
  padding: .4em;

}
open-heart:not([disabled]):hover,
open-heart:not([disabled]):focus {
  border-color: var(--accent-color);
  cursor: pointer;
}
open-heart[disabled] {
  background: #FFA7B6;
  border: 1px solid #FFA7B6;
  cursor: not-allowed;
  color: #fff;
}
open-heart[count]:not([count="0"])::after {
  content: attr(count);
  padding: .2em;
}


可以点击下方尝试喔 ⬇️ ⬇️

❤️
本博客已稳定运行
发表了45篇文章 · 总计12万9千字
·
使用 Hugo 构建 · Deployed on Cloudflare
主题 StackJimmy 设计