用Publish建立部落格(二)——主題開發
本系列一共三篇文章。如想獲得更好的閱讀效果可以通路我的部落格 www.fatbobman.com[1] 我的部落格也是用Publish建立的。
擁用強大的主題系統是一個靜态網站生成器能否成功的重要原因之一。Publish[2]采用Plot[3]作為主題的開發工具,讓開發者在高效編寫主題的同時享受到了Swift的類型安全的優勢。本文将從Plot開始介紹,讓讀者最終學會如何建立Publish主題。
Plot
簡介
想要開發Publish的
Theme
主題,就不能不從Plot說起。
在Swift社群中,有不少優秀的項目緻力于使用Swift生成HTML:比如Vapor的Leaf[4],Point-Free的swift-html[5]等,Plot也是其中的一員。Plot最初是由John Sundell[6]編寫的并作為Publish套件的一部分,它主要的關注點是Swift的靜态網站HTML生成,以及建立建站所需的其他格式文檔,包括
RSS
、
podcast
、
Sitemap
。它與Publish緊密內建但同時也作為一個獨立項目存在。
Plot使用了一種被稱作
Phantom Types
的技術,該技術将類型用作編譯器的“标記”,進而能夠通過泛型限制來強制類型安全。Plot使用了非常輕量級的API設計,最大限度的減少外部參數标簽,進而減少渲染文檔所需的文法量,使其呈現了具有“類似DSL”的代碼表現。
使用
基礎
•Node是任何Plot文檔中所有元素和屬性的核心構件。它可以表示元素和屬性,以及文本内容和節點組。每個節點都被綁定到一個Context類型,它決定了它可以通路哪種DSL API(例如
HTML.BodyContext
用于放置在HTML頁面
<body>
中的節點)。•Element 代表一個元素,可以使用兩個獨立的标簽打開和關閉(比如
<body></body>
),也可以自閉(比如
<img/>
)。當使用Plot時,你通常不需要與這個類型進行互動,基礎Node中會建立它的執行個體。•Attribute表示附加在元素上的屬性,例如
<a>
元素的 href,或者
<img>
元素的 src。你可以通過它的初始化器來構造
Attribute
值,也可以通過DSL,使用
.attribute()
指令來構造。•Document和DocumentFormat給定格式的文檔,如HTML、RSS和PodcastFeed。這些都是最進階别的類型,你可以使用Plot的DSL來開始一個文檔建構會話。
類DSL文法
import Plotlet html = HTML( .head( .title("My website"), .stylesheet("styles.css") ), .body( .div( .h1("My website"), .p("Writing HTML in Swift is pretty great!") ) ))
複制
上面的Swift代碼将生成下面的HTML代碼。代碼形式同DSL非常類似,代碼污染極少。
<!DOCTYPE html><html> <head> <title>My website</title> <meta name="twitter:title" content="My website"/> <meta name="og:title" content="My website"/> <link rel="stylesheet" href="styles.css" type="text/css"/> </head> <body> <div> <h1>My website</h1> <p>Writing HTML in Swift is pretty great!</p> </div> </body></html>
複制
有些時候,感覺上Plot隻是将每個函數直接映射到一個等效的HTML元素上——至少上面的代碼看起來如此,但其實Plot還會自動插入許多非常有價值的中繼資料,在後面我們還将看到Plot更多的功能。
屬性
屬性的應用方式也可以和添加子元素的方式完全一樣,隻需在元素的逗号分隔的内容清單中添加另一個條目即可。例如,下面是如何定義一個同時具有CSS類和URL的錨元素。屬性、元素和内聯文本都是以同樣的方式定義的,這不僅使Plot的API更容易學習,也讓輸入體驗非常流暢--因為你可以在任何上下文中簡單地鍵入
.
來不斷定義新的屬性和元素。
let html = HTML( .body( .a(.class("link"), .href("https://github.com"), "GitHub") ))
複制
類型安全
Plot大量使用了Swift的進階泛型能力,不僅使采用原生代碼編寫HTML和XML成為可能,并在這一過程中實作了完全的類型安全。Plot的所有元素和屬性都是作為上下文綁定的節點來實作的,這既能強制執行有效的HTML語義,也能讓Xcode和其他IDE在使用Plot的DSL編寫代碼時提供豐富的自動補全資訊。
let html = HTML(.body( .p(.href("https://github.com"))))
複制
比如,
<herf>
是不能直接被放置在
<p>
中的,當輸入
.p
的時候自動補全是不會提示的(因為上下文不比對),代碼也将在編譯時報錯。
這種高度的類型安全既帶來了非常愉快的開發體驗,也使利用Plot建立的HTML和XML文檔在語義上正确的幾率大大增加--尤其是與使用原始字元串編寫文檔和标記相比。
對于筆者這種HTML知識極度匮乏的人來說,在Plot下我也沒有辦法寫出下面的錯誤代碼(無法通過)。
let html = HTML(.body) .ul(.p("Not allowed"))))
複制
自定義元件
同樣的,上下文綁定的Node架構不僅賦予了Plot高度的類型安全,也使得可以定義更多更高層次的元件,然後将這些自定義元件與Plot本身定義的元素靈活地混合使用。
例如,我們要為網站添加一個advertising元件,該元件綁定在HTML文檔的
<body>
上下文中。
extension Node where Context: HTML.BodyContext { //嚴格的上下文綁定 static func advertising(_ slogan: String,herf:String) -> Self { .div( .class("avertising"), .a( .href(herf), .text(slogan) ) ) }}
複制
現在可以使用與内置元素完全相同的文法來使用
advertising
。
let html = HTML( .body( .div( .class("wrapper"), .article( .... ), .advertising("肘子的Swift記事本", herf: "https://fatbobman.com") ) ))
複制
控制流程
盡管Plot專注于靜态站點生成,但它還是附帶了幾種控制流機制,可讓您使用其DSL的内聯邏輯。目前支援的控制指令有
.if( )
,
.if(_,else:)
,
unwrap()
以及
forEach()
。
var books:[Book] = getbooks()let show:Bool = truelet html = HTML(.body( .h2("Books"), .if(show, .ul(.forEach(books) { book in .li(.class("book-title"), .text(book.title)) }) ,else: .text("請添加書庫") )))
複制
使用上述控制流機制,尤其是與自定義元件結合使用時,可以使你以類型安全的方式建構真正靈活的主題,建立所需的文檔和HTML頁面。
自定義元素和屬性
盡管Plot旨在涵蓋與其支援的文檔格式相關的盡可能多的标準,但你仍可能會遇到Plot尚不具備的某種形式的元素或屬性 。我們可以非常容易的在Plot中自定義元素和屬性,這一點在生成XML的時候尤為有用。
extension Node where Context == XML.ProductContext { static func name(_ name: String) -> Self { .element(named: "name", text: name) } static func isAvailable(_ bool: Bool) -> Self { .attribute(named: "available", value: String(bool)) }}
複制
文檔渲染
let header = Node.header( .h1("Title"), .span("Description"))let string = header.render()
複制
還可以對輸出縮排進行控制
html.render(indentedBy: .tabs(4))
複制
其他支援
Plot還支援生成RSS feeds,podcasting,site maps等。Publish中對應的部分同樣由Plot實作。
Publish 主題
閱讀下面内容前,最好已閱讀用Publish建立部落格(一)——入門[7],。
文中提到範例模闆可以在GIthub[8]處下載下傳。
自定義主題
在Publish中,主題需要遵循
HTMLFactory
協定。如下代碼可以定義一個新主題:
import Foundationimport Plotimport Publishextension Theme { public static var myTheme: Self { Theme( htmlFactory: MyThemeHTMLFactory<MyWebsite>(), resourcePaths: ["Resources/MyTheme/styles.css"] ) }}private struct MyThemeHTMLFactory<Site: Website>: HTMLFactory { // ... 具體的頁面,需實作六個方法}private extension Node where Context == HTML.BodyContext { // Node 的定義,比如header,footer等}
複制
在pipeline中使用如下代碼指定主題
.generateHTML(withTheme:.myTheme ), //使用自定義主題
複制
HTMLFactory協定要求我們必須全部實作六個方法,對應着六種頁面,分别是:
•
makeIndexHTML(for index: Index,context: PublishingContext<Site>)
網站首頁,通常是最近文章、熱點推薦等等,預設主題中是顯式全部
Item
清單•
makeSectionHTML(for section: Section<Site>,context: PublishingContext<Site>)
當
Section
作為
Item
容器時的頁面。通常顯示隸屬于該
Section
的
Item
清單•
makeItemHTML(for item: Item<Site>, context: PublishingContext<Site>)
單篇文章(
Item
)的顯示頁面•
makePageHTML(for page: Page,context: PublishingContext<Site>)
自由文章(
Page
)的顯示頁面,當Section不作為容器時,它的index.md也是作為
Page
渲染的•
makeTagListHTML(for page: TagListPage,context: PublishingContext<Site>)Tag
清單的頁面。通常會在此顯示站點文章中出現過的全部
Tag
•
makeTagDetailsHTML(for page: TagDetailsPage,context: PublishingContext<Site>)
通常為擁有該
Tag
的
Item
清單
我們在MyThemeHTMLFactory每個方法中,按照上文介紹的Plot表述方式進行編寫即可。比如:
func makePageHTML(for page: Page, context: PublishingContext<Site>) throws -> HTML { HTML( .lang(context.site.language), .head(for: page, on: context.site), .body( .header(for: context, selectedSection: nil), .wrapper(.contentBody(page.body)), .footer(for: context.site) ) ) }
複制
header
、
wrapper
、
footer
都是自定義的
Node
生成機制
Publish采用工作流機制,通過範例代碼[9]來了解一下資料是如何在
Pipeline
中操作的。
try FatbobmanBlog().publish( using: [ .installPlugin(.highlightJS()), //添加文法高亮插件。此插件在markdown解析時被調用 .copyResources(), //拷貝網站所需資源,Resource目錄下的檔案 .addMarkdownFiles(), /*逐個讀取Content下的markdown檔案,對markdown檔案進行解析, 1:解析metadata,将中繼資料儲存在對應的 Item 2:對文章中的markdown語段逐個解析并轉換成HTML資料 3:當碰到 highlightJS 要求處理的(codeBlocks)文字塊時調用該插件 4:所有的處理好的内容儲存到 PublishingContext 中 */ .setSctionTitle(), //修改section 的顯示标題 .installPlugin(.setDateFormatter()), //為HTML輸出設定時間顯示格式 .installPlugin(.countTag()), //通過注入,為tag增加tagCount屬性,計算每個tag下有幾篇文章 .installPlugin(.colorfulTags(defaultClass: "tag", variantPrefix: "variant", numberOfVariants: 8)), //通過注入,為每tag增加colorfiedClass屬性,傳回css檔案中對應的色彩定義 .sortItems(by: \.date, order: .descending), //所有文章降序 .generateHTML(withTheme: .fatTheme), //指定自定義的主題,并在Output目錄中生成HTML檔案 /* 使用主題模闆,逐個調用頁面生成方法。 根據每個方法要求的參數不同,傳遞對應的 PublishingContext,Item,Scetion等 主題方法根據資料,使用Plot渲染成HTML 比如makePageHTML中,顯示page文章的内容便是通過 page.body 來擷取的 */ .generateRSSFeed( including: [.posts,.project], itemPredicate: nil ), //使用Plot生成RSS .generateSiteMap(), //使用Plot生成Sitemap ])
複制
從上面的代碼可以看出,使用主題模闆生成HTML并儲存是在整個Pipeline的末段,通常情況下,當主題方法調用給定的資料時,資料已經是準備好的。不過由于Publish的主題并非描述檔案而是标準的程式代碼,我們仍可以在最終
render
前,對資料再處理。
盡管Publish目前提供的頁面種類并不多,但即使我們僅使用上述的種類仍可對不同的内容作出完全不同渲染結果。比如:
func makeSectionHTML(for section: Section<Site>, context: PublishingContext<Site>) throws -> HTML { //如果section是posts,則顯示完全不同的頁面 if section.id as! Myblog.SectionID == .posts { return HTML( postSectionList(for section: Section<Site>, context: PublishingContext<Site>) ) } else { return HTML( otherSctionList(for section: Section<Site>, context: PublishingContext<Site>) ) } }
複制
也可以使用Plot提供的控制指令來完成,下面的代碼和上面是等效的
func makeSectionHTML(for section: Section<Site>, context: PublishingContext<Site>) throws -> HTML { HTML( .if(section.id as! Myblog.SectionID == .posts, postSectionList(for section: Section<Site>, context: PublishingContext<Site>) , else: otherSctionList(for section: Section<Site>, context: PublishingContext<Site>) ) ) }
複制
總之在Publish中用着寫普通程式的思路來處理網頁即可,主題不僅僅是描述檔案。
和CSS的配合
主題代碼定義了對應頁面的基本布局和邏輯,更具體的布局、尺寸、色彩、效果等都要在
CSS
檔案中進行設定。
CSS
檔案在定義主題時指定(可以有多個)。
如果你是一個有經驗的CSS使用者,通常沒有什麼難度。但筆者幾乎完全不會使用CSS,在此次用Publish重建Blog的過程中,在CSS上花費的時間最長、精力最多。
請幫忙推薦一個能夠整理css的工具或者vscode 插件,由于我在css上沒有經驗是以代碼寫的很亂,是否有可能将同一層級或類似的tag class自動調整到一起,便于查找。
實戰
接下來通過修改兩個主題方法來體驗一下的開發過程。
準備工作
一開始完全重建所有的主題代碼是不太現實的,是以我推薦先從Publish自帶的預設主題
foundation
入手。
完成Publish建立部落格(一)——入門[10]中的安裝工作
修改
main.swift
enum SectionID: String, WebsiteSectionID { // Add the sections that you want your website to contain here: case posts case about //添加一項,為了示範上方導覽列 }
複制
$http://cdn myblog$publish run
複制
通路
http://localhost:8000
,頁面差不多這樣
publis-2-defaultIndex
在
Resource
目錄中建立
MyTheme
目錄。在XCode中将Publish庫中的兩個檔案
styles.css
、
Theme+Foundation.swift
拷貝到
MyTheme
目錄,也可以在MyTheme目錄中新建立檔案後粘貼代碼。
Publish--Resources--FoundatioinTheme-- styles.css
複制
Publish--Sources--Publish--API-- Theme+Foundation.swift
複制
将
Theme+Foundation.swift
改名為
MyTheme.swift
,并編輯内容
将:
private struct FoundationHTMLFactory<Site: Website>: HTMLFactory {
複制
改成:
private struct MyThemeHTMLFactory<Site: Website>: HTMLFactory {
複制
将
static var foundation: Self { Theme( htmlFactory: FoundationHTMLFactory(), resourcePaths: ["Resources/FoundationTheme/styles.css"] ) }
複制
改為
static var myTheme: Self { Theme( htmlFactory: MyThemeHTMLFactory(), resourcePaths: ["Resources/MyTheme/styles.css"] )}
複制
在
main.swift
中
将
try Myblog().publish(withTheme: .foundation)
複制
改為
try Myblog().publish(withTheme: .myTheme)
複制
随便在
Content
的
posts
目錄下建立幾個
.md
檔案。比如
---date: 2021-01-30 19:58description: 第二篇tags: second, articletitle: My second post---hello world...
複制
至此準備完畢,頁面看起來差不多是這個樣子,建立目前顯示頁面的是
makeIndexHTML
方法。
publish-2-defaultindex2
例子1:在makeIndexHTML中改變Item Row的顯示内容
目前的makeIndexHTML的代碼如下:
func makeIndexHTML(for index: Index, context: PublishingContext<Site>) throws -> HTML { HTML( .lang(context.site.language), //<html lang="en"> language 可以在main.swift中修改 .head(for: index, on: context.site), //<head>内容,title及meta .body( .header(for: context, selectedSection: nil), //上部網站名稱Site.name及nav導航 SectionID .wrapper( .h1(.text(index.title)), // Welcome to MyBlog! 對應Content--index.md的title .p( .class("description"), //在styels.css 對應 .description .text(context.site.description) //對應main.swift中的Site.description ), .h2("Latest content"), .itemList( //自定義Node,顯示Item清單,目前makeIndex makeSection makeTagList都使用這一個 for: context.allItems( sortedBy: \.date, //按建立時間降序,根據 metatdata date order: .descending ), on: context.site ) ), .footer(for: context.site) //自定義Node,顯示下部版權資訊 ) ) }
複制
在
makeIndexHTML
中做如下修改
.itemList(
複制
改為
.indexItemList(
複制
在後添加
.h2("Latesht content")
,變成如下代碼
.h2("Latesht content"), .unwrap(context.sections.first{ $0.id as! Myblog.SectionID == .posts}){ posts in .a( .href(posts.path), .text("顯示全部文章") ) },
複制
在
extension Node where Context == HTML.BodyContext
中進行添加:
static func indexItemList<T: Website>(for items: [Item<T>], on site: T) -> Node { let limit:Int = 2 //設定index頁面最多顯示的Item條目數 let items = items[0...min((limit - 1),items.count)] return .ul( .class("item-list"), .forEach(items) { item in .li(.article( .h1(.a( .href(item.path), .text(item.title) )), .tagList(for: item, on: site), .p(.text(item.description)), .p(item.content.body.node) //添加顯示Item全文 )) } ) }
複制
現在Index變成如下狀态:
image-20210201135111053
例子2:為makeItemHTML添加臨近文章的導航
本例,我們将在makeItemHTML上添加文章導航功能,類似效果如下:
image-20210201105104706
點選進入任意Item(文章)
func makeItemHTML(for item: Item<Site>, context: PublishingContext<Site>) throws -> HTML { HTML( .lang(context.site.language), .head(for: item, on: context.site), .body( .class("item-page"), .header(for: context, selectedSection: item.sectionID), .wrapper( .article( //<article>标簽 .div( .class("content"), //css .content .contentBody(item.body) //.raw(body.html) 顯示item.body.html 文章正文 ), .span("Tagged with: "), .tagList(for: item, on: context.site) //下方tag清單,forEach(item.tags) ) ), .footer(for: context.site) ) ) }
複制
在代碼
HTML(
前添加如下内容:
var previous:Item<Site>? = nil //前一篇Item var next:Item<Site>? = nil //下一篇Item let items = context.allItems(sortedBy: \.date,order: .descending) //擷取全部Item /* 我們目前是擷取全部的Item,可以在擷取時對範圍進行限定,比如: let items = context.allItems(sortedBy: \.date,order: .descending) .filter{$0.tags.contains(Tag("article"))} */ //目前Item的index guard let index = items.firstIndex(where: {$0 == item}) else { return HTML() } if index > 0 { previous = items[index - 1] } if index < (items.count - 1) { next = items[index + 1] } return HTML( ....
複制
在
.footer
前添加
.itemNavigator(previousItem:previous,nextItem:next),.footer(for: context.site)
複制
在
extension Node where Context == HTML.BodyContext
中添加自定義Node
itemNavigator
static func itemNavigator<Site: Website>(previousItem: Item<Site>?, nextItem: Item<Site>?) -> Node{ return .div( .class("item-navigator"), .table( .tr( .td( .unwrap(previousItem){ item in .a( .href(item.path), .text(item.title) ) } ), .td( .unwrap(nextItem){ item in .a( .href(item.path), .text(item.title) ) } ) ) ) ) }
複制
在
styles.css
中添加
.item-navigator table{ width:100%;}.item-navigator td{ width:50%;}
複制
以上代碼僅作為概念示範。結果如下:
publish-2-makeitem-with-navigator
總結
如果你有SwiftUI的開發經驗,你會發現使用方式非常相似。在Publish主題中,你有充足的手段來組織、處理資料,并布局視圖(把
Node
當做
View
)。
Publish的
FoundationHTMLFactory
目前僅定義了六個頁面種類,如果想增加新的種類目前有兩種方法:
1.Fork Publish,直接擴充它的代碼這種方式最徹底,但維護起來比較麻煩。2.在Pipeline執行過
.generateHTML
後,再執行自定義的generate Step無需改動核心代碼。可能會有備援動作,并且需要在
FoundationHTMLFactory
内置方法中做一點處理以便和我們新定義的頁面做連接配接。比如,目前
index
,
section list
都不支援分頁(隻會輸出一個HTML檔案),我們可以在内置的
makeIndex
之後,再重新生成一組分頁的
index
,并覆寫掉原來的。
在本篇中,我們介紹了如何使用Plot[11],以及如何在Publish[12]中定制自己的主題。在下一篇文章中,我們要探讨如何在不改動Publish核心代碼的情況下,增加各種功能的手段(不僅僅是Plugin)。
我的個人部落格肘子的Swift記事本[13]中會有更多關于Swift、SwiftUI、CoreData的内容。
引用連結
[1]
www.fatbobman.com: http://www.fatbobman.com
[2]
Publish: https://github.com/JohnSundell/Publish
[3]
Plot: https://github.com/JohnSundell/Plot
[4]
Leaf: https://github.com/vapor/leaf
[5]
swift-html: https://github.com/pointfreeco/swift-html
[6]
John Sundell: https://swiftbysundell.com
[7]
用Publish建立部落格(一)——入門: https://www.fatbobman.com/posts/publish-1/
[8]
GIthub: https://github.com/fatbobman/PublishThemeForFatbobmanBlog
[9]
範例代碼: https://github.com/fatbobman/PublishThemeForFatbobmanBlog
[10]
Publish建立部落格(一)——入門: https://www.fatbobman.com/posts/publish-1/
[11]
Plot: https://github.com/JohnSundell/Plot
[12]
Publish: https://github.com/JohnSundell/Publish
[13]
肘子的Swift記事本: https://www.fatbobman.com