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(upstream);
-
- Assert.IsTrue(
- ScenarioSanitizer.XamlIsWellFormed(cleaned),
- $"flattened sample must survive structural validation, got: {cleaned}");
- StringAssert.Contains(cleaned, "Content=\"Standard XAML button\"", "the real markup must survive");
- Assert.IsFalse(cleaned.Contains("$("), "the substitution token must not be served raw");
- }
-
- [TestMethod]
- public void CleanGalleryContent_ValuePositionSubstitution_DropsTheAttribute()
- {
- // Flattening a value-position token to "..." keeps the markup well-formed but not
- // pasteable: a measured 140 attributes in the previous bake were typed properties
- // (StrokeThickness, Height, Orientation, IsChecked...) where "..." is a compile
- // error. Dropping the attribute lets the property fall back to its own default.
- const string upstream = """""";
-
- var cleaned = GalleryFetcher.CleanGalleryContent(upstream);
-
- Assert.IsTrue(ScenarioSanitizer.XamlIsWellFormed(cleaned), $"got: {cleaned}");
- Assert.IsFalse(cleaned.Contains("\"...\""), $"the token must not be flattened, got: {cleaned}");
- Assert.IsFalse(cleaned.Contains("Value="), $"the unresolvable attribute must be dropped, got: {cleaned}");
- Assert.IsFalse(cleaned.Contains("$("), $"no token may be served raw, got: {cleaned}");
- }
-
- [TestMethod]
- public void CleanGalleryContent_ContentPositionSubstitution_BecomesAComment()
- {
- // Every content-position token upstream ships injects markup into a property element,
- // never text. Flattening it assigns the literal string "..." as the collection's
- // content, which parses but does not compile when pasted.
- const string upstream = """
-
-
- $(MultipleButtonsSecondaryCommands)
-
-
- """;
-
- var cleaned = GalleryFetcher.CleanGalleryContent(upstream);
-
- Assert.IsTrue(ScenarioSanitizer.XamlIsWellFormed(cleaned), $"got: {cleaned}");
- StringAssert.Contains(cleaned, "", "the injection point must be marked, not stringified");
- Assert.IsFalse(
- Regex.IsMatch(cleaned, @">\s*\.\.\.\s*<"),
- $"no bare '...' may sit in element content, got: {cleaned}");
- }
}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/GalleryFetcherCleanContentTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/GalleryFetcherCleanContentTests.cs
deleted file mode 100644
index 800d164c8..000000000
--- a/src/winapp-CLI/WinApp.Cli.Tests/GalleryFetcherCleanContentTests.cs
+++ /dev/null
@@ -1,128 +0,0 @@
-// 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 demo-cleanup
-/// pass. The invariant that matters to a user: the emitted XAML must not reference an
-/// event handler the accompanying C# never defines, because the Gallery's code-behind
-/// extractor does not surface *_Loaded methods and an agent pastes the snippet
-/// verbatim. A dangling Loaded="X_Loaded" is a compile break in the user's project.
-///
-[TestClass]
-public class GalleryFetcherCleanContentTests
-{
- [TestMethod]
- public void CleanGalleryContent_InlineLoadedHandler_IsRemoved()
- {
- // The real gallery-tabview-1 shape: the demo handler sits INLINE, on a line that
- // also carries '<' and '>'. The line-level filter deliberately keeps such lines,
- // so the attribute has to be stripped before it runs.
- var xaml = "";
-
- var cleaned = GalleryFetcher.CleanGalleryContent(xaml);
-
- Assert.IsFalse(cleaned.Contains("TabView_Loaded"), "dangling Loaded handler must be stripped");
- StringAssert.Contains(cleaned, "TabView_AddButtonClick", "real handlers must survive");
- StringAssert.Contains(cleaned, "TabView_TabCloseRequested", "real handlers must survive");
- StringAssert.Contains(cleaned, "/>", "the tag must stay well-formed");
- }
-
- [TestMethod]
- public void CleanGalleryContent_OwnLineLoadedHandler_IsRemovedWithoutBreakingTheTag()
- {
- var xaml = "\n"
- + "";
-
- var cleaned = GalleryFetcher.CleanGalleryContent(xaml);
-
- Assert.IsFalse(cleaned.Contains("TabView_Loaded"), "dangling Loaded handler must be stripped");
- StringAssert.Contains(cleaned, "TabWidthMode=\"Equal\"", "sibling attributes must survive");
- Assert.IsTrue(
- ScenarioSanitizer.XamlIsWellFormed(cleaned),
- $"cleanup must leave well-formed XAML. Got:\n{cleaned}");
- }
-
- [TestMethod]
- public void CleanGalleryContent_NonDemoLoadedHandler_IsKept()
- {
- // Only the "*_Loaded" demo convention is stripped. A handler named anything else
- // is the sample's own logic and the code-behind extractor does surface it.
- var xaml = "";
-
- var cleaned = GalleryFetcher.CleanGalleryContent(xaml);
-
- StringAssert.Contains(cleaned, "OnGridReady", "non-demo handlers must not be stripped");
- }
-
- [TestMethod]
- public void CleanGalleryContent_DemoLayoutAttributesOnOwnLine_StillRemoved()
- {
- // Guards the pre-existing line-level filter against regression from the new
- // attribute-level pass running ahead of it.
- var xaml = "";
-
- 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)