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版(普通に使えるけど保守性を考えると個人の趣味程度しか使えなさそう)
- 使ってる人が少ない、日本語圏は皆無、英語圏でも少ない、よって使うときは公式ドキュメントとソースとにらめっこになる
つまり
レシピとテーマがありますが使ってないので当記事では紹介しないです
とりあえず動かしてみる
環境の用意
リリースページ
ここを読んでから入れろと書いてありますがWindowsのインストーラーのSetup.exeを動かせばWyamのコマンドプロンプトがインストールされます
VisualStudioプロジェクトの用意
C#使うからにはやっぱりVisualStudioの補完が使いたい!ってなるのでVS先輩使います
まずプロジェクト作成ですがASP .NET(.NET Framework)プロジェクトにします
テンプレートですが空のテンプレートでMVCのところにチェックを入れてください(チェックを入れなくても自分で用意することもできますが)
作成されたらプロジェクトのプロパティで必ず対象フレームワークを.NET Framework 4.6にしてください
4.5のままだとNuGetのパッケージが用意されていません
次にVSのビルドでWyamのビルドをできるようにしたら楽なのでWyamのパッケージをNuGetからインストールします
Preview版なのでパッケージマネージャーコンソールからインストールすることにします
Install-Package Wyam -Pre
Wyamのバージョンはすべて同じ系統で統一したほうがいいです
こんな感じでソリューションフォルダ直下のpackagesフォルダにWyamのツールが追加されるのでこれをビルドイベントに追加します
これをビルドイベントに書き足すのですがバージョンとコンフィグファイルは各自合わせてください
"$(SolutionDir)\packages\Wyam.0.15.6-beta\tools\wyam.exe" "$(SolutionDir)\config.wyam"
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を生成するコードにします
#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;
}
@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>
@{
Layout = "../Shared/_Layout";
}
---
Title: Titleだよーん
---
<p>Helloooooooooooooooooo</p>
ビルド
先ほどVS先輩のビルドイベントに追加していたらVisualStudioのビルドメニューから該当プロジェクトをビルドするだけでWyamのビルドがされます
少し(自分の環境で30秒ぐらい)時間がかかるのでTwitterでも見ながら暇つぶしするといいです
ビルドが成功したらソリューション直下のoutputフォルダに生成されるはずなので確認しましょう
<!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に以下のように書きます
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モジュールのように書くだけです
Pipelines.Add("Archives",
ReadFiles("Archive/*.md"),
FrontMatter(Yaml()),
Markdown(),
WriteFiles((doc,ctx) => $"Archive/{doc.Source.FileNameWithoutExtension}/index.html".ToLower())
);
Razorモジュールと組み合わせて使うこともできます、また同じフォルダにcshtmlファイルとmdファイルを共存させることもできます
その場合はConcatモジュールを使います
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)
にします
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モジュールを使い再格納します
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でメタデータ中心のレイアウト組めばいいです)
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モジュールを参照してください
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を圧縮したりできます
Pipelines.Add("Asset",
CopyFiles("Asset/**/*")
);
Pipelines.Add("Less",
ReadFiles("Asset/Less/*.less"),
Less(),
MinifyCss(),
WriteFiles((doc,ctx) => $"Asset/Css/{doc.Source.FileNameWithoutExtension}.css")
);
最後に
以上のようにやりたいことは何でもできるようになっています(知識が必要でないとは言っていない)
紹介してないモジュールもたくさんありますのでぜひ使ってみてください
あといつまでbeta版なんだろ…