Posted at

静的サイトジェネレーターで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で作ったもの