C#
静的サイトジェネレーター
Wyam

静的サイトジェネレーターでC#を使いたかったからWyamを試したら最高だった

More than 1 year has passed since last update.

Github Pagesでちょっとしたサイトを公開するのにJekyll(Github側でビルドしてたからプラグインがほとんど使えない)を使っていたのですがやっぱりC#使いたくなったのでC#を使える静的サイトジェネレーターを探したらWyamがありました

試したら最高だったので紹介します

当記事では現状最新の0.15.*-betaをもとにしています

Wyamについて

特徴

  • C#をRoslynでスクリプトとして動かす
  • モジュールパイプライン形式
  • 簡単なことならC#のコーディングをしなくてもいい(後述)
  • ASP .NETでお馴染みのRazorが使える
  • Front matterとしてYAMLが使えるほかMarkdown,Lessなどにも対応
  • ぶっちゃけモジュールさえ書けばなんでもできる(用意されてるモジュールもたくさんある)
  • そのモジュールさえ書かなくてもC#のスクリプトで拡張できる
  • もちろんNuGetに対応し.NETの資産を使える

あと今後.NET Coreとしてクロスプラットフォームにする予定みたいです

ダメなところ

  • ビルドに時間がかかる(ビルド毎にNuGetパッケージをインストールしてる)
  • beta版(普通に使えるけど保守性を考えると個人の趣味程度しか使えなさそう)
  • 使ってる人が少ない、日本語圏は皆無、英語圏でも少ない、よって使うときは公式ドキュメントとソースとにらめっこになる

つまり

wyam_1.PNG
https://wyam.ioトップのスクショです

レシピとテーマがありますが使ってないので当記事では紹介しないです

とりあえず動かしてみる

環境の用意

リリースページ
ここを読んでから入れろと書いてありますがWindowsのインストーラーのSetup.exeを動かせばWyamのコマンドプロンプトがインストールされます

VisualStudioプロジェクトの用意

C#使うからにはやっぱりVisualStudioの補完が使いたい!ってなるのでVS先輩使います

まずプロジェクト作成ですがASP .NET(.NET Framework)プロジェクトにします
wyam_2.PNG
テンプレートですが空のテンプレートでMVCのところにチェックを入れてください(チェックを入れなくても自分で用意することもできますが)
wyam_3.PNG

作成されたらプロジェクトのプロパティで必ず対象フレームワークを.NET Framework 4.6にしてください
4.5のままだとNuGetのパッケージが用意されていません
wyam_4.PNG

次にVSのビルドでWyamのビルドをできるようにしたら楽なのでWyamのパッケージをNuGetからインストールします

Preview版なのでパッケージマネージャーコンソールからインストールすることにします

Install-Package Wyam -Pre

Wyamのバージョンはすべて同じ系統で統一したほうがいいです

こんな感じでソリューションフォルダ直下のpackagesフォルダにWyamのツールが追加されるのでこれをビルドイベントに追加します
wyam_5.PNG

これをビルドイベントに書き足すのですがバージョンとコンフィグファイルは各自合わせてください

"$(SolutionDir)\packages\Wyam.0.15.6-beta\tools\wyam.exe" "$(SolutionDir)\config.wyam"

wyam_6.PNG

VS先輩の補完に使うためにWyam.Commonをプロジェクトに追加しておきます

こちらもパッケージマネージャーコンソールからインストールします

Install-Package Wyam.Common -Pre

configファイルの用意

先ほどビルドイベントで指定したようにソリューションフォルダ直下にconfig.wyamを追加します
#nの行はNuGetパッケージです(-pはプレリリース版) 使うモジュールなどに合わせて追記してください
FileSystem.InputPaths.Add(string);で用意するファイル群ディレクトリを追加します(デフォルトではinput,theme)
最初はFront matter YAMLとRazorを使って直下のindex.htmlを生成するコードにします

(SolutionDir)/config.wyam
#n Wyam.Markdown -p
#n Wyam.Razor -p
#n Wyam.Yaml -p
#n Wyam.Html -p
#n Wyam.Less -p
#n Wyam.Minification -p

FileSystem.InputPaths.Add("Site/Views");

Pipelines.Add("Index",
    ReadFiles("Index.cshtml"),
    FrontMatter(Yaml()),
    Razor(),
    WriteFiles((doc,ctx) => new FilePath("index.html"))
);

C#風な書き方ですが

The configuration file is evaluated as C# code, so you can make use of the full C# language and the entire .NET ecosystem. However, it's not necessary to know C# to write Wyam configuration files. The syntax has been carefully crafted to be usable by anyone no matter their level of programming experience. Some extra pre-processing is also done to the file to make certain code easier to write (which actually makes the syntax a superset of C#, though this extra magic is entirely optional).
https://wyam.io/docs/usage/configurationより

構成ファイルはC#コードとして評価されるため、完全なC#言語と.NETエコシステム全体を利用できます。ただし、Wyamの設定ファイルを書き込むためにC#を知る必要はありません。構文は、プログラミング経験のレベルに関係なく誰もが使用できるように慎重に作られています。いくつかの余分な前処理もファイルに対して行われ、特定のコードを簡単に書くことができます(この余分なマジックは完全にオプションですが、実際にはC#のスーパーセットになります)。
Google翻訳より

とあるように知識がなくても書けて、知識があったらやりたいように書けるという風です

ファイルの用意

VS先輩の補完を使うには以下のものをcshtmlファイルに追加するといいです
下記ではModel.MetadataからメタデータメソッドにアクセスしてますがModelにも生えてるのでわざわざMetadataにアクセスしなくていいです(小声)

@using Wyam.Common.Documents;
@using Wyam.Common.Execution;
@model IDocument
@{
    var context = Context as IExecutionContext;
    var globalMetadata = context.GlobalMetadata;
    var metadata = Model.Metadata;
}
Views/Shared/_Layout.cshtml
@using Wyam.Common.Documents;
@using Wyam.Common.Execution;
@model IDocument
@{
    var context = Context as IExecutionContext;
    var globalMetadata = context.GlobalMetadata;
    var metadata = Model.Metadata;
}
<!DOCTYPE html>
<html>
<head>
    <title>@Html.Raw(metadata.String("Title",string.Empty))</title>
</head>
<body>
    @RenderBody()
</body>
</html>
Views/_ViewStart.cshtml
@{ 
    Layout = "../Shared/_Layout";
}
Views/Index.cshtml
---
Title: Titleだよーん
---
<p>Helloooooooooooooooooo</p>

ビルド

先ほどVS先輩のビルドイベントに追加していたらVisualStudioのビルドメニューから該当プロジェクトをビルドするだけでWyamのビルドがされます
少し(自分の環境で30秒ぐらい)時間がかかるのでTwitterでも見ながら暇つぶしするといいです

ビルドが成功したらソリューション直下のoutputフォルダに生成されるはずなので確認しましょう

(SolutionDir)/output/index.html
<!DOCTYPE html>
<html>
<head>
    <title>Titleだよーん</title>
</head>
<body>
    <p>Helloooooooooooooooooo</p>
</body>
</html>

こんな感じのが生成されていたら成功です
もしビルドエラーがでたらVS先輩からじゃ詳細がわからないのでWyam.exeのパスが通ったコマンドプロンプトでwyam buildをして確認してください

ローカル環境で確認

Wyam.exeのパスが通ったコマンドプロンプトでwyam previewするとlocalhostでブラウザから確認できます

カスタマイズ

View

これ以降各cshtmlファイルで以下のものが宣言されてる前提にします

@using Wyam.Common.Documents;
@using Wyam.Common.Execution;
@model IDocument
@{
    var context = Context as IExecutionContext;
    var globalMetadata = context.GlobalMetadata;
    var metadata = Model.Metadata;
}

以下が参考になります

各ViewのモデルにはIDocumentインターフェースを実装したもの(おおかたDocumentクラスだろうけども)が来ます
ASP .NETでのMVCパターンのModelとViewの部分が表面に出てきてControllerはWyamが勝手にするという感じですかね

メタデータを取得

ジェネリクスなGetメソッドが用意されています
以下はTitleキーのstring型を取ってくる例です

Model.Get<string>("Title");
metadata.Get<string>("Title");

Model.Get("Title",string.Empty);// default value付き
metadata.Get("Title",string.Empty);// default value付き

string型用のStringメソッドも用意されています

Model.String("Title");
metadata.String("Title");

Model.String("Title",string.Empty);// default value付き
metadata.String("Title",string.Empty);// default value付き

グローバルメタデータの場合はconfig.wyamに以下のように書きます

config.wyam
GlobalMetadata["Title"] = "Titleだよーーん";

アクセス方法はglobalMetadata変数から通常のメタデータを取ってくるのと同じ方法です

IMetadataインターフェースが参考になります
補足ですがIDocumentインターフェースはIMetadataインターフェースを継承してるのでModelからでもアクセスできます

リンクを取得

IExecutionContextインターフェースにはModelと関連付けられたViewへのリンク(実際の相対URL)を取得するメソッドが用意されています(引数はIMetadataインターフェースでいいです)

context.GetLink(IDocument);//IDocument型を渡す方が多いと思います
context.GetLink(IMetadata);

ただ、この方法はメタデータにある、後述するWriteFilesモジュールなどでセットされるFilePathを利用するのでそのあとの処理でしか使えないです

他には、FilePath型のIDocument.Sourceプロパティから出力されるパスに変換するという手もあります
FilePathクラス参考

他のViewのModelを取得

使い道はページ一覧とかタグ一覧とかになりますがちゃんと他のViewのModelを取得することができます(※ただし処理済みのものに限る)

IEnumerableを継承したIDocumentCollection型context.Documentsで取得できます
パイプラインによる絞り込みもFromPipeline(string)メソッドで行えます

そこからメタデータなどによって絞り込みをすれば一覧ページなどを作ることができますが、処理済みのModelしか取得できないのでconfig.wyamに書くパイプラインの順番には気を付けたほうがいいです

config.wyam

どう説明したらいいかわからないので作ったサイトのconfig.wyamを上から補足するように説明します

Markdownモジュールを使う

Razorモジュールのように書くだけです

config.wyam
Pipelines.Add("Archives",
    ReadFiles("Archive/*.md"),
    FrontMatter(Yaml()),
    Markdown(),
    WriteFiles((doc,ctx) => $"Archive/{doc.Source.FileNameWithoutExtension}/index.html".ToLower())  
);

Razorモジュールと組み合わせて使うこともできます、また同じフォルダにcshtmlファイルとmdファイルを共存させることもできます
その場合はConcatモジュールを使います

config.wyam
Pipelines.Add("Archives",
    ReadFiles("Archive/{!_,}*.cshtml"),
    FrontMatter(Yaml()),
    Concat(
        ReadFiles("Archive/*.md"),
        FrontMatter(Yaml()),
        Markdown()
    ),
    Razor(),
    WriteFiles((doc,ctx) => $"Archive/{doc.Source.FileNameWithoutExtension}/index.html".ToLower())  
);

Metaモジュールを使う

パイプライン内でもModelのメタデータを追加できます

自分の場合Jekyllで書いたファイルをそこまで手直ししたくなかったのでファイル名が(Year)-(Month)-(Day)-(Title).md形式になっているのですがここから時刻をメタデータに格納して出力パスも(Year)/(Month)/(Day)/(Title)にします

config.wyam
    public static class DateHelper {

        public static DateTime FromFileName(string fileName) {
            string[] ar = fileName.Split('-');
            if(ar.Length < 4) {
                return DateTime.MinValue;
            }
            int year;
            int month;
            int day;
            if(int.TryParse(ar[0],out year) && int.TryParse(ar[1],out month) && int.TryParse(ar[2],out day)) {
                return new DateTime(year,month,day);
            }
            return DateTime.MinValue;
        }
    }

Pipelines.Add("Archives",
    ReadFiles("Archive/{!_,}*.cshtml"),
    FrontMatter(Yaml()),
    Concat(
        ReadFiles("Archive/*.md"),
        FrontMatter(Yaml()),
        Markdown()
    ),
    Meta("Date",DateHelper.FromFileName(@doc.Source.FileNameWithoutExtension.ToString())),
    Razor(),
    WriteFiles((doc,ctx) => $"Archive/{doc.Source.FileNameWithoutExtension.ToString().Replace("-","/")}/index.html".ToLower())  
);

Excerptモジュールを使う

サイトの概要を自動で生成したくなりますが、そのときはExcerptモジュールを使います
デフォルトでは一番最初に現れたpタグをメタデータにキーをExcerptとして格納します

またデフォルトではpタグ自身を含めたり、内部のHTMLタグをそのまま入れたりするのでIfモジュールとMetaモジュールを使い再格納します

config.wyam
Pipelines.Add("Archives",
    ReadFiles("Archive/{!_,}*.cshtml"),
    FrontMatter(Yaml()),
    Concat(
        ReadFiles("Archive/*.md"),
        FrontMatter(Yaml()),
        Markdown()
    ),
    Meta("Date",DateHelper.FromFileName(@doc.Source.FileNameWithoutExtension.ToString())),
    Excerpt().WithOuterHtml(false),
    If(
        (doc,ctx) => doc.String("Excerpt",null) != null,
        Meta("Excerpt",System.Text.RegularExpressions.Regex.Replace(@doc.String("Excerpt"), "<.*?>", string.Empty))
    ),
    Razor(),
    WriteFiles((doc,ctx) => $"Archive/{doc.Source.FileNameWithoutExtension.ToString().Replace("-","/")}/index.html".ToLower())  
);

WriteFilesモジュールを使う

基本的なモジュールですが出力したいパスが各自違うと思うのでまとめておきます

そのまま出力する

WriteFiles()

相対パスはそのままで拡張子だけ変える

WriteFiles(".html")

相対パスをstring型で返す
(例はブラウザでアクセスするときファイル名を隠せるようにindex.htmlファイルにする奴)

WriteFiles((doc,ctx) => $"Archive/{doc.Source.FileNameWithoutExtension}/index.html")    

相対パスをFilePath型で返す
(例は出力直下にindex.htmlファイルを置く奴)

WriteFiles((doc,ctx) => new FilePath("index.html")

相対パスをstring型で返す方法はたまにうまくいかないパターンがあるのでFilePath型を返す方が確実です

Executeモジュールを使う

ここでWyamの真骨頂Executeモジュールです
やりたいことが用意されているモジュールにないとき、わざわざモジュールを作らなくてもC#をスクリプトとして動かしてやりたいようにできるモジュールです

やりたいことの例として、すべての(処理済みの)ドキュメントからTagsというキーのメタデータがあるModelを集めてそのタグごとのModel一覧ページを作るです

通常はReadFilesモジュールからコンテンツを作成していきますが、元ファイルがないところからでも作れます

ただパイプライン上で各Viewを表現するのは鬼畜の所業なのでRazorさんを使ってテンプレートからViewを作成します
また、ファイルがないとこからの処理なのでRazorのとこでWithViewStart(FilePath)メソッドでViewStartを指定します
(View側は_ViewStart.cshtmlが参照してるLayoutでメタデータ中心のレイアウト組めばいいです)

config.wyam
Pipelines.Add("Tags",
    Execute(@ctx.Documents
        .WhereContainsKey("Tags")
        .SelectMany(x => x.Get<IEnumerable<string>>("Tags"))
        .Distinct()
        .Select(x => @ctx.GetDocument("",new Dictionary<string, object>()
            {
                { "Title", $"{x} Tag" },
                { "Description", $"{x}タグに関するページを表示します" },
                { "Tag", x},
                { "TagPages", @ctx.Documents.WhereContainsKey("Tags").Where(y => y.Get<IEnumerable<string>>("Tags").Contains(x)) }
            })
        )
    ),
    Razor().WithViewStart(new FilePath("/Tag/_ViewStart.cshtml")),
    WriteFiles((doc,ctx) => $"Tag/{doc.String("Tag",string.Empty).Replace("#","Sharp")}/index.html".ToLower())
);

Paginateモジュールを使う

ページネーションもちゃんとできます
最初の引数で1ページごとのコンテンツ数を指定し、そのあとの引数で処理するコンテンツをモジュールで引き出します
DocumentsモジュールではReadFilesのようにパイプラインからコンテンツを引き出せます
OrderByモジュールはその名の通りソートしてます

メタデータのキーについてはPaginateモジュールを参照してください

config.wyam
Pipelines.Add("Archive",
    ReadFiles("Page/Archive.cshtml"),
    Paginate(5,
        Documents("Archives"),
        OrderBy((doc, ctx) => doc["Date"]).Descending().ThenBy((doc,ctx) => doc.String("Title",string.Empty))   
    ),
    FrontMatter(Yaml()),
    Razor(),
    WriteFiles((doc,ctx) => "Archive/index"+(doc.Get<int>("CurrentPage") == 1 ? "" : ("-"+doc.Get<int>("CurrentPage")))+".html".ToLower())
);

Asset

特に説明することはないですがCopyFilesモジュールでそのままコピーしたり、LessモジュールでLessを処理したり、MinifyCSSモジュールでCSSを圧縮したりできます

config.wyam
Pipelines.Add("Asset",
    CopyFiles("Asset/**/*")
);

Pipelines.Add("Less",
    ReadFiles("Asset/Less/*.less"),
    Less(),
    MinifyCss(),
    WriteFiles((doc,ctx) => $"Asset/Css/{doc.Source.FileNameWithoutExtension}.css")
);

最後に

以上のようにやりたいことは何でもできるようになっています(知識が必要でないとは言っていない)
紹介してないモジュールもたくさんありますのでぜひ使ってみてください

あといつまでbeta版なんだろ…

Wyamで作ったもの