# 插件
以本项目 wheatear (opens new window) 为例
# 导航菜单排序
目的
将现有的根据 nav-sort.json 对目录树排序的操作进行可视化处理
同时为目录/文件排序提供一个可用的 node 服务器
因为 vuepress 本身在 dev 下已经使用了 webpack-dev-server 作为开发环境的服务器
所以考虑直接使用该 server 而不是再新建一个
# 开发插件
新建 docs/.vuepress/plugins/ 目录用于存放插件
在目录下新建插件目录 docs/.vuepress/plugins/nav-sort-server
在 nav-sort-server 目录下新建 index.js
const fse = require("fs-extra");
const path = require("path");
const bodyParser = require("body-parser");
const standardResponse = (isSuccess, msgCode, response) => ({
resultCode: isSuccess ? 1 : 0,
data: isSuccess ? response || null : null,
msg:
{
1: "成功",
2: "失败"
}[msgCode] || "没有消息"
});
const targetDirectory = `${process.cwd()}/docs/zh/.resources`;
const navTreePath = `${targetDirectory}/nav-dev.json`;
const navSortPath = `${targetDirectory}/nav-sort.json`;
module.exports = (options, ctx) => {
return {
beforeDevServer(app, server) {
/**
* 基于 express
* @see http://www.expressjs.com.cn/4x/api.html
*/
// for parsing application/json
app.use(bodyParser.json());
// for parsing application/x-www-form-urlencodedƒ
app.use(bodyParser.urlencoded({ extended: true }));
/* 页面 */
// 页面 导航菜单配置
app.get("/page/nav", function(req, res) {
// res.sendFile(`${process.cwd()}/docs/.vuepress/plugins/nav-sort-server/pages/navsort/index.html`);
// 更好的路径获取方案
res.sendFile(path.resolve(__dirname, "pages/navsort/index.html"));
});
/* 服务 */
// 获取 导航菜单
app.get("/serve/nav/tree", function(req, res) {
const nav = fse.readFileSync(navTreePath, "utf-8");
res.json(standardResponse(true, 1, nav));
});
// 更新 导航菜单
app.post("/serve/nav/tree", function(req, res) {
const { data } = req.body;
fse.writeFileSync(navTreePath, JSON.stringify(data));
res.json(standardResponse(true, 1));
});
// 获取 导航菜单排序
app.get("/serve/nav/sort", function(req, res) {
const navsort = fse.readFileSync(navSortPath, "utf-8");
res.json(standardResponse(true, 1, navsort));
});
// 更新 导航菜单排序
app.post("/serve/nav/sort", function(req, res) {
const { data } = req.body;
fse.writeFileSync(navSortPath, JSON.stringify(data));
res.json(standardResponse(true, 1));
});
}
};
};
参考 beforeDevServer (opens new window)
# 引入插件
在 docs/.vuepress/config.js 中添加
plugins: [
[require('./plugins/nav-sort-server')],
],
# 配置页面
因为 vuepress 是 Vue 驱动的静态网站生成器,所以考虑直接使用 vue 编写页面
至于如何引入 vue 文件,可以往 webpack-dev-server 及 Markdown 转 Vue 的中间过程方向思考
取巧的是,本项目使用了 vuepress 的默认主题,根据 特定页面的自定义布局 (opens new window) 可以实现快速引入
这里采用 设置 作为这一类功能的目录
新建 docs/zh/设置/导航菜单排序.md
---
layout: Navsort
---
新建 docs/.vuepress/components/Navsort.vue
依据默认主题的 Layouts.vue 改写,保留了 Sidebar 和 Navbar
<template>
<div
class="theme-container"
:class="pageClasses"
@touchstart="onTouchStart"
@touchend="onTouchEnd"
>
<Navbar v-if="shouldShowNavbar" @toggle-sidebar="toggleSidebar" />
<div class="sidebar-mask" @click="toggleSidebar(false)"></div>
<Sidebar :items="sidebarItemsPlus" @toggle-sidebar="toggleSidebar">
<slot name="sidebar-top" slot="top" />
<slot name="sidebar-bottom" slot="bottom" />
</Sidebar>
<!-- 通过类名使用默认主题的样式 -->
<main class="page">
<!-- 通过类名使用默认主题的样式 -->
<div class="theme-default-content content__default">
<h1>导航菜单排序</h1>
<section>
<el-input placeholder="输入关键字进行过滤" v-model="filterText">
</el-input>
</section>
<section class="badge-desc">
<span v-for="badge in badges" :key="badge.type">
<el-badge :type="badge.type" is-dot> </el-badge>
{{ badge.desc }}
</span>
<span class="sort-info">
<span>未排序个数:{{ unsortedLinksLength }}</span>
<span>排序已改变个数:{{ changedLinksLength }}</span>
<el-button
@click="handleSave"
:disabled="
changedLinksLength === 0 && unsortedLinksLength.length === 0
"
type="primary"
circle
size="mini"
>存</el-button
>
</span>
</section>
<section>
<el-tree
:data="navTree"
node-key="link"
default-expand-all
draggable
@node-drag-end="handleDragEnd"
icon-class="el-icon-s-flag"
:filter-node-method="filterNode"
:props="defaultProps"
:allow-drop="allowDrop"
:allow-drag="allowDrag"
ref="tree"
>
<span class="custom-tree-node" slot-scope="{ node, data }">
<span
class="custom-tree-node-text"
:class="`custom-tree-node-text-${sortType(data.link)}`"
>
{{ data.originName }}
</span>
<el-badge :type="sortType(data.link)" is-dot> </el-badge>
</span>
</el-tree>
</section>
</div>
</main>
</div>
</template>
<script>
import Navbar from "@parent-theme/components/Navbar.vue";
import Sidebar from "@parent-theme/components/Sidebar.vue";
import { resolveSidebarItems } from "@theme/util";
import axios from "axios";
import qs from "qs";
// 引入 element-ui
import { Button, Tree, Badge, Input, Message } from "element-ui";
import "element-ui/lib/theme-chalk/index.css";
export default {
components: {
Sidebar,
Navbar,
[Button.name]: Button,
[Tree.name]: Tree,
[Badge.name]: Badge,
[Input.name]: Input
},
data() {
return {
isSidebarOpen: false,
defaultProps: {
children: "children",
label: "originName"
},
badges: [
{
type: "success",
desc: "已排序"
},
{
type: "warning",
desc: "排序已改变"
},
{
type: "info",
desc: "未排序"
}
],
navTree: [],
navSort: [],
originNavTreeSort: [],
filterText: ""
};
},
computed: {
shouldShowNavbar() {
const { themeConfig } = this.$site;
const { frontmatter } = this.$page;
if (frontmatter.navbar === false || themeConfig.navbar === false) {
return false;
}
return (
this.$title ||
themeConfig.logo ||
themeConfig.repo ||
themeConfig.nav ||
this.$themeLocaleConfig.nav
);
},
shouldShowSidebar() {
const { frontmatter } = this.$page;
return (
!frontmatter.home &&
frontmatter.sidebar !== false &&
this.sidebarItems.length
);
},
sidebarItems() {
return resolveSidebarItems(
this.$page,
this.$page.regularPath,
this.$site,
this.$localePath
);
},
pageClasses() {
const userPageClass = this.$page.frontmatter.pageClass;
return [
{
"no-navbar": !this.shouldShowNavbar,
"sidebar-open": this.isSidebarOpen,
"no-sidebar": !this.shouldShowSidebar
},
userPageClass
];
},
/**
* 给 sidebarItems 赋名称
* 因为 YAML front matter 未获取到一级标题
*/
sidebarItemsPlus() {
if (this.sidebarItems.length > 0) {
this.sidebarItems[0].children.forEach(x => {
x.title = /\/([^/]+)\.html/.exec(x.path)[1];
});
}
return this.sidebarItems;
},
changedLinksLength() {
return this.originNavTreeSort.filter((x, i) => {
return i !== this.currentNavTreeSort.findIndex(y => y === x);
}).length;
},
unsortedLinksLength() {
return this.currentNavTreeSort.length - this.navSort.length;
},
currentNavTreeSort() {
const newSort = [];
const recursion = array => {
array.forEach(x => {
newSort.push(x.link);
if (Array.isArray(x.children) && x.children.length > 0) {
recursion(x.children);
}
});
};
recursion(this.navTree);
return newSort;
}
},
watch: {
filterText(val) {
this.$refs.tree.filter(val);
}
},
mounted() {
this.$router.afterEach(() => {
this.isSidebarOpen = false;
});
this.init();
},
methods: {
toggleSidebar(to) {
this.isSidebarOpen = typeof to === "boolean" ? to : !this.isSidebarOpen;
},
onTouchStart(e) {
this.touchStart = {
x: e.changedTouches[0].clientX,
y: e.changedTouches[0].clientY
};
},
onTouchEnd(e) {
const dx = e.changedTouches[0].clientX - this.touchStart.x;
const dy = e.changedTouches[0].clientY - this.touchStart.y;
if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 40) {
if (dx > 0 && this.touchStart.x <= 80) {
this.toggleSidebar(true);
} else {
this.toggleSidebar(false);
}
}
},
DefaultValues() {
return {
originNavTreeSort: []
};
},
init() {
Promise.all([this.getNavTree(), this.getNavSort()])
.then(() => {
this.originNavTreeSort = JSON.parse(
JSON.stringify(this.DefaultValues().originNavTreeSort)
);
const recursion = array => {
array.forEach(x => {
this.originNavTreeSort.push(x.link);
if (Array.isArray(x.children) && x.children.length > 0) {
recursion(x.children);
}
});
};
recursion(this.navTree);
})
.catch(() => {});
},
async getNavTree() {
return axios
.get("/serve/nav/tree")
.then(res => {
const { data } = res.data;
this.navTree = JSON.parse(data);
})
.catch(() => {
Message.error("获取【导航菜单树】失败");
});
},
async getNavSort() {
return axios
.get("/serve/nav/sort")
.then(res => {
const { data } = res.data;
this.navSort = JSON.parse(data);
})
.catch(() => {
Message.error("获取【导航菜单排序】失败");
});
},
async setNavTree(tree) {
return axios({
url: "/serve/nav/tree",
method: "post",
headers: {
"Content-Type": "application/json;charset=UTF-8"
},
data: { data: tree }
})
.then(() => {})
.catch(() => {
Message.error("设置【导航菜单树】失败");
});
},
async setNavSort(sort) {
return axios({
url: "/serve/nav/sort",
method: "post",
headers: {
"Content-Type": "application/json;charset=UTF-8"
},
data: { data: sort }
})
.then(res => {
const { resultCode } = res.data;
if (resultCode === 1) {
this.init();
}
})
.catch(() => {
Message.error("设置【导航菜单排序】失败");
});
},
handleSave() {
Promise.all([
this.setNavTree(this.navTree),
this.setNavSort([...this.currentNavTreeSort])
])
.then(() => {})
.catch(() => {});
},
filterNode(value, data) {
if (!value) return true;
return data.link.indexOf(value) !== -1;
},
sortType(link) {
const originIndex = this.originNavTreeSort.findIndex(x => x === link);
const currentIndex = this.currentNavTreeSort.findIndex(x => x === link);
if (originIndex === currentIndex) {
if (this.navSort.includes(link)) {
// 已排序
return "success";
} else {
// 未排序
return "info";
}
}
// 排序已改变
return "warning";
},
handleDragEnd(draggingNode, dropNode, dropType, ev) {
// 拖拽放置未成功
if (dropType === "none") return;
},
allowDrop(draggingNode, dropNode, type) {
// 不能改变菜单层级
if (type === "inner") return false;
// 只能改变同层级前后顺序
return draggingNode.parent === dropNode.parent;
},
allowDrag(draggingNode) {
return true;
}
}
// end
};
</script>
<style scoped>
.theme-default-content section {
margin: 10px 0;
}
.badge-desc {
font-size: 12px;
color: #aaa;
}
.badge-desc .el-badge {
top: 2px;
margin-left: 8px;
}
.sort-info {
margin-left: 10px;
}
.sort-info > span {
margin-right: 10px;
}
.custom-tree-node {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
padding-right: 8px;
}
.custom-tree-node-text {
padding: 2px 3px;
}
.custom-tree-node-text-warning {
color: #fff;
background-color: #e6a23c;
}
.custom-tree-node-text-info {
color: #fff;
background-color: #909399;
}
</style>
# 注意事项
因为服务中是通过 nav-dev.json 获取的目录树
而非通过分析目录结构(在 docs/.vuepress/nav/index.js 中实现,并最终写入 nav-dev.json)获得
因此当开发环境启动后的目录变化比如添加了一个文件,不会在 设置/导航菜单排序 的页面中体现
同样的,该变化也不会在页面的导航栏或侧边栏中体现
这时,重新启动开发环境即可(会重新分析目录结构,生成 nav-dev.json)
另,本方案可能不是最好的方案,不过可以作为解决方案之一,也相对适合本项目的现有进度
# 最近更新列表
目的
提供一个按更新时间倒序排列的文章快速入口
因为已有官方插件 last-update (opens new window)
所以参考其源码 node_modules/@vuepress/plugin-last-updated/index.js
# 开发插件
新建 docs/.vuepress/plugins/last-updated-files/index.js
const path = require("path");
const spawn = require("cross-spawn");
const { includesFiles } = require("../../nav/zh");
const lastUpdatedOfAllFiles = {};
module.exports = (options = {}, context) => ({
extendPageData($page) {
const timestamp = getGitLastUpdatedTimeStamp($page._filePath);
if (includesFiles.includes($page.relativePath) && timestamp) {
lastUpdatedOfAllFiles[$page.relativePath] = {
title: $page.title || /\/([^/]+)\.md/.exec($page.relativePath)[1],
path: $page.path,
timestamp
};
}
$page.lastUpdatedOfAllFiles = lastUpdatedOfAllFiles;
}
});
function getGitLastUpdatedTimeStamp(filePath) {
let lastUpdated;
try {
lastUpdated =
parseInt(
spawn
.sync("git", ["log", "-1", "--format=%at", path.basename(filePath)], {
cwd: path.dirname(filePath)
})
.stdout.toString("utf-8")
) * 1000;
} catch (e) {
/* do not handle for now */
}
return lastUpdated;
}
为了区分开发环境和生产环境
修改 docs/.vuepress/nav/index.js
// ... some code
let [excludesFiles, ignoreList, sortList] = [[], [], []];
const includesFiles = [];
// ... some code
if (dirent.isFile()) {
includesFiles.push(`${path}/${dirent.name}`);
if (dirent.name === "README.md") {
file.text = "";
file.pathName = "";
hasReadme = true;
} else {
file.text = getFileName(dirent);
}
}
// ... some code
// getSort 的返回值修改为
return Object.assign(func(currentDirectoryPath), { includesFiles });
修改 docs/.vuepress/nav/zh.js 为
const { getDirectoryFiles, getNav, getSidebar } = require("./index");
const { files, includesFiles } = getDirectoryFiles("zh");
const nav = getNav(files);
const sidebar = getSidebar(files);
// 可以在这里再次进行处理
module.exports = {
nav,
sidebar,
includesFiles
};
# 引入插件
在 docs/.vuepress/config.js 中添加
plugins: [
[require('./plugins/last-updated-files')],
],
# 使用插件
考虑直接在首页上展示
详见【首页】代码
# 优化
# 2020.03.03
添加最近发布列表
-- 将 git log 的第一条记录的时间作为文件的发布时间
优化最近更新列表规则
-- 将更新时间和发布时间相等的排除
主要代码
docs/.vuepress/plugins/last-updated-files/index.js 中新增
function getGitFirstTrackedTimeStamp(filePath) {
let firstTracked;
try {
// 1565602676\n1565548863\n1565539297\n
const tracked = spawn
.sync("git", ["log", "--format=%at", path.basename(filePath)], {
cwd: path.dirname(filePath)
})
.stdout.toString("utf-8")
.split("\n");
firstTracked = parseInt(tracked[tracked.length - 2]) * 1000;
} catch (e) {
/* do not handle for now */
}
return firstTracked;
}
完整代码
const path = require('path')
const spawn = require('cross-spawn')
const { includesFiles } = require('../../nav/zh')
const lastUpdatedOfAllFiles = {};
module.exports = (options = {}, context) => ({
extendPageData($page) {
const createTimestamp = getGitFirstTrackedTimeStamp($page._filePath)
const timestamp = getGitLastUpdatedTimeStamp($page._filePath)
if (includesFiles.includes($page.relativePath) && timestamp) {
lastUpdatedOfAllFiles[$page.relativePath] = {
title: $page.title || /\/([^/]+)\.md/.exec($page.relativePath)[1],
path: $page.path,
timestamp,
createTimestamp,
}
}
$page.createTimestamp = createTimestamp;
// 只有 home 页用到
if ($page.path === '/') {
$page.lastUpdatedOfAllFiles = lastUpdatedOfAllFiles;
}
}
})
function getGitLastUpdatedTimeStamp(filePath) {
let lastUpdated
try {
lastUpdated = parseInt(spawn.sync(
'git',
['log', '-1', '--format=%at', path.basename(filePath)],
{ cwd: path.dirname(filePath) }
).stdout.toString('utf-8')) * 1000
} catch (e) { /* do not handle for now */ }
return lastUpdated
}
function getGitFirstTrackedTimeStamp(filePath) {
let firstTracked
try {
// 1565602676\n1565548863\n1565539297\n
const tracked = spawn.sync(
'git',
['log', '--format=%at', path.basename(filePath)],
{ cwd: path.dirname(filePath) }
).stdout.toString('utf-8').split('\n')
firstTracked = parseInt(tracked[tracked.length - 2]) * 1000
} catch (e) { /* do not handle for now */ }
return firstTracked
}
在 docs/.vuepress/theme/components/Home.vue 中增加最近发布列表
完整代码
<template>
<main class="home"
aria-labelledby="main-title">
<section class="main-content">
<!-- hero -->
<header class="hero">
<img v-if="data.heroImage"
:src="$withBase(data.heroImage)"
:alt="data.heroAlt || 'hero'">
<h1 v-if="data.heroText !== null"
id="main-title">{{ data.heroText || $title || 'Hello' }}</h1>
<p class="description">
{{ data.tagline || $description || 'Welcome' }}
</p>
<p class="action"
v-if="data.actionText && data.actionLink">
<NavLink class="action-button"
:item="actionLink" />
</p>
</header>
<!-- 最近发布列表 最近更新列表 -->
<div class="last-updated-files">
<h3>最近发布</h3>
<div class="links">
<div class="link"
v-for="(link, index) in lastCreatedFiles"
:key="index">
<span>
{{moment(link.createTimestamp).format('YYYY年MM月DD日')}}
</span>
<i class="el-icon-d-arrow-right"></i>
<el-link :href="link.path"
:underline="false">
{{`${link.title}`}}
</el-link>
</div>
</div>
<h3>最近更新</h3>
<div class="links">
<div class="link"
v-for="(link, index) in lastUpdatedFiles"
:key="index">
<span>
{{moment(link.timestamp).format('YYYY年MM月DD日')}}
</span>
<i class="el-icon-d-arrow-right"></i>
<el-link :href="link.path"
:underline="false">
{{`${link.title}`}}
</el-link>
</div>
</div>
</div>
</section>
<section class="features"
v-if="data.features && data.features.length">
<div class="feature"
v-for="(feature, index) in data.features"
:key="index">
<h2>{{ feature.title }}</h2>
<p v-for="(detail, detailIndex) in feature.details"
:key="detailIndex">{{ detail }}</p>
</div>
</section>
<Content class="theme-default-content custom" />
<!-- live2d -->
<section class="live2d-container">
<live2d class="live2d"></live2d>
</section>
<div class="footer"
v-if="data.footer">
{{ data.footer }}
</div>
</main>
</template>
<script>
import NavLink from '@parent-theme/components/NavLink.vue';
import {
Icon,
Link,
} from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
import moment from 'moment';
import live2d from '@theme/components/live2d.vue'
export default {
components: {
NavLink,
[Icon.name]: Icon,
[Link.name]: Link,
live2d,
},
computed: {
data() {
return this.$page.frontmatter
},
actionLink() {
return {
link: this.data.actionLink,
text: this.data.actionText
}
},
lastCreatedFiles() {
return this.$page.lastUpdatedOfAllFiles
? Object.values(this.$page.lastUpdatedOfAllFiles)
.sort((a, b) => b.createTimestamp - a.createTimestamp)
.slice(0, 10)
: []
},
lastUpdatedFiles() {
return this.$page.lastUpdatedOfAllFiles
? Object.values(this.$page.lastUpdatedOfAllFiles)
.filter((x) => x.timestamp !== x.createTimestamp)
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, 5)
: []
}
},
data() {
return {
moment,
}
}
}
</script>
<style lang="stylus" scope>
.home
padding: $navbarHeight 2rem 0
max-width: 960px
margin: 0px auto
display: block
.main-content
display: flex
flex-wrap: wrap
align-items: flex-start
align-content: stretch
justify-content: space-around
.hero
text-align: center
img
max-width: 100%
max-height: 280px
display: block
margin: 3rem auto 1.5rem
border-radius: 50%
box-shadow: 0 10px 5px #999
h1
font-size: 3rem
h1, .description, .action
margin: 1.8rem auto
.description
max-width: 35rem
font-size: 1.6rem
line-height: 1.3
color: lighten($textColor, 40%)
.action-button
display: inline-block
font-size: 1.2rem
color: #fff
background-color: $accentColor
padding: 0.8rem 1.6rem
border-radius: 4px
transition: background-color 0.1s ease
box-sizing: border-box
border-bottom: 1px solid darken($accentColor, 10%)
&:hover
background-color: lighten($accentColor, 10%)
.last-updated-files
margin-top: 0rem
margin-left: 2rem
h3
margin: 2rem 0 0.5rem
.links
max-height: 360px
overflow: auto
.link
user-select: none
padding: 2px 0
> span
display: inline-block
width: 100px
font-size: 12px
color: #909399
> i
font-size: 12px
color: #909399
margin-right: 5px
.features
border-top: 1px solid $borderColor
padding: 1.2rem 0
margin-top: 2.5rem
display: flex
flex-wrap: wrap
align-items: flex-start
align-content: stretch
justify-content: space-between
.live2d-container
position: fixed
right: 0px
bottom: 20px
width: 380px
height: 200px
.feature
flex-grow: 1
flex-basis: 30%
max-width: 30%
h2
font-size: 1.4rem
font-weight: 500
border-bottom: none
padding-bottom: 0
color: lighten($textColor, 10%)
p
color: lighten($textColor, 25%)
&:last-of-type
padding-right: 1rem
text-align: right
.footer
padding: 2.5rem
border-top: 1px solid $borderColor
text-align: center
color: lighten($textColor, 25%)
@media (max-width: $MQMobile)
.home
.hero
width: 100%
.last-updated-files
width: 100%
margin-top: 0
.features
flex-direction: column
.feature
max-width: 100%
padding: 0 2.5rem
@media (max-width: $MQMobileNarrow)
.home
padding-left: 1.5rem
padding-right: 1.5rem
.hero
width: 100%
img
max-height: 210px
margin: 2rem auto 1.2rem
h1
font-size: 2rem
h1, .description, .action
margin: 1.2rem auto
.description
font-size: 1.2rem
.action-button
font-size: 1rem
padding: 0.6rem 1.2rem
.last-updated-files
width: 100%
margin-top: 0
.feature
h2
font-size: 1.25rem
</style>