美文网首页程序员
用Publish创建博客(三)——插件开发

用Publish创建博客(三)——插件开发

作者: 东坡肘子3000 | 来源:发表于2021-02-03 22:11 被阅读0次

    我们不仅可以利用Publish内置的接口来开发插件进行扩展,同时还可以使用Publish套件中其他的优秀库(Ink、Plot、Sweep、Files、ShellOut等)来完成更多的创意。本文将通过几个实例(添加标签、增加属性、用代码生成内容、全文搜索、命令行部署)在展示不同扩展手段的同时向大家介绍Publish套件中其他的优秀成员。在阅读本文前,最好能先阅读用Publish创建博客(一)——入门用Publish创建博客(二)——主题开发。对Publish有个基本了解。本文篇幅较长,你可以选择自己感兴趣的实战内容阅读。

    基础

    PublishingContext

    用Publish创建博客(一)——入门中我们介绍过Publish有两个Content概念。其中PublishingContext作为根容器包含了你网站项目的全部信息(SiteSectionItemPage等)。在对Publish进行的大多数扩展开发时,都需要和PublishingContext打交道。不仅通过它来获取数据,而且如果要对现有数据进行改动或者添加新的ItemPage时(在Content中采用不创建markdown文件的方式)也必须要调用其提供的方法。比如mutateAllSectionsaddItem等。

    Pipeline中的顺序

    Publish会逐个执行Pipeline中的Step,因此必须要在正确的位置放置StepPlugin。比如需要对网站的所有数据进行汇总,则该处理过程应该放置在addMarkdownFiles(数据都被添加进Content)之后;而如果想添加自己的部署(Deploy),则应放置在生成所有文件之后。下面会通过例子具体说明。

    热身

    下面的代码,以放置在Myblog(第一篇中创建,并在第二篇中进行了修改)项目里为例。

    准备

    请将

    try Myblog().publish(withTheme: .foundation)
    

    换成

    try Myblog().publish(using: [
        .addMarkdownFiles(), //导入Content目录下的markdown文件,并解析添加到PublishingContent中
        .copyResources(), //将Resource内容添加到Output中
        .generateHTML(withTheme:.foundation ), //指定模板
        .generateRSSFeed(including: [.posts]), //生成RSS
        .generateSiteMap() //生成Site Map
    ])
    

    创建Step

    我们先通过官方的一个例子了解一下Step的创建过程。当前导航菜单的初始状态:

    image-20210203121214511

    下面的代码将改变SectionID。

    //当前的Section设置
    enum SectionID: String, WebsiteSectionID {
            // Add the sections that you want your website to contain here:
            case posts //rawValue 将影响该Section对应的Content的目录名。当前的目录为posts
            case about //如果改成 case abot = "关于" 则目录名为“关于”,所以通常会采用下方更改title的方法
     }
    
    //创建Step
    extension PublishingStep where Site == Myblog {
        static func addDefaultSectionTitles() -> Self {
          //name为step名称,在执行该Step时在控制台显示
            .step(named: "Default section titles") { context in //PublishingContent实例
                context.mutateAllSections { section in //使用内置的修改方法
                    switch section.id {
                    case .posts:
                        section.title = "文章"  //修改后的title,将显示在上方的Nav中
                    case .about:
                        section.title = "关于" 
                    }
                }
            }
        }
    }
    

    Step添加到main.swiftpipeline中:

        .addMarkdownFiles(),
        .addDefaultSectionTitles(), 
        .copyResources(),
    

    添加该Step后的导航菜单:

    image-20210203123545306

    Pipeline中的位置

    如果将addDefaultSectionTitles放置在addMarkdownFiles的前面,会发现posts的title变成了

    image-20210203123440066

    这是因为,当前的Content--posts目录中有一个index.md文件。addMarkdownFiles会使用从该文件中解析的title来设置postsSection.title。解决的方法有两种:

    1. 向上面那样将addDefaultSectionTitles放置在addMarkdownFiles的后面
    2. 删除掉index.md

    等效的Plugin

    用Publish创建博客(一)——入门中提过StepPlugin在作用上是等效的。上面的代码用Plugin的方式编写是下面的样子:

    extension Plugin where Site == Myblog{
        static func addDefaultSectionTitles() -> Self{
            Plugin(name:  "Default section titles"){
                context in
                context.mutateAllSections { section in
                    switch section.id {
                    case .posts:
                        section.title = "文章"
                    case .about:
                        section.title = "关于"
                    }
                }
            }
        }
    }
    

    Pipeline中使用下面的方式添加:

        .addMarkdownFiles(),
        .copyResources(),
        .installPlugin(.addDefaultSectionTitles()),
    

    它们的效果完全一样。

    实战1:添加Bilibili标签解析

    Publish使用Ink作为markdown的解析器。Ink作为Publish套件的一部分,着重点在markdownHTML的高效转换。它让使用者可以通过添加modifier的方式,对markdown转换HTML的过程进行定制和扩展。Ink目前并不支持全部的markdonw语法,太复杂的它不支持(而且语法支持目前是锁死的,如想扩充必须forkInk代码,自行添加)。

    在本例中我们尝试为如下markdowncodeBlock语法添加新的转义功能:

    image-20210203142914881

    aid为B站视频的aid号码,danmu弹幕开关

    让我们首先创建一个Inkmodifier

    /*
    每个modifier对应一个markdown语法类型。
    目前支持的类型有: metadataKeys,metadataValues,blockquotes,codeBlocks,headings
             horizontalLines,html,images,inlineCode,links,lists,paragraphs,tables
    */
    var bilibili = Modifier(target: .codeBlocks) { html, markdown in
         // html为Ink默认的HTML转换结果,markdown为该target对应的原始内容
         // firstSubstring是Publish套件中的Sweep提供的快速配对方法.
        guard let content = markdown.firstSubstring(between: .prefix("```bilibili\n"), and: "\n```") else {
            return html
        }
        var aid: String = ""
        var danmu: Int = 1
        // scan也是Sweep中提供另一种配对获取方式,下面的代码是获取aid:和换行之间的内容
        content.scan(using: [
            Matcher(identifier: "aid: ", terminator: "\n", allowMultipleMatches: false) { match, _ in
                aid = String(match)
            },
            Matcher(identifiers: ["danmu: "], terminators: ["\n", .end], allowMultipleMatches: false) {
                match,
                _ in
                danmu = match == "true" ? 1 : 0
            },
        ])
        //modifier的返回值为HTML代码,本例中我们不需要使用Ink的默认转换,直接全部重写
        //在很多的情况下,我们可能只是在默认转换的html结果上做出一定的修改即可
        return
            """
            <div style="position: relative; padding: 30% 45% ; margin-top:20px;margin-bottom:20px">
            <iframe style="position: absolute; width: 100%; height: 100%; left: 0; top: 0;" src="https://player.bilibili.com/player.html?aid=\(aid)&page=1&as_wide=1&high_quality=1&danmaku=\(danmu)" frameborder="no" scrolling="no"></iframe>
            </div>
            """
    }
    

    通常情况下,我们会将上面的modifier包裹在一个Plugin中,通过installPlugin来注入,不过现在我们直接创建一个新的Step专门来加载modifier

    extension PublishingStep{
        static func addModifier(modifier:Modifier,modifierName name:String = "") -> Self{
            .step(named: "addModifier \(name)"){ context in
                context.markdownParser.addModifier(modifier)
            }
        }
    }
    

    现在就可以在main.swiftPipeline中添加了

    .addModifier(modifier: bilibili,modifierName: "bilibili"), //bilibili视频
    .addMarkdownFiles(),
    

    modifier在添加后并不会立即使用,当Pipeline执行到addMarkdownFilesmarkdown文件进行解析时才会调用。因此modifier的位置一定要放在解析动作的前面。

    Ink允许我们添加多个modifier,即使是同一个target。因此尽管我们上面的代码是占用了对markdowncodeBlocks的解析,但只要我们注意顺序,就都可以和平共处。比如下面:

     .installPlugin(.highlightJS()), //语法高亮插件,也是采用modifier方式,对应的也是codeBlock
     .addModifier(modifier: bilibili), //在这种状况下,bilibili必须在highlightJS下方。
    

    Ink将按照modifier的添加顺序来调用。添加该插件后的效果

    publish-3-bilibili-videodemo

    可以直接在https://www.fatbobman.com/video/查看演示效果。

    上面代码在我提供的范例模板中可以找到

    通过modifier扩展markdownHTML的转义是Publish中很常见的一种方式。几乎所有的语法高亮、style注入等都利用了这个手段。

    实战2:为Tag添加计数属性

    在Publish中,我们只能获取allTags或者每个Itemtags,但并不提供每个tag下到底有几个Item。本例我们便为Tag增加count属性。

    //由于我们并不想在每次调用tag.count的时候进行计算,所以一次性将所有的tag都提前计算好
    //计算结果通过类属性或结构属性来保存,以便后面使用
    struct CountTag{
        static var count:[Tag:Int] = [:]
        static func count<T:Website>(content:PublishingContext<T>){
            for tag in content.allTags{
              //将计算每个tag下对应的item,放置在count中
                count[tag] =  content.items(taggedWith: tag).count
            }
        }
    }
    
    extension Tag{
        public var count:Int{
            CountTag.count[self] ?? 0
        }
    }
    

    创建一个调用在Pipeline中激活计算的Plugin

    extension Plugin{
        static func countTag() -> Self{
            return Plugin(name: "countTag"){ content in
                return CountTag.count(content: content)
            }
        }
    }
    

    Pipeline中加入

    .installPlugin(.countTag()),
    

    现在我们就可在主题中直接通过tag.count来获取所需数据了,比如在主题方法makeTagListHTML中:

    .forEach(page.tags.sorted()) { tag in
           .li(
           .class(tag.colorfiedClass), //tag.colorfieldClass 也是通过相同手段增加的属性,在文章最后会有该插件的获取地址
                  .a(
                   .href(context.site.path(for: tag)),
                   .text("\(tag.string) (\(tag.count))")
                   )
              )
      }
    

    显示结果

    image-20210203104002714

    实战3:将文章按月份汇总

    Publish创建博客(二)——主题开发中我们讨论过目前Publish的主题支持的六种页面,其中有对Item以及tag的汇总页面。本例演示一下如何用代码创建主题不支持的其他页面类型。

    本例结束时,我们将让Publish能够自动生成如下的页面:

    publish-3-dateAchive
    //创建一个Step
    extension PublishingStep where Site == FatbobmanBlog{
        static func makeDateArchive() -> Self{
            step(named: "Date Archive"){ content in
                var doc = Content()
                 /*创建一个Content,此处的Content是装载页面内容的,不是PublishingContext
                  Publish在使用addMarkdownFiles导入markdown文件时,会为每个Item或Page创建Content
                  由于我们是使用代码直接创建,所以不能使用markdown语法,必须直接使用HTML
                 */
                doc.title = "时间线" 
                let archiveItems = dateArchive(items: content.allItems(sortedBy: \.date,order: .descending))
                 //使用Plot生成HTML,第二篇文章有Plot的更多介绍
                let html = Node.div(
                    .forEach(archiveItems.keys.sorted(by: >)){ absoluteMonth in
                        .group(
                            .h3(.text("\(absoluteMonth.monthAndYear.year)年\(absoluteMonth.monthAndYear.month)月")),
                            .ul(
                                .forEach(archiveItems[absoluteMonth]!){ item in
                                    .li(
                                        .a(
                                            .href(item.path),
                                            .text(item.title)
                                        )
                                    )
                                }
                            )
                        )
                    }
                )
                //渲染成字符串
                doc.body.html = html.render()
                //本例中直接生成了Page,也可以生成Item,Item需在创建时指定SectionID以及Tags
                let page = Page(path: "archive", content:doc)
                content.addPage(page)
            }
        }
        //对Item按月份汇总
        fileprivate static func dateArchive(items:[Item<Site>]) -> [Int:[Item<Site>]]{
            let result = Dictionary(grouping: items, by: {$0.date.absoluteMonth})
            return result
        }
    }
    
    extension Date{
        var absoluteMonth:Int{
            let calendar = Calendar.current
            let component = calendar.dateComponents([.year,.month], from: self)
            return component.year! * 12 + component.month!
        }
    }
    
    extension Int{
        var monthAndYear:(year:Int,month:Int){
            let month = self % 12
            let year = self / 12
            return (year,month)
        }
    }
    
    

    由于该Step需要对PublishingContent中的所有Item进行汇总,所以在Pipeline中应该在所有内容都装载后再执行

    .addMarkdownFiles(),
    .makeDateArchive(),
    

    可以访问https://www.fatbobman.com/archive/查看演示。上面的代码可以在Github下载。

    实战4:给Publish添加搜索功能

    谁不想让自己的Blog支持全文搜索呢?对于多数的静态页面来说(比如github.io),是很难依靠服务端来实现的。

    下面的代码是在参照local-search-engine-in-Hexo的方案实现的。local-search-engin提出的解决方式是,将网站的全部需检索文章内容生成一个xmljson文件。用户搜索前,自动从服务端下载该文件,通过javascript代码在本地完成搜索工作。javascripte代码使用的是hexo-theme-freemind创建的。另外 Liam Huang的这篇博客也给了我很大的帮助。

    最后实现的效果是这样的:

    search-demo

    创建一个Step用来在Pipeline的末端生成用于检索的xml文件。

    extension PublishingStep{
        static func makeSearchIndex(includeCode:Bool = true) -> PublishingStep{
            step(named: "make search index file"){ content in
                let xml = XML(
                    .element(named: "search",nodes:[
                        //之所以将这个部分分开写,是因为有时候编译器对于复杂一点的DSL会TimeOut
                        //提示编译时间过长。分开则完全没有问题。这种情况在SwiftUI中也会遇到
                        .entry(content:content,includeCode: includeCode)
                    ])
                )
                let result = xml.render()
                do {
                    try content.createFile(at: Path("/Output/search.xml")).write(result)
                }
                catch {
                    print("Failed to make search index file error:\(error)")
                }
            }
        }
    }
    
    extension Node {
        //这个xml文件的格式是local-search-engin确定的,这里使用Plot把网站内容转换成xml
        static func entry<Site: Website>(content:PublishingContext<Site>,includeCode:Bool) -> Node{
            let items = content.allItems(sortedBy: \.date)
            return  .forEach(items.enumerated()){ index,item in
                .element(named: "entry",nodes: [
                    .element(named: "title", text: item.title),
                    .selfClosedElement(named: "link", attributes: [.init(name: "href", value: "/" + item.path.string)] ),
                    .element(named: "url", text: "/" + item.path.string),
                    .element(named: "content", nodes: [
                        .attribute(named: "type", value: "html"),
                        //为Item增加了htmlForSearch方法
                        //由于我的Blog的文章中包含不少代码范例,所以让使用者选择是否在检索文件中包含Code。
                        .raw("<![CDATA[" + item.htmlForSearch(includeCode: includeCode) + "]]>")
                    ]),
                    .forEach(item.tags){ tag in
                        .element(named:"tag",text:tag.string)
                    }
                ])
            }
        }
    }
    

    我需要再称赞一下Plot,它让我非常轻松地完成了xml的创建工作。

    extension Item{
        public func htmlForSearch(includeCode:Bool = true) -> String{
            var result = body.html
            result = result.replacingOccurrences(of: "]]>", with: "]>")
            if !includeCode {
            var search = true
            var check = false
            while search{
                check = false
                //使用Ink来获取配对内容
                result.scan(using: [.init(identifier: "<code>", terminator: "</code>", allowMultipleMatches: false, handler: { match,range in
                    result.removeSubrange(range)
                    check = true
                })])
                if !check {search = false}
            }
            }
            return result
        }
    }
    

    创建搜索框搜索结果容器:

    //里面的id和class由于要和javascript配合,需保持现状
    extension Node where Context == HTML.BodyContext {
        //显示搜索结果的Node
        public static func searchResult() -> Node{
            .div(
                .id("local-search-result"),
                .class("local-search-result-cls")
            )
        }
    
        //显示搜索框的Node
        public static func searchInput() -> Node{
            .div(
            .form(
                .class("site-search-form"),
                .input(
                    .class("st-search-input"),
                    .attribute(named: "type", value: "text"),
                    .id("local-search-input"),
                    .required(true)
                    ),
                .a(
                    .class("clearSearchInput"),
                    .href("javascript:"),
                    .onclick("document.getElementById('local-search-input').value = '';")
                )
            ),
            .script(
                .id("local.search.active"),
                .raw(
                """
                var inputArea       = document.querySelector("#local-search-input");
                inputArea.onclick   = function(){ getSearchFile(); this.onclick = null }
                inputArea.onkeydown = function(){ if(event.keyCode == 13) return false }
                """
                )
            ),
                .script(
                    .raw(searchJS) //完整的代码后面可以下载
                )
            )
        }
    }
    

    本例中,我将搜索功能设置在标签列表的页面中(更多信息查看主题开发),因此在makeTagListHTML中将上面两个Node放到自己认为合适的地方。

    由于搜索用的javascript需要用到jQuery,所以在head中添加了jQuery的引用(通过覆写了head,当前只为makeTagListHTML添加了引用)。

    在Pipeline中加入

    .makeSearchIndex(includeCode: false), //根据自己需要决定是否索引文章中的代码
    

    完整的代码可以在Github下载。

    实战5:部署

    最后这个实例略微有点牵强,主要是为了介绍Publish套件中的另外一员ShellOut

    ShellOut是一个很轻量的库,它的作用是方便开发者从Swift代码中调用脚本或命令行工具。在Publish中,使用publish deploy进行Github部署的代码便使用了这个库。

    import Foundation
    import Publish
    import ShellOut
    
    extension PublishingStep where Site == FatbobmanBlog{
        static func uploadToServer() -> Self{
            step(named: "update files to fatbobman.com"){ content in
                print("uploading......")
                do {
                    try shellOut(to: "scp -i ~/.ssh/id_rsa -r  ~/myBlog/Output web@112.239.210.139:/var/www") 
                    //我是采用scp部署的,你可以用任何你习惯的方式
                }
                catch {
                    print(error)
                }
            }
        }
    }
    

    main.swift添加:

    var command:String = ""
    if CommandLine.arguments.count > 1 {
        command = CommandLine.arguments[1]
    }
    
    try MyBlog().publish(
      .addMarkdownFiles(),
      ...
      .if(command == "--upload", .uploadToServer())
    ]
    

    执行 swift run MyBlog --upload 即可完成网站生成+上传(MyBlog为你的项目名称)

    其他的插件资源

    目前Publish的插件和主题在互联网上能够找到的并不很多,主要集中在Github的#publish-plugin上。

    其中使用量比较大的有:

    如果想在Github上分享你制作的plugin,请务必打上publish-plugin标签以便于大家查找

    最后

    就在即将完成这篇稿件的时候,手机上收到了赵英俊因病过世的新闻。英年早逝,令人唏嘘。回想到自己这些年经历的治疗过程,由衷地感觉平静、幸福的生活真好。

    在使用Publish的这些天,让我找到了装修房子的感觉。虽然不一定做的多好,但网站能按自己的想法逐步变化真是乐趣无穷。

    相关文章

      网友评论

        本文标题:用Publish创建博客(三)——插件开发

        本文链接:https://www.haomeiwen.com/subject/zwtvtltx.html