# Hugo Mastodon 评论系统 极简版

> [Hugo Mastodon 评论系统 极简版](https://www.ftls.xyz/posts/2026-03-28-hugo-simple-mastodon-comment/)
> Penned by [恐咖兵糖](https://www.ftls.xyz/) on 2026-03-28


基于 Alpine.js + UnoCSS 的 Hugo Mastodon 评论系统 极简版

<!--more-->

## 缘起

最近新主题研究了一下，应该适配什么评论系统。

静态博客可选的评论系统还挺多的。支持的平台也多。即使要使用一些没有受到支持的平台，使用非主流 DB，大概现在 AI 也能很快搓一个出来。

想了想最后还是随便搞了一个基于长毛象的，评论系统。或者说互动吧，其实称不上是评论系统。

以前搞过长毛象登录博客回复，也搞过发博后自动创建关联嘟嘟。不过这次倒是没有那么复杂了。功能就是传入嘟嘟链接，调用 API 获取点赞等数据，获取回复内容。

## 效果

见
- https://www.ftls.xyz/links/
- https://www.ftls.xyz/posts/2026-03-22-hugo-theme-somnia/

## 实现

功能十分的简陋。也就比没有强一点。好处是轻量(~简陋~)。

```yaml
---
title: Title
comments: true
fediverse: "https://mastodon.social/@name/xxxxxxxxx"
---
```

```html
{{$url:=.Params.fediverse}}
{{if $url}}
<div x-data="mastodonCommentComponent()" x-init="initMastodonCommentComponent('{{$url}}', '{{$id}}')"
    class="relative flex flex-col gap-y-2 rounded-xl border px-3 sm:px-4 py-2 sm:py-3 mt-3">
    <div class="font-medium text-foreground">Fediverse 
    </div>
    <div x-text="info"></div>
    {{/*  <a class="text-foreground" x-text="`${status.account.display_name}`" :href="status.account.url" target="_blank" rel="noopener noreferrer"></a>  */}}
    <div x-html="sanitizeHTML(status.content)"></div>
    <a class="flex gap-x-4 justify-end" :href="url" target="_blank" rel="noopener noreferrer">
        <span class="mdi--reply"></span>
        <span x-text="status.replies_count"></span>
        <span class="mdi--star"></span>
        <span x-text="status.favourites_count"></span>
        <span class="mdi--twitter-retweet"></span>
        <span x-text="status.reblogs_count"></span>
    </a>
    <div class="flex gap-x-4 justify-center flex-col">
        <template x-for="reply in replies">
            <span class="gap-y-2 rounded-xl border px-3 sm:px-4 py-2 sm:py-3 mt-3">
                <a class="text-foreground" x-text="reply.account.display_name" :href="reply.account.url" target="_blank" rel="noopener noreferrer"></a>
                <span x-html="sanitizeHTML(reply.content)"></span>
            </span>
        </template>
    </div>
</div>
<script>
    function mastodonCommentComponent() {
    return {
        info: "",
        url: "",
        status: {
            content: "",
            account: {
                display_name: "",
                url: "",
            },
            replies_count: 0,
            reblogs_count: 0,
            favourites_count: 0,
        },
        replies: [],
        async initMastodonCommentComponent(url, id) {
            this.url = url;
            const api = `https://${url.split("/")[2]}/api/v1/statuses/${url.split("/")[4]}`;
            this.fetchStatus(api);
        },
        async fetchStatus(url) {
            fetch(url)
                .then((response) => response.json())
                .then((data) => {
                    this.status = data;
                    if (this.status.replies_count > 0) {
                        this.fetchReplies(url + "/context");
                    }
                })
                .catch((error) => this.info = error);
        },
        async fetchReplies(url) {
            fetch(url)
                .then((response) => response.json())
                .then((data) => {
                    this.replies = data.descendants;
                })
                .catch((error) => this.info = error);
        },
        sanitizeHTML(html) {
            // 移除不安全的标签
            html = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
            html = html.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '');

            // 移除不安全的属性
            html = html.replace(/javascript:/gi, '');

            return html;
        }
    }
}
</script>
<style>
    .mdi--reply {
        display: inline-block;
        width: 1.3em;
        height: 1.3em;
        --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M10 9V5l-7 7l7 7v-4.1c5 0 8.5 1.6 11 5.1c-1-5-4-10-11-11'/%3E%3C/svg%3E");
        background-color: currentColor;
        -webkit-mask-image: var(--svg);
        mask-image: var(--svg);
        -webkit-mask-repeat: no-repeat;
        mask-repeat: no-repeat;
        -webkit-mask-size: 100% 100%;
        mask-size: 100% 100%;
    }

    .mdi--star {
        display: inline-block;
        width: 1.3em;
        height: 1.3em;
        --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.62L12 2L9.19 8.62L2 9.24l5.45 4.73L5.82 21z'/%3E%3C/svg%3E");
        background-color: currentColor;
        -webkit-mask-image: var(--svg);
        mask-image: var(--svg);
        -webkit-mask-repeat: no-repeat;
        mask-repeat: no-repeat;
        -webkit-mask-size: 100% 100%;
        mask-size: 100% 100%;
    }

    .mdi--twitter-retweet {
        display: inline-block;
        width: 1.3em;
        height: 1.3em;
        --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M6 5.75L10.25 10H7v6h6.5l2 2H7a2 2 0 0 1-2-2v-6H1.75zm12 12.5L13.75 14H17V8h-6.5l-2-2H17a2 2 0 0 1 2 2v6h3.25z'/%3E%3C/svg%3E");
        background-color: currentColor;
        -webkit-mask-image: var(--svg);
        mask-image: var(--svg);
        -webkit-mask-repeat: no-repeat;
        mask-repeat: no-repeat;
        -webkit-mask-size: 100% 100%;
        mask-size: 100% 100%;
    }
</style>
{{else}}
{{/* <div class="relative flex flex-col gap-y-2 rounded-xl border px-3 sm:px-4 py-2 sm:py-3 mt-3">
    <div class="font-medium text-foreground text-center">Fediverse Unready</div>
</div> */}}
{{end}}
```