Typography

四畳半のへや

给 icarus 主题增加所有文章的字数统计

发布于 # 技术文档

看到苏卡卡大佬的 profile 上有一个统计所有文章的字数功能,感觉很有意思,于是本菜鸡也决定给自己搞一个。

虽然说菜,但是菜有菜的办法,先到 profile.jsx 这个文件看一下组件的源码:

const { Component } = require('inferno');
const gravatrHelper = require('hexo-util').gravatar;
const { cacheComponent } = require('hexo-component-inferno/lib/util/cache');

class Profile extends Component {
    renderSocialLinks(links) {
        if (!links.length) {
            return null;
        }
        return <div class="level is-mobile is-multiline">
            {links.filter(link => typeof link === 'object').map(link => {
                return <a class="level-item button is-transparent is-marginless"
                    target="_blank" rel="noopener" title={link.name} href={link.url}>
                    {'icon' in link ? <i class={link.icon}></i> : link.name}
                </a>;
            })}
        </div>;
    }

    render() {
        const {
            avatar,
            avatarRounded,
            author,
            authorTitle,
            location,
            counter,
            followLink,
            followTitle,
            socialLinks
        } = this.props;
        return <div class="card widget" data-type="profile">
            <div class="card-content">
                <nav class="level">
                    <div class="level-item has-text-centered flex-shrink-1">
                        <div>
                            <figure class="image is-128x128 mx-auto mb-2">
                                <img class={'avatar' + (avatarRounded ? ' is-rounded' : '')} src={avatar} alt={author} />
                            </figure>
                            {author ? <p class="title is-size-4 is-block" style={{'line-height': 'inherit'}}>{author}</p> : null}
                            {authorTitle ? <p class="is-size-6 is-block">{authorTitle}</p> : null}
                            {location ? <p class="is-size-6 is-flex justify-content-center">
                                <i class="fas fa-map-marker-alt mr-1"></i>
                                <span>{location}</span>
                            </p> : null}
                        </div>
                    </div>
                </nav>
                <nav class="level is-mobile">
                    <div class="level-item has-text-centered is-marginless">
                        <div>
                            <p class="heading">{counter.post.title}</p>
                            <a href={counter.post.url}>
                                <p class="title">{counter.post.count}</p>
                            </a>
                        </div>
                    </div>
                    <div class="level-item has-text-centered is-marginless">
                        <div>
                            <p class="heading">{counter.category.title}</p>
                            <a href={counter.category.url}>
                                <p class="title">{counter.category.count}</p>
                            </a>
                        </div>
                    </div>
                    <div class="level-item has-text-centered is-marginless">
                        <div>
                            <p class="heading">{counter.tag.title}</p>
                            <a href={counter.tag.url}>
                                <p class="title">{counter.tag.count}</p>
                            </a>
                        </div>
                    </div>
                </nav>
                {followLink ? <div class="level">
                    <a class="level-item button is-primary is-rounded" href={followLink} target="_blank" rel="noopener">{followTitle}</a>
                </div> : null}
                {socialLinks ? this.renderSocialLinks(socialLinks) : null}
            </div>
        </div>;
    }
}

Profile.Cacheable = cacheComponent(Profile, 'widget.profile', props => {
    const { site, helper, widget } = props;
    const {
        avatar,
        gravatar,
        avatar_rounded = false,
        author = props.config.author,
        author_title,
        location,
        follow_link,
        social_links
    } = widget;
    const { url_for, _p, __ } = helper;

    function getAvatar() {
        if (gravatar) {
            return gravatrHelper(gravatar, 128);
        }
        if (avatar) {
            return url_for(avatar);
        }
        return url_for('/img/avatar.png');
    }

    const postCount = site.posts.length;
    const categoryCount = site.categories.filter(category => category.length).length;
    const tagCount = site.tags.filter(tag => tag.length).length;

    const socialLinks = social_links ? Object.keys(social_links).map(name => {
        const link = social_links[name];
        if (typeof link === 'string') {
            return {
                name,
                url: url_for(link)
            };
        }
        return {
            name,
            url: url_for(link.url),
            icon: link.icon
        };
    }) : null;

    return {
        avatar: getAvatar(),
        avatarRounded: avatar_rounded,
        author,
        authorTitle: author_title,
        location,
        counter: {
            post: {
                count: postCount,
                title: _p('common.post', postCount),
                url: url_for('/archives')
            },
            category: {
                count: categoryCount,
                title: _p('common.category', categoryCount),
                url: url_for('/categories')
            },
            tag: {
                count: tagCount,
                title: _p('common.tag', tagCount),
                url: url_for('/tags')
            }
        },
        followLink: follow_link ? url_for(follow_link) : undefined,
        followTitle: __('widget.follow'),
        socialLinks
    };
});

module.exports = Profile;

很长,虽然没什么头猪但是关键的部分还是能看懂的,首先我们需要添加 html,改个名字,直接复制过来就好了:

<div class="level-item has-text-centered is-marginless">
  <div>
    <p class="heading">{counter.word.title}</p>
    <a href={counter.word.url}>
      <p class="title">{counter.word.count}</p>
    </a>
  </div>
<div>

很简单,但是这时我们需要看一下这个 counter 是什么:

counter: {
  post: {
    count: postCount,
    title: _p('common.post', postCount),
    url: url_for('/archives')
  },
  category: {
    count: categoryCount,
    title: _p('common.category', categoryCount),
    url: url_for('/categories')
  },
  tag: {
    count: tagCount,
    title: _p('common.tag', tagCount),
    url: url_for('/tags')
  }
}

这个不就是定义三个计数器的属性的嘛,我们也来一个就好了:

word: {
    count: wordCount,
    title: _p('字数', wordCount),
}

这里需要注意的是这里我们 title 直接写死,要不然还得去其他地方配置。同时我们不需要点击跳转,所以也就不需要给它 url。 ~~~想写也没有~~~

这样一来样式就没问题了,我们开始计算字数,这是个麻烦事,先看看其他三个 counter 是怎么写的吧:

const postCount = site.posts.length;
const categoryCount = site.categories.filter(category => category.length).length;
const tagCount = site.tags.filter(tag => tag.length).length;

看起来是在 site 这个对象中储存了一些网站的信息,我们打印一下它的 post 属性看看是什么。

看起来还挺多,terminal 都放不下了,都是文章的各种信息,而且因为无法显示全部信息,我们不知道这个对象的全部属性是什么,也就没有办法拿到文章内容了。这个时候就需要用到 Object.getOwnPropertyNames 这个方法了。

Object.getOwnPropertyNames 方法返回一个数组,成员是参数对象自身的全部属性的属性名,不管该属性是否可遍历。

这样以来我们就能够摸清楚这个对象的结构了,大概是这样子:

site: {
  posts: { 
    data: [
      {
        _content: 文章内容,
        ...,
        ...
      },
      {
        ...
      }
    ],
    length: num
  },
  categories: { ... },
  tags: { ... }
}

文章内容其实有好多个属性都有保存,但是这个格式相对较少,我们就选择这个来统计文章字数。

site.posts.data[0]._content.length

试一下,好像计算出来的结果有点不对,看起来正好是一倍,那这好办,也懒得管为什么,直接给它砍一半

site.posts.data[0]._content.length / 2

OK,这样以来就没有问题了,之后只需要遍历 data 对象,计算出所有文章的字数总和就好了,这里是我写的函数:

function getWords(site) {
    let posts = site.posts.data;
    let words = 0;
    for (const post of posts) {
        words = words + post._content.length / 2;
    }
    words = (words / 10000).toFixed(2);
    return words;
}

这里我选择的单位是 ,保留了两位小数。最后我们只需要调用这个函数就 ok 了。

  const postCount = site.posts.length;
  const categoryCount = site.categories.filter(category => category.length).length;
  const tagCount = site.tags.filter(tag => tag.length).length;
  const wordCount = getWords(site);

最后放一张完成后的截图: