refactor(layout): widget to jsx

pull/588/head
ppoffice 2019-12-22 17:23:49 -05:00
parent 0dab77811f
commit 7d544c5c3b
31 changed files with 720 additions and 314 deletions

View File

@ -26,7 +26,9 @@
{
"allowModules": [
"inferno",
"inferno-create-element"
"inferno-create-element",
"hexo-util",
"cheerio"
]
}
]

View File

@ -20,13 +20,16 @@ module.exports = {
* @param {string} prefix Cache ID prefix
* @param {Function} transform Transform the input props to target props and
* its result is used to compute cache ID
* @returns Cached JSX element if cache ID is found.
* Return null if the props transform result is empty, which means the props
* @returns A cache-enabled component.
* It returns cached JSX element when called if cache ID is found.
* It returns null if the props transform result is empty, which means the props
* passed to the createElement() indicates the element does not need to be created.
* Otherwise, create a new element and cache it if the transform function is provided.
* Otherwise, it creates a new element and caches it if the transform function is provided.
* The original component can be accessed from the `_type` property of the return value.
* The props transform function can be accessed from the `_transform` property of the return value.
*/
cacheComponent(type, prefix, transform) {
return props => {
const component = props => {
const targetProps = transform(props);
if (!targetProps) {
return null;
@ -40,5 +43,8 @@ module.exports = {
}
return cache[cacheId];
};
component._type = type;
component._transform = transform;
return component;
}
};

View File

@ -1,23 +0,0 @@
<div class="card widget">
<div class="card-content">
<div class="menu">
<h3 class="menu-label">
<%= _p('common.archive', Infinity) %>
</h3>
<ul class="menu-list">
<% _list_archives().forEach(archive => { %>
<li>
<a class="level is-marginless" href="<%= archive.url %>">
<span class="level-start">
<span class="level-item"><%= archive.name %></span>
</span>
<span class="level-end">
<span class="level-item tag"><%= archive.count %></span>
</span>
</a>
</li>
<% }) %>
</ul>
</div>
</div>
</div>

View File

@ -1,3 +0,0 @@
module.exports = (ctx, locals) => {
return locals;
}

View File

@ -0,0 +1,99 @@
'use strict';
const { Component } = require('inferno');
const { cacheComponent } = require('../util/cache');
class Archives extends Component {
render() {
const {
items,
title,
showCount
} = this.props;
return <div className="card widget">
<div class="card-content">
<div class="menu">
<h3 class="menu-label">{title}</h3>
<ul class="menu-list">
{items.map(archive => <li>
<a class="level is-marginless" href={archive.url}>
<span class="level-start">
<span class="level-item">{archive.name}</span>
</span>
{showCount ? <span class="level-end">
<span class="level-item tag">{archive.count}</span>
</span> : null}
</a>
</li>)}
</ul>
</div>
</div>
</div>;
}
}
module.exports = cacheComponent(Archives, 'widget.archives', props => {
// adapted from hexo/lib/plugins/helper/list_archives.js
const { config, page, type = 'monthly', order = -1, url_for, _p } = props;
const posts = props.site.posts.sort('date', order);
if (!posts.length) {
return null;
}
const language = page.lang || page.language || config.language;
const format = props.format || type === 'monthly' ? 'MMMM YYYY' : 'YYYY';
const showCount = Object.prototype.hasOwnProperty.call(props, 'show_count') ? props.show_count : true;
const data = [];
let length = 0;
posts.forEach(post => {
// Clone the date object to avoid pollution
let date = post.date.clone();
if (config.timezone) {
date = date.tz(config.timezone);
}
if (language) {
date = date.locale(language);
}
const year = date.year();
const month = date.month() + 1;
const name = date.format(format);
const lastData = data[length - 1];
if (!lastData || lastData.name !== name) {
length = data.push({
name,
year,
month,
count: 1
});
} else {
lastData.count++;
}
});
const link = item => {
let url = `${config.archive_dir}/${item.year}/`;
if (type === 'monthly') {
if (item.month < 10) url += '0';
url += `${item.month}/`;
}
return url_for(url);
};
return {
items: data.map(item => ({
name: item.name,
count: item.count,
url: link(item)
})),
title: _p('common.archive', Infinity),
showCount
};
});

View File

@ -0,0 +1,99 @@
'use strict';
const { Component } = require('inferno');
const { cacheComponent } = require('../util/cache');
class Categories extends Component {
renderList(categories, showCount) {
return categories.map(category => <li>
<a class="level is-marginless" href={category.url}>
<span class="level-start">
<span class="level-item">{category.name}</span>
</span>
{showCount ? <span class="level-end">
<span class="level-item tag">{category.count}</span>
</span> : null}
</a>
{category.children.length ? <ul>{this.renderList(category.children)}</ul> : null}
</li>);
}
render() {
const {
title,
showCount,
categories
} = this.props;
return <div className="card widget">
<div class="card-content">
<div class="menu">
<h3 class="menu-label">{title}</h3>
<ul class="menu-list">
{this.renderList(categories, showCount)}
</ul>
</div>
</div>
</div>;
}
}
module.exports = cacheComponent(Categories, 'widget.categories', props => {
// adapted from hexo/lib/plugins/helper/list_categories.js
const categories = props.categories || props.site.categories;
if (!categories.length) {
return null;
}
const { orderBy = 'name', order = 1, page, url_for, _p } = props;
const depth = props.depth ? parseInt(props.depth, 10) : 0;
const showCurrent = props.show_current || false;
const showCount = Object.prototype.hasOwnProperty.call(props, 'show_count') ? props.show_count : true;
function prepareQuery(parent) {
const query = {};
if (parent) {
query.parent = parent;
} else {
query.parent = { $exists: false };
}
return categories.find(query).sort(orderBy, order).filter(cat => cat.length);
}
function hierarchicalList(level, parent) {
return prepareQuery(parent).map((cat, i) => {
let children = [];
if (!depth || level + 1 < depth) {
children = hierarchicalList(level + 1, cat._id);
}
let isCurrent = false;
if (showCurrent && page) {
for (let j = 0; j < cat.length; j++) {
const post = cat.posts.data[j];
if (post && post._id === page._id) {
isCurrent = true;
break;
}
}
// special case: category page
isCurrent = isCurrent || (page.base && page.base.startsWith(cat.path));
}
return {
children,
isCurrent,
name: cat.name,
count: cat.length,
url: url_for(cat.path)
};
});
}
return {
showCount,
categories: hierarchicalList(0),
title: _p('common.category', Infinity)
};
});

View File

@ -1 +0,0 @@
<%- _partial('categories') %>

View File

@ -1,3 +0,0 @@
module.exports = (ctx, locals) => {
return locals;
}

View File

@ -1,23 +0,0 @@
<div class="card widget">
<div class="card-content">
<div class="menu">
<h3 class="menu-label">
<%= __('widget.links') %>
</h3>
<ul class="menu-list">
<% for (let i in links) { %>
<li>
<a class="level is-mobile" href="<%- links[i] %>" target="_blank" rel="noopener">
<span class="level-left">
<span class="level-item"><%= i %></span>
</span>
<span class="level-right">
<span class="level-item tag"><%- get_domain(links[i]) %></span>
</span>
</a>
</li>
<% } %>
</ul>
</div>
</div>
</div>

46
layout/widget/links.jsx Normal file
View File

@ -0,0 +1,46 @@
'use strict';
const { URL } = require('url');
const { Component } = require('inferno');
const { cacheComponent } = require('../util/cache');
class Links extends Component {
render() {
const { title, links } = this.props;
return <div class="card widget">
<div class="card-content">
<div class="menu">
<h3 class="menu-label">{title}</h3>
<ul class="menu-list">
{Object.keys(links).map(i => {
let hostname = links[i];
try {
hostname = new URL(hostname).hostname;
} catch (e) { }
return <li>
<a class="level is-mobile" href="<%- links[i] %>" target="_blank" rel="noopener">
<span class="level-left">
<span class="level-item">{i}</span>
</span>
<span class="level-right">
<span class="level-item tag">{hostname}</span>
</span>
</a>
</li>;
})}
</ul>
</div>
</div>
</div>;
}
}
module.exports = cacheComponent(Links, 'widget.links', props => {
if (!Object.keys(props.links).length) {
return null;
}
return {
title: props.__('widget.links'),
links: props.links
};
});

View File

@ -1,8 +0,0 @@
module.exports = (ctx, locals) => {
const { get_config_from_obj } = ctx;
const links = get_config_from_obj(locals.widget, 'links');
if (Object.keys(links).length == 0) {
return null;
}
return Object.assign(locals, { links });
}

View File

@ -1,101 +0,0 @@
<% function avatar() {
const avatar = get_config_from_obj(widget, 'avatar');
const gravatar_email = get_config_from_obj(widget, 'gravatar');
if (gravatar_email) {
return gravatar(gravatar_email, 128);
}
if (avatar) {
return url_for(avatar)
}
return url_for('images/avatar.png');
} %>
<div class="card widget">
<div class="card-content">
<nav class="level">
<div class="level-item has-text-centered" style="flex-shrink: 1">
<div>
<% const is_rounded = get_config_from_obj(widget, 'avatar_rounded', false); %>
<figure class="image is-128x128 has-mb-6">
<img class="<%= is_rounded ? 'is-rounded' : '' %>" src="<%= avatar() %>" alt="<%= get_config_from_obj(widget, 'author') %>">
</figure>
<% if (get_config_from_obj(widget, 'author')) { %>
<p class="is-size-4 is-block">
<%= get_config_from_obj(widget, 'author') %>
</p>
<% } %>
<% if (get_config_from_obj(widget, 'author_title')) { %>
<p class="is-size-6 is-block">
<%= get_config_from_obj(widget, 'author_title') %>
</p>
<% } %>
<% if (get_config_from_obj(widget, 'location')) { %>
<p class="is-size-6 is-flex is-flex-center has-text-grey">
<i class="fas fa-map-marker-alt has-mr-7"></i>
<span><%= get_config_from_obj(widget, 'location') %></span>
</p>
<% } %>
</div>
</div>
</nav>
<nav class="level is-mobile">
<div class="level-item has-text-centered is-marginless">
<div>
<p class="heading">
<%= _p('common.post', post_count()) %>
</p>
<a href="<%= url_for('/archives') %>">
<p class="title has-text-weight-normal">
<%= post_count() %>
</p>
</a>
</div>
</div>
<div class="level-item has-text-centered is-marginless">
<div>
<p class="heading">
<%= _p('common.category', category_count()) %>
</p>
<a href="<%= url_for('/categories') %>">
<p class="title has-text-weight-normal">
<%= category_count() %>
</p>
</a>
</div>
</div>
<div class="level-item has-text-centered is-marginless">
<div>
<p class="heading">
<%= _p('common.tag', tag_count()) %>
</p>
<a href="<%= url_for('/tags') %>">
<p class="title has-text-weight-normal">
<%= tag_count() %>
</p>
</a>
</div>
</div>
</nav>
<% if (widget.follow_link) { %>
<div class="level">
<a class="level-item button is-link is-rounded" href="<%= url_for(widget.follow_link) %>" target="_blank" rel="noopener">
<%= __('widget.follow') %></a>
</div>
<% } %>
<% const socialLinks = get_config_from_obj(widget, 'social_links'); %>
<% if (socialLinks !== null) { %>
<div class="level is-mobile">
<% for (let name in socialLinks) {
let link = socialLinks[name]; %>
<a class="level-item button is-white is-marginless" target="_blank" rel="noopener"
title="<%= name %>" href="<%= url_for(typeof(link) === 'string' ? link : link.url) %>">
<% if (typeof(link) === 'string') { %>
<%= name %>
<% } else { %>
<i class="<%= link.icon %>"></i>
<% } %>
</a>
<% } %>
</div>
<% } %>
</div>
</div>

159
layout/widget/profile.jsx Normal file
View File

@ -0,0 +1,159 @@
'use strict';
const { Component } = require('inferno');
const gravatrHelper = require('hexo-util').gravatar;
const { cacheComponent } = require('../util/cache');
class Profile extends Component {
renderSocialLinks(links) {
if (!links.length) {
return null;
}
return <div class="level is-mobile">
{links.map(link => {
return <a class="level-item button is-white is-marginless"
target="_blank" rel="noopener" title={link.name} href={link.url}>
{Object.prototype.hasOwnProperty.call(link, 'icon') ? <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">
<div class="card-content">
<nav class="level">
<div class="level-item has-text-centered" style="flex-shrink: 1">
<div>
<figure class="image is-128x128 has-mb-6">
<img class={avatarRounded ? 'is-rounded' : ''} src={avatar} alt={author} />
</figure>
{author ? <p class="is-size-4 is-block">{author}</p> : null}
{authorTitle ? <p class="is-size-6 is-block">{authorTitle}</p> : null}
{location ? <p class="is-size-6 is-flex is-flex-center has-text-grey">
<i class="fas fa-map-marker-alt has-mr-7"></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 has-text-weight-normal">{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 has-text-weight-normal">{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 has-text-weight-normal">{counter.tag.count}</p>
</a>
</div>
</div>
</nav>
{followLink ? <div class="level">
<a class="level-item button is-link is-rounded" href={followLink} target="_blank" rel="noopener">{followTitle}</a>
</div> : null}
{this.renderSocialLinks(socialLinks)}
</div>
</div>;
}
}
module.exports = cacheComponent(Profile, 'widget.profile', props => {
const {
avatar,
gravatar,
avatar_rounded = false,
author = props.config.author,
author_title,
location,
follow_link,
social_links,
site,
url_for,
_p,
__
} = props;
function getAvatar() {
if (gravatar) {
return gravatrHelper(gravatar, 128);
}
if (avatar) {
return url_for(avatar);
}
return url_for('/images/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 = 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.name),
icon: link.icon
};
});
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: url_for(follow_link),
followTitle: __('widget.follow'),
socialLinks
};
});

View File

@ -1,3 +0,0 @@
module.exports = (ctx, locals) => {
return locals;
}

View File

@ -1,32 +0,0 @@
<div class="card widget">
<div class="card-content">
<h3 class="menu-label">
<%= __('widget.recents') %>
</h3>
<% posts.forEach(post => { %>
<article class="media">
<% if (thumbnail) { %>
<a href="<%- url_for(post.link ? post.link : post.path) %>" class="media-left">
<p class="image is-64x64">
<img class="thumbnail" src="<%= post.thumbnail %>" alt="<%= post.title %>">
</p>
</a>
<% } %>
<div class="media-content">
<div class="content">
<div><time class="has-text-grey is-size-7 is-uppercase" datetime="<%= date_xml(post.date) %>"><%= date(post.date) %></time></div>
<a href="<%- url_for((post.link?post.link:post.path)) %>" class="title has-link-black-ter is-size-6 has-text-weight-normal"><%= post.title %></a>
<p class="is-size-7 is-uppercase">
<%- list_categories(post.categories(), {
show_count: false,
class: 'has-link-grey ',
depth:2,
style: 'none',
separator: ' / '}) %>
</p>
</div>
</div>
</article>
<% }) %>
</div>
</div>

View File

@ -0,0 +1,64 @@
'use strict';
const { Component } = require('inferno');
const { cacheComponent } = require('../util/cache');
class RecentPosts extends Component {
render() {
const { title, thumbnail, posts } = this.props;
return <div class="card widget">
<div class="card-content">
<h3 class="menu-label">{title}</h3>
{posts.map(post => {
const categories = [];
post.categories.forEach((category, i) => {
categories.push(<a class="has-link-grey" href={category.url}>{category.name}</a>);
if (i < post.categories.length - 1) {
categories.push('/');
}
});
return <article class="media">
{thumbnail ? <a href={post.url} class="media-left">
<p class="image is-64x64">
<img class="thumbnail" src={post.thumbnail} alt={post.title} />
</p>
</a> : null}
<div class="media-content">
<div class="content">
<div><time class="has-text-grey is-size-7 is-uppercase" datetime={post.dateXml}>{post.date}</time></div>
<a href={post.url} class="title has-link-black-ter is-size-6 has-text-weight-normal">{post.title}</a>
<p class="is-size-7 is-uppercase">{categories}</p>
</div>
</div>
</article>;
})}
</div>
</div>;
}
}
module.exports = cacheComponent(RecentPosts, 'widget.recentposts', props => {
const { site, config, get_thumbnail, url_for, __, date_xml, date } = props;
if (!site.posts.length) {
return null;
}
const thumbnail = config.article && config.article.thumbnail;
const posts = site.posts.sort('date', -1).limit(5).map(post => ({
url: url_for(post.permalink || post.link || post.path),
title: post.title,
date: date(post.date),
dateXml: date_xml(post.date),
thumbnail: thumbnail ? get_thumbnail(post) : null,
// TODO: check if categories work
categories: post.categories.map(category => ({
name: category.name,
url: url_for(category.path)
}))
}));
return {
posts,
thumbnail,
title: __('widget.recents')
};
});

View File

@ -1,18 +0,0 @@
module.exports = (ctx, locals) => {
const { has_config, get_config, get_thumbnail } = ctx;
const { posts } = ctx.site;
if (!posts.length) {
return null;
}
const thumbnail = !has_config('article.thumbnail') || get_config('article.thumbnail') !== false;
const _posts = posts.sort('date', -1).limit(5).map(post => ({
link: post.link,
path: post.path,
title: post.title,
date: post.date,
thumbnail: thumbnail ? get_thumbnail(post) : null,
// fix circular JSON serialization issue
categories: () => post.categories
}));
return Object.assign(locals, { thumbnail, posts: _posts });
}

View File

@ -1,30 +0,0 @@
<div class="card widget">
<div class="card-content">
<div class="menu">
<h3 class="menu-label">
<%= __('widget.email.title') %>
</h3>
<div>
<form action="https://feedburner.google.com/fb/a/mailverify" method="post" target="popupwindow"
onsubmit="window.open('https://feedburner.google.com/fb/a/mailverify?uri=<%= get_config_from_obj(widget, 'feedburner_id') %>', 'popupwindow', 'scrollbars=yes,width=550,height=520');return true">
<input type="hidden" value="<%= get_config_from_obj(widget, 'feedburner_id') %>" name="uri" />
<input type="hidden" name="loc" value="en_US" />
<div class="field">
<div class="control has-icons-left">
<input class="input" name="email" type="email" />
<span class="icon is-small is-left">
<i class="fas fa-envelope"></i>
</span>
</div>
<p class="help"><%= get_config_from_obj(widget, 'description') %></p>
</div>
<div class="field is-grouped is-grouped-right">
<div class="control">
<input class="button is-link" type="submit" value="<%= __('widget.email.button') %>" />
</div>
</div>
</form>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,50 @@
'use strict';
const { Component } = require('inferno');
const { cacheComponent } = require('../util/cache');
class SubscribeEmail extends Component {
render() {
const { title, description, feedburnerId, buttonTitle } = this.props;
return <div class="card widget">
<div class="card-content">
<div class="menu">
<h3 class="menu-label">{title}</h3>
<div>
<form action="https://feedburner.google.com/fb/a/mailverify" method="post" target="popupwindow"
onsubmit={`window.open('https://feedburner.google.com/fb/a/mailverify?uri=${feedburnerId}','popupwindow','scrollbars=yes,width=550,height=520');return true`}>
<input type="hidden" value={feedburnerId} name="uri" />
<input type="hidden" name="loc" value="en_US" />
<div class="field">
<div class="control has-icons-left">
<input class="input" name="email" type="email" />
<span class="icon is-small is-left">
<i class="fas fa-envelope"></i>
</span>
</div>
<p class="help">{description}</p>
</div>
<div class="field is-grouped is-grouped-right">
<div class="control">
<input class="button is-link" type="submit" value={buttonTitle} />
</div>
</div>
</form>
</div>
</div>
</div>
</div>;
}
}
module.exports = cacheComponent(SubscribeEmail, 'widget.subscribeemail', props => {
const { feedburner_id, description, __ } = props;
return {
title: __('widget.email.title'),
feedburnerId: feedburner_id,
description,
buttonTitle: __('widget.email.button')
};
});

View File

@ -1,3 +0,0 @@
module.exports = (ctx, locals) => {
return locals;
}

View File

@ -1 +0,0 @@
<%- _partial('tags') %>

View File

@ -1,8 +0,0 @@
<div class="card widget">
<div class="card-content">
<h3 class="menu-label">
<%= __('widget.tag_cloud') %>
</h3>
<%- tagcloud() %>
</div>
</div>

View File

@ -1,6 +0,0 @@
module.exports = (ctx, locals) => {
if (!ctx.site.tags.length) {
return null;
}
return locals;
}

55
layout/widget/tags.jsx Normal file
View File

@ -0,0 +1,55 @@
'use strict';
const { Component } = require('inferno');
const { cacheComponent } = require('../util/cache');
class Tags extends Component {
render() {
const {
tags,
title,
showCount
} = this.props;
return <div className="card widget">
<div class="card-content">
<div class="menu">
<h3 class="menu-label">{title}</h3>
<div class="field is-grouped is-grouped-multiline">
{tags.map(tag => <div class="control">
<a class="tags has-addons" href={tag.url}>
<span class="tag">{tag.name}</span>
{showCount ? <span class="tag is-grey">{tag.count}</span> : null}
</a>
</div>)}
</div>
</div>
</div>
</div>;
}
}
module.exports = cacheComponent(Tags, 'widget.tags', props => {
// adapted from hexo/lib/plugins/helper/list_tags.js
let tags = props.tags || props.site.tags;
if (!tags.length) {
return null;
}
const { orderBy = 'name', order = 1, amount, url_for, _p } = props;
const showCount = Object.prototype.hasOwnProperty.call(props, 'show_count') ? props.show_count : true;
tags = tags.sort(orderBy, order).filter(tag => tag.length);
if (amount) {
tags = tags.limit(amount);
}
return {
showCount,
title: _p('common.tag', Infinity),
tags: tags.map(tag => ({
name: tag.name,
count: tag.length,
url: url_for(tag.path)
}))
};
});

View File

@ -1,38 +0,0 @@
<% function buildToc(toc) {
let result = '';
if (toc.hasOwnProperty('id') && toc.hasOwnProperty('index') && toc.hasOwnProperty('text')) {
result += `<li>
<a class="is-flex" href="#${toc.id}">
<span class="has-mr-6">${toc.index}</span>
<span>${toc.text}</span>
</a>`;
}
let keys = Object.keys(toc);
keys.indexOf('id') > -1 && keys.splice(keys.indexOf('id'), 1);
keys.indexOf('text') > -1 && keys.splice(keys.indexOf('text'), 1);
keys.indexOf('index') > -1 && keys.splice(keys.indexOf('index'), 1);
keys = keys.map(k => parseInt(k)).sort((a, b) => a - b);
if (keys.length > 0) {
result += '<ul class="menu-list">';
for (let i of keys) {
result += buildToc(toc[i]);
}
result += '</ul>';
}
if (toc.hasOwnProperty('id') && toc.hasOwnProperty('index') && toc.hasOwnProperty('text')) {
result += '</li>';
}
return result;
} %>
<% let tocContent = buildToc(_toc(content)); if (tocContent !== undefined && tocContent !== ''){ %>
<div class="card widget" id="toc">
<div class="card-content">
<div class="menu">
<h3 class="menu-label">
<%= _p('widget.catalogue', Infinity) %>
</h3>
<%- tocContent %>
</div>
</div>
</div>
<% } %>

135
layout/widget/toc.jsx Normal file
View File

@ -0,0 +1,135 @@
'use strict';
const cheerio = require('cheerio');
const { Component } = require('inferno');
const { cacheComponent } = require('../util/cache');
/**
* Export a tree of headings of an article
* {
* "1": {
* "id": "How-to-enable-table-of-content-for-a-post",
* "text": "How to enable table of content for a post",
* "index": "1"
* },
* "2": {
* "1": {
* "1": {
* "id": "Third-level-title",
* "text": "Third level title",
* "index": "2.1.1"
* },
* "id": "Second-level-title",
* "text": "Second level title",
* "index": "2.1"
* },
* "2": {
* "id": "Another-second-level-title",
* "text": "Another second level title",
* "index": "2.2"
* },
* "id": "First-level-title",
* "text": "First level title",
* "index": "2"
* }
* }
*/
function getToc(content) {
const $ = cheerio.load(content, { decodeEntities: false });
const toc = {};
const levels = [0, 0, 0];
// Get top 3 headings that are present in the content
const tags = [1, 2, 3, 4, 5, 6].map(i => 'h' + i).filter(h => $(h).length > 0).slice(0, 3);
if (tags.length === 0) {
return toc;
}
$(tags.join(',')).each(function() {
const level = tags.indexOf(this.name);
const id = $(this).attr('id');
const text = $(this).text();
for (let i = 0; i < levels.length; i++) {
if (i > level) {
levels[i] = 0;
} else if (i < level) {
if (levels[i] === 0) {
// if headings start with a lower level heading, set the former heading index to 1
// e.g. h3, h2, h1, h2, h3 => 1.1.1, 1.2, 2, 2.1, 2.1.1
levels[i] = 1;
}
} else {
levels[i] += 1;
}
}
let node = toc;
for (const i of levels.slice(0, level + 1)) {
if (!Object.prototype.hasOwnProperty.call(node, i)) {
node[i] = {};
}
node = node[i];
}
node.id = id;
node.text = text;
node.index = levels.slice(0, level + 1).join('.');
});
return toc;
}
class Toc extends Component {
renderToc(toc) {
let result;
const keys = Object.keys(toc)
.filter(key => !['id', 'index', 'text'].includes(key))
.map(key => parseInt(key, 10))
.sort((a, b) => a - b);
if (keys.length > 0) {
result = <ul class="menu-list">
{keys.map(i => this.renderToc(toc[i]))}
</ul>;
}
if (Object.prototype.hasOwnProperty.call(toc, 'id')
&& Object.prototype.hasOwnProperty.call(toc, 'index')
&& Object.prototype.hasOwnProperty.call(toc, 'text')) {
result = <li>
<a class="is-flex" href={'#' + toc.id}>
<span class="has-mr-6">{toc.index}</span>
<span>{toc.text}</span>
</a>
{result}
</li>;
}
return result;
}
render() {
const toc = getToc(this.props.content);
if (!Object.keys(toc).length) {
return null;
}
return <div class="card widget" id="toc">
<div class="card-content">
<div class="menu">
<h3 class="menu-label">{this.props.title}</h3>
{this.renderToc(toc)}
</div>
</div>
</div>;
}
}
module.exports = cacheComponent(Toc, 'widget.toc', props => {
const { toc, page, _p } = props;
const { layout, content } = page;
if (toc !== true || (layout !== 'page' && layout !== 'post')) {
return null;
}
return {
title: _p('widget.catalogue', Infinity),
content
};
});

View File

@ -1,8 +0,0 @@
module.exports = (ctx, locals) => {
const { layout, content } = ctx.page;
const { get_config } = ctx;
if (get_config('toc') !== true || (layout !== 'page' && layout !== 'post')) {
return null;
}
return Object.assign(locals, { content });
}