From cb8f6e15f3f10a2a3b6907cbce40d49606eb9b07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sun, 3 May 2026 13:38:49 +0200 Subject: [PATCH] docs: support cross-repo README substitution for aweXpect Migration page Add ExtraReadmes config on DocsSource so a slice can pull a foreign repo's README and substitute it into a named target file. New StripReadmeFront helper handles READMEs without ## headings (drops H1, consecutive badge lines, and surrounding blanks). The aweXpect slice now pulls aweXpect.Migration's README and inlines it into 09-migration.md, giving the rendered Migration page a single source of truth. --- Pipeline/Build.Pages.cs | 112 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 108 insertions(+), 4 deletions(-) diff --git a/Pipeline/Build.Pages.cs b/Pipeline/Build.Pages.cs index 9cadcf5b..ffd724a7 100644 --- a/Pipeline/Build.Pages.cs +++ b/Pipeline/Build.Pages.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Net.Http; using System.Net.Http.Headers; @@ -29,12 +30,30 @@ partial class Build /// a 00-* file. Lets extension repos keep their public README and their first docs page /// in sync without duplicating content. /// + /// + /// Optional cross-repo README substitutions. Each entry pulls the named foreign repo's + /// README.md, strips the leading H1 plus any badge block, and substitutes it into the + /// placeholder of the named file inside this slice. Lets a docs page own its scaffolding while + /// sourcing its body from a different repo's README. + /// record DocsSource( string Organization, string Repository, string SourcePath, string TargetSubDirectory, - bool InlineReadme = false); + bool InlineReadme = false, + ReadmeSubstitution[]? ExtraReadmes = null); + + /// + /// A cross-repo README substitution. Fetches README.md from /, + /// strips its leading H1 and any consecutive badge lines, and replaces + /// in the docs file named . + /// + record ReadmeSubstitution( + string Organization, + string Repository, + string TargetFileName, + string Placeholder = "{README}"); /// /// The slices that compose the testably.org documentation portal. Order matters: @@ -45,7 +64,11 @@ record DocsSource( static readonly DocsSource[] AggregatedSources = [ new("Testably", "Testably.Abstractions", "Docs/pages/docs", "abstractions"), - new("Testably", "aweXpect", "Docs/pages", "awexpect"), + new("Testably", "aweXpect", "Docs/pages", "awexpect", + ExtraReadmes: + [ + new("Testably", "aweXpect.Migration", "09-migration.md"), + ]), new("Testably", "aweXpect.Json", "Docs/pages", "extensions/aweXpect.Json", InlineReadme: true), new("Testably", "aweXpect.Mockolate", "Docs/pages", "extensions/aweXpect.Mockolate", InlineReadme: true), new("Testably", "aweXpect.Reflection", "Docs/pages", "extensions/aweXpect.Reflection", InlineReadme: true), @@ -89,10 +112,24 @@ async Task DownloadDocsContent(DocsSource source, AbsolutePath baseDirectory) ? await FetchReadmeIntro(client, source) : string.Empty; - Func? transformer = source.InlineReadme + // Cross-repo README substitutions: each entry's README is fetched once, then + // substituted into the named target file. Stored as a mutable list so the + // transformer can clear an entry after it fires (substitute-once semantics). + List<(ReadmeSubstitution Config, string Body)> extraReadmes = new(); + if (source.ExtraReadmes != null) + { + foreach (ReadmeSubstitution sub in source.ExtraReadmes) + { + string body = await FetchReadmeBody(client, sub.Organization, sub.Repository); + extraReadmes.Add((sub, body)); + } + } + + Func? transformer = (source.InlineReadme || extraReadmes.Count > 0) ? (name, content) => { - if (name.StartsWith("00-", StringComparison.Ordinal) && + if (source.InlineReadme && + name.StartsWith("00-", StringComparison.Ordinal) && content.Contains("{README}", StringComparison.Ordinal)) { string substitution = readmeContent.Replace("Docs/pages/", "./", StringComparison.Ordinal); @@ -100,6 +137,21 @@ async Task DownloadDocsContent(DocsSource source, AbsolutePath baseDirectory) readmeContent = string.Empty; // substitute into the first match only Log.Information($" Inlined README.md into {name}"); } + for (int i = 0; i < extraReadmes.Count; i++) + { + (ReadmeSubstitution cfg, string body) = extraReadmes[i]; + if (string.IsNullOrEmpty(body)) + { + continue; + } + if (string.Equals(name, cfg.TargetFileName, StringComparison.Ordinal) && + content.Contains(cfg.Placeholder, StringComparison.Ordinal)) + { + content = content.Replace(cfg.Placeholder, body, StringComparison.Ordinal); + extraReadmes[i] = (cfg, string.Empty); // substitute once + Log.Information($" Inlined {cfg.Organization}/{cfg.Repository} README.md into {name}"); + } + } return content; } : null; @@ -146,6 +198,58 @@ async Task FetchReadmeIntro(HttpClient client, DocsSource source) return indexOfFirstH2 > 0 ? readme.Substring(indexOfFirstH2) : string.Empty; } + /// + /// Fetches a foreign repo's README.md and strips its leading H1 plus any consecutive + /// badge block, returning the body. Used by ; unlike + /// , this does not require a ## heading to be present. + /// + async Task FetchReadmeBody(HttpClient client, string organization, string repository) + { + HttpResponseMessage response = await client.GetAsync( + $"https://api.github.com/repos/{organization}/{repository}/contents/README.md"); + string responseContent = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) + { + Log.Warning($"Could not fetch README.md from {organization}/{repository}: {response.StatusCode}"); + return string.Empty; + } + + using JsonDocument document = JsonDocument.Parse(responseContent); + string readme = Base64Decode(document.RootElement.GetProperty("content").GetString()!); + return StripReadmeFront(readme); + } + + /// + /// Drops the leading H1 line, any consecutive badge lines that follow, and the resulting + /// leading blank lines. Leaves the rest of the README intact. + /// + static string StripReadmeFront(string readme) + { + string[] lines = readme.Split('\n'); + int i = 0; + while (i < lines.Length && string.IsNullOrWhiteSpace(lines[i])) + { + i++; + } + if (i < lines.Length && lines[i].TrimStart().StartsWith("# ", StringComparison.Ordinal)) + { + i++; + } + while (i < lines.Length && string.IsNullOrWhiteSpace(lines[i])) + { + i++; + } + while (i < lines.Length && lines[i].TrimStart().StartsWith("[!", StringComparison.Ordinal)) + { + i++; + } + while (i < lines.Length && string.IsNullOrWhiteSpace(lines[i])) + { + i++; + } + return string.Join('\n', lines, i, lines.Length - i); + } + async Task DownloadFileOrDirectory(HttpClient client, DocsSource source, string subPath, JsonElement fileOrDirectory, AbsolutePath targetDirectory, Func? transformer = null) {