tech.guitarrapc.cóm

Technical updates

コードと記事に集中できるはてなブログの新テーマ「CodeFocus」を公開しました

長い間テーマストアで公開されているテーマを使ってはてなブログを書いてきましたが、ここ2,3年は他の技術記事サービスに比べてスタイル的に読みにくいなぁと感じていました。そこで、自分にとって技術記事を読みやすいはてなブログテーマCodeFocusを作成しました。

CodeFocus

CodeFocus自体の紹介はテーマの紹介記事カスタマイズ記事で書いたので、ここではなぜCodeFocusを作成したのかを書いていきます。

技術記事を読みやすいテーマを作ってみたい

数年ブログを書いていると、ブログの流行りや飽きでテーマを変えたくなることがあります。QiitaやZenn、dev.toなど技術ブログを書くためのサービスが増え、記事が書きやすいだけでなくはてなブログよりも読みやすく感じるサービスが増え、毎日読んで刺激を受けていることも影響している気がします。

はてなブログはテーマストアに公開されているテーマのおかげでサクッと見た目を変更できます。ただ、技術記事の読みやすさというよりシンプル、レスポンシブ、写真の見栄えを重視したテーマが多いと感じます。もちろん技術記事を書くことを想定したテーマもあるのですが、好みとばっちり合うものがなく何かしら割り切ってました。そもそもはてなブログは技術ブログだけを対象にしたサービスではないため、Qiita・Zennと同等の読みやすさを求めるのも酷な話ですしね。

ちょうど生成AIが自分で盛り上がっていたこともあり、はてなブログで技術記事を書きやすく・読みやすいテーマを作ることにしました。あまり万人向けは目指していません。

技術記事を書きやすい、読みやすいとは?

ぼんやりとはてなブログよりも読みやすいサービスが増えたと書きましたが、技術記事を読みやすいとはどういうことでしょうか? CodeFocusを作成するにあたり、Zenn、Note、dev.toなどの記事スタイルがなぜ読みやすいのか調査しました。以下に各サービスの特徴をまとめます。

Zenn

Zennは、PC/タブレットで2カラム、スマホでシングルカラムです。2カラムだと右カラムに目次が固定されており、シングルカラムだとヘッダーに目次が固定されます。また目次は現在の見出し箇所を追随してハイライト表示します。目次にいつでもアクセスでき、記事全体に対して今どこにいるかが直感的に分かるので、記事が長くなっても読みやすさを保っています。

本文は全体的にシンプルなデザインで、1行あたりの高さが広く、段落間や見出しh1-6の間に十分な余白を設けています。また、見出しレベルh1・h2で下線を引いています。本文中のリンクは青色でリンクを意識しやすくなっています。技術記事は引用が多くなりやすいので、リンクが見やすいのは必要な配慮だと感じます。

個人的にコードブロックが好みです。背景が十分に濃い色で、コードのハイライトも見やすくなっています。さらにコードブロックの折り返しがデフォルトで無効になっており、スマホでもコードが読みやすいです。折り返しのON/OFF切り替えが可能で、コードブロックコピーもあるので困ったことがありません。

  • 余計な装飾がなく、見出し装飾もシンプル
  • タイトルは中央寄せ
  • PCは記事本文が708px、タブレットが517px、スマホが90%幅と、はてなブログよりも広めにとっている
  • 目次に記事の途中でアクセスでき、いつでもアクセスできる/どこまで読んだか分かる
  • 本文の色合いは白黒ベースメインでリンクが青色というシンプルな色合い
  • 本文以外のリンクは黒色で、リンクの色は青色で目に留まるように
  • 行の高さが1.9と広く、段落間も広くセクションの違いが分かりやすい
  • コードブロックセクションの背景色が濃く、ハイライトの色も見やすい
  • コードブロックの折り返しがデフォルト無効でスマホでコードが折り返されず読み下しやすい(ON/OFF切り替え可能)
  • 警告や注意書きのセクションが存在し目立つ

Zenn

Note

Noteは、事実上のシングルカラムと言えます。PCで左側に著者プロフィールが表示されますが、ほぼ目に入らないでしょう。目次ががなく本文ナビゲーションは重視していないようです。

本文はシンプルなデザインで、1行あたりの高さが広く、段落間や見出しh1-6の間に十分な余白を設けています。また、見出しレベルで下線は一切引いていません。本文中のリンク色が本文と変わらない代わりに下線が引かれています。

Noteはたくさんの記事がありますが、技術記事の割合は少ないようです。調べてみると「目次機能がない」「コードブロックのハイライトが乏しい」「本文スペースが狭いため縦に伸びがち」など、技術記事が読みにくく感じます。通常の文章だと特に読みにくく感じないのですが、技術記事だとちょっと弱く感じました。

  • 余計な装飾がなく、見出し装飾もない
  • タイトルは左寄せ
  • PCは記事本文が620px、タブレットが460px、スマホが100%幅と、はてなブログよりも広めにとっている
  • 目次がなく、記事の途中でどこまで読んだか分からない
  • 本文の色合いは白黒ベースメインでリンクも黒というシンプルな色合い
  • タイトルと本文以外のフォントは小さく目立たせない
  • 本文下の他記事の区切りがシンプルにまとまっている
  • 行の高さが1.5と狭め、段落間は広くセクションの違いが分かりやすい
  • コードブロックは黒背景、コードハイライトは4色
  • コードブロックの折り返しがデフォルト無効でスマホでコードが折り返されず読み下しやすい(ON/OFF切り替えなし)
  • 警告や注意書きのセクションがない

Note

個人的に、本文下の「他記事へのリンク」セクションの表示が揃っていて好みです。タブレット以上の画面サイズで2列表示、それぞれは上から順に画像、タイトル、著者+日付、お気に入りなどが表示されます。スマホで1列表示、左にタイトルとメタ情報、右に画像が表示されます。目が留まりやすいです。

タブレット表示

スマホ表示

dev.to

dev.toは、PC/タブレットで2カラム、スマホでシングルカラムです。2カラムだと右カラムにプロフィールと広告が固定されています。目次はありません。

本文はシンプルなデザインで、1行あたりの高さが狭く、段落間や見出しh1-6の間に十分な余白を設けています。また、見出しレベルで下線は一切引いていません。本文中のリンクは青色でリンクを意識しやすくなっています。dev.toの記事は見出しの頭に絵文字を使っている記事が多いので、下線がなくても以外と目に留まります。

個人的にコードブロックを全画面にするボタンが興味深いです。また、ZennやNoteと違って、基本的にすべて左寄せしています。ログイン以外は左寄せで、英語サイトあるあるな統一感です。

  • 余計な装飾がなく、見出し装飾もない
  • タイトルは左寄せ
  • PCは記事本文が748px、タブレットが584px、スマホが90%幅と、はてなブログよりも広めにとっている
  • 目次がなく、記事の途中でどこまで読んだか分からない
  • 本文の色合いは白黒ベースメインでリンクが青色というシンプルな色合い
  • タイトルと本文以外のフォントは小さく目立たせない
  • 本文下の他記事の区切りがシンプルにまとまっている
  • 行の高さが2と広め、段落間も広くセクションの違いが分かりやすい
  • コードブロックは黒背景、白文字ベース。コードハイライトは最小限でコードは読みにくい
  • コードブロックの折り返しがデフォルト無効でスマホでコードが折り返されず読み下しやすい(ON/OFF切り替えなし)
  • 警告や注意書きのセクションがない

dev.to

他の気になるサイト

MagicOnionのドキュメントサイトは、3カラム構成、PCで記事本文が958pxとかなり広めです。幅が広くても読みやすいです。本文の高さが設定されていませんが、セクション間の幅は設定されているので全体的にきゅっとしている印象があります。窮屈ではないので、割と好みです。

MagicOnionドキュメント

Microsoft Learnは、3カラム構成、PCで記事本文が852pxと広めです。本文の高さは160%です。全体的に一貫性があり、まとまっている印象を受けますね。

Microsoft Learn

Google Cloudブログは、事実上のシングルカラム構成、PCで記事本文が842pxと広めです。本文の高さは1.5です。タイトルが中央寄せなのは面白いですね。

Google Cloudブログ

個人サイトのいくつかを見渡すと、割と長年スタイルは変えていないようなのでここでは割愛します。

CodeFocusのデザイン

自分が感じる技術記事を読みやすいテーマの要素を抜き出してみましょう。

シングルカラムか2カラム1、本文やリンクの色、行の高さ、段落間の余白、見出しの装飾(h1/h2にあったほうがよさそう)、コードブロックの背景・ハイライトの色、コードブロックの折り返し、目次の有無、タイトルと本文以外は目立たせない、などが読みやすさに影響してそうです。特に技術記事ではコードブロックのハイライトと折り返しはスマホで必須のようです。記事が長くなるなら、目次を使いやすくするのも良さそうです。個人的に、Zennのデザインが割と納得感あり、Noteやdev.toの好きなところを取り入れるのがよさそうです。

これを踏まえて、CodeFocusでは以下のようなデザインを採用しています。なお、はてなブログのテーマストアはCSSのみ配布なので、JavaScriptによるカスタマイズをしなくてもデザインは整えつつ入れたい機能を追加できるようにしています。

  • PCでもシングルカラムにする
  • 余計な装飾を設けず、h1/h2見出しだけ下線を引く
  • 記事セクションの広さをPCで850px、タブレットが790px~700px、スマホは100%(余白あり)と広めに
  • はてなブログの[:contents]で表示される目次のコントロールを追加
    • 目次のトグルを導入して目次をクリックで開閉できるように。目立たないようデフォルトは閉じるように
    • JavaScriptカスタマイズでフロート目次を導入可能に、現在の見出し箇所を追随してハイライト表示
  • 本文の色合いは白黒ベースメインでリンクを青色に
    • 本文以外のリンクは黒色、太字リンクは下線を引かない、通常リンクに下線を引く
  • 行の高さは広めに1.9、段落間や見出しh1-6の間に十分な余白を設ける
  • コードブロックセクションの背景色を濃く、ハイライトも見やすく
    • コードハイライトライブラリは導入せず、はてなブログのコードハイライトを利用
  • JavaScriptカスタマイズでコードブロックの折り返しをON/OFF導入、デフォルトで折り返し無効に
  • 警告や注意書きのセクションはなし。独自記法を設けたくない
  • 本文下の他記事の区切りをシンプルにまとめる
  • JavaScriptカスタマイズでダークテーマを導入
  • あくまで技術記事なのでタブやグローバルナビゲーションは用いない

追加機能

CodeFocusはJavaScriptで追加できる機能を用意しています。 JavaScriptカスタマイズをしなければ機能は追加されませんが、必要な人はどうぞという感じです。はてなブログのテーマストアでテーマ入れるだけだと導入されないので、割と使われることは少なそうですが、自分がやりたいだけなのでよし。

目次

本文目次のトグル

はてなブログ組み込みキーワードである[:contents]で表示される目次を、クリックで開閉できるようにします。デフォルトは閉じており、目立たないようにしています。 目次って便利なんですが、記事が長くなるほど本文トップを占有して微妙だと感じていました。目次をクリックで開閉できるようにすることで、目次を必要なときだけ表示できるようにしました。 トグルを閉じておくと割とほとんどの人は開かない模様、という認識も背景にあります。

はてなブログはデフォルトで設けてほしい。

目次のトグル

フロート目次

[:contents]で目次を表示している場合、フロート目次を追加できるようにしました。 Zennや他ドキュメントサイトで本文が長いときに、目次が固定されていると便利だと感じていました。実際便利。

はてなブログはデフォルトで設けてほしい。

フロート目次

コードブロック

コードブロックの折り返し

スマホで技術記事を読んでいてつらいのが、コードブロックの折り返しです。はてなブログがそうですが、折り返されると地味に読みにくくてずっと不満でした。 CodeFocusは、コードブロックの折り返しON/OFFを切り替えられるようにしました。デフォルトは折り返し無効で、スマホでもコードが読みやすいです。

はてなブログはデフォルトで設けてほしい。

折り返しは読みにくい

折り返しなしは読みやすい

コードブロックのコピー

ずっと以前からコードブロックにあると便利なのが、コピーボタンです。 割とコピー&ペーストで手元に持ってきて実行してみることは多いので、CodeFocusではコードブロックの右上にコピーボタンを追加しました。 コードブロックが長かったりすると、コピーしそこねとかよくあるんですよね。

はてなブログはデフォルトで設けてほしい。

コードブロックのコピー

ダークテーマ

ダークテーマは是非に議論があると認識しています。ダークモード:ユーザーの考え方と避けるべき課題は割とそうだなぁと同意するところも多いです。 とはいえ、今現在においてシステムテーマでライト/ダーク(あるいはティルト)を設定できる流れは広がっており、任意で選択できるようになりつつあります。私自身、iOSでは時間でダークテーマ・ライトテーマを切り替えています。

CodeFocusはシステムテーマを尊重しつつ、ダーク/ライトテーマを切り替えられるようにしています。 JavaScriptカスタマイズしなければダークテーマは適用されないので、使いたい人はどうぞという感じで。

ダークテーマ

生成AIを使う

CodeFocusは、GitHub Copilot(Claude Sonnet 3.7Claude Sonnet 4)を使って開発しています。 CSSは苦手なのもあってテーマ作成を避けてたものの、生成AI使うことで完成までこぎつけられたのは良い体験でした。

SCSSを使っているうちにCSS苦手意識が消え、インストラクションベースでの開発に夢中になれたのも良かったです。

テーマのコードベース

はてなブログがボイラーテンプレートとなるテーマhatena/Hatena-Blog-Theme-Boilerplate公開しているので、それぞベースに組んでいます。えらい。

Vite + SCSSを使っており、割とシンプルな構成なのでいじるには最適でした。

インストラクション

インストラクションに、はてなブログのデザインテーマ制作の手引きを食わせています.ただ、CSS回りのチェックリストとして、これを食わせるのがベターかは微妙でした。「デザインカスタマイズの仕様」あたりが一番それっぽい箇所ですが、条件付きのセレクターが多く、AIにとってデザイン構成で考慮するには厳しい感じです。文章でしじするより仕様として与える必要があるのですが、組み込みされない傾向でした。デザインテーマの手引きよりも、はてなブログ公式として「はてなブログデザインのMCP」を公開してもらえると嬉しいなぁと感じます。

インストラクションには、生成AIの動作以外にデザイン仕様もすべて書き出しています。これをCopilotに食わせることで、デザインの意図を理解してもらい、CSSを調整しています。

Playwrightテストを組む

AIで組むにあたって真っ先に考えたのが、PlaywrightでのE2Eテストです。AIでデザイン調整していると、インストラクションにかかわらず割と破壊してきます。その対策にPlaywrightのテストをCopilotに書かせています。 PlaywrightでスペックテストやE2Eテストを組むことで、変更でCSSが壊れていないかAIにフィードバックを与えます。

これでデザイン変更後はテストが通るまで修正が続くので、破壊されるケースは減りました。割とうまくいったのでおすすめです。 デザインに対するテスト以外に、JavaScriptカスタマイズの機能ごとのテストも組むことで、安心して機能追加や変更ができるようになります。

playwrightテスト

テーマストアのキャッチ画像

テーマストアのキャッチ画像もAIに書かせています。 キャッチ画像は何度も調整を繰り返すので再現性が欲しくて画像生成は避けました。代わりに、CopilotがHTMLでキャッチページを用意してPlaywrightで自動的にスクリーンショットを撮るのを提示してきたので採用しています。

他のテーマを見ると、テーマストアのキャッチ画像は実際のブログスクリーンショットを埋めているケースが多いようです。 幸いE2Eテストでスクリーンショットを撮るので、テストに使った画像をテーマストアのキャッチ画像のHTMLに埋め込むだけで済みました。 レイアウトもHTMLで組んでいるので、修正はCSSを調整するだけで済みました。便利。

紹介記事

AIに紹介記事の下書きを書かせて、それを手直しする形で書いています。

実際に書いてある内容があってるかは絶対譲れない部分なので、AIに書かせた内容をそのまま使うことはできないんですよね。 ただ、AIが書いた内容をベースに手直しすることで、記事を書く時間を大幅に短縮できました。構成も私が考えるより丁寧だったのでAIに書かせてよかったです。

まとめ

自分にとって技術記事を書きやすく・読みやすいはてなブログテーマを作成しました。 もし気に入って使ってもらえるなら、うれしいのでぜひ。

インストールはテーマストアからどうぞ。

参考記事

Zenn記事

Note記事

dev.to記事


  1. 本文以外は目立たせない

.NET10のdotnet run app.csでファイルベースプログラムを実行する

現在プレビュー提供中の.NET 10から、dotnet runコマンドでファイルベースのプログラムを実行できるようになりました。これにより、dotnet run app.csのように、C#ファイルを直接実行できるようになります。 本日発表されたファイルベースプログラムの実行方法について紹介します。

なお本記事は、2025/5/29時点のブログGitHub ドキュメントに基づいています。

$ dotnet --version
10.0.100-preview.4.25258.110

はじめに

これまでのC#でも.csファイルとプロジェクトファイル(.csproj)を組み合わせてあれば、dotnet run Foo.csprojのようにdotnet runコマンドで実行できました。今回.NET 10で追加されるのは、プロジェクトファイルなしに単一のC#ファイルをdotnet run app.csのように直接実行する機能です。

前者はproject-based program(プロジェクトベースプログラム)と呼ばれ、後者はfile-based program(ファイルベースプログラム)と呼ばれるようです。

従来のプロジェクトファイルなしのC#実行方法

dotnet標準コマンドはサポートしていませんでしたが、コミュニティには単一.csファイルで実行するものがあります。CS-Scriptdotnet-scriptは使っている人もいるのではないでしょうか。

例えば、C#スクリプトファイル(.csx)は、dotnet-scriptコマンドで実行できます1

$ dotnet tool install -g dotnet-script

$ cat app.csx
#r "sdk:Microsoft.NET.Sdk.Web"

using Microsoft.AspNetCore.Builder;

var a = WebApplication.Create();
a.MapGet("/", () => "Hello world");
a.Run();
EOF

$ dotnet script app.csx
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5000
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
      Content root path: D:\github\test\dotnet-csx

コミュニティにあるとはいえ、広く使われているかというと微妙なところです。理由はいくつもあるでしょうが、例えばdotnet標準コマンドでないためdotnetツールを追加インストールが必要だったり、ツール毎のメタデータ表現も方言があって覚えるのが億劫です。また、インテリセンスやデバッグサポートが弱いなどの問題がありました。

そんなこんなで、dotnetコマンド標準のプロジェクトベースの実行方法dotnet run app.csprojに慣れるに従って、dotnetコマンド標準で単一.csファイルを実行できないかと考えていました。

dotnet run app.csを実行してみよう

今回追加されたファイルベースプログラムは、.NET SDKが入っていれば利用できます。早速簡単なC#ファイルapp.csを作成して、dotnet run app.csで実行してみましょう。

foreach (var item in Enumerable.Range(1, 10).Where(x => x % 2 == 0).Select(x => x * x))
{
    Console.WriteLine(item);
}

いつものようにdotnet runコマンドで実行すると、割と期待通りな動作じゃないでしょうか。

$ dotnet run app.cs
4
16
36
64
100

csprojで指定していたことを.csファイルで指定する

現在のC#はNuGetパッケージなしにプログラムを効率的に書くのは難しく、またWebアプリケーションとコンソールアプリケーションの違いはSDKの違いです。

こうったパッケージやプロジェクト設定は従来.csprojファイルで指定していましたが、ファイルベースプログラム用に.csファイルにメタデータとして指定しても実行できるようになりました。

// #:package パッケージ名@バージョン : NuGetパッケージの導入
#:package Humanizer@2.14.1

// #:sdk SDK名 : SDKの指定
#:sdk Microsoft.NET.Sdk.Web

// #:property MSBuildプロパティ名 値
#:property Nullable enable

例えばNuGetパッケージのZLinqを利用する場合、以下のように記述できます。

#:package ZLinq@1.4.9
using ZLinq;

IReadOnlyList<int> numbers = [1, 2, 3, 4, 5];
foreach (var item in numbers.AsValueEnumerable().Where(x => x % 2 == 0).Select(x => x * x))
{
    Console.WriteLine(item);
}

実行してみましょう。

$ dotnet run app.cs
4
16

WebアプリケーションはSDKをMicrosoft.NET.Sdk.Webに指定するだけで、ほんの数行書けば動作します。2

#:sdk Microsoft.NET.Sdk.Web
#:package Microsoft.AspNetCore.OpenApi@10.*-*

var builder = WebApplication.CreateBuilder();

builder.Services.AddOpenApi();

var app = builder.Build();

app.MapGet("/", () => "Hello, world!");
app.Run();

Webアプリケーションを実行


これまで.csprojに指定するしかなかったMSBuildのプロパティを.csファイルで指定できるのが画期的です。 csprojの設定による挙動を検証するため.csprojを用意して.csprojのXXXXプロパティだけいじって実行してを繰り返していたのが、.csファイル単独で書けるようになりました。控えめに言って神です。

単純な例でNullableEnableを有効にして、null許容型の文字列の警告がでるか見てみましょう。

#:property Nullable enable
string? nullableString = null;
foreach (var item in nullableString.ToString())
{
    Console.WriteLine(item);
}

ばっちり警告が出ていますね。

$ dotnet run app.cs
__________________________________________________
Project "D:\github\test\dotnet-run-cs\app.csproj" (Build target(s)):

D:\github\test\dotnet-run-cs\app.cs(3,22): warning CS8602: Dereference of a possibly null reference.
Done building project "app.csproj".
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
   at Program.<Main>$(String[] args) in D:\github\test\dotnet-run-cs\app.cs:line 3

LinuxやmacOSで.csファイルを直接実行する

shebangが指定できるので、Pythonやシェルスクリプト(.sh)のようにLinuxやmacOSでも直接実行できます。

#!/usr/bin/dotnet run
Console.WriteLine("Hello from a C# script!");

実行権限をつけて、直接実行してみましょう。

$ chmod +x app.cs
$ ./app.cs
Hello from a C# script!

Zxを組み合わせれば、シェルスクリプトのようにC#でシェルスクリプトが書けます。

#!/usr/bin/dotnet run
#:package ProcessX@1.5.6

using Zx;
await "ls -l";

ばっちりですね。

$ chmod +x app.cs
$ ./app.cs
total 4
-rw-rw-r--    1 guitarrapc guitarrapc       244 May 29 02:32 app.cs
drwxrwxr-x    2 guitarrapc guitarrapc         0 May 29 04:41 nest

シェルスクリプトの代替として利用できるのか

shebangをつかうことでシェルスクリプトを同じ感覚でスクリプトを実行しつつ、C#で書いて実行できるのは代えがたいメリットです。 シェルスクリプト代替としてはZxを使って雑にC#スクリプトを書いて、dotnet run app.csで実行するのは罠が少なく直線でたどりつけるでしょう。 さらに、後述するdotnet project convert app.csでプロジェクト化して、大規模になったスクリプトをConsoleAppFrameworkで管理する未来まで見据えられます。

ConsoleAppFrameworkとファイルベースプログラム実行は一見すると競合しますが、実際には補完関係にあると感じます。これまでも、ConsoleAppFrameworkを使うことでC#プロジェクトとして管理しつつZxでシェルスクリプトっぽくかくことができました。ユニットテストで挙動担保しつつ、ちゃんと管理できるのがいい点です。csprojで多くのバッチをまとめて管理もチーム管理するのは、ファイルベースプログラムでは難しいです。

Zx + ConsoleAppFrameworkの組み合わせは始めるのがどうしても億劫でしたが、ファイルベースプログラムはもっとすんなり使える期待があります。シェルスクリプトではなく.csでもいい選択が現実になったのは嬉しいです。

プロジェクト化する

.csxファイルで困ったのが、規模が大きくなってきてプロジェクト化したいときにcsprojファイルを用意してMSBuildプロパティを設定して、拡張子を.csに変えて、ビルドしてうまくいかなくて直して...を繰り返すことでした。ファイルベースプログラムは、これを一発で行うdotnet project convert app.csコマンドを提供します。おー。

実行してみると、ファイル名と同じ名前のディレクトリが作成され、その中に.csファイルと.csprojファイルが作成されます。

$ ls
app.cs
$ dotnet project convert app.cs
$ ls -l app/
total 8
-rw-rw-r--    1 guitarrapc guitarrapc       171 May 29 05:18 app.cs
-rw-rw-r--    1 guitarrapc guitarrapc       372 May 29 05:18 app.csproj

元の.csに書かれていたメタデータから.csprojファイルがいい感じに生成されます。

var builder = WebApplication.CreateBuilder();

builder.Services.AddOpenApi();

var app = builder.Build();

app.MapGet("/", () => "Hello, world!");
app.Run();
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.*-*" />
  </ItemGroup>

</Project>

いろいろ制約

今のところVisual Studio 2022 Preview(17.14.2 Preview 1.0)はファイルベースプログラムをサポートしていないようです。構文エラーは出ていませんが、インテリセンスが一切効かずビルド可否も判断ができません。しょうがない。

VS2022 Previewはまだサポート外

ドキュメントにはいくつか制約について書かれています。きになったのを抜粋します。

  • ファイルベースプログラムは、.csのみサポート (.vb.fsはサポートされていないんですね)
  • 暗黙的プロジェクトファイル3が作られて実行されるという挙動
  • フォルダに複数C#ファイルがある場合、暗黙的プロジェクトファイルの都合がありすべてビルドされる
$ ls
app.cs # ビルドされる
foo.cs # ビルドされる
bar.cs # ビルドされる
$ dotnet run app.cs
  • エントリーポイントは、トップレベルステートメントである必要がある。Mainメソッドはセマンティック分析が必要になるため、現時点ではサポートされていない
  • ネストされた構造のファイルもビルドされる
    • フォルダにネストされた.csファイルがある場合、暗黙的プロジェクトファイルの都合でビルドされる
    • ネストされたフォルダに.csprojがある場合、エラーは出ない
$ ls -R
app.cs                   # ビルドされる
app/File.cs              # ビルドされる
app/Nested/Nested.csproj # エラーは出ない
app/Nested/File.cs       # ビルドされる
$ dotnet run app.cs      # ビルドされる

挙動から動作をのぞく

触っている感じから、ファイルベースプログラムはプロジェクトベースプログラムと同じように動作しているようです。 これはトラブルシューティングに従来の知見がそのまま使えるので、非常にありがたいです。

少し見てみましょう。

NuGetパッケージのリストア

リストアされたパッケージは、引き続き$HOME/.nuget/packagesに展開されていました。スタンダードなNuGetの挙動で素晴らしい。

$ Get-ChildItem $env:UserProfile\.nuget\packages\microsoft.*

    Directory: C:\Users\guitarrapc\.nuget\packages

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d----          29/05/2025    02:40                microsoft.aspnetcore.app.ref
d----          29/05/2025    02:30                microsoft.aspnetcore.openapi
d----          29/05/2025    02:35                microsoft.bcl.asyncinterfaces
d----          29/05/2025    02:35                microsoft.codeanalysis.analyzers
d----          29/05/2025    02:35                microsoft.codeanalysis.common
d----          29/05/2025    02:35                microsoft.codeanalysis.csharp
d----          29/05/2025    02:35                microsoft.diagnostics.netcore.client
d----          29/05/2025    02:35                microsoft.diagnostics.runtime
d----          29/05/2025    02:35                microsoft.diagnostics.tracing.traceevent
d----          29/05/2025    02:35                microsoft.dotnet.platformabstractions
d----          29/05/2025    02:35                microsoft.extensions.configuration
d----          29/05/2025    02:35                microsoft.extensions.configuration.abstractions
d----          29/05/2025    02:35                microsoft.extensions.configuration.binder
d----          29/05/2025    02:35                microsoft.extensions.dependencyinjection.abstractions
d----          29/05/2025    02:35                microsoft.extensions.logging
d----          29/05/2025    02:35                microsoft.extensions.logging.abstractions
d----          29/05/2025    02:35                microsoft.extensions.options
d----          29/05/2025    02:35                microsoft.extensions.primitives
d----          29/05/2025    02:40                microsoft.netcore.app.host.win-x64
d----          29/05/2025    02:40                microsoft.netcore.app.ref
d----          29/05/2025    02:35                microsoft.netcore.platforms
d----          29/05/2025    02:30                microsoft.openapi

リストアされたNuGetファイルのパス

ビルドと実行ファイル

C#のビルドは、/objディレクトリにビルド中間生成物が生成され、/binディレクトリに実行ファイルが生成されます。 ファイルベースプログラムも同様の流れなら、いわゆる今までの知見がそのまま使えます。

中間ファイルの作業ディレクトリ

ビルド時の中間ファイル、というか実体としてSDKが扱っているパスは$TMP/dotnet/runfile(Windowsでアクセスすらうなら$HOME/AppData/Local/Temp/dotnet/runfile)のようです。 いくつかのファイルベースプログラムを実行してみると、以下のようにエントリーポイントファイルXXX.csのパスごとにディレクトリが作成されています。

$ ls $HOME\AppData\Local\Temp\dotnet\runfile\


    Directory: C:\Users\guitarrapc\AppData\Local\Temp\dotnet\runfile


Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d-----        29/05/2025     04:54                app-3f99f7618276dfaf94ced2c1ed2fe79d5f8745064563530315302e3eb255c3a5
d-----        29/05/2025     04:41                file-b6d193daca316c514c0590bfaea265526d10861efddde43d88f5c43de182587f
d-----        29/05/2025     02:53                Program-4f3ab1b43520478cb7187dff170a883e48eaf32d03d5cf9f1f84c40063b5dca9

ドキュメントを見るとサブディレクトリ名は「エントリーポイントファイルパスのハッシュ値を使っている」と記載があります。簡単に検証したところ、ファイル名までのフルパスをToUpper()(OrdinalIgnoreCaseかな)してSHA256を計算した値がディレクトリ名になっていることが確認できました。

例えばD:\github\test\dotnet-run-cs\app.csをエントリーポイントとしてdotnet run app.csを実行すると、サブディレクトリがapp-3f99f7618276dfaf94ced2c1ed2fe79d5f8745064563530315302e3eb255c3a5になります。

var path = @"D:\github\test\dotnet-run-cs\app.cs";
var value = UTF8Encoding.UTF8.GetBytes(path.ToUpper());

using var sha256 = System.Security.Cryptography.SHA256.Create();
var hash = Convert.ToHexStringLower(sha256.ComputeHash(value));

var fileName = Path.GetFileNameWithoutExtension(path);
Console.WriteLine($"{fileName}-{hash}");
// 出力: app-3f99f7618276dfaf94ced2c1ed2fe79d5f8745064563530315302e3eb255c3a5

作業フォルダの中身

サブディレクトリの中をみると、よく見かけるbin/obj/ディレクトリがあり、binディレクトリの中に実行ファイルが生成されているのが確認できます。

TMPに作業フォルダが切られる

dotnet runはデバッグビルドなので、bin/debug/ディレクトリになっています。

$ ls $HOME\AppData\Local\Temp\dotnet\runfile\app-3f99f7618276dfaf94ced2c1ed2fe79d5f8745064563530315302e3eb255c3a5\bin\debug\


    Directory: C:\Users\guitarrapc\AppData\Local\Temp\dotnet\runfile\app-3f99f7618276dfaf94ced2c1ed2fe79d5f874506456353
    0315302e3eb255c3a5\bin\debug


Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a----        29/05/2025     05:30           1863 app.deps.json
-a----        29/05/2025     05:30          23552 app.dll
-a----        29/05/2025     05:30         160256 app.exe
-a----        29/05/2025     05:30          30160 app.pdb
-a----        29/05/2025     05:30            459 app.runtimeconfig.json
-a----        29/05/2025     05:30             51 app.staticwebassets.endpoints.json
-a----        09/05/2025     09:48         185144 Microsoft.AspNetCore.OpenApi.dll
-a----        17/04/2025     00:17         465952 Microsoft.OpenApi.dll

ということは、リリースビルドで実行すればbin/release/ディレクトリに実行ファイルが生成されるはずです。やってみましょう。

$ dotnet run app.cs -c Release
# 出力は省略
$ ls $HOME\AppData\Local\Temp\dotnet\runfile\app-3f99f7618276dfaf94ced2c1ed2fe79d5f8745064563530315302e3eb255c3a5\bin\release\


    Directory: C:\Users\guitarrapc\AppData\Local\Temp\dotnet\runfile\app-3f99f7618276dfaf94ced2c1ed2fe79d5f874506456353
    0315302e3eb255c3a5\bin\release


Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a----        29/05/2025     05:55           1863 app.deps.json
-a----        29/05/2025     05:55          23040 app.dll
-a----        29/05/2025     05:55         160256 app.exe
-a----        29/05/2025     05:55          28960 app.pdb
-a----        29/05/2025     05:55            531 app.runtimeconfig.json
-a----        29/05/2025     05:55             51 app.staticwebassets.endpoints.json
-a----        09/05/2025     09:48         185144 Microsoft.AspNetCore.OpenApi.dll
-a----        17/04/2025     00:17         465952 Microsoft.OpenApi.dll

期待通りですね。

観測から予想される挙動

挙動を見る限り、以下のような流れで動作していると思われます。4

  1. dotnet run app.csを実行
  2. .NET SDKがOSのTMPフォルダに作業フォルダを切る
  $TMP\dotnet\runfile\エントリーポイントcsファイル名-{ファイルパスをToUpperしたSHA256ハッシュ}
  1. .csファイルのメタデータに指定されたmsbuildプロパティを元にインメモリでcsproj構築
  2. dotnet run プロジェクト.csprojと同様に、dotnet restore/build
  3. dotnet エントリーポイント.dllを呼び出して実行

ファイルベースプログラムのソースコードを読む

実際のソースコードを見てみましょう。dotnet/sdkのソースコードはオープンソースなので、GitHubで確認できます。

エントリーポイント検出からビルドまでの流れ

具体的なコードで推測があっているか見てみましょう。ファイルベースプログラムは、主に2つのクラスで実装されています。

  1. RunCommand.cs - メインのコマンド処理
  2. VirtualProjectBuildingCommand.cs - ファイルベースプログラムの仮想プロジェクト作成・ビルド

コードを抜粋してコメントをつけます。

1. エントリーポイントの検出

まずは、dotnet run app.csのように実行したときに、エントリーポイントのファイルパスを検出します。

// RunCommand.cs: 442-496行目
// https://github.com/dotnet/sdk/blob/1dd8c27e8a5bcdebe57517c5ed042645afe25692/src/Cli/dotnet/Commands/Run/RunCommand.cs#L442
private static string? DiscoverProjectFilePath(string? projectFileOrDirectoryPath, ref string[] args, out string? entryPointFilePath)
{
    // projectFileOrDirectoryPathは、--projectオプションで指定されたプロジェクトファイルパス

    // プロジェクトファイルが見つからない場合、エントリーポイントファイルを探す
    entryPointFilePath = projectFilePath is null && emptyProjectOption
        ? TryFindEntryPointFilePath(ref args)
        : null;

    // args配列の最初の引数が.csファイルかチェック
    static string? TryFindEntryPointFilePath(ref string[] args)
    {
        if (args is not [{ } arg, ..] ||
            !VirtualProjectBuildingCommand.IsValidEntryPointPath(arg))
        {
            return null;
        }

        args = args[1..]; // 引数配列から.csファイルパスを除去
        return Path.GetFullPath(arg);
    }
}

2. ファイルベースプログラムの判定

先のコードで、次の条件を満たす場合にファイルベースプログラムとして処理されています。

  1. プロジェクトファイルが見つからない(現在ディレクトリまたは --projectオプション経由)
  2. 引数で指定されたファイルが存在し、.cs拡張子を持つ
  3. そのファイルがエントリーポイントファイル(top-level statementsを含む)である

注目すべきは、csprojの有無でプロジェクトベースプログラムかファイルベースプログラムかを判定し、プロジェクトファイルがなければファイルベースパスとしています。 実際、.csprojがあるとdotnet run app.csを指定しても、プロジェクトベースプログラムとして実行しようとしているのが分かります。

$ touch app.csproj
$ ls
app.cs    app.csproj
$ dotnet run app.cs
    D:\github\test\dotnet-run-cs\app.csproj : error MSB4025: The project file could not be loaded. Root element is missing.

Restore failed with 1 error(s) in 0.0s

The build failed. Fix the build errors and run again.

仮にcsprojに適切な内容をいれても、今度は.csのメタデータ部分#:sdk Microsoft.NET.Sdk.Web#:package Microsoft.AspNetCore.OpenApi@10.*-*が解釈できずエラーになります。

$ dotnet run app.cs
Restore complete (0.4s)
You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy
  app failed with 3 error(s) (0.7s)
    D:\github\test\dotnet-run-cs\app.cs(1,2): error CS9298: '#:' directives can be only used in file-based programs ('-features:FileBasedProgram')
    D:\github\test\dotnet-run-cs\nest\file.cs(2,2): error CS9298: '#:' directives can be only used in file-based programs ('-features:FileBasedProgram')
    D:\github\test\dotnet-run-cs\app.cs(2,2): error CS9298: '#:' directives can be only used in file-based programs ('-features:FileBasedProgram')

Build failed with 3 error(s) in 1.5s

The build failed. Fix the build errors and run again.

VirtualProjectBuildingCommand.IsValidEntryPointPathメソッドで、ファイルベースプログラムは.csファイルに限定されていることが確認できます。

// VirtualProjectBuildingCommand.cs: 848-851行目
// https://github.com/dotnet/sdk/blob/1dd8c27e8a5bcdebe57517c5ed042645afe25692/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs#L848
public static bool IsValidEntryPointPath(string entryPointFilePath)
{
    return entryPointFilePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) && File.Exists(entryPointFilePath);
}

3. 仮想プロジェクトの作成

ここで、インメモリにプロジェクトファイル(.csproj)を作成してビルドするためのVirtualProjectBuildingCommandが呼び出されます。

// RunCommand.cs: 249-275行目
// https://github.com/dotnet/sdk/blob/1dd8c27e8a5bcdebe57517c5ed042645afe25692/src/Cli/dotnet/Commands/Run/RunCommand.cs#L249
private void EnsureProjectIsBuilt(out Func<ProjectCollection, ProjectInstance>? projectFactory)
{
    if (EntryPointFileFullPath is not null)
    {
        // ファイルベースプログラムの場合、VirtualProjectBuildingCommandを使用
        var command = CreateVirtualCommand();
        projectFactory = command.CreateProjectInstance;
        buildResult = command.Execute();
    }
    else
    {
        // 通常のプロジェクトベースの場合
        buildResult = new RestoringCommand(...).Execute();
    }
}

// RunCommand.cs: 277-290行目
// https://github.com/dotnet/sdk/blob/1dd8c27e8a5bcdebe57517c5ed042645afe25692/src/Cli/dotnet/Commands/Run/RunCommand.cs#L277
private VirtualProjectBuildingCommand CreateVirtualCommand()
{
    return new(
        entryPointFileFullPath: EntryPointFileFullPath,
        msbuildArgs: RestoreArgs,
        verbosity: Verbosity,
        interactive: Interactive)
    {
        NoRestore = NoRestore,
        NoCache = NoCache,
    };
}

気になる.csproj作成はVirtualProjectBuildingCommandクラスのCreateProjectInstanceメソッドで行われています。

// VirtualProjectBuildingCommand.cs: 406-44行目
// https://github.com/dotnet/sdk/blob/1dd8c27e8a5bcdebe57517c5ed042645afe25692/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs#L406
private ProjectInstance CreateProjectInstance(
    ProjectCollection projectCollection,
    Action<IDictionary<string, string>>? addGlobalProperties)
{
    // SDKのプロジェクトルート要素を作成
    var projectRoot = CreateProjectRootElement(projectCollection);

    // MSBuildプロパティを追加
    var globalProperties = projectCollection.GlobalProperties;
    if (addGlobalProperties is not null)
    {
        globalProperties = new Dictionary<string, string>(projectCollection.GlobalProperties, StringComparer.OrdinalIgnoreCase);
        addGlobalProperties(globalProperties);
    }

    return ProjectInstance.FromProjectRootElement(projectRoot, new ProjectOptions
    {
        GlobalProperties = globalProperties,
    });

    // .csprojを作成するメソッド
    ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
    {
        Debug.Assert(!_directives.IsDefault, $"{nameof(PrepareProjectInstance)} should have been called first.");

        var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
        var projectFileWriter = new StringWriter();
        // .csファイルのメタデータを使ってプロジェクトファイルを生成
        WriteProjectFile(
            projectFileWriter,
            _directives,
            isVirtualProject: true,
            targetFilePath: EntryPointFileFullPath,
            artifactsPath: GetArtifactsPath());
        var projectFileText = projectFileWriter.ToString();

        using var reader = new StringReader(projectFileText);
        using var xmlReader = XmlReader.Create(reader);
        var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
        projectRoot.FullPath = projectFileFullPath;
        return projectRoot;
    }
}

csprojの中身はWriteProjectFileメソッドで作っています。入れるべきMSBuildのプロパティは、後述するAST解析で.csファイルのメタデータから取得しています。

// VirtualProjectBuildingCommand.cs: 464-694行目
// https://github.com/dotnet/sdk/blob/1dd8c27e8a5bcdebe57517c5ed042645afe25692/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs#L464
public static void WriteProjectFile(
    TextWriter writer,
    ImmutableArray<CSharpDirective> directives,
    bool isVirtualProject,
    string? targetFilePath = null,
    string? artifactsPath = null)
{
    var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
    var propertyDirectives = directives.OfType<CSharpDirective.Property>();
    var packageDirectives = directives.OfType<CSharpDirective.Package>();

    // SDKディレクティブの処理
    string sdkValue = "Microsoft.NET.Sdk";
    if (sdkDirectives.FirstOrDefault() is { } firstSdk)
    {
        sdkValue = firstSdk.ToSlashDelimitedString();
    }

    // プロパティディレクティブの処理
    if (propertyDirectives.Any())
    {
        writer.WriteLine("""
            <PropertyGroup>
            """);

        foreach (var property in propertyDirectives)
        {
            writer.WriteLine($"""
                    <{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
                """);
        }

        writer.WriteLine("  </PropertyGroup>");
    }

    // パッケージディレクティブの処理
    if (packageDirectives.Any())
    {
        writer.WriteLine("""
            <ItemGroup>
            """);

        foreach (var package in packageDirectives)
        {
            if (package.Version is null)
            {
                writer.WriteLine($"""
                        <PackageReference Include="{EscapeValue(package.Name)}" />
                    """);
            }
            else
            {
                writer.WriteLine($"""
                        <PackageReference Include="{EscapeValue(package.Name)}" Version="{EscapeValue(package.Version)}" />
                    """);
            }
        }

        writer.WriteLine("  </ItemGroup>");
    }

    // 省略
}

出力先の作業ディレクトリは、先ほど挙動から推測した通りEntryPointFileFullPathのパスを元に.csprojファイルを生成しています。

// VirtualProjectBuildingCommand.cs: 448-462行目
// https://github.com/dotnet/sdk/blob/1dd8c27e8a5bcdebe57517c5ed042645afe25692/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs#L449
internal static string GetArtifactsPath(string entryPointFileFullPath)
{
    // ユーザー固有の一時ディレクトリを使用
    string directory = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
        ? Path.GetTempPath()
        : Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);

    // ファイルパスのハッシュ値を使用してディレクトリ名を生成
    string fileName = Path.GetFileNameWithoutExtension(entryPointFileFullPath);
    string hash = Sha256Hasher.HashWithNormalizedCasing(entryPointFileFullPath);
    string directoryName = $"{fileName}-{hash}";

    return Path.Join(directory, "dotnet", "runfile", directoryName);
}

4. ビルド

ビルドは、VirtualProjectBuildingCommand.Execute()メソッドで行われます。長いので変形して重要な部分だけ抜粋しますが、リストア、ビルドと想定通り順番に実行されています。

// VirtualProjectBuildingCommand.cs: 87-223行目
//https://github.com/dotnet/sdk/blob/1dd8c27e8a5bcdebe57517c5ed042645afe25692/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs#L87
public override int Execute()
{
    // MSBuildのバイナリロガーを用意
    var binaryLogger = GetBinaryLogger(BinaryLoggerArgs);

    if (!NoBuild)
    {
        // ビルドが必要な場合、キャッシュを確認
        if (NoCache)
        {
            cacheEntry = ComputeCacheEntry(out _);
        }

        // ArtifactPathのディレクトリやファイルを作成
        MarkBuildStart();
    }

    try
    {
        // ビルド用の環境変数を設定
        foreach (var (key, value) in MSBuildForwardingAppWithoutLogging.GetMSBuildRequiredEnvironmentVariables())
        {
            savedEnvironmentVariables[key] = Environment.GetEnvironmentVariable(key);
            Environment.SetEnvironmentVariable(key, value);
        }

        // MSBuildを用意
        BuildManager.DefaultBuildManager.BeginBuild(parameters);

        // リストア
        if (!NoRestore)
        {
            var restoreResult = BuildManager.DefaultBuildManager.BuildRequest(restoreRequest);
            if (restoreResult.OverallResult != BuildResultCode.Success)
            {
                return 1;
            }
        }

        // ビルド
        if (!NoBuild)
        {
            var buildRequest = new BuildRequestData(
                CreateProjectInstance(projectCollection),
                targetsToBuild: [NoIncremental ? "Rebuild" : "Build"]);
            var buildResult = BuildManager.DefaultBuildManager.BuildRequest(buildRequest);
        }

        BuildManager.DefaultBuildManager.EndBuild();

        return 0;
    }
    catch (Exception e)
    {
    }
    finally
    {
        // ビルド終了後のクリーンアップ
    }
}

メタデータディレクティブの解析部分

.csファイル内のメタデータディレクティブ#:....は、VirtualProjectBuildingCommand.FindDirectives()メソッドで抽出されます。 個別のディレクティブは、CSharpDirectiveクラスで表現され、Sdk, Property, Packageなどの種類があります。

// VirtualProjectBuildingCommand.cs: 708-812行目
// https://github.com/dotnet/sdk/blob/1dd8c27e8a5bcdebe57517c5ed042645afe25692/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs#L708
public static ImmutableArray<CSharpDirective> FindDirectives(SourceFile sourceFile, bool reportAllErrors, ImmutableArray<SimpleDiagnostic>.Builder? errors)
{
    var builder = ImmutableArray.CreateBuilder<CSharpDirective>();

    // Roslynの実験的なSyntaxTokenParserを使用
    SyntaxTokenParser tokenizer = SyntaxFactory.CreateTokenParser(sourceFile.Text,
        CSharpParseOptions.Default.WithFeatures([new("FileBasedProgram", "true")]));

    // ParseLeadingTrivia = 最初のC#トークンの前にあるトリビア = コメントやプリプロセッサディレクティブだけ解析 = このメソッドで解析するのは最初のC#トークンの前までという制約になる
    var result = tokenizer.ParseLeadingTrivia();
    TextSpan previousWhiteSpaceSpan = default;

    foreach (var trivia in result.Token.LeadingTrivia)
    {
        // エラーを含むトリビアで停止(#ifの後など)
        if (trivia.ContainsDiagnostics)
        {
            break;
        }

        // 空白文字の処理
        if (trivia.IsKind(SyntaxKind.WhitespaceTrivia))
        {
            previousWhiteSpaceSpan = trivia.FullSpan;
            continue;
        }

        // Shebang (#!) の処理
        if (trivia.IsKind(SyntaxKind.ShebangDirectiveTrivia))
        {
            TextSpan span = getFullSpan(previousWhiteSpaceSpan, trivia);
            builder.Add(new CSharpDirective.Shebang { Span = span });
        }
        // 無視されるディレクティブ (#:) の処理
        else if (trivia.IsKind(SyntaxKind.IgnoredDirectiveTrivia))
        {
            TextSpan span = getFullSpan(previousWhiteSpaceSpan, trivia);

            // ディレクティブの内容を抽出
            var message = trivia.GetStructure() is IgnoredDirectiveTriviaSyntax { Content: { RawKind: (int)SyntaxKind.StringLiteralToken } content }
                ? content.Text.AsSpan().Trim()
                : "";

            // 空白文字で分割(最大2つの部分)
            var parts = Patterns.Whitespace.EnumerateSplits(message, 2);
            var name = parts.MoveNext() ? message[parts.Current] : default;
            var value = parts.MoveNext() ? message[parts.Current] : default;

            // ディレクティブを解析
            if (CSharpDirective.Parse(errors, sourceFile, span, name.ToString(), value.ToString()) is { } directive)
            {
                builder.Add(directive);
            }
        }

        previousWhiteSpaceSpan = default;
    }

    return builder.ToImmutable();
}

// 利用できるディレクティブの定義
// 886-895行目
public static CSharpDirective? Parse(ImmutableArray<SimpleDiagnostic>.Builder? errors, SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText)
{
    return directiveKind switch
    {
        "sdk" => Sdk.Parse(errors, sourceFile, span, directiveKind, directiveText),
        "property" => Property.Parse(errors, sourceFile, span, directiveKind, directiveText),
        "package" => Package.Parse(errors, sourceFile, span, directiveKind, directiveText),
        _ => ReportError<CSharpDirective>(errors, sourceFile, span, string.Format(CliCommandStrings.UnrecognizedDirective, directiveKind, sourceFile.GetLocationString(span))),
    };
}

コメントにある通り、ディレクティブはファイルの先頭かつ最初のC#トークンの前にのみ配置可能という制限があります。

/// <param name="reportAllErrors">
/// If <see langword="true"/>, the whole <paramref name="sourceFile"/> is parsed to find diagnostics about every app directive.
/// Otherwise, only directives up to the first C# token is checked.
/// The former is useful for <c>dotnet project convert</c> where we want to report all errors because it would be difficult to fix them up after the conversion.
/// The latter is useful for <c>dotnet run file.cs</c> where if there are app directives after the first token,
/// compiler reports <see cref="ErrorCode.ERR_PPIgnoredFollowsToken"/> anyway, so we speed up success scenarios by not parsing the whole file up front in the SDK CLI.
/// </param>

なので、次のようにusingディレクティブの後にメタデータディレクティブを置くことはできません。なるほど、.csxも同じ制限があるのを思い出しました。

// ✅ OK: ファイルの先頭
#:package System.CommandLine
// ✅ OK: コメントの後でも最初のトークンの前なら OK
// これはコメント
#:property TargetFramework net10.0

using System; // ← ここが最初のC#トークン

// ❌ NG: 最初のC#トークンの後
#:package Newtonsoft.Json // この位置では無効

Console.WriteLine("Hello");

ソースコード通り、エラーになりますね。

dotnet run app.cs
__________________________________________________
Project "D:\github\test\dotnet-run-cs\app.csproj" (Restore target(s)):

D:\github\test\dotnet-run-cs\app.csproj : warning NU1604: Project dependency System.CommandLine does not contain an inclusive lower bound. Include a lower bound in the dependency version to ensure consistent restore results.
D:\github\test\dotnet-run-cs\app.csproj : warning NU1701: Package 'System.CommandLine 1.0.0.1' was restored using '.NETFramework,Version=v4.6.1, .NETFramework,Version=v4.6.2, .NETFramework,Version=v4.7, .NETFramework,Version=v4.7.1, .NETFramework,Version=v4.7.2, .NETFramework,Version=v4.8, .NETFramework,Version=v4.8.1' instead of the project target framework 'net10.0'. This package may not be fully compatible with your project.
D:\github\test\dotnet-run-cs\app.csproj : warning NU1701: Package 'TypeUtils 1.0.0.2' was restored using '.NETFramework,Version=v4.6.1, .NETFramework,Version=v4.6.2, .NETFramework,Version=v4.7, .NETFramework,Version=v4.7.1, .NETFramework,Version=v4.7.2, .NETFramework,Version=v4.8, .NETFramework,Version=v4.8.1' instead of the project target framework 'net10.0'. This package may not be fully compatible with your project.
Done building project "app.csproj".
__________________________________________________
Project "D:\github\test\dotnet-run-cs\app.csproj" (Build target(s)):

D:\github\test\dotnet-run-cs\app.csproj : warning NU1604: Project dependency System.CommandLine does not contain an inclusive lower bound. Include a lower bound in the dependency version to ensure consistent restore results.
D:\github\test\dotnet-run-cs\app.csproj : warning NU1701: Package 'System.CommandLine 1.0.0.1' was restored using '.NETFramework,Version=v4.6.1, .NETFramework,Version=v4.6.2, .NETFramework,Version=v4.7, .NETFramework,Version=v4.7.1, .NETFramework,Version=v4.7.2, .NETFramework,Version=v4.8, .NETFramework,Version=v4.8.1' instead of the project target framework 'net10.0'. This package may not be fully compatible with your project.
D:\github\test\dotnet-run-cs\app.csproj : warning NU1701: Package 'TypeUtils 1.0.0.2' was restored using '.NETFramework,Version=v4.6.1, .NETFramework,Version=v4.6.2, .NETFramework,Version=v4.7, .NETFramework,Version=v4.7.1, .NETFramework,Version=v4.7.2, .NETFramework,Version=v4.8, .NETFramework,Version=v4.8.1' instead of the project target framework 'net10.0'. This package may not be fully compatible with your project.
D:\github\test\dotnet-run-cs\app.cs(10,2): error CS9297: '#:' directives cannot be after first token in file
Done building project "app.csproj" -- FAILED.

The build failed. Fix the build errors and run again.

ディレクティブの種類ごとの処理

サポートされるディレクティブは、ディレクティブ種別ごとに処理が分かれています。

#:sdkディレクティブ

#:sdk Microsoft.NET.Sdk.WebのようなSDKディレクティブは、以下のように処理されます。

// 939-965行目
// https://github.com/dotnet/sdk/blob/1dd8c27e8a5bcdebe57517c5ed042645afe25692/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs#L939
public sealed class Sdk : CSharpDirective
{
    public required string Name { get; init; }
    public string? Version { get; init; }

    public static new Sdk? Parse(ImmutableArray<SimpleDiagnostic>.Builder? errors, SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText)
    {
        if (ParseOptionalTwoParts(errors, sourceFile, span, directiveKind, directiveText) is not var (sdkName, sdkVersion))
        {
            return null;
        }

        return new Sdk
        {
            Span = span,
            Name = sdkName,
            Version = sdkVersion,
        };
    }

    public string ToSlashDelimitedString()
    {
        return Version is null ? Name : $"{Name}/{Version}";
    }
}

利用例

#:sdk Microsoft.NET.Sdk.Web
#:sdk Microsoft.NET.Sdk.Worker 1.0.0

#:propertyディレクティブ

#:property Nullable enableのようなプロパティディレクティブは、以下のように処理されます。

// 970-1005行目
// https://github.com/dotnet/sdk/blob/1dd8c27e8a5bcdebe57517c5ed042645afe25692/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs#L970
public sealed class Property : CSharpDirective
{
    public required string Name { get; init; }
    public required string Value { get; init; }

    public static new Property? Parse(ImmutableArray<SimpleDiagnostic>.Builder? errors, SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText)
    {
        if (ParseOptionalTwoParts(errors, sourceFile, span, directiveKind, directiveText) is not var (propertyName, propertyValue))
        {
            return null;
        }

        if (propertyValue is null)
        {
            return ReportError<Property?>(errors, sourceFile, span, string.Format(CliCommandStrings.PropertyDirectiveMissingParts, sourceFile.GetLocationString(span)));
        }

        try
        {
            // XML要素名として有効かチェック
            propertyName = XmlConvert.VerifyName(propertyName);
        }
        catch (XmlException ex)
        {
            return ReportError<Property?>(errors, sourceFile, span, string.Format(CliCommandStrings.PropertyDirectiveInvalidName, sourceFile.GetLocationString(span), ex.Message), ex);
        }

        return new Property
        {
            Span = span,
            Name = propertyName,
            Value = propertyValue,
        };
    }
}

利用例

#:property LangVersion preview
#:property Nullable enable

#:packageディレクティブ

#:package Microsoft.AspNetCore.OpenApi@10.*-*のようなパッケージディレクティブは、以下のように処理されます。バージョン省略できるんですね。

// 1010-1033行目
// https://github.com/dotnet/sdk/blob/1dd8c27e8a5bcdebe57517c5ed042645afe25692/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs#L1010
public sealed class Package : CSharpDirective
{
    private static readonly SearchValues<char> s_separators = SearchValues.Create(' ', '@');

    public required string Name { get; init; }
    public string? Version { get; init; }

    public static new Package? Parse(ImmutableArray<SimpleDiagnostic>.Builder? errors, SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText)
    {
        if (ParseOptionalTwoParts(errors, sourceFile, span, directiveKind, directiveText, s_separators) is not var (packageName, packageVersion))
        {
            return null;
        }

        return new Package
        {
            Span = span,
            Name = packageName,
            Version = packageVersion,
        };
    }
}

利用例

#:package System.CommandLine@2.0.0-*
#:package Newtonsoft.Json 13.0.1
#:package Microsoft.Extensions.Hosting  // バージョン省略できるっぽい

ディレクティブを.csから削除

メタデータのディレクティブはC#的には不正なコードなので、ビルド前に削除されます。 なるほど後ろから削除。

// 820-836行目
// https://github.com/dotnet/sdk/blob/1dd8c27e8a5bcdebe57517c5ed042645afe25692/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs#L820
public static SourceText? RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text)
{
    if (directives.Length == 0)
    {
        return null;
    }

    // ディレクティブを後ろから順に削除(位置がずれないように)
    for (int i = directives.Length - 1; i >= 0; i--)
    {
        var directive = directives[i];
        text = text.Replace(directive.Span, string.Empty);
    }

    return text;
}

使いどころ

使い捨て処理でバージョン指定が不要になる

これまでのC#は、.csprojにターゲットフレームワークを指定する必要がありました。これってインストールするSDKと合わせないといけないので、使い捨てのスクリプトとしては非常に面倒でした。

<PropertyGroup>
  <TargetFramework>net10</TargetFramework>
</PropertyGroup>

ファイルベースプログラムでは、ターゲットフレームワークは書かなければ実行しているSDKになりそうです。このため、使い捨てスクリプトのように「どのSDKバージョンが入っていないといけないかを省略したい」用途には非常に便利です。

後述するGitHub Actionsのホストランナーには.NET SDKがデフォルトで入っているんですが、SDKバージョンはランナー更新に応じて随時変わります。ということは、プロジェクトベースプログラムだとターゲットフレームワークの一致される必要がありました。ファイルベースプログラムなら、入っているSDKを使うはずなので使いやすくなります。

GitHub Actionsのシェルスクリプトの代わりに使いたい

GitHub Actionsのシェルスクリプトの代わりに使うこともできそうです。シェルスクリプトをbash ./app.shのように実行していたのを、dotnet run app.csにすることで、次のようなメリットがあります。

  • 簡単なC#スクリプトをすぐに実行できるので、ちょっとしたツールやスクリプトを書ける
  • シェルスクリプトは分岐やループが書きにくく実行するまで文法エラーがあるか分からないが、.csに置き換えることで解消できる
  • Pythonに置き換えるのと同じような理由ですが、C#の型安全性やIDEの補完が使える
  • コマンド実行は素のC#では書きにくいため、ZxChellを用いるといいでしょう

CI/CD環境はホステッドランナーであれば環境を壊しても気にならないのがいいところです。

  • ローカルでも処理が再現できる
  • クロスプラットフォームに処理を書ける (シェルスクリプトが苦手なところ)

これまでのやり方と何が違う

これまでもdotnet run Foo.csproj -- 引数のようにプロジェクトファイルを指定して実行できましたが、プロジェクトファイルを作成する必要がありました。これに対して、dotnet run app.csのようにC#ファイルを直接実行できるようになったことで、プロジェクトファイルを意識せずにC#スクリプトを書いて実行できるようになりました。

また、プロジェクトベースプログラムはたくさんの処理を詰め込んだりライブラリを使いやすく、実行前のリストア/ビルドのオーバーヘッドが無視できませんでした。ファイルベースプログラムなら、利用する処理のライブラリだけ使うでしょうからオーバーヘッドが小さいと見込めます。

懸念

暗黙的な仮想csprojでビルドしているため、同一階層、および子階層の.csがまとめてビルドされることが気になっています。自分が使いたい.cs以外のビルドエラーが出るのは、興味の範疇外の対応が必要になることが予想されます。

他のファイルで利用しているライブラリの影響を考えると、フォルダ構成を考えないといけなそうなのがいやですね。

まとめ

待望の機能ですし、割とスタンダードなMSBuildの流れに乗っているので使い方も分かりやすいですね。 将来は、dotnet run ./foo/のようにディレクトリを指定してその中の*.csファイルを実行する機能も考えられているようです。標準入力を受け取るdotnet run --cs-from-stdinも検討されているようで、実際ほしくなりそうです。dotnet run --cs-code 'Console.WriteLine("Hi")'にも触れられていますが、これが入るといよいよREPLっぽさが出てきますね。

将来どうなるかは分かりませんが、割と楽しみな機能です。長年の要望に応えて、C#ファイルを直接実行できるようになったのは素晴らしいことです。

dotnet build app.csdotnet restore app.csはIDEの要望からできる、と書かれていますができないのはきのせいじゃろうか。

$ dotnet build app.cs
    D:\github\test\dotnet-run-cs\app.cs(1,1): error MSB4025: The project file could not be loaded. Data at the root level is invalid. Line 1, position 1.

Restore failed with 1 error(s) in 0.0s

$ dotnet restore app.cs
    D:\github\test\dotnet-run-cs\app.cs(1,1): error MSB4025: The project file could not be loaded. Data at the root level is invalid. Line 1, position 1.

Restore failed with 1 error(s) in 0.0s

参考


  1. Azure Functionsでも利用されています。
  2. ブログのコードはbuilder.AddOpenApi();となっていてビルドエラーになるので、builder.Services.AddOpenApi();に修正すればOKです。
  3. インメモリのcsproj、仮想プロジェクトファイルともいう
  4. straceしたり該当部分のソースコードを読めば確定できるでしょう。

CloudFrontでオリジンを書き換えるのにCloudFront Functionsを使う

これまではCloudFrontでオリジンを書き換えにLambda@Edgeを利用していましたが、CloudFront JavaScript runtime 2.0以降はCloudFront Functionsでもオリジンの書き換えが可能になりました。 今回は、CloudFront Functionsを利用してオリジンを書き換えるのがおすすめという話です。

CloudFrontでオリジンを書き換えるケース

一般的に、リクエストを別の場所へ転送する場合はリダイレクト(HTTP 3xx)を利用するのが単純かつ扱いやすいです。特にGETリクエストは、クライアント(ブラウザやAPIクライアント)がリダイレクト先に再リクエストを送信するため、サーバーとしても扱いやすくほとんどのケースで問題なく動作します。

ただ、POSTリクエストを処理する場合は、リダイレクトは適さないことがあります。例えば、npmはPOSTリクエストのクライアントリダイレクトをサポートしていません。この場合、CloudFrontでオリジンを書き換えて、受け取ったPOSTリクエストを転送する必要があります。

単純にいうと、GETリクエストならリダイレクトで問題ないですが、POSTリクエストなどリダイレクトできないリクエストをCloudFrontで受け取る場合にオリジン書き換えを検討する必要があります。

CloudFrontでオリジンを書き換える方法

CloudFrontでオリジンを書き換える方法は2つあります。

  1. Lambda@Edgeを利用する
  2. CloudFront Functionsを利用する

今回は、//fooはリダイレクト、それ以外はオリジンを書き換える例を示します。

CloudFront Functionsでもオリジン書き換えが可能になった

CloudFront FunctionsはHostヘッダーを変更できないため、JavaScript runtime 1.0ではオリジン書き換えは不可能でした。

ビューアリクエストイベントでHostヘッダーは書き換えられない

しかしJavaScript runtime 2.0からupdateRequestOriginメソッドが追加されたことでCloudFront Functionsでもオリジン書き換えが可能になりました。

Lambda@Edgeでオリジン書き換えをする

Lambda@Edgeを利用する場合、オリジンリクエストに設定するとHostヘッダーをいじってオリジン書き換えられます。なお、request.origin.custom.domainName = domainName;がなくてもオリジン書き換えは機能します。

'use strict';
exports.handler = (event, context, callback) => {
    const domainName = 'example.net';
    const request = event.Records[0].cf.request;
    const uri = request.uri;

    switch (uri) {
        case '/':
        case '/foo':
            // リダイレクト用レスポンスをクライアントに返す
            const response = {
                status: '301',
                statusDescription: 'Moved Permanently',
                headers: {
                    location: [{
                        key: 'Location',
                        value: `https://${domainName}${uri}`,
                    }],
                },
            };
            callback(null, response);
            break;
        default:
            // リライトはリクエストをアップストリームに転送する
            const clientIp = request.clientIp;
            request.origin.custom.domainName = domainName;
            request.headers['host'] = [{ key : 'host', value : domainName}];
            request.headers['X-Forwarded-For'] = [{ key: 'X-Forwarded-For', value: clientIp }];
            callback(null, request);
    }
};

Lambda@Edgeは月間100万リクエストまで無料です。また、リクエスト本文へのアクセスもでき、ビューアリクエストイベントで触らないヘッダー操作ができます。Lambda@EdgeはCloudFront Functionsより制約が緩く、ある程度複雑なこともできます。一方でただのオリジン書き換えにはオーバースペックです。

CloudFront Functionsでオリジン書き換えをする

CloudFront Functionsを利用する場合、ビューアリクエストイベントに設定します。JavaScript runtime 2.0を使うと、updateRequestOriginメソッドを利用してオリジン書き換えられます。JavaScript runtime 1.0はこの関数がなく、Hostヘッダーをいじれないためオリジン書き換えができません。

import cf from 'cloudfront';

function handler(event) {
  var domainName = 'example.net';
  var request = event.request;
  var uri = request.uri;
  var clientIP = event.viewer.ip;

  switch (uri) {
    case '/':
    case '/foo':
      // リダイレクト用レスポンスをクライアントに返す
      var response = {
        statusCode: 301,
        statusDescription: 'Moved Permanently',
        headers: {
          'location': { value: `https://${domainName}${uri}` }
        }
      };
      return response;
    default:
      // リライトはリクエストをアップストリームに転送する
      // cf.updateRequestOriginでオリジン書き換えができる
      cf.updateRequestOrigin({
        "domainName": domainName,
        "timeouts": {
          "readTimeout": 60,
          "connectionTimeout": 5
        },
        "customHeaders": {
          "X-Forwarded-For": clientIP,
        }
      });
      return request;
  }
}

CloudFront Functionsは月間1000万リクエストまで無料です。CloudFront FunctionsはLambda@Edgeに比べて制限が多く、リクエスト本文へのアクセスもできませんが、今回のようなオリジン書き換えには十分です。

Lambda@Edgeを使うかCloudFront Functionsを使うか

個人的には、リダイレクト、オリジン書き換えだけが目的ならCloudFront Functionsを利用するのがおすすめです。

Lambda@Edgeを避ける理由はいくつかあります。これまで出会ったケースでは、IaCとの相性、コスト、ログの3つであまり好みじゃありません。

IaCと相性の悪さは、Lambda@EdgeがLambdaのパブリッシュを必要とすることに起因します。例えばCloudFrontをLambda@EdgeからCloudFront Fucntionsへ差し替える操作は1度のterraform applyで完了できないため、2手順に分ける必要があります。まずCloudFrontとLambda@Edgeの紐づけを削除するterraform applyを実行してAWSがLambdaの公開を削除するまで待つ、次にLambdaを削除するterraform applyを実行します。Lambda発行をAWSが削除するタイミングは予測不能なため、IaCで1発操作できないのですが、本当に面倒です。

コスト1も避けたい理由です。LambdaはDatadogなどの監視サービスにて「サーバーレス」課金対象になり一定のコストがかかります。一方、CloudFront Functionsは「サーバーレス」課金対象になりません。

Lambda@Edgeは、CloudWatch Logsの管理リージョンがばらつくのも嫌な点です。Lambda@EdgeのCloudWatch LogsはCloudFrontのエッジリージョンで出力されるためログがばらつきます。地味にだるいです。

まとめ

2025年現在なら、CloudFront Functionsを利用してオリジン書き換えが可能です。Lambda@Edgeを使う強い理由がないなら、CloudFront Functionsを利用するのがおすすめです。

参考


  1. 無料枠を超えても微々たるコストでしょうから考慮には入れません

ArgoCDのOIDC認証とリフレッシュトークンチェック不具合

ArgoCDはOIDC認証をサポートしていますがリフレッシュトークンのチェックが機能していないため注意が必要です。 2025年4月の暫定対応メモです。

2025年5月7日追記

半年ぶりにPR進捗が出たのでマージが近いといいですね。

ArgoCDのJWT認証

ArgoCDへのログインには、ローカルユーザー認証、OIDC認証の2つがあります。ArgoCDのローカルユーザー作成はオンザフライにユーザー、追加、削除がしにくいためユーザー個別の認証を提供するならJWT認証を使うのが良いでしょう。公式も、管理者ユーザーを1名用意して、OIDCを利用するSSO統合を推奨しています。

さてArgoCDのSSO統合には、ArgoCDにバンドルされたDexと、ArgoCD自身のOIDCの2つがあります。 Dexは以前からあった方法で、OIDCをサポートしていなかったりDexコネクター機能1で利用が推奨されています。OIDCプロバイダーなら、ArgoCD自身のOIDCを利用するのが推奨されています。

とはいえ、ArgoCDのOIDCプロバイダーを使うとリフレッシュトークンによる延長が行われないため、注意が必要です。

CognitoをOIDCプロバイダーとして利用してArgoCDのSSOを実現する

Cognitoを使ったOIDC認証を通して状況を説明しましょう。

ArgoCDのOIDCプロバイダーを使うと、CognitoやGoogle Workspace、Auth0、OkataなどのSSOを簡単なコンフィグ利用できます。 例えばCognitoを利用するArgoCDの設定は次の通りです。

# argocd-cm.yaml
data:
  oidc.config: |
    name: Cognito
    issuer: https://cognito-idp.<region>.amazonaws.com/<cognito-user-pool-id>
    redirectUrl: https://<argocd-server-url>
    clientID: <cognito-client-id>
    clientSecret: <cognito-client-secret>
    requestedScopes: ["openid", "profile", "email"]
    requestedIDTokenClaims: {"groups": {"essential": true}}
    logoutURL: https://<argocd-server-url>/logout?client_id=<cognito-client-id>&logout_uri=https://<argo-cd-server-url>/logout
  # Cognitoにapp-readonlyグループを、ArgoCDのapp-readonly-role権限にマッピングする例
  policy.csv: |
    p, role:app-readonly-role, applications, get, *, allow
    p, role:app-readonly-role, logs, get, *, allow
    p, role:app-readonly-role, projects, get, *, allow
    g, app-readonly, role:app-readonly-role

# argocd-rbac-cm.yaml
data:
  scopes: '[cognito:groups]'

ArgoCD向けのCognitoクライアントは次のように登録して、Cognitoユーザー/グループにCognitoのグループをマッピングしておけばOKです。

resource "aws_cognito_user_pool_client" "main" {
  name = "cognito"

  # OAuth 2.0を使用するための設定
  allowed_oauth_flows_user_pool_client = true
  allowed_oauth_flows                  = ["code"]
  allowed_oauth_scopes                 = ["email", "openid", "profile"]

  # 認証後のリダイレクトURL
  callback_urls        = "https://<argocd-server-url>/auth/callback"
  logout_urls          = "https://<argocd-server-url>/logout"

  # 認証に使用するプロバイダ
  supported_identity_providers = ["COGNITO"]
  user_pool_id                 = aws_cognito_user_pool.main.id

  # トークンの有効期限(単位は時間)
  id_token_validity     = 1 # 1h。ユーザーが認証されたままになる期間を表します。この期間が過ぎると、再度ログインするか、セッションを更新する必要があります。
  access_token_validity = 1 # 1h。ユーザーが再度サインインするかセッションを更新するまで認証された状態が続く期間。この期間が過ぎると、再度ログインするか、セッションを更新する必要があります。

  # ArgoCDにはシークレット生成が必要
  generate_secret = true
}

これでArgoCDのトップページにアクセスすると、Cognitoログインボタンが表示、Cognito認証を経て、Cognitoユーザーログインします。 ArgoCDの権限は、policy.csvでマッピングしたCognitoグループとArgoCDロール通りです。

cognitoログインページ

リフレッシュトークンが使われない

さて、一見するとこの設定は完璧に見えますが、実はリフレッシュトークンが使われません。 Cognitoのリフレッシュトークンは、OIDCの認証フローで取得したアクセストークンとIDトークンを更新するために使用されますが、ArgoCDのOIDCプロバイダーはリフレッシュトークンのチェックを正しく行いません。 このため、Issueの通り、IDトークン/アクセストークンが更新されず、ユーザーはトークン期限切れに伴う再ログイン2が必要です。

リフレッシュトークンの修正PRは上がっているものの、2023年に作られてからレビューが進まず、2025年4月に入ってもマージされていません。

暫定対応

あまりやりたくないものの、修正されるまではArgoCDのアクセストークンとIDトークンの有効期限をある程度長くすることで、ユーザーに頻繁なログインを強いることを避けることができます。 例えば毎日ログイン程度なら8hに延ばすのも良いでしょう。

resource "aws_cognito_user_pool_client" "main" {
  name = "cognito"

  # 省略....

  # 暫定対応
  id_token_validity     = 8 # 8h。リフレッシュトークンが効かないので暫定で延ばして設定
  access_token_validity = 8 # 8h。リフレッシュトークンが効かないので暫定で延ばして設定
}

まとめ

ArgoCDのOIDCプロバイダーは、現在の実装ではリフレッシュトークンチェックが機能していないため、ユーザーはトークン期限切れに伴う再ログインが必要です。 暫定でアクセストークンとIDトークンの有効期限を長くするか、あるいはDexを利用するのが良いでしょう。

参考


  1. GitHubの組織やチームをOIDCグループのクレームにマッピングする機能など
  2. 上のIDトークン、アクセストークン設定なら1時間ごとに再ログインが必要

はてなブログのみたままモード記事をMarkdownモード記事へ変換する

このブログの古い記事はWordPressからはてなブログにインポートしたもので、当時はHTMLを直書きしていました。 HTML直書きははてなブログの「みたままモード」に相当し、これらの記事はマークダウンなのにHTML構文が溢れてtextlintも効かない状況でした。

先日これらの記事をMarkdownモードにまとめて変換したのでメモです。

方針

はてなブログ開発ブログによると、みたままモードの記事からMardownモードへの変換をサポートしていません。このため、みたままモードの記事を一度消してから、同じ日付でMarkdownモードの記事を新たに作成する必要があります。

はてなブログの編集モードは変換できない

私の場合、対象記事が200件以上あったので、手動で消して新たに作成するのは大変です。そこで、ヘルパーコードを用意して以下の手順で変換しました。

  1. 既存コンテンツをバックアップ
  2. 対象記事の日付一覧を取得
  3. はてなブログの下書きをまとめて作成
  4. はてなブログのHTML記事をマークダウンフォーマットに変換コピー
  5. マークダウン変換時の漏れを修正
  6. 既存の記事を削除
  7. マークダウン記事を公開 (3-7を全記事終わるまで繰り返し)
  8. 後始末

順に説明します。

1.既存記事をバックアップ

この処理の実行前に、元記事にはCustomPathを設定しています。CustomPathは、記事ファイルの配置パスに相当するyyyy/MM/dd/HHmmssを指定するメタデータです。

既存のコンテンツをバックアップします。次のC#ヘルパーコードを利用して、HTMLの元記事を記事名_2.mdのように末尾に_2を付けてリネームして退避します。作業用に元のファイル名へコンテンツをコピーして、メタデータにあるCustomPathの情報も書き換えてます。

// 既存コンテンツをバックアップする処理。
// 既存コンテンツを退避して、マークダウン変換前の作業ファイルと元ファイルをそれぞれ用意する
var basePath = @"D:\github\guitarrapc\blog\entries\guitarrapc-tech.hatenablog.com\entry\";
var targetMonths = new[] {
    "2012/11",
    "2012/12",
    "2013/01",
    "2013/02",
    "2013/03",
    "2013/04",
    "2013/05",
    "2013/06",
    "2013/07",
    "2013/08",
    "2013/09/02",
    "2013/09/03",
    "2013/09/06",
    "2013/09/07",
    "2013/09/08",
    "2013/09/10",
};

Rename(basePath, targetMonths, "_2");
Copy(basePath, targetMonths, "_2".Length);
RenameCustomPath(basePath, targetMonths, "_2");

static void Rename(string basePath, string[] targetMonths, string suffix)
{
    foreach (var path in targetMonths)
    {
        var files = Directory.EnumerateFiles(Path.Combine(basePath, path), "*.md", SearchOption.AllDirectories);
        foreach (var file in files)
        {
            var dst = Path.Combine(Path.GetDirectoryName(file), Path.GetFileNameWithoutExtension(file) + suffix + Path.GetExtension(file));
            dst.Dump(file);
            if (File.Exists(dst))
                continue;
            File.Move(file, dst);
        }
    }
}

static void Copy(string basePath, string[] targetMonths, int removeSuffixLetters)
{
    foreach (var path in targetMonths)
    {
        var files = Directory.EnumerateFiles(Path.Combine(basePath, path), "*.md", SearchOption.AllDirectories);
        foreach (var file in files)
        {
            var name = Path.GetFileNameWithoutExtension(file);
            var dst = Path.Combine(Path.GetDirectoryName(file), name.Substring(0, name.Length - removeSuffixLetters)  + Path.GetExtension(file));
            dst.Dump(file);
            if (File.Exists(dst))
                continue;
            File.Copy(file, dst);
        }
    }
}

static void RenameCustomPath(string basePath, string[] targetMonths, string excludeSuffix)
{
    foreach (var path in targetMonths)
    {
        var files = Directory.EnumerateFiles(Path.Combine(basePath, path), "*.md", SearchOption.AllDirectories);
        foreach (var file in files)
        {
            var name = Path.GetFileNameWithoutExtension(file);
            if (name.EndsWith(excludeSuffix))
                continue;
            var customPath = file.Substring(basePath.Length, file.Length - basePath.Length - Path.GetExtension(file).Length).Replace("\\", "/");
            file.Dump(customPath);
            var content = File.ReadAllText(file);
            var newContent = content.Replace($"CustomPath: {customPath}_2", $"CustomPath: {customPath}");
            File.WriteAllText(file, newContent);
        }
    }
}

2.対象記事の日付一覧を取得

HTML直書きの対象記事だけ変換すればいいので、記事の対象日付を取得します。 月・日付が分かっていて手元に記事一覧があるので、記事のパスから取得することにしました。C#でヘルパーツールを書いて作っと実行します。

var basePath = @"D:\github\guitarrapc\blog\entries\guitarrapc-tech.hatenablog.com\entry\";
var targetMonths = new[] {
    "2012/11",
    "2012/12",
    "2013/01",
    "2013/02",
    "2013/03",
    "2013/04",
    "2013/05",
    "2013/06",
    "2013/07",
    "2013/08",
    "2013/09/02",
    "2013/09/03",
    "2013/09/06",
    "2013/09/07",
    "2013/09/08",
    "2013/09/10",
};

foreach (var path in targetMonths)
{
    var files = Directory.EnumerateFiles(Path.Combine(basePath, path), "*.md", SearchOption.AllDirectories);
    foreach (var file in files.Where(x => !Path.GetFileNameWithoutExtension(x).EndsWith("_2")))
    {
        var name = file.Replace(basePath, "").Replace("\\", "/").Replace(".md", "");
        Console.WriteLine($"- {name}");
    }
}

実行すると次のような結果が得られます。

- 2012/11/09/101113
- 2012/11/09/211115
- 2012/11/13/001154
- 2012/11/13/221115
- 2012/11/14/071151
- 2012/12/11/231250
- 2012/12/18/221226
- 2012/12/19/001244
- 2012/12/20/161249
- 2012/12/20/211206
- 2012/12/25/201225
- 2012/12/25/201230
- 2012/12/26/121207
- 2012/12/31/141220
- 2013/01/06/080136
- 2013/01/08/030100
- 2013/01/15/050140
- 2013/01/19/210114

3.はてなブログの下書きをまとめて作成

はてなが提供するワークフローcreate-draft.yamlはworkflow_dispatchでタイトルを指定して実行すると、下書きを生成します。これをいじって、対象の日付をマトリックスで指定してまとめて下書き記事を作成するcreate-draft-bulk.yamlワークフローを用意します。先ほど取得した日付をマトリックスに指定して実行します。

name: create draft (bulk)

on:
  workflow_dispatch:

jobs:
  create-draft:
    strategy:
      max-parallel: 5
      matrix:
        title:
          # 作成する対象の記事日付を指定
          - 2012/11/09/101113
          - 2012/11/09/211115
          - 2012/11/13/001154
          - 2012/11/13/221115
          - 2012/11/14/071151
          - 2012/12/11/231250
          - 2012/12/18/221226
          - 2012/12/19/001244
          - 2012/12/20/161249
          - 2012/12/20/211206
          - 2012/12/25/201225
          - 2012/12/25/201230
          - 2012/12/26/121207
          - 2012/12/31/141220
    uses: ./.github/workflows/_create-draft.yaml
    with:
      title: ${{ matrix.title }}
      draft: true
      BLOG_DOMAIN: ${{ vars.BLOG_DOMAIN }}
    secrets:
      OWNER_API_KEY: ${{ secrets.OWNER_API_KEY }}

_create-draft.yamlは、もともとのはてなブログのcreate-draft-pull-requestアクションがgithub.inputs.titleを使っていてアクションで指定したtitleを使ってなかったのを修正したものです。現在はPRを投げて修正されています。

下書き記事を作成を1ブランチにまとめる

create-branchアクションは、1下書きあたり1PRを作ります。PRマージ作業がつらいので、PRを1ブランチにまとめてマージできるようにします。

1ブランチにまとめたら下書き用のブランチは削除しておきます。私は次のようなシェルスクリプトを作成してリモートブランチを削除しました。

#!/bin/bash
set -eo pipefail

repo=guitarrapc/blog
for branch in $(gh api "repos/$repo/branches" --jq '.[].name' | grep '^draft-entry-'); do
  echo "Deleting remote branch: $branch"
  gh api --method DELETE -H "Accept: application/vnd.github+json" "/repos/$repo/git/refs/heads/$branch"
done

実行すると次のようにリモートブランチが削除されます。

Deleting remote branch: draft-entry-6802418398340967690
Deleting remote branch: draft-entry-6802418398340967692
Deleting remote branch: draft-entry-6802418398340967694
Deleting remote branch: draft-entry-6802418398340967696
Deleting remote branch: draft-entry-6802418398341016588
Deleting remote branch: draft-entry-6802418398341016611
Deleting remote branch: draft-entry-6802418398341016620

はてなブログのAtomPubレートリミットに注意

はてなブログのAtomPubには100件/24hまでしか記事を作成できないAPIレートリミットがあるので注意してください。 APIレートリミットに到達するとEntry limit was exceededというエラーが帰ってきます。

AtomPubのAPIリミット

私は下書き記事を月ごとに作成したのですが、一日に作業できる記事的に3日かかりました。レートリミットに引っかかって感じたんですが、1日100件の制限はドキュメントに記載がなく、残り何件とか分からないのは残念です。 特に、レートリミットがあるのにAPIドキュメントに書かないのは修正してほしいです。

4.はてなブログのHTML記事をマークダウンフォーマットに変換コピー

作成した下書き記事に、HTML記事のコンテンツをマークダウンフォーマットに変換しつつコピーします。C#でヘルパーツールを書いて作成しました。この処理は下書き記事が存在しないとコピーしないので、作業したい下書き記事を作成した後に実行します。

// 既存コンテンツをベースにDraftにコンテンツを持ってくる処理。
// 既存コンテンツがHTMLなのをマークダウンに変換する機能をはてなブログはもっていないので、同一URLで記事を新規で作り直すために行う。
// Steps to use:
// 1. 既存の記事にCustomPath: yyyy/MM/dd/HHmmss を設定
// 2. create-draft-bulk ワークフローでyyyy/MM/ddの下書きをまとめて作成
// 3. ドラフトのブランチを1ブランチにまとめて、複数日まとめて処理する
// 4. このスクリプトを実行して、既存コンテンツをベースに下書きを更新 <- イマココ
var draftBasePath = @"D:\github\guitarrapc\blog\draft_entries\";
var basePath = @"D:\github\guitarrapc\blog\entries\guitarrapc-tech.hatenablog.com\entry\";
var targetMonths = new[] {
    "2012/11",
    "2012/12",
    "2013/01",
    "2013/02",
    "2013/03",
    "2013/04",
    "2013/05",
    "2013/06",
    "2013/07",
    "2013/08",
    "2013/09/02",
    "2013/09/03",
    "2013/09/06",
    "2013/09/07",
    "2013/09/08",
    "2013/09/10",
};
var titlePattern = new Regex("201[2,3]/[0-9]{2}/[0-9]{2}/[0-9]{6}", RegexOptions.Compiled);

var drafts = Directory.EnumerateFiles(draftBasePath, "*.md");
foreach (var draft in drafts)
{
    var title = File.ReadLines(draft)
        .Where(x => x.StartsWith("Title:"))
        .Select(x => x.Split(":")[1].Trim())
        .Single();

    if (!titlePattern.IsMatch(title))
        continue;

    var draftContent = File.ReadAllLines(draft);
    var draftEditUrlLine = draftContent.Where(x => x.StartsWith("EditURL: ")).Single();
    var draftPreviewURL = draftContent.Where(x => x.StartsWith("PreviewURL: ")).Single();

    foreach (var targetMonth in targetMonths)
    {
        var searchBase = Path.Combine(basePath, targetMonth);
        var files = Directory.EnumerateFiles(searchBase, "*.md", SearchOption.AllDirectories);
        foreach (var file in files)
        {
            // 既存コンテンツか判定
            var lines = File.ReadAllLines(file);
            var isTargetFile = lines.Any(x => x.Contains($"CustomPath: {title}"));
            if (!isTargetFile)
                continue;

            // _2.md は除外
            if (Path.GetFileNameWithoutExtension(file).EndsWith("_2"))
            {
                File.Delete(file);
                continue;
            }

            // 既存コンテンツをベースにdraftにコンテンツを持ってくる
            var sectionLines = GetHeaderSectionLines(lines);
            var titleLine = sectionLines.Where(x => x.StartsWith("Title: ")).Single();
            var categoryLine = GetCategories(sectionLines);
            var dateLine = sectionLines.Where(x => x.StartsWith("Date: ")).Single();
            var urlLine = sectionLines.Where(x => x.StartsWith("URL: ")).Single();
            var customPathLine = sectionLines.Where(x => x.StartsWith("CustomPath: ")).Single();

            var contentBuilder = new StringBuilder();
            // ヘッダー
            contentBuilder.AppendLine("---");
            contentBuilder.AppendLine(titleLine);
            contentBuilder.AppendLine(categoryLine);
            contentBuilder.AppendLine(dateLine);
            contentBuilder.AppendLine(draftEditUrlLine);
            contentBuilder.AppendLine(draftPreviewURL);
            contentBuilder.AppendLine(customPathLine);
            contentBuilder.AppendLine("---");

            // コンテンツ
            var contentsLines = lines.Skip(sectionLines.Length);
            contentBuilder.AppendLine();
            contentBuilder.AppendLine("<!--");
            contentBuilder.AppendLine($"{dateLine}");
            contentBuilder.AppendLine($"{urlLine}");
            contentBuilder.AppendLine("-->");
            foreach (var line in contentsLines)
            {
                contentBuilder.AppendLine(line);
            }
            var content = contentBuilder.ToString();

            // 書き込み
            if (content != "")
            {
                File.WriteAllText(draft, content);
            }

            // 既存コンテンツを削除する
            File.Delete(file);
        }
    }
}

static string[] GetHeaderSectionLines(string[] lines)
{
    int number = 0;
    var inSection = false;
    foreach (var line in lines)
    {
        // enter
        if (!inSection && line == "---")
        {
            inSection = true;
            number++;
            continue;
        }
        // exit
        if (inSection && line == "---")
        {
            inSection = false;
            number++;
            break;
        }
        // inside
        if (inSection && line != "---")
        {
            number++;
            continue;
        }
    }

    return lines.Take(number).ToArray();
}

static string GetCategories(string[] lines)
{
    var categoryLines = new StringBuilder();
    var inCategory = false;
    foreach (var line in lines)
    {
        if (line.StartsWith("Category:"))
        {
            inCategory = true;
            categoryLines.AppendLine(line);
            continue;
        }
        if (inCategory)
        {
            if (!line.StartsWith("- "))
            {
                inCategory = false;
                break;
            }

            categoryLines.AppendLine(line);
            continue;
        }
    }

    // 最終行の空行はトリム除去
    return categoryLines.ToString().TrimEnd();
}

作業後、あとから記事を識別できるように次のようなHTMLコメントを残してあります。

<!--
Date: 2012-12-20T21:12:06+09:00
URL: https://tech.guitarrapc.com/entry/2012/12/20/211206
-->

5.マークダウン変換時の漏れを修正

C#コード上である程度のマークダウン変換しているのですが、HTMLでいろいろなタグを使っていたので網羅しきれません。マークダウン変換した下書き記事をはてなブログで見て変換漏れを修正します。

世の中にはHTMLをマークダウンに変換するツールがたくさんあるので、それをつかっても良かったですね。残念ながら記事分量が多すぎてLLMで変換もできなかったので、手作業で修正しました。私は次の修正が多かったです。

  • <a>タグの属性指定ばらつき
  • <pre>タグの属性指定ばらつき
  • 文中の<code>タグの変換
  • URLをはてなブログURLに変換
  • リンク切れ対処
  • 画像のリンク切れ修正

加えてtextlintを使ってマークダウンの文法チェックを行いました。これもかなり手間でした。

6.既存の記事を削除

既存のHTML記事を削除します。これははてなブログの管理画面から作っと消しましょう。

7.マークダウン記事を公開

下書きPRをマージして、マークダウン記事を公開します。PRをマージすると、CustomPathにしたがってファイルが正しいフォルダに配置されます。

あとはすべてのHTML記事をマークダウン記事に置き換えるまで繰り返します。

8.後始末

バックアップしておいてHTML元記事を削除して置きます。以上の行程で、同一URLで記事の置き換え完了です。

あとは時間がある時に、マークダウンファイルからHTMLコメントを削除します。次のC#コードを実行すればOKです。

// マージ後に記事から以下のセクションを抜く処理
/*
<!--
Date: 2012-12-11T23:12:50+09:00
URL: https://tech.guitarrapc.com/entry/2012/12/11/231250
-->
*/
var basePath = @"D:\github\guitarrapc\blog\entries\guitarrapc-tech.hatenablog.com\entry\";
var targetMonths = new[] {
    "2012/11",
    "2012/12",
    "2013/01",
    "2013/02",
    "2013/03",
    "2013/04",
    "2013/05",
    "2013/06",
    "2013/07",
    "2013/08",
    "2013/09/02",
    "2013/09/03",
    "2013/09/06",
    "2013/09/07",
    "2013/09/08",
    "2013/09/10",
};

foreach (var targetMonth in targetMonths)
{
    var searchBase = Path.Combine(basePath, targetMonth);
    var files = Directory.EnumerateFiles(searchBase, "*.md", SearchOption.AllDirectories);
    foreach (var file in files)
    {
        // _2.md は除外
        if (Path.GetFileNameWithoutExtension(file).EndsWith("_2"))
            continue;

        var lines = File.ReadAllLines(file);
        var contentBuilder = new StringBuilder();
        var inSkip = false;
        foreach (var line in lines)
        {
            if (line.StartsWith("<!--"))
            {
                inSkip = true;
                continue;
            }
            if (inSkip && line.StartsWith("-->"))
            {
                inSkip = false;
                continue;
            }
            if (inSkip && line.StartsWith("Date: "))
                continue;
            if (inSkip && line.StartsWith("URL: "))
                continue;

            contentBuilder.AppendLine(line);
        }

        var content = contentBuilder.ToString();
        File.WriteAllText(file, content);
    }
}

まとめ

はてなブログがマークダウン変換サポートしてくれれば簡単でしたがしょうがない。 LinqPadが作業のお供でした。C#で適当にサポートツールを作成するの、割と楽なんですよね。

参考