Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# The sample index is generated, byte-compared in CI, and regenerated by contributors on
# whichever OS they happen to use. Checking it out with native line endings would make the
# working copy differ from the committed bytes on Windows, so it is kept as LF everywhere.
catalog/*.json text eol=lf
21 changes: 21 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,27 @@ jobs:
- name: Check XAML Styling
run: powershell -version 5.1 -command "./ApplyXamlStyling.ps1 -Passive" -ErrorAction Stop

# Verifies the published sample index still describes the samples in this branch. Runs on
# Linux and needs no workloads, because the exporter reads the samples as text rather than
# building them — so it reports a stale index in under a minute instead of after the matrix.
Sample-Index:
runs-on: ubuntu-latest

steps:
- name: Checkout Repository
uses: actions/checkout@v4

- name: Install .NET SDK
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json

- name: Check the sample index is up to date
run: dotnet run --project tools/SampleIndexExporter -- check

- name: Run sample index tests
run: dotnet test tools/SampleIndexExporter.Tests

# Build both Uno.UI/WinUI2/UWP and Uno.WinUI/WinUI3/WindowsAppSDK versions of our packages using a matrix
build:
needs: [Xaml-Style-Check]
Expand Down
74 changes: 74 additions & 0 deletions catalog/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Sample index

`toolkit-samples.json` is a machine-readable index of every sample in this repository. It exists so that tools outside the Toolkit — documentation sites, search, AI coding assistants — can offer Toolkit samples without scraping the repository and guessing at its layout.

The file is generated. Do not edit it by hand.

## What is in it

One entry per documentation page under `components/*/samples/`, and one sample per `[!SAMPLE]` marker in that page, in the order the page presents them.

```jsonc
{
"schemaVersion": 1,
"source": "toolkit",
"controls": [
{
"id": "settingscard",
"name": "SettingsCard",
"description": "A card control that can be used to create Windows 11 style settings experiences.",
"nugetPackage": "CommunityToolkit.WinUI.Controls.SettingsControls",
"curatedKeywords": ["SettingsCard", "Control", "Layout", "Settings"],
"usings": ["System.ComponentModel"],
"docs": [{ "uri": "https://github.com/CommunityToolkit/Windows/blob/main/components/..." }],
"samples": [
{
"header": "SettingsCard",
"xaml": "<StackPanel Spacing=\"4\">…</StackPanel>",
"xmlnsImports": ["xmlns:controls=\"using:CommunityToolkit.WinUI.Controls\""],
"toolkit": { "sampleId": "SettingsCardSample", "sourcePath": "components/…/SettingsCardSample.xaml" }
}
]
}
]
}
```

Pages that document APIs with no markup to show — most of `Extensions` and `Helpers` — appear with an empty `samples` array, so the index carries the whole component surface rather than only the parts that happen to have XAML.

Everything specific to this repository lives under a `toolkit` object, leaving the rest of each entry portable across sample sources.

## The XAML is meant to be pasted

Each `xaml` value is the sample's markup with the sample app removed from it:

- The `<Page>` wrapper and its `x:Class` are gone, along with design-time namespaces.
- Options that the sample app renders as sliders and toggles are replaced by the value the app starts with, so the snippet shows the sample in the state the gallery opens it in. An option whose value cannot be written as a literal has its attribute removed instead of guessed at, which leaves the control at its own default.
- `<Page.Resources>` moves onto the element that survives, so referenced keys stay in scope.
- Conditional `win:` prefixes are dropped, since on Windows they name the same elements as no prefix at all.
- `xmlnsImports` lists only the namespace declarations the snippet actually uses.

What changes is the environment, never what the sample demonstrates. A sample whose markup cannot be made pasteable is left out and reported rather than published broken.

## The C# is the sample, not the page

A sample that has code-behind also carries a `code` value: the handlers its markup calls and the types its markup binds to, as members to drop into a page. 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, so none of them appear. Conditional branches are resolved for WinAppSDK, so the reader is not handed a choice that has already been made.

Most samples have nothing left once that is removed, and those publish no `code` at all rather than a constructor that says nothing. A constructor that does something the sample needs is kept, named after the sample — rename it to your own page, the same adaptation the markup's `x:Class` already asks for.

The namespaces that code needs are published once per entry, as `usings`, rather than repeated as `using` lines inside every snippet — consumers prepend them. The list is narrowed to what the published members actually use: a sample file imports whatever its whole page needed, and most of that page is scaffolding nobody is handed, so publishing the file's imports verbatim would ask you to reference packages for code you never got. Namespaces that exist only in this repository's sample app are never published, since they would not resolve anywhere a snippet is pasted.

## Regenerating

```shell
dotnet run --project tools/SampleIndexExporter -- generate
```

Commit the result alongside the sample change that caused it. CI runs the same tool in `check` mode and fails if the committed file no longer matches the samples, so an index that drifts is caught in the pull request that caused the drift.

```shell
dotnet run --project tools/SampleIndexExporter -- check
dotnet test tools/SampleIndexExporter.Tests
```

The tests are the guarantees consumers rely on: the committed file matches the samples, generation is deterministic, every published snippet parses and declares the prefixes and namespaces it uses, and anything excluded is listed by name rather than disappearing quietly.
4,146 changes: 4,146 additions & 0 deletions catalog/toolkit-samples.json

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions tools/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!--
Stops MSBuild walking further up to the repository root.
The root Directory.Build.props exists to ship multitargeted NuGet packages: it sets
IsPackable, IsPublishable, package metadata and the MultiTarget machinery. Nothing under
tools/ ships as a package, and these are plain platform-agnostic projects that must build
on any CI agent without the Uno/WinUI workloads, so they opt out of all of it rather than
overriding it property by property.
-->
<Project>
</Project>
224 changes: 224 additions & 0 deletions tools/SampleIndexExporter.Tests/ContractConformanceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace CommunityToolkit.SampleIndex.Tests;

/// <summary>
/// The contract gate: the published file has to stay usable by consumers that already read it.
/// </summary>
/// <remarks>
/// The index is a public artifact fetched from this repository's main branch. Anything checked
/// here is something a consumer is entitled to rely on without defensive code: ids that are
/// stable and safe in a URL, names that are present, a version it can branch on.
/// </remarks>
[TestClass]
public class ContractConformanceTests
{
[TestMethod]
public void IndexDeclaresItsSourceAndVersion()
{
// Read back from the committed file rather than the object that produced it. A consumer
// branches on these two fields before it reads anything else, so what matters is that
// they survive serialization under the field names the contract publishes — asserting
// against the in-memory defaults would only restate their initializers.
using var document = JsonDocument.Parse(RepositoryIndex.CommittedJson);

Assert.AreEqual(1, document.RootElement.GetProperty("schemaVersion").GetInt32());
Assert.AreEqual("toolkit", document.RootElement.GetProperty("source").GetString());
}

[TestMethod]
public void EntryIdsAreUniqueAndUrlSafe()
{
var ids = RepositoryIndex.Index.Controls.Select(c => c.Id).ToList();

CollectionAssert.AllItemsAreUnique(ids, "Entry ids must be unique; a consumer keys on them.");

var unsafeIds = ids
.Where(id => !id.All(c => char.IsAsciiLetterLower(c) || char.IsAsciiDigit(c) || c == '-'))
.ToList();

Assert.AreEqual(
0,
unsafeIds.Count,
"Entry ids must be lowercase letters, digits and hyphens:\n " + string.Join("\n ", unsafeIds));
}

[TestMethod]
public void EntryIdDependsOnlyOnItsOwnDocumentFileName()
{
// An id is published as stable, so it has to be a function of the entry's own document
// and nothing else. Deriving it from the set of entries present — qualifying whichever
// of two colliding file names happened to be read second — would silently rename an
// entry that already shipped the day an unrelated component was added. A collision is
// reported as an error instead, so this invariant holds by construction.
var unexpected = RepositoryIndex.Index.Controls
.Where(c => c.Toolkit?.DocumentPath is { } path && c.Id != ExpectedId(path))
.Select(c => $"{c.Id} (expected '{ExpectedId(c.Toolkit!.DocumentPath!)}' from {c.Toolkit!.DocumentPath})")
.ToList();

Assert.AreEqual(
0,
unexpected.Count,
"Entry ids must be derived from their own documentation file name alone:\n "
+ string.Join("\n ", unexpected));
}

private static string ExpectedId(string documentPath) =>
Regex.Replace(Path.GetFileNameWithoutExtension(documentPath).ToLowerInvariant(), "[^a-z0-9]+", "-").Trim('-');

[TestMethod]
public void EveryEntryHasANameAndSourceDocument()
{
var incomplete = RepositoryIndex.Index.Controls
.Where(c => string.IsNullOrWhiteSpace(c.Name) || string.IsNullOrWhiteSpace(c.Toolkit?.DocumentPath))
.Select(c => c.Id)
.ToList();

Assert.AreEqual(
0,
incomplete.Count,
"Entries missing a name or source document:\n " + string.Join("\n ", incomplete));
}

[TestMethod]
public void EveryEntryLinksToItsDocumentation()
{
var unlinked = RepositoryIndex.Index.Controls
.Where(c => c.Docs is null || c.Docs.Count == 0 || c.Docs.Any(d => string.IsNullOrWhiteSpace(d.Uri)))
.Select(c => c.Id)
.ToList();

Assert.AreEqual(0, unlinked.Count, "Entries with no documentation link:\n " + string.Join("\n ", unlinked));
}

[TestMethod]
public void EverySampleHasAHeader()
{
// The header is what a consumer shows in a picker. Without it the entry is a body of
// markup the reader has to decode before they can tell whether they want it.
var headerless = RepositoryIndex.Samples
.Where(s => string.IsNullOrWhiteSpace(s.Sample.Header))
.Select(s => s.Sample.Toolkit!.SourcePath)
.ToList();

Assert.AreEqual(0, headerless.Count, "Samples with no header:\n " + string.Join("\n ", headerless));
}

[TestMethod]
public void EverySampleRecordsWhereItCameFrom()
{
var untraceable = RepositoryIndex.Samples
.Where(s => string.IsNullOrWhiteSpace(s.Sample.Toolkit?.SourcePath)
|| string.IsNullOrWhiteSpace(s.Sample.Toolkit?.SampleId))
.Select(s => $"{s.Control.Id}: {s.Sample.Header}")
.ToList();

Assert.AreEqual(
0,
untraceable.Count,
"Samples that do not say which file they came from:\n " + string.Join("\n ", untraceable));
}

[TestMethod]
public void EveryNuGetPackageNameLooksLikeAPackage()
{
var suspicious = RepositoryIndex.Index.Controls
.Where(c => c.NuGetPackage is { } package
&& !package.StartsWith("CommunityToolkit.", StringComparison.Ordinal))
.Select(c => $"{c.Id}: {c.NuGetPackage}")
.ToList();

Assert.AreEqual(
0,
suspicious.Count,
"Entries naming a package that is not a toolkit package:\n " + string.Join("\n ", suspicious));
}

[TestMethod]
public void EveryPublishedUsingIsANamespaceAConsumerCanWriteOut()
{
// Read back from the committed file: a consumer builds 'using {value};' lines straight
// from this array, so anything that is not a namespace name becomes a syntax error in
// the reader's file rather than a missing import they could work around.
using var document = JsonDocument.Parse(RepositoryIndex.CommittedJson);

var malformed = new List<string>();

foreach (var control in document.RootElement.GetProperty("controls").EnumerateArray())
{
if (!control.TryGetProperty("usings", out var usings))
{
continue;
}

malformed.AddRange(usings
.EnumerateArray()
.Select(u => u.GetString())
.Where(u => u is null || !Regex.IsMatch(u, @"^[A-Za-z_]\w*(\.[A-Za-z_]\w*)*$"))
.Select(u => $"{control.GetProperty("id").GetString()}: '{u}'"));
}

Assert.AreEqual(
0,
malformed.Count,
"Published usings that are not namespace names:\n " + string.Join("\n ", malformed));
}

[TestMethod]
public void KeywordsAreTrimmedAndNonEmpty()
{
// Consumers weight curated keywords above generated ones, so a stray empty string or
// untrimmed entry becomes a search term that matches nothing. Read back from the
// committed file, and covering both lists: the generated keywords come from the
// category frontmatter by a different path than the curated ones, and were not checked
// at all.
using var document = JsonDocument.Parse(RepositoryIndex.CommittedJson);

var malformed = new List<string>();

foreach (var control in document.RootElement.GetProperty("controls").EnumerateArray())
{
foreach (var field in new[] { "curatedKeywords", "keywords" })
{
if (!control.TryGetProperty(field, out var keywords))
{
continue;
}

malformed.AddRange(keywords
.EnumerateArray()
.Select(k => k.GetString())
.Where(k => string.IsNullOrWhiteSpace(k) || k != k.Trim())
.Select(k => $"{control.GetProperty("id").GetString()}.{field}: '{k}'"));
}
}

Assert.AreEqual(
0,
malformed.Count,
"Blank or untrimmed keywords:\n " + string.Join("\n ", malformed));
}

[TestMethod]
public void KeywordSplittingDiscardsPaddingAndEmptyTerms()
{
// Pins the behaviour the gate above relies on. Frontmatter is hand-written, so a
// trailing comma or a space after one is a matter of time rather than a hypothetical.
var keywords = MarkdownDocument.SplitKeywords("WrapPanel, Layout ,, Panel,");

CollectionAssert.AreEqual(new[] { "WrapPanel", "Layout", "Panel" }, keywords);
}

[TestMethod]
public void KeywordSplittingTreatsAnAbsentFieldAsNoKeywords()
{
Assert.AreEqual(0, MarkdownDocument.SplitKeywords(null).Count);
Assert.AreEqual(0, MarkdownDocument.SplitKeywords(" ").Count);
}
}
Loading
Loading