diff --git a/src/PRDigest.NET/HtmlGenerator.cs b/src/PRDigest.NET/HtmlGenerator.cs index f2a2b07..a96be5f 100644 --- a/src/PRDigest.NET/HtmlGenerator.cs +++ b/src/PRDigest.NET/HtmlGenerator.cs @@ -8,6 +8,14 @@ namespace PRDigest.NET; internal static class HtmlGenerator { + private enum HtmlPageKind + { + Index, // outputs/index.html + Daily, // outputs/yyyy/MM/dd.html + LabelIndex, // outputs/labels/index.html + LabelPage, //outputs/labels/{label}/index.html + } + private static readonly SearchValues AllowedLabelPathChars = SearchValues.Create("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-"); @@ -130,9 +138,10 @@ public static string GenerateIndex(string archivesDir, string outputsDir) } return GenerateTemplateHtml( - title: $"PR Digest.NET", - subTitle: "dotnet/runtimeにマージされたPull RequestをAIで日本語要約", - content: latestPullRequestInfo + detailsBuilder.ToStringAndClear(), + pageKind: HtmlPageKind.Index, + title: $"PR Digest.NET", + subTitle: "dotnet/runtimeにマージされたPull RequestをAIで日本語要約", + content: latestPullRequestInfo + detailsBuilder.ToStringAndClear(), viewScript: GenerateViewScript(), floatingTocHtml: "", floatingTocScript: ""); @@ -213,7 +222,8 @@ public static string GenerateHtmlFromMarkdown(string startTargetDate, string mar """; return GenerateTemplateHtml( - title: $"Pull Request on {startTargetDate}", + pageKind: HtmlPageKind.Daily, + title: $"Pull Request on {startTargetDate}", subTitle: "dotnet/runtimeにマージされたPull RequestをAIで日本語要約", content: content, viewScript: GenerateViewScript(), @@ -355,7 +365,7 @@ public static string GenerateLabelIndexHtml(Dictionaryラベル情報がありません。

"); - return GenerateLabelPage("ラベル一覧", builder.ToStringAndClear()); + return GenerateLabelPage(HtmlPageKind.LabelIndex, "ラベル一覧", builder.ToStringAndClear()); } builder.AppendLiteral(""); @@ -391,7 +401,7 @@ public static string GenerateLabelIndexHtml(Dictionary"); builder.AppendLiteral(Environment.NewLine); - return GenerateLabelPage("ラベル一覧", builder.ToStringAndClear(), GenerateLabelSortScript()); + return GenerateLabelPage(HtmlPageKind.LabelIndex, "ラベル一覧", builder.ToStringAndClear(), GenerateLabelSortScript()); } // Client-side column sorting for the labels/index.html table — no third-party library. @@ -489,7 +499,7 @@ public static string GenerateLabelPageHtml(string label, LabelPullRequestInfo la builder.AppendLiteral(""); builder.AppendLiteral(Environment.NewLine); - return GenerateLabelPage($"ラベル: {HtmlEncoder.Default.Encode(label)}", builder.ToStringAndClear(), GenerateScrollToTopHtml()); + return GenerateLabelPage(HtmlPageKind.LabelPage, $"ラベル: {HtmlEncoder.Default.Encode(label)}", builder.ToStringAndClear(), GenerateScrollToTopHtml()); } public static string SanitizeLabelForPath(string label) @@ -522,9 +532,10 @@ private static void AppendIndexBadgeStyle(ref DefaultInterpolatedStringHandler b 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 = "") + private static string GenerateLabelPage(HtmlPageKind pageKind, string title, string content, string bodyEndHtml = "") { return GenerateTemplateHtml( + pageKind: pageKind, title: title, subTitle: "dotnet/runtimeにマージされたPull RequestをAIで日本語要約", content: content, @@ -533,7 +544,7 @@ private static string GenerateLabelPage(string title, string content, string bod floatingTocScript: ""); } - private static string GenerateTemplateHtml(string title, string subTitle, string content, string viewScript, string floatingTocHtml, string floatingTocScript) + private static string GenerateTemplateHtml(HtmlPageKind pageKind, string title, string subTitle, string content, string viewScript, string floatingTocHtml, string floatingTocScript) { return $$""" @@ -578,7 +589,7 @@ private static string GenerateTemplateHtml(string title, string subTitle, string @@ -796,9 +807,34 @@ function closeToc() { """; } - private static string GenerateCssStyle() + private static string GetCssStyle(HtmlPageKind pageKind) => pageKind switch { - return $$""" + HtmlPageKind.Index => IndexCss, + HtmlPageKind.Daily => DailyCss, + HtmlPageKind.LabelIndex => LabelIndexCss, + HtmlPageKind.LabelPage => LabelPageCss, + _ => BaseCss, + }; + + // outputs/index.html: month
list, latest digest stats, scroll-to-top button. + private const string IndexCss = + BaseCss + DetailsCss + DayListCss + StatsCss + ScrollToTopCss; + + // outputs/yyyy/MM/dd.html: markdown body (code/table/strong), view tabs, label groups, floating TOC. + private const string DailyCss = + BaseCss + DetailsCss + MarkdownContentCss + TableCss + TableOfContentsCss + + ViewTabsCss + LabelGroupCss + LabelPrCountCss + LabelPrListCss + FloatingTocCss + ScrollToTopCss; + + // outputs/labels/index.html: sortable label table only (no
, no scroll-to-top button). + private const string LabelIndexCss = + BaseCss + TableCss + LabelPrCountCss + LabelIndexTableCss; + + // outputs/labels/{label}/index.html: a single PR list plus the scroll-to-top button. + private const string LabelPageCss = + BaseCss + LabelPrListCss + ScrollToTopCss; + + // Layout shared by every page: navbar, header, content card, base typography and footer. + private const string BaseCss = """ * { box-sizing: border-box; } @@ -806,7 +842,7 @@ private static string GenerateCssStyle() :root { interpolate-size: allow-keywords; } - + body { margin: 0; padding: 0; @@ -844,7 +880,7 @@ private static string GenerateCssStyle() padding-bottom: 40px; margin-bottom: 0; } - + .container { display: flex; justify-content: space-between; @@ -853,16 +889,16 @@ private static string GenerateCssStyle() margin: 0 auto; padding: 0 24px; } - + .page { min-height: 100vh; padding: 24px 16px; } - + .main { margin: 0 auto; } - + .content { background: #ffffff; width: 100%; @@ -879,7 +915,7 @@ private static string GenerateCssStyle() padding-bottom: 16px; color: #1a1a1a; } - + h2 { font-size: 24px; font-weight: 700; @@ -889,13 +925,13 @@ private static string GenerateCssStyle() padding-top: 16px; border-top: 1px solid #e5e7eb; } - + h2:first-of-type { margin-top: 0; padding-top: 0; border-top: none; } - + h3 { font-size: 20px; font-weight: 600; @@ -911,14 +947,14 @@ private static string GenerateCssStyle() overflow-wrap: break-word; color: #374151; } - + a { color: #2563eb; border-radius: 24px; line-height: 24px; text-decoration: none; } - + a:hover { color: #2563eb; text-decoration: underline; @@ -936,7 +972,7 @@ private static string GenerateCssStyle() color: #9ca3af; text-decoration: none; } - + ul { margin: 0; padding: 2px 2px; @@ -948,77 +984,124 @@ ul p{ padding: 0; } - .daylist { - list-style-type: none; - display: grid; - grid-auto-flow: column; - grid-template-rows: repeat(12, auto); - } - - .dayitem { - list-style: none; - display: flex; - align-items: center; - } - li { margin: 0; padding: 2px 2px; color: #374151; } - code { - font-family: 'JetBrains Mono', 'Consolas', 'Monaco', 'Courier New', monospace; + footer { + background: #151b23; + color: #9ca3af; + padding: 32px 24px; + margin-top: 48px; + text-align: center; font-size: 14px; - background: #f3f4f6; - padding: 2px 6px; - border-radius: 4px; - color: #e11d48; - } - - pre { - margin: 24px 0; - padding: 0; - border-radius: 8px; - overflow: hidden; - background: #f9fafb; - border: 1px solid #e5e7eb; } - - pre code { - display: block; - padding: 16px 20px; - overflow-x: auto; - background: transparent; - color: inherit; - border-radius: 0; + + footer p { + margin: 8px 0; + color: #9ca3af; } - table { - width: 100%; - border-collapse: collapse; - margin: 16px 0; - font-size: 14px; - color: #374151; + @media (min-width: 1200px) { + .container { + max-width: 1140px; + } + + .content { + max-width: 1140px; + } + + .main { + max-width: 1140px; + } + } - th { - background: #f3f4f6; - color: #1a1a1a; - font-weight: 600; - text-align: left; - padding: 10px 14px; - border: 1px solid #e5e7eb; - overflow-wrap: break-word; + @media (max-width: 768px) { + .page { + padding: 16px 8px; + } + + .container { + max-width: 720px; + } + + + .main { + max-width: 720px; + } + + .content { + max-width: 720px; + padding: 32px 24px; + } + + h1 { + font-size: 28px; + } + + h2 { + font-size: 22px; + } + + h3 { + font-size: 18px; + } + + body { + font-size: 15px; + } + + li { + word-break: break-word; + overflow-wrap: anywhere; + } + } - td { - padding: 8px 14px; - border: 1px solid #e5e7eb; - overflow-wrap: anywhere; - word-break: break-word; + @media (prefers-color-scheme: dark) { + body { + background-color: #111827; + color: #e5e7eb; + } + + .content { + background: #1f2937; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + } + + h1, h2, h3 { + color: #f9fafb; + } + + h1 { + border-bottom-color: #374151; + } + + h2 { + border-top-color: #374151; + } + + p, li { + color: #d1d5db; + } + + a { + color: #60a5fa; + } + + a:hover { + color: #93c5fd; + } } + +"""; + + //
/ blocks: the month list on index.html and the label groups on daily pages. + private const string DetailsCss = """ details { margin: 0px 0px 8px 0px; background-color: #f5f5f5; @@ -1031,7 +1114,7 @@ pre code { transition: height 0.1s ease, opacity 0.1s ease, content-visibility 0.1s ease allow-discrete; } - + details[open]::details-content { height: auto; /* for unsupported browser */ height: calc-size(auto, size); @@ -1045,43 +1128,56 @@ pre code { font-weight: bold; cursor: pointer; } - - strong { - font-weight: 600; - color: #1a1a1a; - } - #table-of-contents + ol li { - padding: 0; - margin: 0; - font-weight: bold; + @media (max-width: 768px) { + summary { + padding: 1.2em 1em; + font-size: 16px; + } } - #table-of-contents + ol li a { - color: #2563eb; - font-weight: bold; - text-decoration: underline; + @media (prefers-color-scheme: dark) { + details { + background-color: #1f2937; + } + + summary { + background-color: #374151; + } } - #table-of-contents + ol li a:hover { - color: #1d4ed8; - text-decoration: underline; + +"""; + + // Day links on index.html, laid out as a multi-column grid. + private const string DayListCss = """ + .daylist { + list-style-type: none; + display: grid; + grid-auto-flow: column; + grid-template-rows: repeat(12, auto); } - footer { - background: #151b23; - color: #9ca3af; - padding: 32px 24px; - margin-top: 48px; - text-align: center; - font-size: 14px; + .dayitem { + list-style: none; + display: flex; + align-items: center; } - footer p { - margin: 8px 0; - color: #9ca3af; + @media (max-width: 768px) { + .daylist { + list-style-type: none; + display: grid; + grid-auto-flow: column; + grid-template-rows: repeat(16, auto); + } } + +"""; + + // PR count cards for the latest digest on index.html. + private const string StatsCss = """ .stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -1120,118 +1216,70 @@ footer p { overflow-wrap: break-word; } - .view-tabs { - display: flex; - border-bottom: 2px solid #e5e7eb; - margin: 24px 0 16px 0; - } - - .view-tab { - padding: 10px 24px; - background: transparent; - color: #6b7280; - font-size: 16px; - font-weight: 600; - border: none; - border-bottom: 2px solid transparent; - margin-bottom: -2px; - cursor: pointer; - transition: color 0.15s, border-bottom-color 0.15s; - } + @media (max-width: 768px) { + .stats-grid, + .stats-label-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } - .view-tab:hover { - color: #2563eb; + .stat-value { + font-size: 28px; + } } - .view-tab.active { - color: #2563eb; - border-bottom-color: #2563eb; - } + @media (prefers-color-scheme: dark) { + .stat-card { + background: #374151; + border-color: #4b5563; + } - .label-group { - margin: 0 0 8px 0; - } + .stat-value { + color: #f9fafb; + } - .label-group-summary { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; + .stat-label { + color: #9ca3af; + } } - .label-pr-count { - font-size: 13px; - color: #6b7280; - font-weight: normal; - } - .label-pr-list { - padding: 8px 16px 8px 32px; - } +"""; - .label-pr-list li { - padding: 2px 0; + // Inline code, code blocks, emphasis and label badges rendered from the summarized markdown. + private const string MarkdownContentCss = """ + code { + font-family: 'JetBrains Mono', 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 14px; + background: #f3f4f6; + padding: 2px 6px; + border-radius: 4px; + color: #e11d48; } - .label-pr-list li a { - color: #2563eb; - text-decoration: underline; + pre { + margin: 24px 0; + padding: 0; + border-radius: 8px; + overflow: hidden; + background: #f9fafb; + border: 1px solid #e5e7eb; } - .label-pr-list li a:hover { - color: #1d4ed8; + pre code { + display: block; + padding: 16px 20px; + overflow-x: auto; + background: transparent; + color: inherit; + border-radius: 0; } - @media (min-width: 1200px) { - .container { - max-width: 1140px; - } - - .content { - max-width: 1140px; - } - - .main { - max-width: 1140px; - } - + strong { + font-weight: 600; + color: #1a1a1a; } @media (max-width: 768px) { - .page { - padding: 16px 8px; - } - - .container { - max-width: 720px; - } - - - .main { - max-width: 720px; - } - - .content { - max-width: 720px; - padding: 32px 24px; - } - - h1 { - font-size: 28px; - } - - h2 { - font-size: 22px; - } - - h3 { - font-size: 18px; - } - - body { - font-size: 15px; - } - code { word-break: break-all; overflow-wrap: anywhere; @@ -1243,105 +1291,59 @@ pre code { overflow-wrap: anywhere; } - li { - word-break: break-word; - overflow-wrap: anywhere; - } - li > span[style*="border-radius:2em"] { white-space: normal !important; overflow-wrap: anywhere; } - - .daylist { - list-style-type: none; - display: grid; - grid-auto-flow: column; - grid-template-rows: repeat(16, auto); - } - - summary { - padding: 1.2em 1em; - font-size: 16px; - } - - .stats-grid, - .stats-label-row { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .stat-value { - font-size: 28px; - } - - .view-tab { - padding: 8px 16px; - font-size: 14px; - } - - .label-pr-list { - padding: 8px 8px 8px 24px; - } - } @media (prefers-color-scheme: dark) { - body { - background-color: #111827; - color: #e5e7eb; - } - - details { - background-color: #1f2937; - } - - summary { - background-color: #374151; - } - - .content { - background: #1f2937; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); - } - - h1, h2, h3, strong { + strong { color: #f9fafb; } - - h1 { - border-bottom-color: #374151; - } - - h2 { - border-top-color: #374151; - } - - p, li { - color: #d1d5db; - } - - a { - color: #60a5fa; - } - - a:hover { - color: #93c5fd; - } - #table-of-contents + ol li a { - color: #60a5fa; - } - code { background: #374151; color: #fca5a5; } - + pre { background: #111827; border-color: #374151; } + } + + +"""; + + // Tables: markdown tables on daily pages and the label table on labels/index.html. + private const string TableCss = """ + table { + width: 100%; + border-collapse: collapse; + margin: 16px 0; + font-size: 14px; + color: #374151; + } + + th { + background: #f3f4f6; + color: #1a1a1a; + font-weight: 600; + text-align: left; + padding: 10px 14px; + border: 1px solid #e5e7eb; + overflow-wrap: break-word; + } + td { + padding: 8px 14px; + border: 1px solid #e5e7eb; + overflow-wrap: anywhere; + word-break: break-word; + } + + @media (prefers-color-scheme: dark) { th { background: #374151; color: #f9fafb; @@ -1352,20 +1354,77 @@ pre code { border-color: #4b5563; color: #d1d5db; } + } - .stat-card { - background: #374151; - border-color: #4b5563; - } - .stat-value { - color: #f9fafb; +"""; + + // The markdown-generated table of contents on daily pages. + private const string TableOfContentsCss = """ + #table-of-contents + ol li { + padding: 0; + margin: 0; + font-weight: bold; + } + + #table-of-contents + ol li a { + color: #2563eb; + font-weight: bold; + text-decoration: underline; + } + + #table-of-contents + ol li a:hover { + color: #1d4ed8; + text-decoration: underline; + } + + @media (prefers-color-scheme: dark) { + #table-of-contents + ol li a { + color: #60a5fa; } + } - .stat-label { - color: #9ca3af; + +"""; + + // List / category / label view switcher on daily pages. + private const string ViewTabsCss = """ + .view-tabs { + display: flex; + border-bottom: 2px solid #e5e7eb; + margin: 24px 0 16px 0; + } + + .view-tab { + padding: 10px 24px; + background: transparent; + color: #6b7280; + font-size: 16px; + font-weight: 600; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + cursor: pointer; + transition: color 0.15s, border-bottom-color 0.15s; + } + + .view-tab:hover { + color: #2563eb; + } + + .view-tab.active { + color: #2563eb; + border-bottom-color: #2563eb; + } + + @media (max-width: 768px) { + .view-tab { + padding: 8px 16px; + font-size: 14px; } + } + @media (prefers-color-scheme: dark) { .view-tabs { border-bottom-color: #374151; } @@ -1379,7 +1438,25 @@ pre code { color: #60a5fa; border-bottom-color: #60a5fa; } + } + +"""; + + // Collapsible category / label groups on daily pages. + private const string LabelGroupCss = """ + .label-group { + margin: 0 0 8px 0; + } + + .label-group-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + } + + @media (prefers-color-scheme: dark) { .label-group { background-color: #1f2937; } @@ -1387,11 +1464,54 @@ pre code { .label-group-summary { background-color: #374151; } + } + + +"""; + + // "(N PRs)" annotation used by daily pages and the label table. + private const string LabelPrCountCss = """ + .label-pr-count { + font-size: 13px; + color: #6b7280; + font-weight: normal; + } + @media (prefers-color-scheme: dark) { .label-pr-count { color: #9ca3af; } + } + + +"""; + + // PR link lists inside label groups and on label pages. + private const string LabelPrListCss = """ + .label-pr-list { + padding: 8px 16px 8px 32px; + } + + .label-pr-list li { + padding: 2px 0; + } + + .label-pr-list li a { + color: #2563eb; + text-decoration: underline; + } + + .label-pr-list li a:hover { + color: #1d4ed8; + } + + @media (max-width: 768px) { + .label-pr-list { + padding: 8px 8px 8px 24px; + } + } + @media (prefers-color-scheme: dark) { .label-pr-list li a { color: #60a5fa; } @@ -1401,6 +1521,11 @@ pre code { } } + +"""; + + // Sidebar table of contents shown on daily pages. + private const string FloatingTocCss = """ .floating-toc { position: fixed; top: 220px; @@ -1512,6 +1637,11 @@ pre code { .floating-toc-nav ol li:has(a.toc-active) { border-left-color: #60a5fa; } } + +"""; + + // Floating "back to top" button. + private const string ScrollToTopCss = """ .scroll-to-top { position: fixed; bottom: 24px; @@ -1557,6 +1687,11 @@ pre code { } } + +"""; + + // Sort indicators for the label table on labels/index.html. + private const string LabelIndexTableCss = """ .label-index-table th.sortable { cursor: pointer; user-select: none; @@ -1580,5 +1715,4 @@ pre code { opacity: 1; } """; - } }