HexoPlusPlus-从一个妄想到现实

我一直都习惯在线写作,但因为口袋里没钱,不能买服务器用动态博客,使用Hexo,即使实现了集成部署,想要在github上直接书写,尤其是出门在外有所灵感,国内手机登陆github真的是极其糟糕的体验。博客本就是碎片化写作和高质量文章发布处,使用hexo却使我无法发挥博客的用处。

先前,我曾使用白嫖的Euserv搭建的Typecho,也是用过wordpress.com白嫖的wordpress,但两个都不符合我对速度和可用性的追求,一个连CloudFlare能不能连上都是问题,另一个中国支持贼差【虽然可以用万能Worker可以替换加解决,但是就是不想用啊】。免空的选择又难以择手,弄来弄去还是用回Hexo。

但是Hexo就是有一点不爽,每次使用的时候就必须要在本地进行构建静态网页,然后上传到GithubPage。后来实现了集成部署【没想到折腾了很长时间的集成部署最后用到这里了】,方便了不少,直接在Github上面改源代码。但相较于Typecho和Wordpress,没有后台的写作总感觉有点难受,每次更改源代码都要上Github,在国内这种大环境下总是不太好使的。

2020年最后一个月,我总是在想如何解决这个问题,我的要求很简单,能弄个在线书写环境就好了。

在当时,真的只是睡觉的时候想想,现在回头不禁感慨,这妄想真的实现了。

由于我的文件是存储在Github上,于是我第一个先去Github文档查找相关资料,果不其然,Github的API能够上传、删除、下载【废话】、列表文件,并且能通过base64上传,直接免去了手写头的问题.关于调用限制,没鉴权时每个ip每小时只有60次,但一旦鉴权每个用户每小时就有5000次。这些api完全能够支撑起一个在线写作的环境,https://developer.github.com/v3/guides/getting-started/更是详细讲解并提供了数个例子。

这篇文章,就是详细讲解我如何把这个梦想变成现实.具体步骤很多,请慢慢咀嚼

这篇不是使用文档,而是教程

原理 - GithubAPI

譬如罢,上传一个文件,首先你要鉴权,在header中写入:

content-type: "application/json;charset=UTF-8"
Authorization: "token  ${hpp_githubimagetoken}"

Anyone,你也可以在url后面加上?access_token=传参,但是这样不安全,Github官方也是提示将在明年彻底禁用传参鉴权

但是记得GithubAPI不允许空User-Agent,所以你还得在header中加入UA:

user-agent: "GoogleChrome",

OK这么一搞鉴权这一块就完毕了,接下来,我们要搞基本功能

Github更改一个文件的url是一样的,为了方便接下来的书写和表达,我们统一将以下url称为RESTURL:

https://api.github.com/repos/${Github用户名}/${Github仓库名字}/contents/${Github文件路径}/${Github文件名}?ref=${Github分支}

拉取信息

默认情况下,直接GET RESTURL就能获取该文件/文件夹的信息,例如获取我AVorBV.md源文件,那么RESTURL如下:

https://api.github.com/repos/ChenYFan/blog/contents/source/_posts/AVorBV.md?ref=master

直接GET[我的是公开仓库,不需要鉴权就能获取],得到数据如下:

{
"name": "AVorBV.md",
"path": "source/_posts/AVorBV.md",
"sha": "a0bd826f999a9bb73ac56251415f9e57199600a7",
"size": 15742,
"url": "https://api.github.com/repos/ChenYFan/blog/contents/source/_posts/AVorBV.md?ref=master",
"html_url": "https://github.com/ChenYFan/blog/blob/master/source/_posts/AVorBV.md",
"git_url": "https://api.github.com/repos/ChenYFan/blog/git/blobs/a0bd826f999a9bb73ac56251415f9e57199600a7",
"download_url": "https://raw.githubusercontent.com/ChenYFan/blog/master/source/_posts/AVorBV.md",
"type": "file",
"content": "dGl0bGU6IEFWP0JWIQphdX...",
"encoding": "base64",
"_links": {
"self": "https://api.github.com/repos/ChenYFan/blog/contents/source/_posts/AVorBV.md?ref=master",
"git": "https://api.github.com/repos/ChenYFan/blog/git/blobs/a0bd826f999a9bb73ac56251415f9e57199600a7",
"html": "https://github.com/ChenYFan/blog/blob/master/source/_posts/AVorBV.md"
}
}

这样子,我们只要提取json中的sha,就能知道到hash,进而进行修改.
但这样子有个非常尴尬的一点,单文件获取会把content一口气拿过来
例如下面的RESTURL

https://api.github.com/repos/ChenYFan/CDN/contents/img/hpp_upload/1612843011000.jpg?ref=master

你获取的时候会发现返回了这个:

{
"message": "This API returns blobs up to 1 MB in size. The requested blob is too large to fetch via the API, but you can use the Git Data API to request blobs up to 100 MB in size.",
"errors": [
{
"resource": "Blob",
"field": "data",
"code": "too_large"
}
],
"documentation_url": "https://docs.github.com/rest/reference/repos#get-repository-content"
}

很显然,直接用GithubAPI不能获取单个文件的hash值

那怎么办?

答:列表获取

我们把之前的RESTURL去掉小尾巴,变成这样:

https://api.github.com/repos/ChenYFan/CDN/contents/img/hpp_upload?ref=master

这样就能获取这个目录下整个列表,然后用json循环查找遍历name,再通过name拉hash即可.

只是这样查询时间会略微变长.

新建

如果是新建,body中这么写

{
    branch: ${上传的分支},
    message: ${上传的信息},
    content: ${base64过的文件}, 
    sha: ""
}

接着使用PUT形式访问RESTURL

创建成功后状态码应该返回:

201 Created

更新

body与新建类似,但是首先你要通过拉取信息获取该文件sha值.

{
    branch: ${上传的分支},
    message: ${上传的信息},
    content: ${base64过的文件}, 
    sha: "${此文件hash}"
}

接着使用PUT形式访问RESTURL

更新成功后状态码应该返回:

200 OK

删除

相对来说,删除就更简单了

{
    branch: ${删除文件的分支},
    message: ${删除的信息},
    sha: "${此文件hash}"
}

hash这一步逃不掉,用DELETE形式访问RESTURL,返回200说明删除成功

原理 - CloudFlareWorkers

之前看过Laziji-VBlog项目,这个项目新颖的一点是将文章发布在gists,然后用户通过api访问获取.

但这样有两个致命问题:

1.API没鉴权,每小时单个ip只能访问60次,一开就爆
2.在国内受干扰,不稳定

并且什么迁入迁出麻烦、token容易忘记等等问题

最最最早版本中,我是打算纯静态实现文章编辑和更改的,但很快我就遇到了和VBlog一样的缺陷,这逼使我切换了平台。

好诶,既然直连效果那么差,我们就选择中继。利用服务器中继我们首先排除【用Hexo基本就是贪无服务器】。目前比较流行的无服务器平台有Heroku、CloudFlareWorker和Vercel,Heroku支持了多种服务器语言,CFWorker基于GoogleV8,因为JSProxy在国内意外走红,Vercel在国内拥有较好的运营商线路。

我们第一个排除heroku,冷启动唤醒需要10s,并且无法绑定域名【这里其实也可用worker反代(bushi】。目光看向worker和vercel,又有一个新问题出来,自定义配置存哪?

存变量里当然是个好主意,但是很难修改。外部存储也不是什么大问题,mongodb、firebase、Leancloud都可以上手,但我个人终究不喜欢为了查询而发送子请求。

由于我是OIer【虽然很差】,习惯使用C++的逻辑,因为JS的逻辑和C++其实差不多,所以我更倾向用WorkerJS书写。

非常赞的是,去年11月,CloudFlare官方宣布KV在一定额度内免费,并且免费额度喜人:

存:1GB大小
读:10W次/天【注:这里和Worker免费版本调用次数相同】
写:1k次/天
删:1k次/天
列:1k次/天
单个限额:25MB

并且worker里面使用KV函数异常简单,绑定KVNAME后:

async function FUNCNAME(){
await KVNAME.get(INDEX) //读
await KVNAME.put(INDEX,VALUE) //写
await KVNAME.delete(INDEX) //删
}

按照官方文档的说法,实际读取与读取静态页面差不多,我写了个简单测试函数,根据时间戳判断,单次读取只需要不超过2ms。

并且worker有非常赞的fetch函数,无痛自定义header,拉取后端无压力。

好,那么就开始吧。

实现 - 迈出的第一步

首先你要绑定个监听器:

addEventListener("fetch", event => {
  event.respondWith(handleRequest(event.request))
})

由于fetch只能在async函数执行,于是我们写个async:

async function handleRequest(request) {
return new Response()
}

可以,这样我们就简单实现了一个无服务器函数

接下来的函数就应该在async这个主函数写。

然后是最基本的fetch,fetch应该说是worker里最特色的函数了。

如果直接返回,那么就不用加await,因为在async里面返回了一个await

return fetch('https://api.github.com/repos/ChenYFan/blog/contents/source/_posts')

如果要拉回来做运算,那么要加await,毕竟fetch返回的是promise

const res = await fetch('https://api.github.com/repos/ChenYFan/blog/contents/source/_posts')

CFWorker能用.text()函数和.json()函数处理返回的内容:

这地方我偷懒了,本来应该用then来获取promise的值,但是个人习惯了await嵌套写法,所以这地方写的其实不标准,轻喷

const first_name = await JSON.parse(await(await fetch('https://api.github.com/repos/ChenYFan/blog/contents/source/_posts')).text())[0]["name"]
return new Response(first_name)

这个其实等价下面的:

const first_name = (await(await fetch('https://api.github.com/repos/ChenYFan/blog/contents/source/_posts')).json())[0]["name"]
return new Response(first_name)

当然显然是下面的好写,但我习惯测试方便都用上面的

我们也可以通过自定义方式来自定义header完成鉴权和UA设置:

const getinit = {
          method: "GET",
          headers: {
            "content-type": "application/json;charset=UTF-8",
            "user-agent": `${USERAGENT}`,
            "Authorization": `token ${TOKEN}`
          },
}
const first_name = await JSON.parse(await(await fetch('https://api.github.com/repos/ChenYFan/blog/contents/source/_posts',getinit)).text())[0]["name"]
return new Response(first_name)

那么接下来就很简单了。

实现 - 面板的设计

Worker支持返回数据的设置,我们可以通过修改content-type达到返回页面的效果,并且可以通过JS奇妙的语法完成PHP难以做到的事情。

首先先定义一个网页:

const re_html =  `<h1>Hello,World!</h1>`

然后要返回吧:

return new Response(re_html, {
    headers: { "content-type": "text/html;charset=UTF-8" }
})

这个地方content-type务必要设置,不然默认返回是文本形式

然后打开预览就能看到了:

然后关于拼接,其实完全不必用+连接,可以用``包裹,然后用${变量名}来代替

const inner = `Hello,World!`
const re_html =  `<h1>${inner}</h1>`
return new Response(re_html, {
    headers: { "content-type": "text/html;charset=UTF-8" }
})

这种写法帮我省下精力重看代码

面板怎么说,其实直接用material-dashboard套的

实现 - 后端API的设计

后端API本质上是一个中继,简单如我

废话不说直接上代码。

问题解决 - 存储问题

KV是能存东西.配置是符合键的形式的,一个键名配对一个键值,这和KV的存储方式相同.但是这么多配置项,如果一个一个读过去,KV迟早比worker早读爆.缓存没用,还得赔一个清除缓存的APIKey,太亏了.

所以HPP将所有配置JSON.stringify后存储到了一个键名为hpp_config的键.

那关于账户密码,难道不能存KV吗?

能,当然能,但是问题是如果在登录页面还要读KV,那被打了怎么办

况且,在粘贴代码完后到设置界面,中间有一段时间,万一有个人搞你咋办呢.

所以HPP学习Twikoo进行强鉴权,在保证不被盗取的情况下还能减少KV读取量,岂不美哉

问题解决 - 多层文件夹

默认情况下,访问无文件名的RESTURL会列出当前文件夹下的所有文件,但列不出文件夹下的文件.我们先看获取示例,以https://api.github.com/repos/ChenYFan/blog/contents/source/_drafts?ref=master为例子:

[
    {
        "name": "TEST.md",
        "path": "source/_drafts/TEST.md",
        "sha": "3b12464976a5fd9e07d67dd7d5cf4f0f10188410",
        "size": 4,
        "url": "https://api.github.com/repos/ChenYFan/blog/contents/source/_drafts/TEST.md?ref=master",
        "html_url": "https://github.com/ChenYFan/blog/blob/master/source/_drafts/TEST.md",
        "git_url": "https://api.github.com/repos/ChenYFan/blog/git/blobs/3b12464976a5fd9e07d67dd7d5cf4f0f10188410",
        "download_url": "https://raw.githubusercontent.com/ChenYFan/blog/master/source/_drafts/TEST.md",
        "type": "file",
        "_links": {
            "self": "https://api.github.com/repos/ChenYFan/blog/contents/source/_drafts/TEST.md?ref=master",
            "git": "https://api.github.com/repos/ChenYFan/blog/git/blobs/3b12464976a5fd9e07d67dd7d5cf4f0f10188410",
            "html": "https://github.com/ChenYFan/blog/blob/master/source/_drafts/TEST.md"
        }
    },
    {
        "name": "TEST",
        "path": "source/_drafts/TEST",
        "sha": "18391dac960bd390d4213818b7a79c63dcd2fb44",
        "size": 0,
        "url": "https://api.github.com/repos/ChenYFan/blog/contents/source/_drafts/TEST?ref=master",
        "html_url": "https://github.com/ChenYFan/blog/tree/master/source/_drafts/TEST",
        "git_url": "https://api.github.com/repos/ChenYFan/blog/git/trees/18391dac960bd390d4213818b7a79c63dcd2fb44",
        "download_url": null,
        "type": "dir",
        "_links": {
            "self": "https://api.github.com/repos/ChenYFan/blog/contents/source/_drafts/TEST?ref=master",
            "git": "https://api.github.com/repos/ChenYFan/blog/git/trees/18391dac960bd390d4213818b7a79c63dcd2fb44",
            "html": "https://github.com/ChenYFan/blog/tree/master/source/_drafts/TEST"
        }
    }
]

文件夹是dir,文件是file,甚至可以通过self往下找,连路径都不用拼接了,那事情就好办了,写个搜索递归吧.

这个地方在群里我一直和2X吵架,因为我觉得此处用广搜比较好,然后我一直想写BFS,结果写着写着就成DFS了,你甚至现在还能看到一个叫fetch_bfs的函数

async function fetch_bfs(arr, url, getinit) { //开始深搜
          try {
            const hpp_getlist = await JSON.parse(await (await fetch(url, hpp_githubgetdocinit)).text()) //拉取github列表
            for (var i = 0; i < getJsonLength(hpp_getlist); i++) { //循环查找
              if (hpp_getlist[i]["type"] != "dir") { //如果不是文件夹
                arr.push(hpp_getlist[i])//弹到目标数组末尾
              } else { //否则
                await fetch_bfs(arr, hpp_getlist[i]["_links"]["self"], getinit) //进入该文件夹深搜
              }
            }
            return arr;
          } catch (e) { return {} }
}

代码本意很简单,传入一个空数组,抓取列表,循环递归,如果不是文件夹就扔到数组,是的话就向下搜索其实就是DFS嘛

try的原因是因为莫些人没有草稿,不用try的话这个函数就会炸,没草稿返回空数组。

然后就试试呗,以获取草稿列表为例:

if (path == "/hpp/admin/api/get_draftlist") { //判断路径
          let hpp_doc_draft_list_index = await KVNAME.get("hpp_doc_draft_list_index") //获取索引
          if (hpp_doc_draft_list_index === null) {//如果没有索引
            const filepath = githubdocdraftpath.substr(0, (githubdocdraftpath).length - 1) //分离路径
            const url = `https://api.github.com/repos/${hpp_githubdocusername}/${hpp_githubdocrepo}/contents${filepath}?ref=${hpp_githubdocbranch}` //拼接RESTURL
            hpp_doc_draft_list_index = await JSON.stringify(await fetch_bfs([], url, hpp_githubgetdocinit)) //开始深搜
            await KVNAME.put("hpp_doc_draft_list_index", hpp_doc_draft_list_index) //保存索引
          }
          return new Response(hpp_doc_draft_list_index, { //返回路径
            headers: {
              "content-type": "application/json;charset=UTF-8",
              "Access-Control-Allow-Origin": hpp_cors
            }
          })
}

我们来做个小实验:

结构如下:

-source/_drafts
  ~TEST.md
  -TEST
    ~TEST.md
    -TEST
      ~TEST.md
      -TEST
        ~TEST.md

那么CloudFlareWorker会这样搜索:

其实我本来想这样的

【考虑到大多数人都没有建立文件夹的习惯,本来bfs的效率会更高的(´இ皿இ`)】

【但其实两者子请求数目是一样的】

我们去CloudFlare发一个请求啊,结果非常Amazing啊:

dfs完美解决嵌套问题。

问题解决 - 缓存问题

手机端POST之谜

之前开发网页的时候,我总是希望缓存越长越好,因为有些资源从来没有变过却要重复使用。于是,我给博客加上了ServiceWorker这就是我咕咕咕的理由

但hpp不能进行太强的缓存,否则可能造成获取文件不够及时.

于是,在文章获取这一块,我故意将get写成post,发送空值,电脑端乖乖的每次都把请求发出去,毫无异常.

然后手机端炸了

万万没想到,safari会将post请求给缓存了

缓存也就罢了,结果ajax连onreadystatechange都缓存了不返回,然后接下去的函数全炸了

没办法,只好在post里面加时间戳

ajax.send(new Date().getTime());

文章索引问题

然后是索引问题【本质上是把结果缓存在KV里】,因为在文件夹众多的情况下dfs会将每个文件夹找过去,先不说时间这个问题(毕竟一次子请求大约在60ms-150ms徘徊,文件夹多的情况下也尚能忍受),主要是文件夹一多,子请求跟着多起来了,worker子请求超时是30s(10ms是运算时间,我寻思只要没有上亿篇文章,加个数组应该不会炸10ms时间),并且子请求算总请求,要是这么搞一次,worker怕是不够用了,所以得加个KV强缓存:

await KVNAME.put("hpp_doc_list_index", hpp_doc_list_index)
await KVNAME.put("hpp_doc_draft_list_index", hpp_doc_draft_list_index)

在发布、删除等可能会导致缓存失效的情况下清除KV缓存:

await KVNAME.del("hpp_doc_list_index")
await KVNAME.del("hpp_doc_draft_list_index")

功能实现 - 自动更新

这怕是所有Worker程序里面第一个实现自动更新的程序了【所以我最近发包很快啊】

其实刚开始没想到这么多,后来@MCSeekeri 开了#21,其中提到了这一点,然后我就开了#23

查一遍CloudFlareAPI文档,我们就会发现这做起来简直轻而易举:

curl -X PUT "https://api.cloudflare.com/client/v4/accounts/9a7806061c88ada191ed06f989cc3dac/workers/scripts/this-is_my_script-01" \
     -H "X-Auth-Email: user@example.com" \
     -H "X-Auth-Key: c2547eb745079dac9320b638f5e225cf483cc5cfdda41" \
     -H "Content-Type: application/javascript" \
--data "addEventListener('fetch', event => { event.respondWith(fetch(event.request)) })"

curl,我寻思fetch也能做到.

const update_script = await (await fetch(`https://raw.githubusercontent.com/HexoPlusPlus/HexoPlusPlus/main/index.js`)).text() //获取更新脚本
const up_init = {
            body: update_script,//更新脚本内容
            method: "PUT",//method是put
            headers: {
              "content-type": "application/javascript",//content-type和文档一样
              "X-Auth-Key": hpp_CF_Auth_Key,//GlobalKey,账户最高Token
              "X-Auth-Email": hpp_Auth_Email//登录邮箱
            }
}
          const update_resul = await (await fetch(`https://api.cloudflare.com/client/v4/accounts/${hpp_account_identifier}/workers/scripts/${hpp_script_name}`, up_init)).text()//拼接workerid,请求url,上传
          return new Response(JSON.parse(update_resul)["success"])//查询更新状态

OK那没问题了,手动更新完成.

那自动更新呢?

目前自动更新理论上可以实现,使用CronJob每天定时执行函数.
但我懒

其实你也可以用其它什么能定时访问的带上cookie访问/hpp/admin/api/update就行

功能实现 - 文章管理&草稿

其实就是上传文件

当然因为是hexo,本来就有/source/_posts/source/_drafts两个草稿分区,所以在1.1.0版本,将docpath改为了docroot,通过定位hexo根目录来实现全站自适应管理.

功能实现 - 图床

我知道有很多人还是困扰于图床这个问题,PicGo虽然能实现上传,但是配置一大堆,麻烦,并且配置不能随意迁移;PicX也使用Github+JSD做图床,但是没有中继速度慢,国内难以上传.

其实还是上传文件

但是我们必须知道,CFWorker单次执行最多10ms,正常图片三四百KB,在worker里base64,这能不超时我把CF吃了.

没办法,我们只能在前端进行base64,然后将编码后的值直接上传,用Worker中继.

功能实现 - 说说

这个最早受Artitalk影响,在artitalk官方群里潜伏了一年,我明确知道说说这一块的用户需求是多么大,并且大多数都是小白,不想用太多配置。

于是,HPP_TALK诞生了。诞生的初衷就是简化发布和配置流程,在1.1.2版本版本中自带了一个预览页面,实现了无域名也能使用说说。

'说说发布页面'

'用户查看界面'

甚至支持自定义主题:

'由2X开发的说说主题'

HPPTALK配置也简单,后端配置可以直接缺省发布,而前段也只要传递4个变量。

【但是我但是傻乎乎用了cookie记录,下次绝壁用LocalStr】

功能实现 - TwikooPlus

其实这个东西写的很粗糙,大家就看看行了哈

Twikoo首次匿名登录实在把我看傻了,6个请求,放国外不得炸掉.

然后就看,实际上只有前面几个有效的,后面其实是获取配置.

首先规定一下RESTURL=https://tcb-api.tencentcloudapi.com/web?env=${ENVID}

三步走:

1.空手拉refresh_token
2.用refresh_token套access_token
3.用access_token套评论

其中refresh_token两小时有效,access_token30天有效

那就很有意思了同学们:

async function get_refresh_token() {
        /*第一步获得refresh_token*/
        const step_1_body = {
          action: "auth.signInAnonymously",
          anonymous_uuid: "",
          dataVersion: "1970-1-1",
          env: env_id,
          refresh_token: "",
          seqId: ""
        }
        const step_1 = {
          body: JSON.stringify(step_1_body),
          method: "POST",
          headers: {
            "content-type": "application/json;charset=UTF-8"
          }
        }
        /*refresh_token到手*/
        //console.log(step_1_body)
        return JSON.parse(await (await fetch(url, step_1)).text())["refresh_token"]
      }
      async function get_access_token(refresh_token) {
        const step_2_body = {
          action: "auth.fetchAccessTokenWithRefreshToken",
          anonymous_uuid: "",
          dataVersion: "1970-1-1",
          env: env_id,
          refresh_token: refresh_token,
          seqId: ""
        }
        const step_2 = {
          body: JSON.stringify(step_2_body),
          method: "POST",
          headers: {
            "content-type": "application/json;charset=UTF-8"
          }
        }
        /*access_token到手*/
        return JSON.parse(await (await fetch(url, step_2)).text())["access_token"];
      }
      async function get_comment(access_token, path, before) {

        const re_data = { "event": "COMMENT_GET", "url": path, "before": before }
        const step_3_body = {
          access_token: access_token,
          action: "functions.invokeFunction",
          dataVersion: "1970-1-1",//开始时间
          env: env_id,
          function_name: "twikoo",
          request_data: JSON.stringify(re_data),
          seqId: ""
        }
        const step_3 = {
          body: JSON.stringify(step_3_body),
          method: "POST",
          headers: {
            "content-type": "application/json;charset=UTF-8"
          }
        }
        return (await (await fetch(url, step_3)).text())
      }

这里要注意以下,套评论的时候要传递两个参数pathbefore,path是当前文章路径,before是上一条评论的创建时间戳CreatedAt

然后使用的时候来一波:

refresh_token = await get_refresh_token()
access_token = await get_access_token(refresh_token)
val = await get_comment(access_token, path, before)

同时用KV缓存

await KVNAME.put("hpp_comment_refresh_token", refresh_token)
await KVNAME.put("hpp_comment_access_token", access_token)

OK起飞

问题解决 - EditorMD移动端问题

本来HPP开始写的时候就是用EditorMD的,好康,功能多.

但是很快手机端就炸出问题了:

安卓:打一个字换一行
苹果:打一个字复制一遍

非常有问题,原仓库有一个Close的issues说把codemirror更新到最新版本就行,但是我更新到5.x最后一个版本问题仍复发.

Github上面大多数编辑器也用的是CodeMirror.

然后找了半天实在没有解决方案,就花一个下午时间手写了一个编辑器

用的是最基础的textarea,这能出兼容性问题我把Github整个吃了

预览功能是靠markedjs通过调整display在一个div里面预览,在1.1.0版本支持了代码高亮.

话说手写一个很多功能就很好集成了诶

结尾

还有很多开发细节想不起来了,先水到这里了,滚回去修bug了

QQ群:467731779

最后加一句,用HPP时CI强烈建议使用GithubAction并公开,我是也不明白我Travis-CI怎么把积分耗完的

预计会添加的功能:

  • Hexo、主题配置修改
  • 输入框粘贴上传图片
  • 友链系统
  • 基于KV/IPFS的自动保存功能
  • 列表分页功能
  • 博主工具箱

 目录