Swiftでブログが作れるPublishの機能を拡張するプラグインを作ってリリースする

Swiftでウェブサイトを作っています。今回はPublishの機能を拡張する方法としてプラグインについて扱いました。
2020.12.07

とある記事を読んで個人サイトを運営し続けるメリットを感じて以降GitHub Pagesに個人サイトを置いて趣味で運営しています。そのサイトは静的サイトジェネレタータにSwift製のJohnSundell/Publish によって生成されていますが、Publishはプラグインの実装で機能を拡張することができます。

Publishはまだまだ新興のOSSなので、他の静的サイトジェネレータだと揃っているような機能が不足しています。それでもSwiftでウェブのコンテンツが作れるのはServer side Swiftなどと同じように興味が惹かれるので利用しているのですが、プラグインの作り方を覚えておくと必要な機能を追加する時の障壁が下って良いなと思って作ってみました。今回はプラグインの作り方やリリースしたプラグインなどについて話をします。

Publish のプラグインについて

Publishについての記事は以前書きました

Publishはプラグインをサポートしています。これによって複数のプロジェクト間でコードを共有したり、Publishの組み込み機能拡張できます。

運営している個人サイトのセットアップを行っているコードは以下になります。

try Blog().publish(using: [
    .installPlugin(.highlightJS()),
    .installPlugin(.twitter()),
    .installPlugin(.youtube()),
    .addMarkdownFiles(),
    .generateHTML(withTheme: .myTheme),
    .copyResources(),
    .sortItems(by: \.date, order: .descending),
    .generateRSSFeed(including: [.posts]),
    .generateSiteMap(),
    .deploy(using: .gitHub("repository_name", useSSH: true)),
])

このコードからもわかるように、静的サイトを生成するプロセスは開発者が制御できます。上から順番に実行されますがdeployする前にRSSFeedを生成したりプラグインの読み込みを行ったりしています。

プラグインでは組み込みのほぼ全ての機能を拡張できますが、リポジトリのREADME.mdにはファイルやフォルダの追加、Webサイトのコンテンツの変更、Markdown解析などが具体的なプラグインの例として紹介されています。

多くのプラグインの実装を見ましたが、README.mdで紹介されていたPluginという構造体にextensionでstaticメソッドを生やす方法でプラグインの提供を行っているものが多かったです。

extension Plugin {
    static var ensureAllItemsAreTagged: Self {
        Plugin(name: "Ensure that all items are tagged") { context in
            let allItems = context.sections.flatMap { $0.items }

            for item in allItems {
                guard !item.tags.isEmpty else {
                    throw PublishingError(
                        path: item.path,
                        infoMessage: "Item has no tags"
                    )
                }
            }
        }
    }
}

作ってみる

静的サイトを生成するようなものによくある特定のフォーマットで記入すると埋め込みに変換してくれるものを作ることにしました。これだと変換部分のロジックは単純で、Publishのプラグインの作り方を学ぶことに集中できると思ったからです。

パッケージの作成

PublishがSwiftPMの利用を必須にしているので、Publishのプラグインもそれに倣いました。

SwiftPMでパッケージを作るのは任意のディレクトリでswift package initを叩きます。ビルドとテストも走らせて動作に問題ないことを確認します。

mkdir YoutubePublishPlugin
cd YoutubePublishPlugin/
swift package init
swift build
swift test

XcodeでPackage.swiftを開くと依存ライブラリのダウンロードなどを勝手にやってくれます。Package.swiftの中身は以下です。

import PackageDescription

let package = Package(
    name: "YoutubePublishPlugin",
    products: [
        .library(
            name: "YoutubePublishPlugin",
            targets: ["YoutubePublishPlugin"]),
    ],
    dependencies: [
        .package(name: "Publish", url: "https://github.com/JohnSundell/Publish.git", from: "0.7.0")
    ],
    targets: [
        .target(
            name: "YoutubePublishPlugin",
            dependencies: ["Publish"]),
        .testTarget(
            name: "YoutubePublishPluginTests",
            dependencies: ["YoutubePublishPlugin"]),
    ]
)

大切なのはtargets以下のdependenciesに依存ライブラリをきちんと指定することです。忘れるとNo Such Moduleとエラーを吐きます。

プラグインの設計は他のライブラリを踏襲します。Pluginにextensionを生やしてstatic メソッドを定義します。これにより.installPlugin(.youtube())のように記述できて既存のシーケンスに組み込むことができます。

public extension Plugin {
    static func youtube(renderer: YoutubeRenderer = DefaultYoutubeRenderer()) -> Self {
        Plugin(name: "Youtube") { context in
            context.markdownParser.addModifier(.youtubeBlockQuote(using: renderer))
        }
    }
}

今回はmarkdown解析を拡張するのでプラグインでは内部的にMarkdownパーサーを利用します。Publishは内部的にInkというmarkdown パーサーに依存しています。このプラグインは内部でPublishに依存しているのでPublishを導入した時点でこのInkが利用できます。

このInkはMarkdownの様々な流派で見られる一般的な機能をカバーすることを目的に作られた組み込みの解析ルールに加えて、modifierを追加して拡張が出来ます。modifierはInkのModifierという型で定義されています。この機能を使って独自に定義したmodifierをmarkdwonパーサーに追加しています。

public extension Modifier {
    static func youtubeBlockQuote(using renderer: YoutubeRenderer) -> Self {
        return Modifier(target: .blockquotes) { html, markdown in
            let prefix = "youtube "
            var markdown = markdown.dropFirst().trimmingCharacters(in: .whitespaces)
            guard markdown.hasPrefix(prefix) else {
                return html
            }

            markdown = markdown.dropFirst(prefix.count).trimmingCharacters(in: .newlines)

            guard let url = URL(string: markdown) else {
                fatalError("Invalid tweet URL \(markdown)")
            }

            let generator = YoutubeEmbedGenerator(url: url, configuration: .default)
            let youtube = try! generator.generate().get()
            return try! renderer.render(youtube: youtube)
        }
    }
}

ここで記法とかを識別しているので、このmodifierを利用しないようにすればプラグイン内の実装を使いつつ独自にプラグインをカスタマイズできるようになります。プラグインによってはあまり受け入れられない記法もあったりするのでその際は実装を拝借しつつ自分のブログに合わせたものを利用します。

Pluginに必要な知識はこれだけで、後はプラグインでどのようなことが行いたいかによって実装が変わります。

YoutubePublishPlugin

先程書いた通り静的サイトジェネレータだとありふれた機能をまだ自分で実装する必要があります。その一つが各種外部サービスの埋め込みです。Youtubeがなかったようなので作りました。

Developers.IOでも特定の記法で外部サービスのコンテンツが自動で埋め込まれますが、それと同じ機能を提供します。

SwiftPM経由で導入した後publishメソッドでinstallPluginで導入できます。

try mysite().publish(using: [
    .installPlugin(.youtube()),
    // ...
])

プラグインのリリース方法は通常のOSSと同じです。tagを付けてreleaseを作成します。Releaseした後のことですが、PublishのREADME.mdで、topicでpublish-pluginと指定するとPublishのコミュニティの人達に見てもらえる機会(publish-plugin · GitHub Topics)が提供できると勧められていました。

まとめ

単純な機能を提供するものではありますがプラグインの紹介と作り方と作ったプラグインの紹介をしました。普通に利用するだけでも不足している機能があるので拡張しつつコミュニティに微力ながら貢献できればと思います。また、今回作ったプログラムは必要に迫られて勢いで作ったところがあるので、使いつつ拡張しやすいように実装を見直したいと思います。