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 Item
s and Page
s (in a way that does not create a markdown
file in Content
). For example, mutateAllSections
, addItem
, etc.
Order of Pipeline
Publish executes the Step
s 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:
try Myblog().publish(withTheme: .foundation)
To:
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:
The following code will change the SectionID.
// 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
:
.addMarkdownFiles(),
.addDefaultSectionTitles(),
.copyResources(),
Navigation menu after adding this Step
:
Location in Pipeline
If addDefaultSectionTitles
is placed before addMarkdownFiles
in the pipeline, you will notice that the title of posts
has changed.
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:
- Move
addDefaultSectionTitles
aboveaddMarkdownFiles
as shown above. - 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:
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
:
.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
:
aid
is the aid
number of the Bilibili video, and danmu
is the switch for danmu
Let’s first create an Ink
modifier:
/*
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
.
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
.
.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:
.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
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 Item
s are under each tag
. In this example, we will add a count
property to Tag
.
// 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
.
extension Plugin{
static func countTag() -> Self{
return Plugin(name: "countTag"){ content in
return CountTag.count(content: content)
}
}
}
Adding to Pipeline
.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
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:
// 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.
.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’.
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.
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
:
// 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:
.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.
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
:
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:
- SplashPublishPlugin code highlighting
- HighlightJSPublishPlugin code highlighting
- ColorfulTagsPublishPlugin add color to tags
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.