肘子的 Swift 记事本

Creating a Blog with Publish: Plugin

Published on

Get weekly handpicked updates on Swift and SwiftUI!

Not only can we use the built-in interfaces in Publish to develop plugins for extension, but we can also use other excellent libraries in the Publish suite (Ink, Plot, Sweep, Files, ShellOut, etc.) to achieve more creativity. This article will showcase different extension methods and introduce other excellent members of the Publish suite through several examples (adding tags, adding attributes, generating content with code, full-text search, command-line deployment). Before reading this article, it is best to read Creating a Blog with Publish: Getting Started and Creating a Blog with Publish: Theme Development to have a basic understanding of Publish. This article is lengthy, and you can choose to read the practical content that interests you.

Basics

PublishingContext

In “Getting Start”, we introduced the two Content concepts in Publish. Among them, PublishingContext serves as the root container that contains all the information of your website project (Site, Section, Item, Page, etc.). When developing most of the extensions for Publish, you need to deal with PublishingContext. Not only can you obtain data through it, but also you must call its provided methods if you want to modify existing data or add new Items and Pages (in a way that does not create a markdown file in Content). For example, mutateAllSections, addItem, etc.

Order of Pipeline

Publish executes the Steps in the Pipeline one by one, so you must place the Step and Plugin in the correct position. For example, if you want to summarize all the data on the website, the processing should be placed after addMarkdownFiles (all data is added to Content); if you want to add your own deployment (Deploy), it should be placed after generating all files. The following will be explained in detail through examples.

Warm-up

The following code is an example of being placed in the Myblog project (created in the first article and modified in the second article).

Preparation

From:

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

To:

Swift
try Myblog().publish(using: [
    .addMarkdownFiles(), // Import markdown files in the Content directory, parse and add them to PublishingContent
    .copyResources(), // Add Resource content to Output
    .generateHTML(withTheme:.foundation ), // Specify theme
    .generateRSSFeed(including: [.posts]), // Generate RSS
    .generateSiteMap() // Generate Site Map
])

Creating a Step

Let’s first understand the process of creating a Step through an official example. The initial state of the current navigation menu:

http://cdn.fatbobman.com/publish-3-changetitle-old.png

The following code will change the SectionID.

Swift
// Current Section settings
enum SectionID: String, WebsiteSectionID {
        // Add the sections that you want your website to contain here:
        case posts //rawValue will affect the directory name of the Content corresponding to this Section. The current directory is posts
        case about //if changed to case about = "关于" then the directory name will be "关于", so the method of changing title below is usually adopted
}

//Create Step
extension PublishingStep where Site == Myblog {
    static func addDefaultSectionTitles() -> Self {
      //name is the step name, displayed in the console when the Step is executed
        .step(named: "Default section titles") { context in //PublishingContent instance
            context.mutateAllSections { section in //use built-in modification method
                switch section.id {
                case .posts:
                    section.title = "文章" //modified title, will be displayed in the Nav above
                case .about:
                    section.title = "关于"
                }
            }
        }
    }
}

Add the Step to the pipeline in main.swift:

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

Navigation menu after adding this Step:

http://cdn.fatbobman.com/publish-3-title-new.png

Location in Pipeline

If addDefaultSectionTitles is placed before addMarkdownFiles in the pipeline, you will notice that the title of posts has changed.

http://cdn.fatbobman.com/publish-3-changetitle-wrong-position.png

This is because there is an index.md file in the current Content--posts directory. addMarkdownFiles will use the title parsed from this file to set the Section.title of posts. There are two solutions to this problem:

  1. Move addDefaultSectionTitles above addMarkdownFiles as shown above.
  2. Delete index.md.

Equivalent Plugin

As mentioned in Creating a Blog with Publish (1) - Getting Started, Step and Plugin are equivalent in their functions. The above code written in Plugin form would look like the following:

Swift
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 = "关于"
                }
            }
        }
    }
}

Use the following method to add it in the Pipeline:

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

They have exactly the same effect.

Practice 1: Adding Bilibili Tag Parsing

Publish uses Ink as the parser for markdown. Ink, as part of the Publish suite, focuses on efficient conversion from markdown to HTML. It allows users to customize and extend the process of converting markdown to HTML by adding modifiers. Currently, Ink does not support all markdown syntax, and it does not support too complicated syntax (and the syntax support is currently locked. If you want to expand it, you must fork Ink code and add it yourself).

In this example, we try to add new escape functionality for the codeBlock syntax of the following markdown:

http://cdn.fatbobman.com/publish-3-bilibili-mardown-code.png

aid is the aid number of the Bilibili video, and danmu is the switch for danmu

Let’s first create an Ink modifier:

Swift
/*
Each modifier corresponds to a markdown syntax type.
Currently supported types include: metadataKeys, metadataValues, blockquotes, codeBlocks, headings
         horizontalLines,html,images,inlineCode,links,lists,paragraphs,tables
*/
var bilibili = Modifier(target: .codeBlocks) { html, markdown in
     // html is the default HTML conversion result of Ink, and markdown is the raw content corresponding to the target.
     // firstSubstring is a quick matching method provided by the Publish suite's Sweep.
    guard let content = markdown.firstSubstring(between: .prefix("```bilibili\n"), and: "\n```") else {
        return html
    }
    var aid: String = ""
    var danmu: Int = 1
    // scan is another matching method provided by Sweep. The code below is used to obtain the content between "aid:" and the newline character.
    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
        },
    ])
    // The return value of the modifier is HTML code. In this example, we don't need to use Ink's default conversion, so we just rewrite everything.
    // In many cases, we may only need to make certain modifications to the HTML result of the default conversion.
    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>
        """
}

Normally, we would encapsulate the above modifier in a Plugin and inject it through installPlugin, but now we will create a new Step specifically to load the modifier.

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

Now you can add it to the Pipeline in main.swift.

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

The modifier is not used immediately after being added. It is only called when the Pipeline executes addMarkdownFiles to parse the markdown files. Therefore, the modifier must be placed before the parsing action.

Ink allows us to add multiple modifiers, even for the same target. So even though the above code occupies the parsing of codeBlocks for markdown, as long as we pay attention to the order, they can coexist peacefully. For example:

Swift
 .installPlugin(.highlightJS()), //Syntax highlighting plugin, also using modifier method, corresponding to codeBlock
 .addModifier(modifier: bilibili), //In this case, bilibili must be below highlightJS.

Ink will call according to the order in which the modifiers are added. The effect after adding this plugin

http://cdn.fatbobman.com/publish-3-bilibili-videodemo.png

The above code can be found in the sample template I provided here.

Extending markdown to HTML through modifier is a common way in Publish. Almost all syntax highlighting, style injection, etc. are achieved using this method.

Practice 2: Adding Count Property to Tags

In Publish, we can only get allTags or the tags of each Item, but it doesn’t provide information on how many Items are under each tag. In this example, we will add a count property to Tag.

Swift
// Since we don't want to calculate tag.count every time it's called, we'll calculate all tags beforehand.
// The calculation results will be saved through class properties or structure properties for later use.
struct CountTag{
    static var count:[Tag:Int] = [:]
    static func count<T:Website>(content:PublishingContext<T>){
        for tag in content.allTags{
          // Calculate the number of items corresponding to each tag and put them in count.
            count[tag] =  content.items(taggedWith: tag).count
        }
    }
}

extension Tag{
    public var count:Int{
        CountTag.count[self] ?? 0
    }
}

Create a Plugin that calls the calculation to activate it in the Pipeline.

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

Adding to Pipeline

Swift
.installPlugin(.countTag()),

Now we can directly obtain the required data in the theme through tag.count, for example in the makeTagListHTML method:

.forEach(page.tags.sorted()) { tag in
       .li(
       .class(tag.colorfiedClass), //tag.colorfieldClass is also an added attribute using the same method. The plugin's download link will be provided at the end of the article.
              .a(
               .href(context.site.path(for: tag)),
               .text("\(tag.string) (\(tag.count))")
               )
          )
  }

Display Result

http://cdn.fatbobman.com/publish-3-tagCount.png

Practice 3: Summarize Articles by Month

In Publish Creating a Blog (Part 2) - Theme Development, we discussed the six types of pages currently supported by Publish themes, including summary pages for Item and tag. In this example, we will demonstrate how to create other types of pages not supported by the theme using code.

By the end of this example, we will enable Publish to automatically generate the following pages:

http://cdn.fatbobman.com/publish-3-dateAchive-2343299.png

Swift
// Create a Step
extension PublishingStep where Site == FatbobmanBlog{
    static func makeDateArchive() -> Self{
        step(named: "Date Archive"){ content in
            var doc = Content()
             /* Create a Content, which is a container for page content and not a PublishingContext.
              When using addMarkdownFiles to import markdown files, Publish creates a Content for each Item or Page.
              Since we are creating directly with code, markdown syntax cannot be used and we must use HTML directly.
             */
            doc.title = "Timeline"
            let archiveItems = dateArchive(items: content.allItems(sortedBy: \.date,order: .descending))
             // Use Plot to generate HTML, more on Plot in the second article
            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)
                                    )
                                )
                            }
                        )
                    )
                }
            )
            // Render as a string
            doc.body.html = html.render()
            // In this example, a Page is generated directly, but an Item can also be generated. When creating an Item, the SectionID and Tags must be specified.
            let page = Page(path: "archive", content:doc)
            content.addPage(page)
        }
    }
    // Summarize Items by month
    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)
    }
}

Since this Step needs to summarize all Items in PublishingContent, it should be executed after all content has been loaded in the pipeline.

Swift
.addMarkdownFiles(),
.makeDateArchive(),

The code can be downloaded from Github.

Practice 4: Adding Search Functionality to Publish

Who wouldn’t want their blog to support full-text search? For most static pages (like github.io), it’s difficult to implement this on the server side.

The following code is based on the solution proposed by local-search-engine-in-Hexo. The method suggested by local-search-engine is to generate an xml or json file containing all the searchable content of the website. Before the user searches, the file is automatically downloaded from the server and local search is performed using JavaScript code. The javascript code used was created by the hexo-theme-freemind. Liam Huang’s blog post was also a great help to me.

Create a ‘Step’ to generate an ‘xml’ file for searching at the end of the ‘Pipeline’.

Swift
extension PublishingStep{
    static func makeSearchIndex(includeCode:Bool = true) -> PublishingStep{
        step(named: "make search index file"){ content in
            let xml = XML(
                .element(named: "search",nodes:[
                    //This part is separated because the compiler sometimes Times Out for more complex DSL. Separating it works without any problem. This situation can also be encountered in 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 {
    //The format of this xml file is determined by local-search-engin, here we use Plot to convert website content into 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"),
                    //The 'htmlForSearch' method is added to the Item.
                    //Since there are many code examples in my blog articles, users can choose whether to include Code in the search file.
                    .raw("<![CDATA[" + item.htmlForSearch(includeCode: includeCode) + "]]>")
                ]),
                .forEach(item.tags){ tag in
                    .element(named:"tag",text:tag.string)
                }
            ])
        }
    }
}

I need to give praise to Plot once again, as it made creating the ‘xml’ file very easy.

Swift
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
            //Use Ink to get matching content
            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
    }
}

Creating the search box and search result container:

Swift
// The id and class inside need to remain unchanged to work with JavaScript
extension Node where Context == HTML.BodyContext {
    // Node to display search results
    public static func searchResult() -> Node{
        .div(
            .id("local-search-result"),
            .class("local-search-result-cls")
        )
    }

    // Node to display search input box
    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) // Full code can be downloaded later
            )
        )
    }
}

In this example, I will set up the search function on the tag list page. Therefore, in makeTagListHTML, I will place the above two Node in the appropriate location.

As the JavaScript used for search requires jQuery, its reference was added in the head section (via overwriting the head section, currently only adding reference for makeTagListHTML).

Add to the Pipeline:

Swift
.makeSearchIndex(includeCode: false), // Decide whether to index code in articles based on your needs

The complete code can be downloaded from Github.

Practice 5: Deployment

This example is a bit forced, mainly to introduce another member of the Publish suite, ShellOut.

ShellOut is a very lightweight library that makes it easy for developers to call scripts or command line tools from Swift code. In Publish, the code for Github deployment using publish deploy uses this library.

Swift
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@111.222.111.139:/var/www")
                // I use scp for deployment, you can use any method you prefer
            }
            catch {
                print(error)
            }
        }
    }
}

Add to main.swift:

Swift
var command:String = ""
if CommandLine.arguments.count > 1 {
    command = CommandLine.arguments[1]
}

try MyBlog().publish(
  .addMarkdownFiles(),
  ...
  .if(command == "--upload", .uploadToServer())
]

Execute swift run MyBlog --upload to complete website generation and upload (MyBlog is your project name).

Other plugin resources

Currently, there are not many plugins and themes available for Publish on the internet, mainly concentrated on Github’s #publish-plugin.

Among them, the ones with relatively high usage are:

If you want to share the plugin you made on Github, be sure to tag it with publish-plugin to make it easy for everyone to find.

Finally

Using Publish these days, I found the feeling of decorating a house. Although I may not have done it very well, it is really fun to gradually change the website according to my own ideas.

I'm really looking forward to hearing your thoughts! Please Leave Your Comments Below to share your views and insights.

Fatbobman(东坡肘子)

I'm passionate about life and sharing knowledge. My blog focuses on Swift, SwiftUI, Core Data, and Swift Data. Follow my social media for the latest updates.

You can support me in the following ways