labels)
+ {
+ var builder = new DefaultInterpolatedStringHandler(0, 0);
+ builder.AppendLiteral("各ラベルをクリックすると、そのラベルが付いた Pull Request の一覧を表示します。
");
+ builder.AppendLiteral(Environment.NewLine);
+
+ if (labels.Count == 0)
+ {
+ builder.AppendLiteral("ラベル情報がありません。
");
+ return GenerateLabelPage("ラベル一覧", builder.ToStringAndClear());
+ }
+
+ builder.AppendLiteral("");
+ builder.AppendLiteral(Environment.NewLine);
+ builder.AppendLiteral(" | ラベル | PR数 |
");
+ builder.AppendLiteral(Environment.NewLine);
+ builder.AppendLiteral(" ");
+ builder.AppendLiteral(Environment.NewLine);
+ foreach (var (key, value) in labels.OrderBy(kv => kv.Key))
+ {
+ var label = key;
+ var color = value.Color;
+ var count = value.Entries.Count;
+
+ var encodedLabel = HtmlEncoder.Default.Encode(label);
+ builder.AppendLiteral(" | ");
+ builder.AppendLiteral(encodedLabel);
+ builder.AppendLiteral(" | ");
+ builder.AppendFormatted(count);
+ builder.AppendLiteral(" PRs |
");
+ builder.AppendLiteral(Environment.NewLine);
+ }
+ builder.AppendLiteral(" ");
+ builder.AppendLiteral(Environment.NewLine);
+ builder.AppendLiteral("
");
+ builder.AppendLiteral(Environment.NewLine);
+
+ return GenerateLabelPage("ラベル一覧", builder.ToStringAndClear(), GenerateLabelSortScript());
+ }
+
+ // Client-side column sorting for the labels/index.html table — no third-party library.
+ // Clicking a header reorders the rows by that column's data-sort value (text via
+ // localeCompare, number via parseFloat) and toggles ascending/descending on repeat clicks.
+ private static string GenerateLabelSortScript()
+ {
+ return """
+
+""";
+ }
+
+ public static string GenerateLabelPageHtml(string label, LabelPullRequestInfo labelAggregate)
+ {
+ var ordered = labelAggregate.Entries
+ .OrderByDescending(static e => e.metadata.MergedAt)
+ .ThenByDescending(static e => int.TryParse(e.metadata.PullRequestNumber, out var n) ? n : 0)
+ .ToArray();
+
+ var builder = new DefaultInterpolatedStringHandler(0, 0);
+ builder.AppendLiteral("ラベル一覧へ戻る
");
+ builder.AppendLiteral(Environment.NewLine);
+
+ builder.AppendLiteral("ラベル: ");
+ builder.AppendLiteral(HtmlEncoder.Default.Encode(label));
+ builder.AppendLiteral(" の Pull Request の一覧を表示します。上から新しい順に表示されています。
");
+ builder.AppendLiteral(Environment.NewLine);
+
+ builder.AppendLiteral("");
+ builder.AppendLiteral(Environment.NewLine);
+ foreach (var (target, md) in ordered)
+ {
+ // Relative link from outputs/labels/{label}/ back to the daily page: ../../yyyy/MM/dd.html#PR番号
+ builder.AppendLiteral(" - ");
+ builder.AppendLiteral(HtmlEncoder.Default.Encode(md.TitleText));
+ builder.AppendLiteral("
");
+ builder.AppendLiteral(Environment.NewLine);
+ }
+ builder.AppendLiteral("
");
+ builder.AppendLiteral(Environment.NewLine);
+
+ return GenerateLabelPage($"ラベル: {HtmlEncoder.Default.Encode(label)}", builder.ToStringAndClear(), GenerateScrollToTopHtml());
+ }
+
+ public static string SanitizeLabelForPath(string label)
+ {
+ // Replace every character outside [A-Za-z0-9._-] with '-' so the label is safe as both a
+ // directory name and a URL segment (e.g. "Priority:2" -> "Priority-2"). The same result is
+ // used for the on-disk folder and for the link to it, keeping path and href in sync.
+ if (label.AsSpan().IndexOfAnyExcept(AllowedLabelPathChars) < 0)
+ return label;
+
+ return string.Create(label.Length, label, static (span, state) =>
+ {
+ for (var i = 0; i < state.Length; i++)
+ {
+ var c = state[i];
+ span[i] = char.IsAsciiLetterOrDigit(c) || c is '.' or '_' or '-' ? c : '-';
+ }
+ });
+ }
+
+ private static void AppendIndexBadgeStyle(ref DefaultInterpolatedStringHandler builder, string? color)
+ {
+ if (string.IsNullOrEmpty(color))
+ return;
+
+ builder.AppendLiteral(" style=\"background-color: ");
+ builder.AppendLiteral(color);
+ builder.AppendLiteral("; color: ");
+ builder.AppendLiteral(GitHubLabalColor.GetFontColor(color));
+ builder.AppendLiteral("; display: inline-block; padding: 0 7px; font-size: 12px; font-weight: 500; line-height: 1.5; border-radius: 0.2em; border: 1px solid transparent;\"");
+ }
+
+ private static string GenerateLabelPage(string title, string content, string bodyEndHtml = "")
+ {
+ return GenerateTemplateHtml(
+ title: title,
+ subTitle: "dotnet/runtimeにマージされたPull RequestをAIで日本語要約",
+ content: content,
+ viewScript: bodyEndHtml,
+ floatingTocHtml: "",
+ floatingTocScript: "");
+ }
+
private static string GenerateTemplateHtml(string title, string subTitle, string content, string viewScript, string floatingTocHtml, string floatingTocScript)
{
return $$"""
@@ -429,6 +631,9 @@ private static string GenerateTemplateHtml(string title, string subTitle, string
private static string GenerateViewScript()
{
return """
+
+""";
+ }
+
+ private static string GenerateScrollToTopHtml()
+ {
+ return """
+
+
""";
@@ -1270,6 +1511,74 @@ pre code {
.floating-toc-nav ol li a.toc-active { color: #60a5fa; }
.floating-toc-nav ol li:has(a.toc-active) { border-left-color: #60a5fa; }
}
+
+ .scroll-to-top {
+ position: fixed;
+ bottom: 24px;
+ right: 24px;
+ width: 44px;
+ height: 44px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ border: none;
+ border-radius: 50%;
+ background: #2563eb;
+ color: #ffffff;
+ cursor: pointer;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.2s ease, visibility 0.2s ease;
+ z-index: 300;
+ }
+
+ .scroll-to-top.visible {
+ opacity: 1;
+ visibility: visible;
+ }
+
+ .scroll-to-top:hover {
+ background: #1d4ed8;
+ }
+
+ /* 画面幅が広いときは .content(最大1140px・中央寄せ)の右下に寄せる。本文右端からの余白は通常時と同じ24px */
+ @media (min-width: 1200px) {
+ .scroll-to-top {
+ right: calc((100vw - 1140px) / 2 + 24px);
+ }
+ }
+
+ @media (max-width: 768px) {
+ .scroll-to-top {
+ bottom: 16px;
+ right: 16px;
+ }
+ }
+
+ .label-index-table th.sortable {
+ cursor: pointer;
+ user-select: none;
+ white-space: nowrap;
+ }
+
+ .label-index-table th.sortable::after {
+ content: "\2195";
+ margin-left: 6px;
+ font-size: 12px;
+ opacity: 0.4;
+ }
+
+ .label-index-table th.sort-asc::after {
+ content: "\2191";
+ opacity: 1;
+ }
+
+ .label-index-table th.sort-desc::after {
+ content: "\2193";
+ opacity: 1;
+ }
""";
}
}
diff --git a/src/PRDigest.NET/Program.cs b/src/PRDigest.NET/Program.cs
index 0994ac1..de0b340 100644
--- a/src/PRDigest.NET/Program.cs
+++ b/src/PRDigest.NET/Program.cs
@@ -1,9 +1,9 @@
using Anthropic;
using Anthropic.Exceptions;
using Anthropic.Models.Messages;
+using Markdig;
using Octokit;
using PRDigest.NET;
-using System.Globalization;
using System.Runtime.InteropServices;
using System.Text;
@@ -25,6 +25,9 @@
// (Re)create RSS feed from archived markdown files
await CreateRss(archivesDir, outputsDir);
+// (Re)create the label index page and per-label PR list pages
+await CreateLabelPageHtml(archivesDir, outputsDir);
+
// end
var endTime = TimeProvider.System.GetTimestamp();
Console.WriteLine($"Total elapsed time: {TimeProvider.System.GetElapsedTime(startTime, endTime).TotalSeconds} seconds.");
@@ -261,7 +264,7 @@ async ValueTask SummarizePullRequestAsync(PullRequestInfo[] pullRequestI
async ValueTask CreateRss(string archivesDir, string outputsDir)
{
const int MaxDays = 3;
- var comparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering);
+ var comparer = StringComparerOptions.DefaultComparer;
var items = new List<(string target, string markdownContent)>(MaxDays);
foreach (var yearDir in Directory.EnumerateDirectories(archivesDir).OrderDescending(comparer))
@@ -335,6 +338,74 @@ await Parallel.ForEachAsync(Directory.EnumerateFiles(monthDirss, "*.md"), async
await File.WriteAllTextAsync(Path.Combine(outputsDir, "index.html"), HtmlGenerator.GenerateIndex(archivesDir, outputsDir));
}
+async ValueTask CreateLabelPageHtml(string archivesDir, string outputsDir)
+{
+ // Collect, across ALL archived markdown files, every PR grouped by label.
+ // Each entry remembers the archive's date path ("yyyy/MM/dd") so a per-label page
+ // can link to the PR's anchor on the corresponding daily HTML page.
+ var comparer = StringComparerOptions.DefaultComparer;
+ var labelTable = new Dictionary(256);
+
+ foreach (var yearDir in Directory.EnumerateDirectories(archivesDir).OrderDescending(comparer))
+ {
+ var year = Path.GetFileName(yearDir);
+ foreach (var monthDir in Directory.EnumerateDirectories(yearDir).OrderDescending(comparer))
+ {
+ var month = Path.GetFileName(monthDir);
+ foreach (var mdFilePath in Directory.EnumerateFiles(monthDir, "*.md").OrderDescending(comparer))
+ {
+ var day = Path.GetFileNameWithoutExtension(mdFilePath);
+ var target = $"{year}/{month}/{day}";
+
+ var markdown = await File.ReadAllTextAsync(mdFilePath);
+ var document = Markdown.Parse(markdown, MarkdownOptions.Pipeline);
+ var analyzerResult = PullRequestAnalyzer.Analyze(document);
+
+ foreach (var (label, metadata) in analyzerResult.LabelMap)
+ {
+ ref var aggregate = ref CollectionsMarshal.GetValueRefOrAddDefault(labelTable, label, out var exists);
+ if (!exists)
+ {
+ aggregate = new LabelPullRequestInfo();
+ }
+
+ // Adopt the first color we encounter for the label (colors are stable per label).
+ if (string.IsNullOrEmpty(aggregate!.Color) && analyzerResult.LabelColorGroups.TryGetValue(label, out var color))
+ {
+ aggregate.Color = color;
+ }
+
+ foreach (var m in metadata)
+ {
+ aggregate.Entries.Add((target, m));
+ }
+ }
+ }
+ }
+ }
+
+ // set up labels directory: outputs/labels
+ var labelsDir = Path.Combine(outputsDir, "labels");
+ if (!Directory.Exists(labelsDir))
+ {
+ Directory.CreateDirectory(labelsDir);
+ }
+
+ // outputs/labels/index.html : every label as a badge (with PR count) linking to its page
+ await File.WriteAllTextAsync(Path.Combine(labelsDir, "index.html"), HtmlGenerator.GenerateLabelIndexHtml(labelTable));
+
+ // outputs/labels/{sanitized}/index.html
+ foreach (var (label, info) in labelTable)
+ {
+ var labelDir = Path.Combine(labelsDir, HtmlGenerator.SanitizeLabelForPath(label));
+ if (!Directory.Exists(labelDir))
+ {
+ Directory.CreateDirectory(labelDir);
+ }
+ await File.WriteAllTextAsync(Path.Combine(labelDir, "index.html"), HtmlGenerator.GenerateLabelPageHtml(label, info));
+ }
+}
+
internal sealed class PullRequestInfo
{
public required Issue Issue { get; init; }
@@ -348,3 +419,10 @@ internal sealed class PullRequestInfo
public required IReadOnlyList Reviews { get; init; }
}
+internal sealed class LabelPullRequestInfo
+{
+ public string? Color { get; set; }
+
+ public List<(string target, PullRequestAnalyzer.Metadata metadata)> Entries { get; } = [];
+}
+
diff --git a/src/PRDigest.NET/StringComparerOptions.cs b/src/PRDigest.NET/StringComparerOptions.cs
new file mode 100644
index 0000000..b0f12b7
--- /dev/null
+++ b/src/PRDigest.NET/StringComparerOptions.cs
@@ -0,0 +1,8 @@
+using System.Globalization;
+
+namespace PRDigest.NET;
+
+internal static class StringComparerOptions
+{
+ public static readonly StringComparer DefaultComparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering);
+}