diff --git a/.github/workflows/find-ui-corpus-check.yml b/.github/workflows/find-ui-corpus-check.yml index 5a6595b2d..64872cd25 100644 --- a/.github/workflows/find-ui-corpus-check.yml +++ b/.github/workflows/find-ui-corpus-check.yml @@ -55,6 +55,9 @@ jobs: // reach for the skip label by reflex. const CORPUS_SOURCE_PATTERNS = [ /^src\/winapp-CLI\/WinApp\.Cli\/Services\/Controls\/(Gallery|Toolkit|Reactor)(Fetcher|Provider)\.cs$/, + // Gallery and Reactor both build their corpus from a published sample index, + // so a change to the shared download or mapping re-shapes two sources at once. + /^src\/winapp-CLI\/WinApp\.Cli\/Services\/Controls\/SampleIndex(Fetcher|Parser)\.cs$/, /^src\/winapp-CLI\/WinApp\.Cli\/Services\/Controls\/ScenarioSanitizer\.cs$/, /^src\/winapp-CLI\/WinApp\.Cli\/Services\/Controls\/Models\.cs$/, /^src\/winapp-CLI\/WinApp\.Cli\/Services\/Controls\/CacheVersion\.cs$/, diff --git a/src/winapp-CLI/WinApp.Cli.Tests/EmbeddedSnapshotTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/EmbeddedSnapshotTests.cs index 68884ce45..d6da7424e 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/EmbeddedSnapshotTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/EmbeddedSnapshotTests.cs @@ -5,6 +5,7 @@ using System.Net.Http; using System.Reflection; using System.Text.Json; +using System.Text.RegularExpressions; using WinApp.Cli.Services.Controls; namespace WinApp.Cli.Tests; @@ -33,6 +34,17 @@ public class EmbeddedSnapshotTests /// private const double MinCodeRetention = 0.90; + /// + /// Serving floors for the Gallery corpus, measured at 327 scenarios / 115 controls / + /// 290 XAML / 116 C# after sanitizing on the bake that introduced the published index. + /// Set roughly 10% below that so ordinary upstream churn doesn't fail the build, while + /// a partial or broken upstream publish does. + /// + private const int MinGalleryScenarios = 295; + private const int MinGalleryControls = 105; + private const int MinGalleryXaml = 260; + private const int MinGalleryCSharp = 100; + // ------------------------------------------------------------------ // Build gates: the committed snapshot must match the code that reads it // ------------------------------------------------------------------ @@ -217,6 +229,162 @@ public void EmbeddedCorpus_SurvivesTheSanitizer() $"Observed: {string.Join(" | ", report)}"); } + [TestMethod] + public void GalleryCorpus_MeetsItsServingFloors() + { + // The Gallery corpus is no longer reconstructed by a scraper — it is whatever + // microsoft/WinUI-Gallery publishes in catalog/windows-samples.json. That removes a + // whole class of parsing bugs, but it also means a bad upstream publish (a partial + // export, a schema change that silently drops a field) now lands in our corpus with + // nothing in between. These floors are that "nothing in between". + // + // They are floors, not equalities: upstream adds and removes samples continuously + // and a bake that tracks that is working as intended. They sit below the measured + // corpus with room for ordinary churn, so what trips them is a collapse, not a + // release. Re-measure and move a floor only after confirming upstream really did + // shrink; a floor that is edited every bake is not guarding anything. + var scenarios = ReadEmbeddedSnapshot("gallery")!.Scenarios; + ScenarioSanitizer.SanitizeAll(scenarios); + + var controls = scenarios.Select(s => s.ControlId).Distinct(StringComparer.OrdinalIgnoreCase).Count(); + var xaml = scenarios.Count(s => !string.IsNullOrWhiteSpace(s.Xaml)); + var csharp = scenarios.Count(s => !string.IsNullOrWhiteSpace(s.CSharp)); + + var observed = $"observed {scenarios.Length} scenarios / {controls} controls / " + + $"{xaml} XAML / {csharp} C# after sanitizing"; + + Assert.IsTrue(scenarios.Length >= MinGalleryScenarios, + $"the Gallery corpus collapsed to {scenarios.Length} scenarios (floor {MinGalleryScenarios}). {observed}"); + Assert.IsTrue(controls >= MinGalleryControls, + $"the Gallery corpus collapsed to {controls} controls (floor {MinGalleryControls}). {observed}"); + Assert.IsTrue(xaml >= MinGalleryXaml, + $"only {xaml} Gallery scenarios still serve XAML (floor {MinGalleryXaml}). {observed}"); + Assert.IsTrue(csharp >= MinGalleryCSharp, + $"only {csharp} Gallery scenarios still serve C# (floor {MinGalleryCSharp}). {observed}"); + } + + [TestMethod] + public void GalleryCorpus_ServesNoScenarioWithoutCode() + { + // A scenario that survives sanitizing with neither XAML nor C# is worse than an + // absent one: search ranks it, the user asks for it by id, and find-ui answers with + // a header and no sample. The scraper shipped five of these because it created a + // scenario per documented sample whether or not it could recover the source; the + // index only describes samples it actually carries, so the expected count is zero. + var scenarios = ReadEmbeddedSnapshot("gallery")!.Scenarios; + ScenarioSanitizer.SanitizeAll(scenarios); + + var empty = scenarios.Where(s => !HasCode(s)).Select(s => s.Id).ToList(); + + Assert.AreEqual(0, empty.Count, + $"{empty.Count} Gallery scenarios are served with no code at all: {string.Join(", ", empty.Take(10))}. " + + "Each one is a fetchable id that returns nothing useful."); + } + + [TestMethod] + public void GalleryCorpus_ServesNoUnresolvedSubstitutionToken() + { + // Gallery's page XAML carries $(Name) substitution tokens that its own runtime + // resolves. They are not valid XAML or C#, so a sample containing one does not + // compile when pasted. SampleIndexParser suppresses those blocks; this asserts none + // reached the corpus by another route. + var scenarios = ReadEmbeddedSnapshot("gallery")!.Scenarios; + ScenarioSanitizer.SanitizeAll(scenarios); + + var leaked = scenarios + .Where(s => (s.Xaml?.Contains("$(", StringComparison.Ordinal) ?? false) + || (s.CSharp?.Contains("$(", StringComparison.Ordinal) ?? false)) + .Select(s => s.Id) + .ToList(); + + Assert.AreEqual(0, leaked.Count, + $"these Gallery samples are served with an unresolved $(...) token in them: {string.Join(", ", leaked.Take(10))}"); + } + + [TestMethod] + public void GalleryCorpus_ServesNoUnbackedEventHandler() + { + // The index carries Gallery's XAML verbatim, including events wired to handlers + // that live in page code-behind it does not publish. Serving such an attribute + // hands the user markup that fails to compile with "handler not found", so + // GalleryProvider strips the ones the emitted C# doesn't declare. This asserts the + // corpus was baked with that step applied — the count guards above all pass without + // it, because nothing about them notices a sample that no longer builds. + var scenarios = ReadEmbeddedSnapshot("gallery")!.Scenarios; + ScenarioSanitizer.SanitizeAll(scenarios); + + var unbacked = scenarios + .Where(s => !string.IsNullOrEmpty(s.Xaml) + && ControlSnippetText.StripUnbackedEventHandlers(s.Xaml!, s.CSharp) != s.Xaml) + .Select(s => s.Id) + .ToList(); + + Assert.AreEqual(0, unbacked.Count, + $"{unbacked.Count} Gallery samples wire a XAML event to a handler they don't define: " + + $"{string.Join(", ", unbacked.Take(10))}. Pasting one of these does not compile."); + } + + [TestMethod] + public void GalleryCorpus_ServesNoGalleryPrivateSymbol() + { + // Gallery's own page classes, its SamplePage navigation targets, its UIHelper + // accessibility helper and its CLR namespaces exist only inside the Gallery app. A + // sample that still names one compiles only if the user happens to have Gallery's + // source, so GalleryProvider rewrites them to obvious placeholders before baking. + // These match identifiers, not prose: a sample may legitimately mention a Gallery + // source file by name, and a "using:" or trailing-dot anchor keeps a source URL + // (".../main/WinUIGallery/ButtonPage.xaml.cs") out of the match. + var scenarios = ReadEmbeddedSnapshot("gallery")!.Scenarios; + ScenarioSanitizer.SanitizeAll(scenarios); + + string[] privateSymbols = + [ + @"\bSamplePage\d*", + @"\bUIHelper\.", + "x:Class=\"WinUIGallery", + @"namespace\s+WinUIGallery", + @"using:(?:WinUIGallery|AppUIBasics)", + @"\b(?:WinUIGallery|AppUIBasics)\.", + ]; + + // XmlnsImports is checked alongside the snippets because SearchEngine renders it as + // the "Setup:" line: a Gallery namespace there is an instruction to declare a prefix + // that cannot resolve, which the two snippet fields would never reveal. + var leaked = scenarios + .Where(s => privateSymbols.Any(sym => + Regex.IsMatch(s.Xaml ?? "", sym) + || Regex.IsMatch(s.CSharp ?? "", sym) + || s.XmlnsImports.Any(import => Regex.IsMatch(import, sym)))) + .Select(s => s.Id) + .ToList(); + + Assert.AreEqual(0, leaked.Count, + $"these Gallery samples still name a Gallery-internal symbol: {string.Join(", ", leaked.Take(10))}"); + } + + [TestMethod] + public void GalleryCorpus_ServesNoGalleryOnlyAsset() + { + // Gallery ships its sample photos and videos under Assets/SampleMedia and its own app + // icon and tile logos under Assets/Tiles, inside its package. A pasted sample naming + // one of those paths compiles and then renders nothing — an that is silently + // blank, an AppWindow.SetIcon that quietly does not take — so GalleryProvider rewrites + // them to a placeholder that fails visibly instead. Nothing else in this file notices: + // the counts pass and the XAML is well-formed either way. + var scenarios = ReadEmbeddedSnapshot("gallery")!.Scenarios; + ScenarioSanitizer.SanitizeAll(scenarios); + + var leaked = scenarios + .Where(s => Regex.IsMatch(s.Xaml ?? "", @"Assets/(?:SampleMedia|Tiles)/") + || Regex.IsMatch(s.CSharp ?? "", @"Assets/(?:SampleMedia|Tiles)/")) + .Select(s => s.Id) + .ToList(); + + Assert.AreEqual(0, leaked.Count, + $"these Gallery samples point at an asset that only exists in Gallery's own package: " + + $"{string.Join(", ", leaked.Take(10))}"); + } + // ------------------------------------------------------------------ // Load order: the snapshot is a floor, never a ceiling // ------------------------------------------------------------------ diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FindUiSearchTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/FindUiSearchTests.cs index e2de81b70..8b0516c6f 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FindUiSearchTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FindUiSearchTests.cs @@ -358,62 +358,4 @@ public void Preprocess_ImageGridPhrase_RoutesToCollectionControls() } } - [TestMethod] - public void CleanGalleryContent_AttributePositionSubstitution_StaysWellFormed() - { - // Upstream writes an optional attribute as a bare $(Name) token inside the start tag - // (Button, ToggleButton, ProgressRing, CommandBar and six others do this). Flattening - // it to "..." like a value-position token produces `Click="Button_Click" .../>`, which - // the sanitizer rejects as malformed — so the control served a fetchable result with - // no code in it at all. - const string upstream = """"; - - var cleaned = GalleryFetcher.CleanGalleryContent(xaml); - - Assert.IsFalse(cleaned.Contains("Width=\"300\""), "own-line demo width must still be dropped"); - Assert.IsFalse(cleaned.Contains("Margin=\"-8\""), "own-line negative margin must still be dropped"); - StringAssert.Contains(cleaned, "Content=\"Go\""); - } - - [TestMethod] - public void CleanGalleryContent_ValuePositionToken_DropsTheAttributeRatherThanFlatteningIt() - { - // "..." is not a double, so StrokeThickness="..." is a compile error on paste. Dropping - // the attribute leaves the property at its own default, which always compiles. - var cleaned = GalleryFetcher.CleanGalleryContent( - ""); - - Assert.IsFalse(cleaned.Contains("..."), $"token must not be flattened: {cleaned}"); - Assert.IsFalse(cleaned.Contains("StrokeThickness"), $"attribute must be dropped: {cleaned}"); - StringAssert.Contains(cleaned, "X1=\"0\""); - StringAssert.Contains(cleaned, "Stroke=\"Red\""); - } - - [TestMethod] - public void CleanGalleryContent_ValuePositionToken_LeavesAWellFormedTag() - { - string[] fragments = - [ - "", - "", - "", - "", - ]; - - foreach (var fragment in fragments) - { - var cleaned = GalleryFetcher.CleanGalleryContent(fragment); - - Assert.IsFalse(cleaned.Contains("$("), $"token survived: {cleaned}"); - Assert.IsFalse(cleaned.Contains("..."), $"token was flattened: {cleaned}"); - Assert.IsTrue(ScenarioSanitizer.XamlIsWellFormed(cleaned), $"tag was corrupted: {cleaned}"); - } - } - - [TestMethod] - public void CleanGalleryContent_KnownValueRewrites_StillWinOverAttributeDropping() - { - // These must be applied before the position-aware pass: an InfoBar that loses IsOpen - // defaults to collapsed, so the sample would render as nothing. - var cleaned = GalleryFetcher.CleanGalleryContent( - ""); - - StringAssert.Contains(cleaned, "IsOpen=\"True\""); - StringAssert.Contains(cleaned, "Severity=\"Informational\""); - } -} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/GalleryProviderNormalizeTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/GalleryProviderNormalizeTests.cs new file mode 100644 index 000000000..e6603bf19 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/GalleryProviderNormalizeTests.cs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Services.Controls; + +namespace WinApp.Cli.Tests; + +/// +/// Hermetic tests for — the pass that makes an +/// index sample pasteable by rewriting the things that resolve only inside the Gallery app. +/// +/// These exist alongside the corpus guards in EmbeddedSnapshotTests rather than instead +/// of them. A corpus guard proves the committed data is clean, but it reads whatever upstream +/// currently publishes: the day Gallery stops shipping a SampleMedia path, the guard +/// passes whether or not the rewrite still works. These pin the rule itself. +/// +[TestClass] +public class GalleryProviderNormalizeTests +{ + private static Scenario NewScenario(string? xaml = null, string? csharp = null, string[]? xmlns = null) => new() + { + Id = "gallery-test-1", + ControlId = "test", + ControlName = "Test", + HeaderText = "Test", + Source = "gallery", + Xaml = xaml, + CSharp = csharp, + XmlnsImports = xmlns ?? [], + }; + + // --- Gallery CLR namespaces --------------------------------------------------- + + [TestMethod] + public void XmlnsImport_PointingAtGalleryNamespace_BecomesYourApp() + { + // SearchEngine renders XmlnsImports as the "Setup:" line, so this is printed to the + // user as an instruction. "using:WinUIGallery.ControlPages" instructs them to declare + // a prefix for a namespace that does not exist in their app. + var s = NewScenario(xmlns: ["xmlns:local=\"using:WinUIGallery.ControlPages\""]); + + GalleryProvider.NormalizeForPaste(s); + + Assert.AreEqual("xmlns:local=\"using:YourApp\"", s.XmlnsImports[0]); + } + + [TestMethod] + public void XmlnsImport_KeepsPrefixName() + { + // Only the mapped namespace is a Gallery detail. The prefix is referenced by the + // markup we serve, so renaming it would break the snippet it belongs to. + var s = NewScenario( + xaml: "", + xmlns: ["xmlns:samplepages=\"using:WinUIGallery.SamplePages\""]); + + GalleryProvider.NormalizeForPaste(s); + + Assert.AreEqual("xmlns:samplepages=\"using:YourApp\"", s.XmlnsImports[0]); + StringAssert.Contains(s.Xaml!, ""); + } + + [TestMethod] + public void InlineXmlnsInXaml_PointingAtGalleryNamespace_BecomesYourApp() + { + var s = NewScenario(xaml: ""); + + GalleryProvider.NormalizeForPaste(s); + + StringAssert.Contains(s.Xaml!, "xmlns:cond=\"using:YourApp\""); + } + + [TestMethod] + public void QualifiedGalleryType_InCSharp_LosesTheGalleryRoot() + { + var s = NewScenario(csharp: "var x = WinUIGallery.ControlPages.CustomDataObject.GetDataObjects();"); + + GalleryProvider.NormalizeForPaste(s); + + StringAssert.Contains(s.CSharp!, "YourApp.ControlPages.CustomDataObject.GetDataObjects()"); + } + + [TestMethod] + public void SourceLinkNamingGalleryFolder_IsLeftAlone() + { + // The rewrite is anchored on the dot that starts a CLR qualifier, so a comment + // pointing at the sample's real source keeps working as a link. + const string link = "// https://github.com/microsoft/WinUI-Gallery/blob/main/WinUIGallery/ButtonPage.xaml.cs"; + var s = NewScenario(csharp: link); + + GalleryProvider.NormalizeForPaste(s); + + Assert.AreEqual(link, s.CSharp); + } + + // --- Gallery package assets --------------------------------------------------- + + [TestMethod] + [DataRow("", "ms-appx:///Assets/YourImage.jpg")] + [DataRow("", "/Assets/YourImage.jpg")] + [DataRow("", "/Assets/YourImage.ico")] + public void GalleryAssetPath_InXaml_BecomesPlaceholder_PreservingUriForm(string xaml, string expected) + { + // The three forms are not interchangeable, so the prefix is preserved rather than + // normalized to one of them. + var s = NewScenario(xaml: xaml); + + GalleryProvider.NormalizeForPaste(s); + + StringAssert.Contains(s.Xaml!, expected); + } + + [TestMethod] + public void PackageRelativeAssetPath_InCSharp_StaysPackageRelative() + { + // AppWindow.SetIcon takes a package-relative path, not a ms-appx URI: rewriting the + // form rather than just the file would turn a missing icon into a wrong API call. + var s = NewScenario(csharp: "AppWindow.SetIcon(\"Assets/Tiles/GalleryIcon.ico\");"); + + GalleryProvider.NormalizeForPaste(s); + + Assert.AreEqual("AppWindow.SetIcon(\"Assets/YourImage.ico\");", s.CSharp); + } + + [TestMethod] + public void NonImageAsset_KeepsItsExtension_AndReadsAsAnAsset() + { + // A MediaPlayerElement pointed at a .png is a different kind of broken than one + // pointed at a video the user has yet to add. + var s = NewScenario(xaml: ""); + + GalleryProvider.NormalizeForPaste(s); + + StringAssert.Contains(s.Xaml!, "/Assets/YourAsset.wmv"); + } + + [TestMethod] + public void UnrelatedAssetPath_IsLeftAlone() + { + // Only Gallery's own two package folders are rewritten. A sample naming an asset the + // user is expected to supply is describing their app, not Gallery's. + const string xaml = ""; + var s = NewScenario(xaml: xaml); + + GalleryProvider.NormalizeForPaste(s); + + Assert.AreEqual(xaml, s.Xaml); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/Controls/CacheVersion.cs b/src/winapp-CLI/WinApp.Cli/Services/Controls/CacheVersion.cs index 87983e6e7..6bede502d 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/Controls/CacheVersion.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/Controls/CacheVersion.cs @@ -92,6 +92,23 @@ namespace WinApp.Cli.Services.Controls; /// element" fallback for oversized XAML is gone with it, so those samples now /// carry their full surrounding markup. Rule 3: same input, different output — /// without the bump an existing cache keeps serving the truncated snippets. +/// "24" — Gallery is now read from the sample index WinUI-Gallery publishes +/// (catalog/windows-samples.json) instead of being scraped, and GalleryFetcher +/// is deleted. Upstream decides what a sample is, so the corpus is theirs +/// rather than our reconstruction of it: samples they withhold as unpasteable +/// are absent, and samples our extraction missed appear. Rule 3 — same source +/// repository, different output. Scenario ids are positional over kept samples +/// (as they have been since "16"), so a sample upstream drops renumbers the +/// ones after it on that control's page; an unbumped cache would keep serving +/// the old numbering against the new data. The index publishes Gallery's source +/// verbatim, so GalleryProvider normalizes each sample for pasting the way the +/// scraper used to — Gallery-private symbols rewritten, event handlers with no +/// implementation dropped — which is itself Rule 3. That pass also reaches two +/// places the scraper never did: the index's xmlnsImports field (rendered as the +/// "Setup:" line, so a "using:WinUIGallery.ControlPages" mapping was an +/// instruction to declare a prefix that cannot resolve) and Gallery's own package +/// assets under Assets/SampleMedia and Assets/Tiles, which rendered as a silently +/// blank Image or an AppWindow.SetIcon that quietly did nothing. /// /// Note: adding the embedded snapshot floor did NOT bump this. The cached payload's /// schema and extraction logic are unchanged, and a bump would have forced every @@ -99,5 +116,5 @@ namespace WinApp.Cli.Services.Controls; /// internal static class CacheVersion { - public const string Current = "23"; + public const string Current = "24"; } diff --git a/src/winapp-CLI/WinApp.Cli/Services/Controls/ControlSnippetText.cs b/src/winapp-CLI/WinApp.Cli/Services/Controls/ControlSnippetText.cs index e9f14a957..94a661730 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/Controls/ControlSnippetText.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/Controls/ControlSnippetText.cs @@ -7,8 +7,8 @@ namespace WinApp.Cli.Services.Controls; using System.Text.RegularExpressions; /// -/// Shared snippet-repair helpers used by every scenario fetcher -/// (, , …). These used to +/// Shared snippet-repair helpers used by every scenario source +/// (, , …). These used to /// be copy-pasted per fetcher and had already drifted (only one copy stripped XML /// comments), so they live here as the single source of truth. Snippets are emitted /// whole — nothing here shortens a sample. The final safety net for malformed output diff --git a/src/winapp-CLI/WinApp.Cli/Services/Controls/Data/snapshot-gallery.json.br b/src/winapp-CLI/WinApp.Cli/Services/Controls/Data/snapshot-gallery.json.br index 3288d8597..a4804db9b 100644 Binary files a/src/winapp-CLI/WinApp.Cli/Services/Controls/Data/snapshot-gallery.json.br and b/src/winapp-CLI/WinApp.Cli/Services/Controls/Data/snapshot-gallery.json.br differ diff --git a/src/winapp-CLI/WinApp.Cli/Services/Controls/Data/snapshot-manifest.json b/src/winapp-CLI/WinApp.Cli/Services/Controls/Data/snapshot-manifest.json index c942f034c..0a9606815 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/Controls/Data/snapshot-manifest.json +++ b/src/winapp-CLI/WinApp.Cli/Services/Controls/Data/snapshot-manifest.json @@ -1,8 +1,8 @@ { - "bakedAtUtc": "2026-09-21T19:21:08.8156991Z", - "cacheVersion": "23", + "bakedAtUtc": "2026-09-22T14:50:14.5287149Z", + "cacheVersion": "24", "scenarioCounts": { - "gallery": 330, + "gallery": 327, "reactor": 95, "toolkit": 48 } diff --git a/src/winapp-CLI/WinApp.Cli/Services/Controls/GalleryFetcher.cs b/src/winapp-CLI/WinApp.Cli/Services/Controls/GalleryFetcher.cs deleted file mode 100644 index 5f331223f..000000000 --- a/src/winapp-CLI/WinApp.Cli/Services/Controls/GalleryFetcher.cs +++ /dev/null @@ -1,727 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace WinApp.Cli.Services.Controls; - -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Text.RegularExpressions; -using System.Xml.Linq; - -internal static partial class GalleryFetcher -{ - private const string ControlInfoUrl = - "https://raw.githubusercontent.com/microsoft/WinUI-Gallery/main/WinUIGallery/SampleSupport/Data/ControlInfoData.json"; - // Per-control sample pages AND their co-located SampleDefinition .txt bundles both live - // under Samples/{UniqueId}/. Upstream retired Samples/ControlPages/ and Samples/SampleCode/. - private const string SamplesBase = - "https://raw.githubusercontent.com/microsoft/WinUI-Gallery/main/WinUIGallery/Samples/"; - - private static readonly HttpClient Http = new() - { - DefaultRequestHeaders = { { "User-Agent", "winui3-gallery-cli/1.0" } }, - Timeout = TimeSpan.FromSeconds(30) - }; - - [GeneratedRegex(@"]*?HeaderText=""([^""]+)""", RegexOptions.IgnoreCase)] - private static partial Regex ControlExampleHeaderRegex(); - - /// First XML comment in a XAML snippet — used as a fallback header when - /// the upstream ControlExample omits HeaderText (common for Accessibility samples). - [GeneratedRegex(@"")] - private static partial Regex FirstXmlCommentRegex(); - - /// New-format SampleDefinition attribute pointing at the co-located .txt bundle - /// (e.g. SampleDefinition="Button\ButtonSimple.txt", relative to Samples/). - [GeneratedRegex(@"SampleDefinition=""([^""]+)""", RegexOptions.IgnoreCase)] - private static partial Regex SampleDefinitionRegex(); - - /// A section-marker line inside a SampleDefinition .txt bundle: - /// --- header, --- xaml, or --- c# (case-insensitive, CRLF-tolerant). - [GeneratedRegex(@"^\s*---\s*(header|xaml|c#)\s*$", RegexOptions.IgnoreCase)] - private static partial Regex SampleSectionRegex(); - - [GeneratedRegex(@"ms-appx:///Assets/SampleMedia/[^""'\s]+")] - private static partial Regex SampleMediaRegex(); - - [GeneratedRegex(@"x:Class=""WinUIGallery\.[^""]+""")] - private static partial Regex GalleryClassRegex(); - - [GeneratedRegex(@"typeof\(SamplePage\d+\)")] - private static partial Regex SamplePageTypeRegex(); - - [GeneratedRegex(@"SamplePage\d+")] - private static partial Regex SamplePageNameRegex(); - - /// Fetch fresh scenarios + tags from GitHub. - /// - /// Which samples exist, and what each one demonstrates, is upstream's to decide. winapp - /// adds no scenarios of its own to this corpus and applies no per-sample rewrites of - /// upstream's implementation, because everything here is presented to users as Gallery's - /// (Scenario.Source becomes the [gallery] tag and the gallery- id - /// prefix in find-ui output). Content winapp considers missing or misleading - /// belongs upstream in WinUI-Gallery, or in as attributed guidance — - /// not silently merged into their data under their name. See #703. - /// - /// This is not a claim that snippets are byte-identical to upstream's files. Extraction - /// is uniform and mechanical, and deliberately lossy: content is cleaned - /// (), C# is compressed, and event handlers with no - /// emitted code-behind are stripped so the snippet compiles on paste. Samples are never - /// shortened. Those transforms apply to every sample by the same rule; what is gone is - /// the bespoke, sample-specific editorializing. - /// - internal static async Task<(Scenario[] scenarios, Dictionary tags)> FetchAsync(CancellationToken cancellationToken = default) - => await FetchFromGitHub(cancellationToken); - - private static async Task<(Scenario[], Dictionary)> FetchFromGitHub(CancellationToken cancellationToken) - { - // Step 1: Fetch ControlInfoData.json — list of controls + Subtitle/Description/RelatedControls/Docs - var infoJson = await ControlsHttpHelper.GetStringCappedAsync(Http, ControlInfoUrl, cancellationToken); - using var doc = JsonDocument.Parse(infoJson); - var groups = doc.RootElement.GetProperty("Groups"); - - var controlPages = new List<(string uniqueId, string title)>(); - var subtitles = new Dictionary(); // controlId → "Title Subtitle Description" (tag-source text) - var controlSubtitles = new Dictionary(); // controlId → Subtitle alone (display-friendly one-liner) - var apiNamespaces = new Dictionary(); // controlId → "Microsoft.Windows.Notifications" etc. - var relatedControls = new Dictionary(); // controlId → ["Pivot","NavigationView",...] - var docs = new Dictionary(); // controlId → [{Title,Uri},...] - - foreach (var group in groups.EnumerateArray()) - { - // Upstream retired the per-group `Folder` subpath (IsSpecialSection items used to - // live under Samples/{Folder}/). Every control page is now uniformly at - // Samples/{UniqueId}/{UniqueId}Page.xaml, so we no longer read Folder. - if (!group.TryGetProperty("Items", out var items)) continue; - foreach (var item in items.EnumerateArray()) - { - var uniqueId = item.GetProperty("UniqueId").GetString() ?? ""; - var title = item.GetProperty("Title").GetString() ?? ""; - controlPages.Add((uniqueId, title)); - - var cid = uniqueId.ToLowerInvariant(); - string subtitle = ""; - if (item.TryGetProperty("Subtitle", out var sub)) subtitle = sub.GetString() ?? ""; - string description = ""; - if (item.TryGetProperty("Description", out var desc)) description = desc.GetString() ?? ""; - subtitles[cid] = $"{title} {subtitle} {description}".Trim(); - if (!string.IsNullOrWhiteSpace(subtitle)) controlSubtitles[cid] = subtitle; - - if (item.TryGetProperty("ApiNamespace", out var apiNs)) - { - var ns = apiNs.GetString(); - if (!string.IsNullOrWhiteSpace(ns)) apiNamespaces[cid] = ns!; - } - - if (item.TryGetProperty("RelatedControls", out var rel) && rel.ValueKind == JsonValueKind.Array) - { - var list = new List(); - foreach (var r in rel.EnumerateArray()) - { - var v = r.GetString(); - if (!string.IsNullOrEmpty(v)) list.Add(v!); - } - if (list.Count > 0) relatedControls[cid] = list.ToArray(); - } - - if (item.TryGetProperty("Docs", out var dList) && dList.ValueKind == JsonValueKind.Array) - { - var list = new List(); - foreach (var d in dList.EnumerateArray()) - { - var t = d.TryGetProperty("Title", out var tProp) ? (tProp.GetString() ?? "") : ""; - var u = d.TryGetProperty("Uri", out var uProp) ? (uProp.GetString() ?? "") : ""; - if (!string.IsNullOrEmpty(u)) list.Add(new DocLink { Title = t, Uri = u }); - } - if (list.Count > 0) docs[cid] = list.ToArray(); - } - } - } - - // Step 2: Fetch each control page and parse ControlExample blocks - var allScenarios = new List(); - var fetchTasks = new List>>(); - using var semaphore = new SemaphoreSlim(10); - foreach (var (uniqueId, title) in controlPages) - { - fetchTasks.Add(FetchControlPageAsync( - uniqueId, title, controlSubtitles.GetValueOrDefault(uniqueId.ToLowerInvariant()), semaphore, cancellationToken)); - } - var results = await Task.WhenAll(fetchTasks); - foreach (var batch in results) - allScenarios.AddRange(batch); - - // Stamp ControlInfoData metadata onto every scenario of that control. - // ControlDescription comes from Subtitle (median 68 chars / max 129) — the longer - // Description (median 144 / max 448) was retired during search-output compression. - // Subtitle is small enough to surface in search list without bloating tokens. - foreach (var s in allScenarios) - { - if (controlSubtitles.TryGetValue(s.ControlId, out var sub)) s.ControlDescription = sub; - if (apiNamespaces.TryGetValue(s.ControlId, out var ns)) s.ApiNamespace = ns; - if (relatedControls.TryGetValue(s.ControlId, out var r)) s.RelatedControls = r; - if (docs.TryGetValue(s.ControlId, out var dl)) s.Docs = dl; - } - - // Step 3: Build tags. For each control, prefer hand-curated embedded tags; - // otherwise auto-derive from Title + Subtitle. Always strip stop words. - var embeddedTags = DataLoader.LoadGalleryTags(); - var allTags = new Dictionary(); - foreach (var (controlId, text) in subtitles) - { - if (embeddedTags.TryGetValue(controlId, out var manual)) - { - allTags[controlId] = FilterStopWords(manual); - } - else - { - allTags[controlId] = ExtractTagsFromText(controlId, text); - } - } - // Also include any embedded tags for controls not in ControlInfoData (jumplist-* etc.) - foreach (var (k, v) in embeddedTags) - { - if (!allTags.ContainsKey(k)) allTags[k] = FilterStopWords(v); - } - - return (allScenarios.ToArray(), allTags); - } - - private static string[] FilterStopWords(string[] tags) - { - return StopWords.FilterTagList(tags); - } - - private static string[] ExtractTagsFromText(string controlId, string text) - { - var tags = new List { controlId }; - var seen = new HashSet { controlId }; - // Split CamelCase/PascalCase into separate words too - text = Regex.Replace(text, @"(?<=[a-z])(?=[A-Z])", " "); - foreach (Match m in Regex.Matches(text.ToLowerInvariant(), @"[a-z]{3,}")) - { - var w = m.Value; - if (StopWords.IsTagNoise(w)) continue; - if (seen.Add(w)) tags.Add(w); - if (tags.Count >= 12) break; - } - return tags.ToArray(); - } - - private static Dictionary CleanTags(Dictionary tags) - { - var result = new Dictionary(tags.Count); - foreach (var (k, v) in tags) result[k] = FilterStopWords(v); - return result; - } - - private static async Task> FetchControlPageAsync( - string uniqueId, string title, string? controlSubtitle, SemaphoreSlim semaphore, CancellationToken cancellationToken) - { - var scenarios = new List(); - await semaphore.WaitAsync(cancellationToken); - try - { - // Pages are uniform: Samples/{UniqueId}/{UniqueId}Page.xaml. - var url = $"{SamplesBase}{uniqueId}/{uniqueId}Page.xaml"; - - var xamlContent = await ControlsHttpHelper.TryGetStringCappedAsync(Http, url, cancellationToken); - if (xamlContent is null) return scenarios; - - var controlId = uniqueId.ToLowerInvariant(); - int scenarioIndex = 0; - - foreach (var (rawHeader, sampleDef, block) in ExtractControlExampleBlocks(xamlContent)) - { - string headerText; - string? xaml; - string? csharp; - - if (!string.IsNullOrEmpty(sampleDef)) - { - // New format: header + xaml + c# all live in the co-located .txt bundle. - (headerText, xaml, csharp) = await FetchSampleDefinition(sampleDef, cancellationToken); - } - else - { - // Legacy inline format — still used by a handful of Accessibility pages - // (AccessibilityKeyboard/ScreenReader): blocks - // with no SampleDefinition/HeaderText. Header falls back to the sample's own - // first XML comment — never the page-level copyright banner, which lives - // outside every ControlExample block and so is never inside `block`/`xaml`. - xaml = ExtractInlineCode(block, "Xaml"); - csharp = ExtractInlineCode(block, "CSharp"); - headerText = rawHeader; - if (string.IsNullOrEmpty(headerText) && xaml != null) - headerText = DeriveHeaderFromComment(xaml); - // A few legacy inline a11y samples have no leading comment either, which - // would leave HeaderText empty and render as "{ControlName}: " (trailing - // colon). Fall back to the control's ControlInfoData Subtitle one-liner. - // New-format samples never reach here — they always carry a --- header. - if (string.IsNullOrEmpty(headerText) && !string.IsNullOrWhiteSpace(controlSubtitle)) - headerText = controlSubtitle!.Trim(); - } - - if (xaml != null) xaml = ControlSnippetText.CloseUnbalancedTags(xaml); - - // Gallery keeps each sample's XAML in its .txt bundle but its event - // handlers in the shared page code-behind we don't fetch (see #703/#704). - // Strip any handler the emitted C# doesn't define so the snippet compiles - // as pasted; handlers that ARE backed (e.g. TabView's) are kept. - if (xaml != null) xaml = ControlSnippetText.StripUnbackedEventHandlers(xaml, csharp); - - if (string.IsNullOrWhiteSpace(xaml)) xaml = null; - if (string.IsNullOrWhiteSpace(csharp)) csharp = null; - if (csharp == null && xaml == null) continue; - - scenarioIndex++; - // Scenario IDs use a simple {controlId}-{N} format (1-indexed). Stable - // within a single fetched cache; rebuilt fresh on each cache refresh. - var scenarioId = $"{controlId}-{scenarioIndex}"; - - scenarios.Add(new Scenario - { - Id = scenarioId, - ControlId = controlId, - ControlName = title, - HeaderText = headerText, - Xaml = xaml, - CSharp = csharp, - Source = "gallery", - }); - } - } - catch (OperationCanceledException) { throw; } - catch { /* skip this control */ } - finally { semaphore.Release(); } - - return scenarios; - } - - /// - /// Fetch and parse a new-format SampleDefinition .txt bundle. Splits it into the - /// "--- header" / "--- xaml" / "--- c#" sections and returns cleaned xaml/c#. - /// A XAML $(...) token is resolved by position in - /// (value-position attribute dropped so the property falls back to its own default, - /// content-position commented), never flattened to "...". A c# section containing $(...) - /// live-substitution tokens is dropped, because flattening them yields non-compileable code - /// (e.g. `new Vector3(..., ..., ...)`) — the same "no misleading C#" rule the inline - /// extractor already applied. - /// - private static async Task<(string header, string? xaml, string? csharp)> FetchSampleDefinition(string sampleDef, CancellationToken cancellationToken) - { - var url = SamplesBase + sampleDef.Replace('\\', '/'); - var content = await ControlsHttpHelper.TryGetStringCappedAsync(Http, url, cancellationToken); - if (content is null) return ("", null, null); - - var (header, rawXaml, rawCsharp) = SplitSampleSections(content); - - string? xaml = null; - if (!string.IsNullOrWhiteSpace(rawXaml)) - { - xaml = CleanGalleryContent(rawXaml.Trim()); - if (string.IsNullOrWhiteSpace(xaml)) xaml = null; - } - - string? csharp = null; - if (!string.IsNullOrWhiteSpace(rawCsharp) && !SampleSubstitutionPlaceholder.Contains(rawCsharp)) - { - csharp = CompressCSharp(CleanGalleryContent(rawCsharp.Trim())); - if (string.IsNullOrWhiteSpace(csharp)) csharp = null; - } - - return (header, xaml, csharp); - } - - /// Split a SampleDefinition .txt bundle into its header/xaml/c# sections on the - /// "--- name" marker lines. Any content before the first marker is ignored. - private static (string header, string? xaml, string? csharp) SplitSampleSections(string content) - { - string header = ""; - string? xaml = null, csharp = null; - string? current = null; - var sb = new System.Text.StringBuilder(); - - void Flush() - { - if (current == null) return; - var text = sb.ToString().Trim('\r', '\n'); - switch (current) - { - case "header": header = text.Trim(); break; - case "xaml": xaml = text; break; - case "c#": csharp = text; break; - } - sb.Clear(); - } - - foreach (var rawLine in content.Split('\n')) - { - var line = rawLine.TrimEnd('\r'); - var m = SampleSectionRegex().Match(line); - if (m.Success) - { - Flush(); - current = m.Groups[1].Value.ToLowerInvariant(); - continue; - } - if (current != null) sb.Append(rawLine).Append('\n'); - } - Flush(); - return (header, xaml, csharp); - } - - /// - /// Find all top-level <controls:ControlExample> blocks via stack-aware tag matching, - /// handling nested ScrollViewer/StackPanel inside ControlExample.Example. - /// - private static IEnumerable<(string headerText, string sampleDefinition, string block)> ExtractControlExampleBlocks(string xaml) - { - const string OpenTag = "' - int openTagEnd = xaml.IndexOf('>', afterPrefix); - if (openTagEnd < 0) yield break; - - // Self-closing? Skip. - if (xaml[openTagEnd - 1] == '/') - { - searchStart = openTagEnd + 1; - continue; - } - - // Extract header text (legacy) + SampleDefinition (new format) from opening-tag attributes - string openingTag = xaml.Substring(openIdx, openTagEnd - openIdx + 1); - var headerMatch = ControlExampleHeaderRegex().Match(openingTag); - string headerText = headerMatch.Success ? headerMatch.Groups[1].Value : ""; - var defMatch = SampleDefinitionRegex().Match(openingTag); - string sampleDefinition = defMatch.Success ? defMatch.Groups[1].Value : ""; - - // Walk forward, balancing tags - int depth = 1; - int pos = openTagEnd + 1; - int blockEnd = -1; - while (pos < xaml.Length) - { - int nextOpen = xaml.IndexOf(OpenTag, pos, StringComparison.OrdinalIgnoreCase); - int nextClose = xaml.IndexOf(CloseTag, pos, StringComparison.OrdinalIgnoreCase); - if (nextClose < 0) break; - if (nextOpen >= 0 && nextOpen < nextClose) - { - int after = nextOpen + OpenTag.Length; - // Skip ControlExample.Xaml/.CSharp/.Example sub-properties (they don't open a new block) - if (after < xaml.Length && xaml[after] != '.' && !char.IsLetterOrDigit(xaml[after])) - { - int gt = xaml.IndexOf('>', after); - if (gt > 0 && xaml[gt - 1] != '/') depth++; - pos = gt > 0 ? gt + 1 : nextOpen + OpenTag.Length; - continue; - } - pos = after; - } - else - { - depth--; - if (depth == 0) - { - blockEnd = nextClose + CloseTag.Length; - break; - } - pos = nextClose + CloseTag.Length; - } - } - - if (blockEnd < 0) yield break; - yield return (headerText, sampleDefinition, xaml.Substring(openIdx, blockEnd - openIdx)); - searchStart = blockEnd; - } - } - - /// Pull a usable header from the first XML comment in a sample's XAML. - /// Collapses whitespace, strips trailing punctuation, truncates at sentence boundary. - /// Returns "" when no comment is found or the comment is too short to be a real header. - private static string DeriveHeaderFromComment(string xaml) - { - var m = FirstXmlCommentRegex().Match(xaml); - if (!m.Success) return ""; - - var raw = m.Groups[1].Value; - // Collapse all whitespace runs (newlines, tabs, multi-space) to single space. - var collapsed = Regex.Replace(raw, @"\s+", " ").Trim(); - if (collapsed.Length < 4) return ""; - - // Strip stray decoration sometimes used as section dividers (****, ====, ----) - collapsed = collapsed.Trim('*', '=', '-', ' '); - if (collapsed.Length < 4) return ""; - - // Prefer the first sentence so very long explanatory comments stay short. - const int MaxHeaderLen = 120; - int firstStop = collapsed.IndexOfAny(new[] { '.', '!', '?', '\n' }); - if (firstStop > 0 && firstStop < MaxHeaderLen) collapsed = collapsed.Substring(0, firstStop); - if (collapsed.Length > MaxHeaderLen) collapsed = collapsed.Substring(0, MaxHeaderLen).TrimEnd() + "…"; - - return collapsed.Trim(); - } - - private static string? ExtractInlineCode(string block, string tagName) - { - var pattern = $@"\s*]*>([\s\S]*?)\s*"; - var match = Regex.Match(block, pattern, RegexOptions.IgnoreCase); - if (!match.Success) return null; - - var code = UnescapeXml(match.Groups[1].Value).Trim(); - if (tagName == "CSharp" && SampleSubstitutionPlaceholder.Contains(code)) - { - // C# inline templates with $(VarName) substitutions are bound to live UI - // controls. Replacing with "..." produces literals like `Title = "..."` - // and `Resize(new SizeInt32(..., ...))` that mislead agents into compiling - // them. The code-behind extractor is the right path for C#; if it failed - // (no .xaml.cs or no event/x:Bind/x:Name seeds), surface no csharp at all. - return null; - } - // A XAML token is deliberately NOT flattened here: CleanGalleryContent resolves each - // one by the position it occupies (value-position attribute dropped, content-position - // commented), which keeps the markup pasteable. Flattening to "..." first would - // pre-empt that and bake in a non-compiling `Prop="..."`. - code = CleanGalleryContent(code); - return string.IsNullOrWhiteSpace(code) ? null : code; - } - - /// Strip Gallery-internal namespaces/types and demo-only layout attributes so the - /// snippet pastes into a user's app. Internal for direct testing, mirroring - /// . - internal static string CleanGalleryContent(string code) - { - code = SampleMediaRegex().Replace(code, "ms-appx:///Assets/YourImage.png"); - code = GalleryClassRegex().Replace(code, @"x:Class=""YourApp.YourPage"""); - code = SamplePageTypeRegex().Replace(code, "typeof(YourPage)"); - code = SamplePageNameRegex().Replace(code, "YourPage"); - code = Regex.Replace(code, @"using WinUIGallery[^;\n]*;?", "// adapt namespace to your app"); - code = Regex.Replace(code, @"using AppUIBasics[^;\n]*;?", "// adapt namespace to your app"); - code = Regex.Replace(code, @"namespace WinUIGallery[^{;\n]*;?", "namespace YourApp;"); - code = Regex.Replace(code, @"namespace AppUIBasics[^{;\n]*;?", "namespace YourApp;"); - code = Regex.Replace(code, @".*NavigationHelper.*\n?", ""); - - // Demo `Loaded="X_Loaded"` handlers must go before the line filter below: unlike - // the fixed sizes and negative margins, this one usually sits INLINE inside a tag - // (""), where the filter's - // markup guard would keep it. The Gallery's code-behind extractor never surfaces - // the *_Loaded method, so leaving the attribute emits XAML referencing a handler - // the snippet doesn't define — which fails to compile when pasted. Consuming the - // leading whitespace also removes the line break when it does sit on its own line. - code = Regex.Replace(code, @"\s+Loaded=""[^""]*_Loaded""", ""); - - // Clean demo-specific layout attributes (fixed sizes, negative margins). - // Only drop an attribute that sits on its OWN continuation line (no '<' or '>'), so we - // never delete a line that also carries the tag's closing '>' or other markup — which - // would corrupt multi-line open tags in the new SampleDefinition format - // (e.g. `Width="300" Margin="12" Height="68">`). - var lines = code.Split('\n').Where(line => - { - var trimmed = line.Trim(); - if (trimmed.IndexOf('<') >= 0 || trimmed.IndexOf('>') >= 0) return true; - if (Regex.IsMatch(trimmed, @"^(Min|Max)?(Height|Width)=""\d")) return false; - if (Regex.IsMatch(trimmed, @"^Margin=""-")) return false; - if (Regex.IsMatch(trimmed, @"^SelectedIndex=""\d+""")) return false; - return true; - }); - code = string.Join('\n', lines); - - // Clean substitution placeholders. The two known-value rewrites run first: they give an - // attribute its intended value, and the position-aware pass below would otherwise drop the - // attribute outright — an InfoBar that loses IsOpen defaults to collapsed and the sample - // renders as nothing. NormalizeMarkupSubstitutions then handles every remaining token by - // position; see its summary. The final flattening is a safety net for a token in some - // position the pass does not model, and should normally find nothing left to do. - code = Regex.Replace(code, @"IsOpen=""(\$\(IsOpen\)|\.\.\.?)""", @"IsOpen=""True"""); - code = Regex.Replace(code, @"Severity=""(\$\(Severity\)|\.\.\.?)""", @"Severity=""Informational"""); - code = NormalizeMarkupSubstitutions(code); - code = SampleSubstitutionPlaceholder.ReplaceAll(code, "..."); - - code = Regex.Replace(code, @"\n\s*\n\s*\n", "\n\n"); - return code.Trim(); - } - - /// - /// Normalizes the $(Name) substitution tokens the Gallery expands at runtime, by the - /// position they occupy. Both cases below break if they are flattened to "..." the way - /// a value-position token is: - /// - /// Attribute position — a bare token in an element's attribute list, - /// e.g. <Button Content="Go" Click="Button_Click" $(IsEnabled)/>. It stands in for - /// a whole attribute, so it is removed. Flattening yields Click="Button_Click" .../>, - /// which is not well-formed, so discards the - /// snippet and the control serves a fetchable result with no code in it at all. That hit - /// Button, ToggleButton, RepeatButton, HyperlinkButton, ProgressRing, CommandBar, AnimatedIcon, - /// PersonPicture and EasingFunction. - /// Element content — a token between elements, e.g. - /// </AppBarButton>$(MultipleButtonsSecondaryCommands). Every one upstream ships - /// injects markup (extra items, a Layout, a DataTemplate) into a property element, never text, - /// so it becomes a comment. Flattening stays well-formed but assigns the literal string "..." - /// as a collection's content, which does not compile when pasted. - /// - /// A token inside an attribute value (Value="$(DeterminateProgressValue)") removes the - /// whole attribute, so the property falls back to its own default. Flattening that one to - /// Value="..." keeps the markup well-formed but does not compile: most of the - /// properties these tokens sit on are typed, and "..." is not a double, an enum member - /// or a bool. A bake of the Gallery corpus carried 156 such attributes — StrokeThickness, - /// Height, Width, Orientation, SelectionMode, PaneDisplayMode and friends — every one of them - /// a compile error on paste. - /// - internal static string NormalizeMarkupSubstitutions(string code) - { - if (!SampleSubstitutionPlaceholder.Contains(code)) return code; - - var sb = new StringBuilder(code.Length); - var inTag = false; - var quote = '\0'; - - for (var i = 0; i < code.Length; i++) - { - var c = code[i]; - - if (quote != '\0') - { - if (c == quote) quote = '\0'; - sb.Append(c); - continue; - } - - // Copy comments verbatim: a '>' inside one would otherwise desynchronize the - // in-tag state and misclassify every token that follows. - if (c == '<' && string.CompareOrdinal(code, i, "", i + 4, StringComparison.Ordinal); - if (end < 0) end = code.Length - 3; - sb.Append(code, i, end + 3 - i); - i = end + 2; - continue; - } - - if (inTag && (c == '"' || c == '\'')) - { - // Value position. Look at the whole value before committing to it: a token - // anywhere inside means the attribute cannot be salvaged, so the attribute goes - // rather than the token, exactly as in attribute position below. - var valueEnd = code.IndexOf(c, i + 1); - if (valueEnd > 0 && SampleSubstitutionPlaceholder.Contains(code[(i + 1)..valueEnd])) - { - RemoveTrailingAttributeName(sb); - i = valueEnd; - continue; - } - - quote = c; - sb.Append(c); - continue; - } - if (c == '<') { inTag = true; sb.Append(c); continue; } - if (c == '>') { inTag = false; sb.Append(c); continue; } - - if (SampleSubstitutionPlaceholder.TryMatchAt(code, i, out var tokenLength)) - { - i += tokenLength - 1; - if (inTag) - { - // Drop the whitespace that separated the token from the previous - // attribute too, so the tag doesn't keep a dangling gap before "/>". - while (sb.Length > 0 && char.IsWhiteSpace(sb[^1])) sb.Length--; - } - else - { - sb.Append(""); - } - - continue; - } - - sb.Append(c); - } - - return sb.ToString(); - } - - /// - /// Rewinds over the Name= just written, and the whitespace in - /// front of it, so the caller can drop an attribute whose value it has decided not to keep. - /// Taking the whitespace from the front is what keeps the tag tidy: an attribute written on - /// its own line takes that line with it, and the last attribute in a tag does not strand the - /// closing />. - /// - private static void RemoveTrailingAttributeName(StringBuilder sb) - { - var cut = sb.Length; - - while (cut > 0 && char.IsWhiteSpace(sb[cut - 1])) cut--; - if (cut > 0 && sb[cut - 1] == '=') cut--; - while (cut > 0 && char.IsWhiteSpace(sb[cut - 1])) cut--; - while (cut > 0 && IsAttributeNameChar(sb[cut - 1])) cut--; - while (cut > 0 && char.IsWhiteSpace(sb[cut - 1])) cut--; - - sb.Length = cut; - } - - private static bool IsAttributeNameChar(char c) - => char.IsLetterOrDigit(c) || c == '.' || c == ':' || c == '_' || c == '-'; - - private static string UnescapeXml(string s) - { - return s.Replace("<", "<") - .Replace(">", ">") - .Replace("&", "&") - .Replace(""", "\"") - .Replace("'", "'") - .Replace(" ", "\n") - .Replace(" ", "\r"); - } - - /// Tighten whitespace, drop license header + Gallery-internal helper lines. - private static string CompressCSharp(string code) - { - // Strip Gallery's UIHelper.* accessibility helper calls (not portable, agent doesn't have it). - code = Regex.Replace(code, @"^\s*UIHelper\.[^;]+;.*\n?", "", RegexOptions.Multiline); - - // Drop a leading "// C# code-behind" / "// C# Code" label line that some upstream - // SampleDefinition bundles put at the top of their --- c# section (pure noise; the - // real class/method code follows). Only the leading marker is removed, not inline - // explanatory comments that document the sample. - code = Regex.Replace(code, @"^\s*//\s*C#\s*(code-behind|code)\s*\r?\n", "", RegexOptions.IgnoreCase); - - // Strip #region / #endregion preprocessor directives — they're noise. - code = Regex.Replace(code, @"^\s*#(?:region|endregion)\b.*\n?", "", RegexOptions.Multiline); - - // Drop "// Copyright (c) Microsoft..." + "// Licensed under..." header pair. - if (code.StartsWith("// Copyright")) - { - int nl1 = code.IndexOf('\n'); - if (nl1 > 0) - { - int nl2 = code.IndexOf('\n', nl1 + 1); - if (nl2 > 0 && code.Substring(nl1 + 1, nl2 - nl1 - 1).TrimStart().StartsWith("// Licensed")) - code = code.Substring(nl2 + 1).TrimStart(); - } - } - - // De-indent: turn 4-space indents into 2-space (saves ~12% per line). - code = Regex.Replace(code, @"(?m)^( {4})+", m => new string(' ', m.Length / 2)); - - // Collapse 3+ consecutive newlines into 2. - code = Regex.Replace(code, @"\n[\t ]*\n[\t ]*\n+", "\n\n"); - return code.Trim(); - } -} diff --git a/src/winapp-CLI/WinApp.Cli/Services/Controls/GalleryProvider.cs b/src/winapp-CLI/WinApp.Cli/Services/Controls/GalleryProvider.cs index 1167d873c..e41b06eca 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/Controls/GalleryProvider.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/Controls/GalleryProvider.cs @@ -3,23 +3,250 @@ namespace WinApp.Cli.Services.Controls; +using System.Text.RegularExpressions; + /// -/// WinUI 3 Gallery scenarios (microsoft/WinUI-Gallery). Parse logic lives -/// in ; this type just wires it into the provider -/// model. Gallery contributes no author keywords. +/// WinUI 3 Gallery scenarios (microsoft/WinUI-Gallery), read from the sample index +/// that repository publishes and validates on every PR. Downloading and mapping it is +/// 's job; what remains here is the one thing the index +/// does not supply — Gallery's search enrichment tags. +/// +/// Those tags are not cosmetic. builds a control's BM25 +/// document from its name, id, curated keywords (weight 5.0), enrichment tags (3.0) and +/// scenario headers (0.8) — a control's prose description is never indexed. So for a query +/// expressing intent rather than a control name ("reusable style definitions"), the +/// enrichment tags are the only field that can match, and a control without them is +/// reachable only by naming it. +/// +/// The index's own keywords can't fill that role: Gallery populates them from +/// a control's base classes (Object, DependencyObject, UIElement), +/// which describe the type hierarchy rather than what the control is for, and which every +/// control shares — indexing them would make "control" or "element" match the whole +/// catalog. The intent-bearing tags are therefore derived here, from the control name and +/// description, exactly as they were when this data came from ControlInfoData.json +/// via a scraper. The index's separate curatedKeywords are hand-written intent +/// phrases ("tab control", "notification dot") and are carried through as the weight-5.0 +/// curated keyword field, which the scraper had no way to populate. /// -internal sealed class GalleryProvider : CachedProviderBase +internal sealed partial class GalleryProvider : CachedProviderBase { public GalleryProvider(string cacheRoot) : base(cacheRoot) { } public override string Id => "gallery"; public override string DisplayName => "Gallery (WinUI 3)"; + /// Cap on derived tags per control. Beyond roughly a dozen terms the marginal + /// term is noise that dilutes BM25 scoring rather than adding recall. + private const int MaxDerivedTags = 12; + protected override async Task FetchAsync(CancellationToken cancellationToken) { - var (scenarios, tags) = await GalleryFetcher.FetchAsync(cancellationToken); + var (scenarios, _, curatedKeywords) = await SampleIndexFetcher + .FetchAsync(SampleIndexFetcher.GalleryIndexUrl, Id, cancellationToken) + .ConfigureAwait(false); + + foreach (var scenario in scenarios) + { + NormalizeForPaste(scenario); + } + return scenarios.Length > 0 - ? new ProviderData(scenarios, tags, new()) + ? new ProviderData(scenarios, BuildEnrichmentTags(scenarios), curatedKeywords) : ProviderData.Empty; } + + /// Gallery's own page class declaration, e.g. + /// x:Class="WinUIGallery.ControlPages.ButtonPage". + [GeneratedRegex(@"x:Class=""(?:WinUIGallery|AppUIBasics)\.[^""]+""")] + private static partial Regex GalleryClassRegex(); + + /// Gallery's per-scenario navigation targets (SamplePage, SamplePage1, …), + /// which exist only inside the Gallery app. Anchored at the start of an identifier but not the + /// end: SamplePage2Item is Gallery-only too, while prose naming a Gallery source file + /// (TabViewWindowingSamplePage.xaml) is left alone. + [GeneratedRegex(@"\bSamplePage\d*")] + private static partial Regex SamplePageNameRegex(); + + /// A namespace declaration putting the sample in Gallery's own namespace. + [GeneratedRegex(@"namespace\s+(?:WinUIGallery|AppUIBasics)[^{;\n]*;?")] + private static partial Regex GalleryNamespaceRegex(); + + /// A using of Gallery's own namespace. + [GeneratedRegex(@"using\s+(?:WinUIGallery|AppUIBasics)[^;\n]*;?")] + private static partial Regex GalleryUsingRegex(); + + /// A statement calling UIHelper, Gallery's internal accessibility helper. + [GeneratedRegex(@"^[^\S\n]*UIHelper\.[^;]+;.*\n?", RegexOptions.Multiline)] + private static partial Regex UiHelperCallRegex(); + + /// A XAML namespace mapping pointed at one of Gallery's own CLR namespaces, e.g. + /// using:WinUIGallery.ControlPages. The sub-namespace is dropped rather than kept + /// under the placeholder root: the prefix exists so the markup can name types the user + /// must supply, and using:YourApp is the namespace the emitted C# declares. + [GeneratedRegex(@"using:(?:WinUIGallery|AppUIBasics)(?:\.[\w.]+)?")] + private static partial Regex GalleryXmlnsRegex(); + + /// A type named through its full Gallery namespace, e.g. + /// WinUIGallery.ControlPages.CustomDataObject. Only the root is rewritten: a regex + /// cannot tell which trailing segments are namespaces and which is the type, and replacing + /// the root is enough to stop the snippet claiming a name that resolves. Requires the dot, + /// so a source link like …/main/WinUIGallery/ButtonPage.xaml.cs is left alone. + [GeneratedRegex(@"\b(?:WinUIGallery|AppUIBasics)\.")] + private static partial Regex GalleryNamespaceRootRegex(); + + /// A package-relative path into one of Gallery's own asset folders. Gallery ships + /// Assets/SampleMedia (the sample photos and videos) and Assets/Tiles (its own + /// app icon and tile logos) inside its package, so a pasted sample naming one resolves to + /// nothing in the user's app. Captures the prefix so the form is preserved: the three that + /// occur upstream (ms-appx:///Assets/, /Assets/, Assets/) are not + /// interchangeable — AppWindow.SetIcon takes a package-relative path, not a URI. + [GeneratedRegex(@"((?:ms-appx:///)?/?Assets/)(?:SampleMedia|Tiles)/([^""'\s<>)]+)")] + private static partial Regex GalleryAssetRegex(); + + /// Extensions that make the placeholder an image rather than a generic asset. The + /// extension itself is always preserved: a MediaPlayerElement pointed at a .png is a + /// different kind of broken than one pointed at a file the user has yet to add. + private static readonly string[] ImageExtensions = + [".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico"]; + + /// Rewrite every Gallery-only asset path to an obvious placeholder. The result is + /// equally absent from the user's app — that is the point: it fails visibly, where the + /// upstream path fails as a blank control with nothing to explain it. + private static string ReplaceGalleryAssets(string content) => + GalleryAssetRegex().Replace(content, static m => + { + var extension = Path.GetExtension(m.Groups[2].Value); + var name = ImageExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) + ? "YourImage" + : "YourAsset"; + return m.Groups[1].Value + name + extension; + }); + + /// + /// Make one index scenario pasteable. + /// + /// The index publishes Gallery's sample source verbatim, so a scenario still + /// refers to things that exist only inside the Gallery app — its page classes, its + /// SamplePageN navigation targets, its UIHelper accessibility helper, its + /// CLR namespaces, the photos and icons in its package — and still wires XAML events to + /// handlers whose implementations live in page code-behind the index does not carry. + /// Pasted into a user's app as-is, those snippets do not compile, or compile and render + /// nothing, which defeats the point of serving them. + /// + /// What is rewritten here is environment, never substance: identifiers and paths + /// that resolve only against Gallery's own assembly and package. The sample still teaches + /// exactly what upstream wrote it to teach. + /// + /// runs the same normalization over its own source; + /// this is Gallery's half of it. + /// + /// Internal for direct testing: the corpus guards in EmbeddedSnapshotTests + /// only prove the baked data is clean, which passes vacuously if upstream stops shipping + /// the thing being rewritten. + /// + internal static void NormalizeForPaste(Scenario scenario) + { + if (!string.IsNullOrEmpty(scenario.CSharp)) + { + var csharp = UiHelperCallRegex().Replace(scenario.CSharp, ""); + csharp = GalleryUsingRegex().Replace(csharp, "// adapt namespace to your app"); + csharp = GalleryNamespaceRegex().Replace(csharp, "namespace YourApp;"); + csharp = SamplePageNameRegex().Replace(csharp, "YourPage"); + csharp = GalleryClassRegex().Replace(csharp, @"x:Class=""YourApp.YourPage"""); + csharp = GalleryNamespaceRootRegex().Replace(csharp, "YourApp."); + csharp = ReplaceGalleryAssets(csharp); + scenario.CSharp = string.IsNullOrWhiteSpace(csharp) ? null : csharp.Trim(); + } + + if (!string.IsNullOrEmpty(scenario.Xaml)) + { + var xaml = GalleryClassRegex().Replace(scenario.Xaml, @"x:Class=""YourApp.YourPage"""); + xaml = SamplePageNameRegex().Replace(xaml, "YourPage"); + xaml = GalleryXmlnsRegex().Replace(xaml, "using:YourApp"); + xaml = ReplaceGalleryAssets(xaml); + + // Strip handlers against the C# settled above, so an attribute survives only + // when the code we actually serve declares its method. This is deliberately + // narrower than a blanket rule: TabView really does supply TabView_Loaded, and + // that handler is kept. + scenario.Xaml = ControlSnippetText.StripUnbackedEventHandlers(xaml, scenario.CSharp); + } + + // Rendered as the "Setup:" line, so a Gallery namespace here is worse than one buried + // in the markup: it reads as an instruction to add a mapping that cannot resolve. + for (var i = 0; i < scenario.XmlnsImports.Length; i++) + { + scenario.XmlnsImports[i] = GalleryXmlnsRegex().Replace(scenario.XmlnsImports[i], "using:YourApp"); + } + } + + /// Word characters that make up a derived tag: three or more letters, so + /// articles and fragments don't become search terms. + [GeneratedRegex(@"[a-z]{3,}")] + private static partial Regex TagWordRegex(); + + /// Boundary between a lowercase and an uppercase letter, so "AutoSuggestBox" + /// also contributes "auto" and "suggest". + [GeneratedRegex(@"(?<=[a-z])(?=[A-Z])")] + private static partial Regex CamelCaseBoundaryRegex(); + + /// + /// Build the per-control enrichment tag dictionary: hand-curated terms where we have + /// them, otherwise terms derived from the control's own name and description. + /// + /// + /// Curated tags win outright rather than merging with derived ones. They exist because + /// the derived terms were judged insufficient for that control, so appending the + /// rejected terms back would reinstate what the curation set out to replace. + /// + private static Dictionary BuildEnrichmentTags(Scenario[] scenarios) + { + var curated = DataLoader.LoadGalleryTags(); + var tags = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var scenario in scenarios) + { + var controlId = scenario.ControlId; + if (string.IsNullOrEmpty(controlId) || tags.ContainsKey(controlId)) continue; + + tags[controlId] = curated.TryGetValue(controlId, out var manual) + ? StopWords.FilterTagList(manual) + : DeriveTags(controlId, scenario); + } + + // Curated entries for controls the index doesn't carry still belong in the + // dictionary: they cost nothing when the control is absent, and they keep a + // control searchable through a release where upstream temporarily drops it. + foreach (var (controlId, manual) in curated) + { + if (!tags.ContainsKey(controlId)) tags[controlId] = StopWords.FilterTagList(manual); + } + + return tags; + } + + /// + /// Derive intent tags for one control from the text the index already carries: its + /// display name, its one-line summary, and its longer description. This is the same + /// source text ("Title Subtitle Description") the scraper read straight out of + /// ControlInfoData.json, so the derived vocabulary is unchanged by the move to + /// the index. + /// + private static string[] DeriveTags(string controlId, Scenario scenario) + { + var text = $"{scenario.ControlName} {scenario.ControlDescription} {scenario.Description}"; + text = CamelCaseBoundaryRegex().Replace(text, " "); + + var tags = new List { controlId }; + var seen = new HashSet(StringComparer.OrdinalIgnoreCase) { controlId }; + + foreach (Match m in TagWordRegex().Matches(text.ToLowerInvariant())) + { + if (StopWords.IsTagNoise(m.Value)) continue; + if (seen.Add(m.Value)) tags.Add(m.Value); + if (tags.Count >= MaxDerivedTags) break; + } + + return [.. tags]; + } } diff --git a/src/winapp-CLI/WinApp.Cli/Services/Controls/ReactorFetcher.cs b/src/winapp-CLI/WinApp.Cli/Services/Controls/ReactorFetcher.cs index 501af1202..8288277bb 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/Controls/ReactorFetcher.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/Controls/ReactorFetcher.cs @@ -3,8 +3,6 @@ namespace WinApp.Cli.Services.Controls; -using System.Net.Http; - /// /// microsoft-ui-reactor ReactorGallery scenarios. Reads the purpose-built /// reactor-search-index.json (a schema the Reactor team owns) and maps it @@ -32,24 +30,15 @@ internal static class ReactorFetcher /// a source can't label its samples as another's. private const string SourceId = "reactor"; - private const string IndexUrl = - "https://raw.githubusercontent.com/microsoft/microsoft-ui-reactor/main/samples/ReactorGallery/reactor-search-index.json"; - - private static readonly HttpClient Http = new() - { - DefaultRequestHeaders = { { "User-Agent", "winapp-find-ui/1.0" } }, - Timeout = TimeSpan.FromSeconds(30) - }; - /// Fetch fresh scenarios + tags from GitHub. Reactor C# and curated - /// keywords are kept verbatim. The body is streamed through the shared - /// byte-capped helper so an accidentally-huge upstream file can't exhaust - /// memory. + /// keywords are kept verbatim. internal static async Task<(Scenario[] scenarios, Dictionary tags)> FetchAsync( CancellationToken cancellationToken = default) { - var json = await ControlsHttpHelper.GetStringCappedAsync(Http, IndexUrl, cancellationToken).ConfigureAwait(false); - return Parse(json); + var (scenarios, tags, _) = await SampleIndexFetcher + .FetchAsync(SampleIndexFetcher.ReactorIndexUrl, SourceId, cancellationToken) + .ConfigureAwait(false); + return (scenarios, tags); } /// Map the reactor-search-index.json document to @@ -60,7 +49,7 @@ internal static class ReactorFetcher /// generalized FROM this file, so Reactor's published index is what proves the shared /// reader works against real upstream data — and every source that publishes an index /// under #703 gets a - /// fetcher this size instead of a scraper. + /// fetcher this size instead of a scraper. WinUI-Gallery is the second such source. /// /// Reactor publishes no curatedKeywords, so that slot is empty here and the /// tuple stays two-wide, exactly as ReactorProvider already expects. Its diff --git a/src/winapp-CLI/WinApp.Cli/Services/Controls/SampleIndexFetcher.cs b/src/winapp-CLI/WinApp.Cli/Services/Controls/SampleIndexFetcher.cs new file mode 100644 index 000000000..989195556 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/Controls/SampleIndexFetcher.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace WinApp.Cli.Services.Controls; + +using System.Net.Http; + +/// +/// Downloads a published sample index and maps it onto the internal +/// model. One HTTP request per source, whatever the size of the +/// corpus behind it — contrast a scraper, which reconstructs the same data from +/// repository layout and pays a request per control page and per snippet file. +/// +/// This is the whole client for a source that publishes an index under +/// #703. The document +/// shape is docs/winui-sample-index.schema.json and the mapping lives in +/// , so a new source needs a URL and a source id and +/// nothing else. +/// +internal static class SampleIndexFetcher +{ + /// WinUI 3 Gallery, generated by its tools/CatalogExporter and validated + /// against our published contract on every one of their PRs. + public const string GalleryIndexUrl = + "https://raw.githubusercontent.com/microsoft/WinUI-Gallery/main/catalog/windows-samples.json"; + + /// microsoft-ui-reactor ReactorGallery. The first source to publish an index, + /// and the file the shared contract was generalized from. + public const string ReactorIndexUrl = + "https://raw.githubusercontent.com/microsoft/microsoft-ui-reactor/main/samples/ReactorGallery/reactor-search-index.json"; + + private static readonly HttpClient Http = new() + { + DefaultRequestHeaders = { { "User-Agent", "winapp-find-ui/1.0" } }, + Timeout = TimeSpan.FromSeconds(30) + }; + + /// + /// Fetch and parse it, stamping + /// onto every scenario. The body is streamed through the shared byte-capped helper so an + /// accidentally-huge upstream file can't exhaust memory. + /// + /// + /// tags feeds ProviderData.Tags (search weight 3.0) and + /// curatedKeywords feeds ProviderData.Keywords (weight 5.0). + /// + internal static async Task<(Scenario[] scenarios, Dictionary tags, Dictionary curatedKeywords)> FetchAsync( + string indexUrl, + string sourceId, + CancellationToken cancellationToken = default) + { + var json = await ControlsHttpHelper.GetStringCappedAsync(Http, indexUrl, cancellationToken).ConfigureAwait(false); + return SampleIndexParser.Parse(json, sourceId); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/Controls/SearchEngine.cs b/src/winapp-CLI/WinApp.Cli/Services/Controls/SearchEngine.cs index 25614a09c..ed6bb4946 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/Controls/SearchEngine.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/Controls/SearchEngine.cs @@ -783,33 +783,39 @@ private static string FormatScenario(Scenario s) }; sb.AppendLine($"## {ControlHeader(s.ControlName, s.HeaderText)}{sourceTag}"); - // Toolkit-specific prerequisites — single compact line - if (s.Source == "toolkit" && (!string.IsNullOrEmpty(s.NuGetPackage) || s.XmlnsImports.Length > 0)) - { - var parts = new List(1 + s.XmlnsImports.Length); - if (!string.IsNullOrEmpty(s.NuGetPackage)) - parts.Add($"NuGet `{s.NuGetPackage}`"); - foreach (var ns in s.XmlnsImports) - parts.Add($"`{ns}`"); - sb.AppendLine($"**Setup:** {string.Join(" · ", parts)}"); - } // Reactor: surface the (uniform) NuGet package. Any control-level `using` // directives are already folded into the C# snippet, and the shared // Microsoft.UI.Reactor api namespace is deliberately NOT emitted as a // **Namespace:** line — all reactor controls share it, so it'd be pure noise. - else if (s.Source == "reactor") + if (s.Source == "reactor") { if (!string.IsNullOrEmpty(s.NuGetPackage)) sb.AppendLine($"**Setup:** NuGet `{s.NuGetPackage}`"); } - // Gallery non-default namespace hint — agents miss `using Microsoft.Windows.Notifications` - // and similar long-tail imports. Skip the dominant Microsoft.UI.Xaml.Controls (auto-imported - // in default templates) so 79/107 controls stay quiet. - else if (s.Source == "gallery" - && !string.IsNullOrEmpty(s.ApiNamespace) - && s.ApiNamespace != "Microsoft.UI.Xaml.Controls") + else { - sb.AppendLine($"**Namespace:** `{s.ApiNamespace}`"); + // Prerequisites — single compact line. Gallery samples carry xmlns imports + // too, and withholding them leaves markup using an undeclared prefix. + if (!string.IsNullOrEmpty(s.NuGetPackage) || s.XmlnsImports.Length > 0) + { + var parts = new List(1 + s.XmlnsImports.Length); + if (!string.IsNullOrEmpty(s.NuGetPackage)) + parts.Add($"NuGet `{s.NuGetPackage}`"); + foreach (var ns in s.XmlnsImports) + parts.Add($"`{ns}`"); + sb.AppendLine($"**Setup:** {string.Join(" · ", parts)}"); + } + + // Gallery non-default namespace hint — agents miss `using Microsoft.Windows.Notifications` + // and similar long-tail imports. Skip the dominant Microsoft.UI.Xaml.Controls (auto-imported + // in default templates) so 79/107 controls stay quiet. This is independent of the Setup + // line above: a sample can need both an xmlns declaration and a C# using. + if (s.Source == "gallery" + && !string.IsNullOrEmpty(s.ApiNamespace) + && s.ApiNamespace != "Microsoft.UI.Xaml.Controls") + { + sb.AppendLine($"**Namespace:** `{s.ApiNamespace}`"); + } } if (s.Xaml != null)