Repository navigation
Publish a machine-readable index of the Toolkit samples - #879
Conversation
Consumers that want to offer Toolkit samples outside this repository - documentation sites, search, AI coding assistants - currently have to scrape components/, parse the sample attributes themselves, and keep a hand-maintained table of the cases their parser gets wrong. Every one of them reimplements the same reading of the same files, and each table drifts silently the moment a sample changes. This adds a generated index, catalog/toolkit-samples.json, describing every documentation page and every sample it presents, with markup that can be pasted into a reader's own app. The exporter reads the samples the way the sample generator does. It parses the [ToolkitSample] attributes with Roslyn rather than a regex, because display names are written as nameof(...) and interpolated strings; and it reuses the generator's own frontmatter and [!SAMPLE] marker patterns, so the index and the sample app cannot disagree about what a page contains. Making the markup pasteable means removing the sample app from it: the Page wrapper and its x:Class, the design-time namespaces, the bindings to the generated options pane - replaced by the value the app starts with, so the snippet shows what the gallery shows. <Page.Resources> moves onto the element that survives, since otherwise the snippet fails to compile the moment it is pasted anywhere that is not a Page. What changes is the environment, never what the sample demonstrates. Four gates keep it honest, and CI runs them on Linux in under a minute because nothing here needs a build: the committed file matches the samples, generation is deterministic, every published snippet parses and declares the prefixes it uses, and anything left out is listed by name rather than disappearing quietly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A sample that wires up handlers teaches nothing from its markup alone - the reader sees Click="Move_Click" and has to go and find what Move_Click does. The index already carries the markup, so it should carry the code that makes it work. What is published is the sample, not the page that hosts it. The license header, the sample app's namespace, the page class, the [ToolkitSample...] attributes and the InitializeComponent call are all scaffolding for an app the reader is not building. The parser is told this is WinAppSDK, so the UWP and Uno branches never enter the tree and the reader is not handed a choice that has already been made for them. Types declared beside a sample come with it. SwitchPresenterValueSample switches on an enum in the same file, and publishing the markup without it would publish a binding to a type the reader does not have. Most samples have nothing left once the scaffolding is gone, and those publish no code at all rather than a constructor that says nothing - 37 of 125 carry code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A consumer searching for what a control does, rather than for its name, has nothing to match on: the index stated each component's category only inside the toolkit extension object, which a generic reader ignores. Category and subcategory now also appear in the entry's generated keywords, kept separate from the author's own keywords line so a consumer can keep weighting a human's word choice higher than the repository's filing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Resolve option bindings whose path carries a cast. x:Bind spells a cast as a parenthesised type ahead of the path, as in (x:Int32)Columns. Reading the cast as part of the path made the option lookup miss while the whole-word scan still matched, so a binding with a perfectly readable default was treated as having none and its attribute was removed. UniformGrid lost its spans and first column, which is what that sample exists to demonstrate. An attached property path such as (Grid.Row) is a path rather than a cast and is left alone. Stop reading commented-out markup as live. A comment ends at --> and a CDATA section at ]]>, not at the first >. Three scans stopped at the first one and resumed inside the comment, so an x:Name in a comment counted as a declared element, an option binding in a comment was rewritten in the published text, and a prefix used only in a comment was published as a required import. An apostrophe in comment prose also opened a quote that never closed, abandoning prefix stripping for the rest of the fragment. Three samples carry comments of the shape that triggers this today, and are unaffected only because none of them happens to contain a name or a binding. Report a duplicate entry id instead of renaming one. The bare slug went to whichever document was read first and the loser was qualified with its component name, so adding a component that sorted earlier would silently change the id of an entry that had already shipped. An id is published as stable, so a collision is now an error a contributor settles by renaming, and every existing id stays where it is. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Five tests asserted things that could not come out any other way, so the gate they belonged to was reporting a pass it had not earned. Three of them asked the in-memory object a question about the published file: the schema version and source restated their own field initializers, the language tag restated the line in IndexGenerator that sets it, and the keyword check restated the TrimEntries already applied by SplitKeywords. They now read the committed JSON, which is what a consumer fetches and the only place the serialized field names can be observed. The keyword gate also covers the generated keywords, which come from the category frontmatter by a different path and were not checked at all, and the trimming it relies on is now pinned by unit tests over messy frontmatter. The fourth asserted that generating twice in one process produced the same bytes, which cannot see the divergence it was written to prevent: the index is generated on Windows and byte-compared by a Linux CI job, and NTFS compares names case-insensitively where ext4 does not. A fixture whose ordinal and case-insensitive orderings disagree catches both halves of that — dropping the sort, and swapping the comparer — on either filesystem. The existing EntriesAreOrderedByDocumentPath cannot, because every component in this repository happens to start with a capital letter. Program.cs had no coverage at all, while CI depends on its exit codes: 2 for a mistyped command, 1 for a stale or missing index, 0 otherwise. A mistyped command reported as drift would send someone looking for a change that was never made. The CRLF normalization is covered too, since without it every Windows working copy reads as stale. Main becomes internal so the tests can call it, and the fixture repository they run against lives in Fixture.cs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
CI note: That job isn't failing on an assertion. 348 tests pass, then the test host dies: This matches #347 ("WinUI 3 tests failing at random in CI"), open since Feb 2024. The named test drifts between runs because it's simply whichever test was executing when the host went down — the runner prints its own disclaimer that the test "may, or may not be the source of the crash." Mine landed on This PR can't be the cause. The diff touches only I closed/reopened to retrigger the whole workflow, since #347 notes a rerun often clears it. |
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. |
The index schema defines a control-level `usings` array and tells consumers to prepend it as `using X;` lines so a sample compiles standalone, which is why the samples themselves carry no directives. The exporter never populated it, so NetworkHelper published code calling `NetworkHelper.Instance` while the `using CommunityToolkit.WinUI.Helpers;` its source opens with was dropped during export and recorded nowhere. Every C# sample the index published failed to compile when pasted. Rendering discarded the sample file's using directives, so capture them first. A sample file imports what the whole page needed and most of a page is scaffolding that is not published, so the directives are narrowed to the ones the published members actually reference: `using static` and renaming aliases have no namespace form, the sample app's own namespaces resolve nowhere but this repository, and a toolkit namespace is kept only when it supplies a name the published code mentions. ToolkitApi reads the component sources for that last question, indexing extension method names as well as types because `using CommunityToolkit.WinUI;` is what `.Debounce()` needs and that never names its declaring class. Namespaces the repository does not declare are kept as written. They cannot be enumerated, and the asymmetry runs one way: a missing import is a compile error the reader has to diagnose, a spare one is a hint they discard. 21 of 72 controls now carry the field; the rest publish no code and so need nothing imported. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47881f31-691e-4cbf-a66f-035dadf947a3
Head branch was pushed to by a user without write access
Two required contexts never reported on 080db55: testspace-analytics was created and left queued, and WIP posted nothing at all. Both are app-side delivery failures rather than anything the branch does, and protection on main is enforced at "everyone", so neither waiting nor an admin override can clear them. A new commit asks both apps for a fresh status. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47881f31-691e-4cbf-a66f-035dadf947a3
CommunityToolkit/Windows#879 merged, so catalog/toolkit-samples.json resolves and the Toolkit corpus can be baked from the published index instead of the reduced floor it held while that URL 404'd. Bake deltas: gallery 327 -> 344, toolkit 48 -> 125, reactor 95 -> 235. The gallery and toolkit figures match the live indexes exactly. CacheVersion goes to "26". That bump is owed for the usings relocation in cb06285 under Rules 1 and 3 — Scenario gained a usings field, and the same upstream sample now yields C# without the glued-on prefix. Without it an existing cache keeps matching on "25" and the offline fallback ignores the TTL, so a filtered or offline machine would never stop pasting a snippet that cannot compile wherever it is put. The bump and the re-bake have to land together or Manifest_Ships_AndMatchesCurrentCacheVersion fails the build. Re-measure the serving floors against the sanitized corpus, as the Toolkit floors' own doc comment instructs, and reset both sets to roughly 90% of observed: gallery 344 scenarios / 118 controls / 309 XAML / 133 C# -> 309/106/278/119 toolkit 125 scenarios / 53 controls / 125 XAML / 37 C# -> 112/47/112/33 The gallery floors are updated too because the same bake moved that corpus, leaving the measurements recorded in their comment stale and the floors sitting further below actual than the intended margin. These tests read the committed snapshot rather than the network, so tightening them cannot introduce flakiness; the margin exists for the next bake. Drop the Toolkit floors' "has not been re-baked since the Toolkit switched to reading its published index" rationale, which described exactly the state this commit ends. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 92222667-9d95-4dce-ba66-b6db29282316
## Description `find-ui` learned about Community Toolkit controls by scraping `CommunityToolkit/Windows`: hundreds of requests to reconstruct which sample belongs to which control, what it is called, and what its option bindings default to. Getting that wrong was invisible, so the fetcher carried hand-written tables correcting 34 samples and mapping 28 documents to controls. The Toolkit now publishes a sample index, built by the same parser its own sample browser uses and checked against the samples on every pull request. This reads that index and deletes the scraper and both tables. A cold Toolkit fetch goes from ~105 requests to 1, and the index cannot disagree with the sample app about what a sample is named or what it defaults to. What stays on our side is what only a consumer can settle: naming the sample after the user's own page, and dropping markup wired to handlers we do not serve. Those removals still run against the index and are expected to find nothing, so a change on the other side cannot reach a user's clipboard unnoticed. Reading the index roughly doubles what `find-ui` can answer, and most of what it adds is not a control at all — helpers, converters, behaviors and extensions that the scraper's skip list dropped. That turned out to need search and output work of its own, which is the rest of this PR. ## Usage Example Measured against the published index, through the real parse → normalize → sanitize path: | | Before (scraped) | Now (index) | |---|---|---| | Scenarios served | 48 | 125 | | Controls served | 26 | 53 | | Scenarios carrying C# | 34 | 37 | | …of those, carrying actual logic | 16 | 37 | The last row is the one that matters. The scraper got C# by pulling each sample page's `.cs` file verbatim, and for most Toolkit samples that file is an empty shell — a constructor calling `InitializeComponent()` and nothing else. 18 of its 34 C# scenarios were that. The index publishes `code` only where there is sample logic to publish, so every one of the 37 is substantive and useful C# roughly doubles. Eleven controls stop serving C# as a result — `colorpicker`, `gridsplitter`, `radialgauge`, `segmented`, `uniformgrid` and others. Every one of those 16 scenarios was an empty shell; no sample logic is lost. What those shells did carry was a `using CommunityToolkit.WinUI.Controls;` line, and that information is not lost either — it is now the `Namespace:` line described below, which is where it belongs. 27 entries become reachable that `find-ui` could not surface, including `converters`, `behaviors`, `animations`, `switchpresenter`, `listviewextensions`, `themelistener` and `networkhelper`. Sample titles become the ones the Toolkit authors wrote rather than ones we manufactured — `toolkit-colorpicker-1` is now `ColorPicker`, not `Basic usage`. **Finding the non-controls.** Converters, behaviors and triggers arrive grouped under an umbrella entry whose samples are named for the specific type, so BM25 saw `filesizetofriendlystringconverter` as one opaque token and only an exact-name query reached it. Headers and queries are now indexed as both the original text and a CamelCase split, so all three of these work: ```bash winapp find-ui "FileSizeToFriendlyStringConverter" --source toolkit # the specific type winapp find-ui "converters" --source toolkit # the whole group winapp find-ui "convert bool to visibility" --source toolkit # described in words ``` The limit is documented rather than papered over: these are indexed by the words in their type names, so a description finds one when it shares those words and misses when it shares none — `check internet connection` does not reach `NetworkHelper`. **Namespaces are a field, not a code prefix.** The index publishes control-level `usings`, and the obvious thing — prepending `using X;` to each C# sample — produces code that compiles nowhere. The published C# is a class-body fragment, so the result failed as its own file (CS0106) *and* failed pasted inside a class (CS1529), which between them are every placement a reader has. They now render as a line above the snippet: ``` ## NetworkHelper: Network Helper [CommunityToolkit] **Namespace:** `CommunityToolkit.WinUI.Helpers` ``` Gallery controls publish both `usings` and `apiNamespace` and the two disagree, so the line is their union. Namespaces a stock `dotnet new winui` project already resolves are filtered out, leaving the long tail an agent cannot guess. ## Related Issue Closes #810. Depended on CommunityToolkit/Windows#879, which has merged — the index is live and this is no longer blocked. ## Type of Change - ✨ New feature - ♻️ Refactoring ## Checklist - [x] New tests added for new functionality (if applicable) - [x] Tested locally on Windows - [x] [docs/usage.md](../docs/usage.md) updated (if CLI commands changed) - [x] Shipped skills updated in `plugins/winapp/skills/` (if CLI commands/workflows changed) ## Additional Notes **One behavior change worth naming:** the control id `colorpickerbutton` goes away. The Toolkit documents ColorPicker and ColorPickerButton in one file, which is the key the index is organized by, so that sample is served as `toolkit-colorpicker-2` instead. The sample is not lost, but `--id toolkit-colorpickerbutton-1` stops resolving. This is the one known granularity loss recorded in #810, and it is upstream's own grouping rather than a defect in the swap. `CacheVersion` moves to `26` and the embedded snapshot is re-baked in the same commit — the two have to land together or `Manifest_Ships_AndMatchesCurrentCacheVersion` fails the build. Without the bump an existing cache keeps matching on `25`, and because the offline fallback ignores the TTL, a filtered or offline machine would never pick the new data up at all. The re-bake moved every source, so the serving floors in `EmbeddedSnapshotTests` were re-measured against the sanitized corpus and reset to ~90% of observed. The Gallery floors are updated too, since the same bake left their recorded measurements stale: | | scenarios | controls | XAML | C# | floors | |---|---|---|---|---|---| | gallery | 344 | 118 | 309 | 133 | 309 / 106 / 278 / 119 | | toolkit | 125 | 53 | 125 | 37 | 112 / 47 / 112 / 33 | These tests read the committed snapshot rather than the network, so tightening them cannot introduce flakiness; the margin exists for the next bake. **A known gap in `usings`, confirmed with upstream.** The exporter derives the field from the `using` directives written in each sample's source file, but the Toolkit's sample projects also inject a large set of C# global usings that never appear in that syntax tree. Narrowing them would make the index depend on a restored package graph and break its determinism gate; publishing them unfiltered would put ~20 entries on every control. So the field is deliberately the *non-obvious* imports — the Toolkit namespaces a consumer cannot guess — not a complete import set. `docs/winui-sample-index.schema.json` is worded to match, and the ambient-namespace filter means our Namespace line already assumes the baseline a WinUI page code-behind has. ### Validation | Command | Result | |---|---| | `dotnet build src\winapp-CLI\WinApp.Cli.Tests\WinApp.Cli.Tests.csproj -c Release` | Succeeded, 0 warnings, 0 errors. Release is the configuration that enforces `TreatWarningsAsErrors`, so it is the one that matches CI | | `dotnet run --project src\winapp-CLI\WinApp.Cli.Tests\WinApp.Cli.Tests.csproj -c Release --no-build -- --filter "FullyQualifiedName~FindUi\|FullyQualifiedName~EmbeddedSnapshot\|FullyQualifiedName~SearchGrouped\|FullyQualifiedName~CacheVersion"` | Passed, 127/127 | | `.\scripts\build-cli.ps1 -Bake -SkipTests -SkipNpm -SkipNuGet -SkipMsix -SkipDocs` | Bake succeeded at cache version 26 | | `.\scripts\validate-plugin-package.ps1` | Exit 0 | | `winapp find-ui --id toolkit-networkhelper` (built binary, cold cache) | Rendered `**Namespace:** CommunityToolkit.WinUI.Helpers`, no `using` prefix on the C# | | `winapp find-ui --list --json` (built binary, cold cache) | 475 entries — 344 gallery, 125 toolkit, 6 curated core; matches the bake exactly | | Search parity against a built `origin/main` binary, 12 bare control-name queries, separate cold caches | 37 samples offered on both branches. An earlier revision of this PR regressed this to 32 by expanding the query before counting its tokens; the fix is covered by `SearchGrouped_ExactControlName_ShowsThatControlAloneWithMoreSamples`, which fails with `Expected:<5>. Actual:<3>` without it | | Full CI on the pushed head | All required checks green, including both `validate-tests` CLI shards, `build-and-package` and `test-samples-result` | --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Zach Teutsch <88554871+zateutsch@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Copilot-Session: 7860854f-180a-4634-865c-c26249097c2c Copilot-Session: 92222667-9d95-4dce-ba66-b6db29282316 Copilot-Session: 53bbb5c7-2d62-45ba-8714-ad476028bfb1 Copilot-Session: 415c898a-0d29-4429-b6f8-e6428d685e5c Copilot-Session: 7c5d3197-8a79-4bee-9294-2be43df316f0
Fixes
No issue number: this repository's issue templates accept bug reports only (
blank_issues_enabled: false), and this is a proposal rather than a defect. I opened it as an Idea instead, and would treat that discussion as the place to settle direction before any of this merges:Discussion: #878
Adds a generated, machine-readable index of every sample in the repository at
catalog/toolkit-samples.json, the .NET tool that produces it, and a CI job that fails when the committed file no longer matches the samples.PR Type
What kind of change does this PR introduce?
tools/, plus a generated artifact undercatalog/. No component source is touched.What is the current behavior?
The samples are only addressable by resolving the repository's own conventions:
[!SAMPLE]markers incomponents/*/samples/*.mdpointing at[ToolkitSample…]-attributed classes, matched to their.xaml/.xaml.cspairs. That works for the sample app and leaves every other consumer — documentation sites, search, AI coding assistants — scraping the repository and reimplementing a partial copy of that resolution, which then drifts as the samples change.Assistants asked for Toolkit markup today commonly emit snippets that do not compile: invented attributes, missing
xmlnsdeclarations, or the sample app'sx:Classand option-binding scaffolding pasted into a page the user is actually writing.What is the new behavior?
catalog/toolkit-samples.jsonis generated from the samples and committed. One entry per documentation page, one sample per[!SAMPLE]marker, in the order the page presents them — currently 72 entries and 125 samples.Each snippet is meant to be pasted. The
<Page>wrapper andx:Classare removed, design-time namespaces dropped,<Page.Resources>relocated onto the surviving element so referenced keys stay in scope, conditionalwin:prefixes dropped, and the sample app's option bindings replaced with the literal value the gallery opens with. Thexmlnsdeclarations the snippet actually uses are published alongside it. Code-behind is reduced to the handlers the markup calls and the types it binds to, with the license header, namespace, page class andInitializeComponentremoved.What changes is the environment, never what the sample demonstrates. An option whose value cannot be written as a literal has its attribute removed rather than guessed at, leaving the control at its own default, and the omission is reported. A sample that cannot be made pasteable is withheld and named rather than published broken.
Pages documenting APIs with no markup — most of
ExtensionsandHelpers— appear with an emptysamplesarray, so the index carries the whole component surface instead of only the parts that have XAML. Everything specific to this repository sits under atoolkitobject, leaving the rest of each entry portable across sample sources.Regenerating is one command, and CI runs the same tool in
checkmode:The check job runs on
ubuntu-latestand needs no workloads, because the exporter reads the samples as text rather than building them. It reports a stale index in under a minute instead of after the build matrix.catalog/README.mddocuments the format, the guarantees and the regeneration step;.gitattributespins the artifact to LF so the file is byte-identical whether it was generated on Windows or Linux.PR Checklist
Please check if your PR fulfills the following requirements:
Based on
413892f("Rename CheckModifierKeys to ModifiersEnabled"), the currentmaintip at the time of writing. All 19 new.csfiles carry the standard .NET Foundation header. No existing file's behaviour changes: the only edits outside new directories are an added job in.github/workflows/build.ymland four added lines in.gitattributes.Validation
Both commands were run on Windows against this branch, with these results:
These are the same two commands the new CI job runs.
I did not run the full build matrix locally, as it needs Windows workloads this machine does not have. The change touches no component, sample or app source, so the matrix exercises nothing it affects — but it is CI, not me, that will confirm that.
Other information
The tests are written as the guarantees a consumer depends on rather than as coverage of the implementation: the committed file matches the samples, generation is deterministic, every published snippet parses and declares the prefixes it uses, and anything excluded is listed by name. A fixture deliberately mixes casing so that ordering is checked ordinally rather than by the host filesystem's collation, since the index is generated on Windows and byte-compared on Linux.
Three things worth a reviewer's attention specifically:
schemaVersion: 1should not be committed to until the field names suit whatever consumers you have in mind. I would rather change them now than add a version 2 later.idis the one thing consumers may reasonably store. Silently qualifying it would move someone's saved reference. There are no duplicates today.The last two commits are review follow-ups rather than new functionality: the first fixes three defects found reviewing the parser — cast-prefixed
x:Bindpaths losing their attribute, commented-out markup being read as live, and theidinstability described above — and the second rewrites five tests that could not have failed, each verified by mutating the code it covers and confirming the test now fails.Happy to split this into separate PRs for the tool, the artifact and the CI job if that reviews more easily, or to drop it if the direction in the discussion turns out to be wrong.