Swiftでブログが作れるPublishで生成したサイトでGoogle Analyticsを利用できるようにする

Swiftでブログが作れるPublishで生成したサイトでGoogle Analyticsを利用する過程を記事にしました。
2021.01.11

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

自分の個人サイトの生成に使用しているPublishはHTMLを生成する部分をSwift製のDSLに依存しています。今回はそのDSLを少し拡張してGoogle Analyticsのトラッキングコードを埋め込んだのでその過程を記事に起こそうと思います。

Publishの基本的な使い方や簡単な拡張については過去に記事にしています。

Plot

SwiftでタイプセーフにHTML、XML、RSSを記述できるDSLとしてPlotが開発されています。

以下のようなHTMLを

<!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>

以下のようなSwiftのコードで記述できます。

let html = HTML(
    .head(
        .title("My website"),
        .stylesheet("styles.css")
    ),
    .body(
        .div(
            .h1("My website"),
            .p("Writing HTML in Swift is pretty great!")
        )
    )
)

Google Analyticsの導入方法

以下のページからウェブサイトの登録をして、Google Analyticsのトラッキングコードを取得します。

※例

<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=tracking-id"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());

  gtag('config', 'tracking-id');
</script>

Google Analyticsの初期設定はこのコードを測定したいすべてのページに埋め込む必要があります。

PlotでのNodeについて

階層内のノードを表現するため、PlatではNodeという列挙型が定義されています。

public enum Node<Context>

そしてPlotでHTMLを表現する場合は型パラメータContextがPlot.HTMLContextの時に使用できるextensionで定義されています。テーマファイルで使用する各種タグがここで定義されています。

extension Node where Context : Plot.HTMLContext

今回のカスタマイズに関係のあるheadはstaticメソッドで定義されていました。 

// Add a `<head>` HTML element within the current context, which
/// contains non-visual elements, such as stylesheets and metadata.
/// - parameter nodes: The element's attributes and child elements.
public static func head(_ nodes: Plot.Node<Plot.HTML.HeadContext>...) -> Plot.Node<Context>

Plotでは作成されたノードに対して後からノードを追加する方法が現在のところ提供されていません。その実装方法について議論が重ねられています。PRを出している人もいるので、それがマージされたら今回の記事も不要になるかまたは大幅に記述量が削減されることになります。

今回はhead関数の実装を基本的には真似て、一部実装を追加することで後からheaderタグにHTMLタグを追加できるようcustomHeader関数を定義します。

Pluginとして以下のように定義したいです。

public extension Plugin {
    static func googleAnalytics(trackingID: String) -> Self {
        Plugin(name: "Google Analytics for Tracking ID \(trackingID)") { context in
            context.site.add(
                headNode: .script(
                    .attribute(named: "async", value: nil, ignoreIfValueIsEmpty: false),
                    .src("https://www.googletagmanager.com/gtag/js?id=\(trackingID)")
                )
            )

            context.site.add(
                headNode: .script(
                    .text("window.dataLayer = window.dataLayer || [];function gtag(){dataLayer.push(arguments);}gtag('js', new Date());gtag('config', '\(trackingID)');")
                )
            )
        }
    }
}

main.swiftで利用する時にはプラグインの追加のみで済むようにします。

try Blog().publish(using: [
    .installPlugin(.highlightJS()),
    .installPlugin(.twitter()),
    .installPlugin(.googleAnalytics(trackingID: "tracking-id")), // googleAnalyticsのトラッキングコードを埋め込むプラグインの追加
    .installPlugin(.youtube()),
    .addMarkdownFiles(),
    .generateHTML(withTheme: .myTheme),
    .copyResources(),
    .sortItems(by: \.date, order: .descending),
    .generateRSSFeed(including: [.posts]),
    .generateSiteMap(),
])

これが実現できるよう実装を進めました。context.siteの型はWebsite型です。この型にノードを追加する機能を追加します。

その前に後から追加するノードを表す構造体を定義します。この構造体で定義したaddメソッドでノードの追加方法を提供します。

private struct CustomNodes {
    private var target: String
    var node: Node<HTML.HeadContext>
    private static var all: [CustomNodes] = []
    static func add<Site: Website>(node: Node<HTML.HeadContext>, for site: Site) {
        self.all.append(CustomNodes(target: String(describing: site.self), node: node))
    }

    static func headNodes<Site: Website>(for site: Site) -> [CustomNodes] {
        self.all.filter { (conditionalHeadNode) -> Bool in
            String(describing: site.self) == String(describing: conditionalHeadNode.target)
        }
    }
}

Website protocolの拡張を行います。これによりgoogleAnalytics(trackingID:)内部の実装のシンタックスエラーが解決します。 

Publishで生成するサイトはこのWebsiteに準拠しているオブジェクトを定義してサイトの基本的な情報を定義します。自分のサイトを例として貼ります。

struct Blog: Website {

    enum SectionID: String, WebsiteSectionID {
        // Add the sections that you want your website to contain here:
        case posts
    }

    struct ItemMetadata: WebsiteItemMetadata {
        // Add any site-specific metadata that you want to use here.
    }

    // Update these properties to configure your website:
    var url = URL(string: "https://tanabe1478.github.io")!
    var name = "t__nabe1478's Blog"
    var description = "t__nabe1478のブログです。務め先のブログには書く程でないプログラミングに関することや私事について書きます。"
    var language: Language { .english }
    var imagePath: Path? { "images/logo.png" }
    var favicon: Favicon? { Favicon() }
}

Websiteの拡張はextensionで行います。addメソッドで後からノードの挿入を、headNodesメソッドで挿入したノードを取り出します。

public extension Website {
    func add(headNode: Node<HTML.HeadContext>) {
        CustomNodes.add(node: headNode, for: self)
    }

    func headNodes(for location: Location) -> [Node<HTML.HeadContext>] {
        CustomNodes.headNodes(for: self).map { $0.node }
    }
}

最後にheadメソッドを模倣して追加しただけのcustomHeadメソッドを実装します。変更点は二箇所、引数の追加と追加したCustomNodesの配列を展開してノードを挿入する処理だけです。

public extension Node where Context == HTML.DocumentContext {
    static func customHead<T: Website>(
        for location: Location,
        on site: T,
        titleSeparator: String = " | ",
        stylesheetPaths: [Path] = ["/styles.css"],
        rssFeedPath: Path? = .defaultForRSSFeed,
        rssFeedTitle: String? = nil,
        additionalNodes: [Node<HTML.HeadContext>] = []) -> Node { // 変更したのはここ
        var title = location.title

        if title.isEmpty {
            title = site.name
        } else {
            title.append(titleSeparator + site.name)
        }

        var description = location.description

        if description.isEmpty {
            description = site.description
        }

        return .head(
            .encoding(.utf8),
            .siteName(site.name),
            .url(site.url(for: location)),
            .title(title),
            .description(description),
            .twitterCardType(location.imagePath == nil ? .summary : .summaryLargeImage),
            .forEach(stylesheetPaths, { .stylesheet($0) }),
            .viewport(.accordingToDevice),
            .unwrap(site.favicon, { .favicon($0) }),
            .unwrap(rssFeedPath, { path in
                let title = rssFeedTitle ?? "Subscribe to \(site.name)"
                return .rssFeedLink(path.absoluteString, title: title)
            }),
            .unwrap(location.imagePath ?? site.imagePath, { path in
                let url = site.url(for: path)
                return .socialImageLink(url)
            }),
            .forEach(site.headNodes(for: location) + additionalNodes, {$0}) // 変更したのはここ
        )
    }
}

確認

実装と実装した機能の導入が終わったところで、publish deployを叩いてGitHub Pagesにデプロイします。ここまでの環境構築については冒頭に紹介したこれまでの記事を参照してください。

Developer toolで確認したところ問題なく埋め込まれていることが確認できました。

まとめ

とある記事に触発されてから自分の関心のある技術を使って個人サイトを作って運営していますが、ほしいものがなければ自前で作りながらやりくりするのは玩具として面白いです。

また、今回紹介した実装ですが、ノードを後から追加する機能をより汎用化して特定の.jsファイルを必要とするサイトの構築ができるよう、より高度な機能を提供するPRが出されているので、これがマージされたら今回紹介した実装はそれでまかなえるため、今回紹介した実装の大部分は不要になります。

ないものばかりではありますが、Swiftをアプリ以外で使える手段の一つなのでないものは作りながら、他の人の実装を参考にさせていただきながら引き続き使っていきたいと思います。記事で誤りなどに気づかれた際にはコメントかTwitterにてレスポンスを頂けると幸いです。