diff --git a/src/PRDigest.NET/HtmlGenerator.cs b/src/PRDigest.NET/HtmlGenerator.cs index a2f30bd..f2a2b07 100644 --- a/src/PRDigest.NET/HtmlGenerator.cs +++ b/src/PRDigest.NET/HtmlGenerator.cs @@ -1,6 +1,6 @@ using Markdig; +using Markdig.Helpers; using System.Buffers; -using System.Globalization; using System.Runtime.CompilerServices; using System.Text.Encodings.Web; @@ -8,19 +8,32 @@ namespace PRDigest.NET; internal static class HtmlGenerator { - private static readonly StringComparer NumericOrderingComparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering); + private static readonly SearchValues AllowedLabelPathChars = + SearchValues.Create("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-"); + + private static bool IsYearDirectoryName(string path) + { + var name = Path.GetFileName(path); + if (name.Length == 4) + { + var nameSpan = name.AsSpan(); + return nameSpan[0].IsDigit() && nameSpan[1].IsDigit() && nameSpan[2].IsDigit() && nameSpan[3].IsDigit(); + } + return false; + } public static string GenerateIndex(string archivesDir, string outputsDir) { - var comparer = NumericOrderingComparer; + var comparer = StringComparerOptions.DefaultComparer; + var yearDirs = Directory.GetDirectories(outputsDir).Where(IsYearDirectoryName).OrderDescending(comparer).ToArray(); var detailsBuilder = new DefaultInterpolatedStringHandler(0, 0); - foreach (var yearDirs in Directory.GetDirectories(outputsDir).OrderDescending(comparer)) + foreach (var yearDir in yearDirs) { - var year = Path.GetFileName(yearDirs); - foreach (var monthDirss in Directory.GetDirectories(yearDirs).OrderDescending(comparer)) + var year = Path.GetFileName(yearDir); + foreach (var monthDir in Directory.GetDirectories(yearDir).OrderDescending(comparer)) { - var month = Path.GetFileName(monthDirss); + var month = Path.GetFileName(monthDir); detailsBuilder.AppendLiteral("
"); detailsBuilder.AppendLiteral(Environment.NewLine); detailsBuilder.AppendLiteral(" "); @@ -33,7 +46,7 @@ public static string GenerateIndex(string archivesDir, string outputsDir) detailsBuilder.AppendLiteral($"
    "); detailsBuilder.AppendLiteral(Environment.NewLine); - foreach (var htmlPath in Directory.GetFiles(monthDirss, "*.html").Order(comparer)) + foreach (var htmlPath in Directory.GetFiles(monthDir, "*.html").Order(comparer)) { detailsBuilder.AppendLiteral($"
  • 0 ? yearDirs[0] : null; + if (!string.IsNullOrWhiteSpace(latestYearDir)) { - var lastedYear = Path.GetFileName(lastedYearDirs); - var lastedMonthDirs = Directory.GetDirectories(lastedYearDirs!).OrderDescending(comparer).FirstOrDefault(); + var lastedYear = Path.GetFileName(latestYearDir); + var lastedMonthDirs = Directory.GetDirectories(latestYearDir!).OrderDescending(comparer).FirstOrDefault(); var lastedMonth = Path.GetFileName(lastedMonthDirs); var lastedDayHtmlPath = Directory.GetFiles(lastedMonthDirs!, "*.html").OrderDescending(comparer).FirstOrDefault(); @@ -110,6 +123,8 @@ public static string GenerateIndex(string archivesDir, string outputsDir)

    最新のダイジェスト

    {lastedYear}年{lastedMonth}月{Path.GetFileNameWithoutExtension(lastedDayHtmlPath)}日

    {statsHtml} +

    ラベルから探す

    +

    全ラベル一覧

    過去の月別ダイジェスト

    """; } @@ -331,6 +346,193 @@ private static void AppendHeadingListItem(ref DefaultInterpolatedStringHandler b builder.AppendLiteral(Environment.NewLine); } + public static string GenerateLabelIndexHtml(Dictionary 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(" "); + 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(Environment.NewLine); + } + builder.AppendLiteral(" "); + builder.AppendLiteral(Environment.NewLine); + builder.AppendLiteral("
    ラベルPR数
    "); + builder.AppendLiteral(encodedLabel); + builder.AppendLiteral(""); + builder.AppendFormatted(count); + builder.AppendLiteral(" PRs
    "); + 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("
    1. "); + builder.AppendLiteral(HtmlEncoder.Default.Encode(md.TitleText)); + builder.AppendLiteral("
    2. "); + 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); +}