From 7a5dfd147863ba2a15d593e7406b55e6721246f0 Mon Sep 17 00:00:00 2001 From: Shewart Date: Fri, 2 Oct 2026 22:23:27 +0200 Subject: [PATCH 1/5] feat(markdown): generic components in previews, inline tags and ComponentPreview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RegisterComponentsFromAssembly skipped generic definitions, so , data tables and typed selects couldn't be used from markdown. Generic components now register under their bare name and SlotRenderer.Component closes them per use from attributes named after the type parameters, as Razor does. Type arguments accept C# spellings (aliases, T?, T[], List, simple or full names). Without one, object is used when the constraints allow it, with a warning; otherwise an inline .shelldocs-render-error replaces the component instead of breaking the page. A non-generic component with the same name keeps the tag, and AutoTypeTable lists type parameters. --- .../Content/AutoTypeTable.razor | 16 +- .../Content/ComponentPreview.razor | 18 +- .../Content/GenericComponents.cs | 165 ++++++++++++++++++ .../Content/MarkdownContent.razor | 5 +- .../Content/PreviewFrame.razor | 9 +- .../Content/SlotRenderer.cs | 37 +++- src/ShellDocs.Components/ShellDocsOptions.cs | 12 +- .../wwwroot/shelldocs-theme.css | 12 ++ src/ShellDocs.Markdown/TypeRegistry.cs | 10 +- tests/ShellDocs.Tests/AssemblyScanTests.cs | 7 +- .../ShellDocs.Tests/GenericComponentTests.cs | 146 ++++++++++++++++ 11 files changed, 409 insertions(+), 28 deletions(-) create mode 100644 src/ShellDocs.Components/Content/GenericComponents.cs create mode 100644 tests/ShellDocs.Tests/GenericComponentTests.cs diff --git a/src/ShellDocs.Components/Content/AutoTypeTable.razor b/src/ShellDocs.Components/Content/AutoTypeTable.razor index 0f635ea..b3ef9e0 100644 --- a/src/ShellDocs.Components/Content/AutoTypeTable.razor +++ b/src/ShellDocs.Components/Content/AutoTypeTable.razor @@ -73,8 +73,19 @@ else private static List BuildRows(Type target) { var rows = new List(); + + // Generic components list their type parameters first, set as TItem="…". + // Defaults are read from an instance closed over object, when that's allowed. + var instanceType = target; + if (target.IsGenericTypeDefinition) + { + foreach (var tp in target.GetGenericArguments()) + rows.Add(new TypeRowInfo(tp.Name, "type parameter", null, "Generic type argument, set as an attribute.", true)); + try { instanceType = target.MakeGenericType(target.GetGenericArguments().Select(_ => typeof(object)).ToArray()); } + catch (ArgumentException) { instanceType = null; } + } object? instance = null; - try { instance = Activator.CreateInstance(target); } catch { } + try { if (instanceType is not null) instance = Activator.CreateInstance(instanceType); } catch { } foreach (var prop in target.GetProperties(BindingFlags.Public | BindingFlags.Instance)) { @@ -83,7 +94,7 @@ else var name = prop.Name; var type = FormatType(prop.PropertyType); var required = prop.GetCustomAttribute() is not null; - var def = ReadDefault(prop, instance); + var def = ReadDefault(instanceType?.GetProperty(name, BindingFlags.Public | BindingFlags.Instance) ?? prop, instance); var desc = XmlDocIndex.SummaryFor(prop); rows.Add(new TypeRowInfo(name, type, def, desc, required)); } @@ -95,6 +106,7 @@ else { var underlying = Nullable.GetUnderlyingType(t); if (underlying is not null) return FormatType(underlying) + "?"; + if (t.IsGenericParameter) return t.Name; if (t == typeof(string)) return "string"; if (t == typeof(bool)) return "bool"; if (t == typeof(int)) return "int"; diff --git a/src/ShellDocs.Components/Content/ComponentPreview.razor b/src/ShellDocs.Components/Content/ComponentPreview.razor index cb17061..d41e5c3 100644 --- a/src/ShellDocs.Components/Content/ComponentPreview.razor +++ b/src/ShellDocs.Components/Content/ComponentPreview.razor @@ -8,7 +8,7 @@ ." : null)" + Error="@_error" ErrorTitle="ComponentPreview error" Id="@_id" Name="@Component" @@ -28,10 +28,25 @@ private IDictionary? _targetParams; private string? _source; private string? _id; + private string? _error; + private HashSet _typeParams = new(StringComparer.OrdinalIgnoreCase); protected override void OnParametersSet() { _target = Component is null ? null : Registry.Resolve(Component); + _error = _target is null ? $"Unknown component <{Component}>." : null; + _typeParams = new(StringComparer.OrdinalIgnoreCase); + if (_target is { IsGenericTypeDefinition: true } open) + { + // TItem="Product" closes a generic target, as in Razor. + var attrs = (ExtraProps ?? new Dictionary()) + .Where(kv => kv.Value is string) + .ToDictionary(kv => kv.Key, kv => (string)kv.Value); + _target = GenericComponents.Close(open, attrs, out _, out var error, out var notes); + foreach (var note in notes) Logger.LogWarning("ShellDocs: {Note}", note); + if (_target is null) _error = error; + _typeParams = new(open.GetGenericArguments().Select(p => p.Name), StringComparer.OrdinalIgnoreCase); + } _targetParams = _target is null ? null : BuildTargetParams(_target); _source = BuildSource(); // Same component twice on a page gets distinct ids via the source hash. @@ -54,6 +69,7 @@ { foreach (var (k, v) in ExtraProps) { + if (_typeParams.Contains(k)) continue; if (!props.TryGetValue(k, out var prop)) { if (!k.StartsWith('@') && SlotRenderer.HasCatchAll(target)) dict[k] = v; diff --git a/src/ShellDocs.Components/Content/GenericComponents.cs b/src/ShellDocs.Components/Content/GenericComponents.cs new file mode 100644 index 0000000..a532cb7 --- /dev/null +++ b/src/ShellDocs.Components/Content/GenericComponents.cs @@ -0,0 +1,165 @@ +using System.Collections.Concurrent; +using System.Reflection; +using ShellDocs.Markdown; + +namespace ShellDocs.Components.Content; + +/* Generic components register under their bare name () as open + definitions and are closed per use from attributes named after their type + parameters, as Razor writes them: . A missing + type argument falls back to object when the constraints allow it. */ +internal static class GenericComponents +{ + private static readonly Dictionary Aliases = new(StringComparer.Ordinal) + { + ["bool"] = typeof(bool), ["byte"] = typeof(byte), ["sbyte"] = typeof(sbyte), ["char"] = typeof(char), + ["decimal"] = typeof(decimal), ["double"] = typeof(double), ["float"] = typeof(float), + ["int"] = typeof(int), ["uint"] = typeof(uint), ["long"] = typeof(long), ["ulong"] = typeof(ulong), + ["short"] = typeof(short), ["ushort"] = typeof(ushort), ["nint"] = typeof(nint), ["nuint"] = typeof(nuint), + ["object"] = typeof(object), ["string"] = typeof(string), + }; + + // Closes `type` if it's a generic definition. `remaining` is `attrs` without the + // type-parameter attributes; `error` is set (and the result null) when it can't close. + public static Type? Close( + Type type, + IReadOnlyDictionary attrs, + out IReadOnlyDictionary remaining, + out string? error, + out IReadOnlyList notes) + { + remaining = attrs; + error = null; + notes = Array.Empty(); + if (!type.IsGenericTypeDefinition) return type; + + var typeParams = type.GetGenericArguments(); + var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (k, v) in attrs) lookup[k] = v; + + var args = new Type[typeParams.Length]; + var messages = new List(); + for (var i = 0; i < typeParams.Length; i++) + { + var name = typeParams[i].Name; + if (lookup.TryGetValue(name, out var raw)) + { + var resolved = ResolveTypeName(raw, type.Assembly); + if (resolved is null) + { + error = $"<{TagName(type)}>: can't resolve {name}=\"{raw}\". Use a type name the app references (e.g. \"int\", \"string\", \"MyApp.Models.Product\")."; + return null; + } + args[i] = resolved; + } + else + { + args[i] = typeof(object); + messages.Add($"<{TagName(type)}>: no {name}=\"…\" attribute; using object."); + } + } + + Type closed; + try { closed = type.MakeGenericType(args); } + catch (ArgumentException) + { + var names = string.Join(", ", typeParams.Select(p => p.Name + "=\"…\"")); + error = $"<{TagName(type)}> is generic and its type arguments don't satisfy its constraints. Set {names}."; + return null; + } + + var typeParamNames = new HashSet(typeParams.Select(p => p.Name), StringComparer.OrdinalIgnoreCase); + remaining = attrs.Where(kv => !typeParamNames.Contains(kv.Key)).ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal); + notes = messages; + return closed; + } + + public static string TagName(Type type) => TypeRegistry.TagNameOf(type); + + // C# spellings: aliases, `T?`, `T[]`, `List`, simple or full type names. + // Searches the component's assembly first, then every loaded assembly. + internal static Type? ResolveTypeName(string raw, Assembly? preferred = null) + { + var name = raw.Trim(); + if (name.StartsWith('@')) name = name[1..].Trim(); + if (name.Length == 0) return null; + + if (name.EndsWith('?')) + { + var inner = ResolveTypeName(name[..^1], preferred); + if (inner is null) return null; + return inner.IsValueType ? typeof(Nullable<>).MakeGenericType(inner) : inner; + } + if (name.EndsWith("[]", StringComparison.Ordinal)) + return ResolveTypeName(name[..^2], preferred)?.MakeArrayType(); + + var lt = name.IndexOf('<'); + if (lt > 0 && name.EndsWith('>')) + { + var argNames = SplitTopLevel(name[(lt + 1)..^1]); + var def = FindType($"{name[..lt].Trim()}`{argNames.Count}", preferred); + if (def is null) return null; + var typeArgs = new Type[argNames.Count]; + for (var i = 0; i < argNames.Count; i++) + { + var a = ResolveTypeName(argNames[i], preferred); + if (a is null) return null; + typeArgs[i] = a; + } + try { return def.MakeGenericType(typeArgs); } + catch (ArgumentException) { return null; } + } + + if (Aliases.TryGetValue(name, out var alias)) return alias; + return FindType(name, preferred); + } + + private static readonly ConcurrentDictionary<(string, Assembly?), Type?> _found = new(); + + private static Type? FindType(string name, Assembly? preferred) + => _found.GetOrAdd((name, preferred), key => Search(key.Item1, key.Item2)); + + private static Type? Search(string name, Assembly? preferred) + { + var direct = Type.GetType(name, throwOnError: false) ?? Type.GetType("System." + name, throwOnError: false); + if (direct is not null) return direct; + + var assemblies = AppDomain.CurrentDomain.GetAssemblies().Where(a => !a.IsDynamic); + if (preferred is not null) assemblies = assemblies.OrderBy(a => a == preferred ? 0 : 1); + + Type? bySimpleName = null; + foreach (var assembly in assemblies) + { + var full = assembly.GetType(name, throwOnError: false); + if (full is not null) return full; + if (bySimpleName is not null || name.Contains('.')) continue; + bySimpleName = ExportedTypes(assembly).FirstOrDefault(t => t.Name == name); + } + return bySimpleName; + } + + private static IEnumerable ExportedTypes(Assembly assembly) + { + try { return assembly.GetExportedTypes(); } + catch { return Array.Empty(); } + } + + private static List SplitTopLevel(string s) + { + var parts = new List(); + var depth = 0; + var start = 0; + for (var i = 0; i < s.Length; i++) + { + if (s[i] == '<') depth++; + else if (s[i] == '>') depth--; + else if (s[i] == ',' && depth == 0) + { + parts.Add(s[start..i].Trim()); + start = i + 1; + } + } + parts.Add(s[start..].Trim()); + return parts; + } +} diff --git a/src/ShellDocs.Components/Content/MarkdownContent.razor b/src/ShellDocs.Components/Content/MarkdownContent.razor index 12bf0ad..9330a72 100644 --- a/src/ShellDocs.Components/Content/MarkdownContent.razor +++ b/src/ShellDocs.Components/Content/MarkdownContent.razor @@ -23,7 +23,7 @@ { @if (slot.Slot is ComponentSlot comp) { - + @SlotRenderer.Component(Renderer, comp.ComponentType, comp.Parameters, comp.ChildContentRaw, Logger) } else if (slot.Slot is PreviewSlot preview) { @@ -52,7 +52,4 @@ if (_parts is null) return; try { await JS.InvokeVoidAsync("shelldocsHighlight"); } catch { } } - - private IDictionary BuildParameters(ComponentSlot slot) => - SlotRenderer.BuildParameters(Renderer, slot.ComponentType, slot.Parameters, slot.ChildContentRaw, Logger); } diff --git a/src/ShellDocs.Components/Content/PreviewFrame.razor b/src/ShellDocs.Components/Content/PreviewFrame.razor index 5bf4f12..c7df446 100644 --- a/src/ShellDocs.Components/Content/PreviewFrame.razor +++ b/src/ShellDocs.Components/Content/PreviewFrame.razor @@ -81,8 +81,7 @@ } else { - + @SlotRenderer.Component(Renderer, Preview!.ComponentType!, Preview!.Parameters, Preview!.ChildContentRaw, Logger, razorChildren: true) }
@@ -132,13 +131,9 @@ _bugHref = _suggestHref = null; return; } - var name = Name ?? Preview?.ComponentType?.Name ?? Title ?? "docs"; + var name = Name ?? (Preview?.ComponentType is { } t ? GenericComponents.TagName(t) : null) ?? Title ?? "docs"; var pageUrl = PreviewLinks.PageUrl(Options, path, Id); _bugHref = PreviewLinks.BugReport(tracker, name, pageUrl); _suggestHref = PreviewLinks.Suggestion(tracker, name, pageUrl); } - - // Only reached when ErrorMessage is null, i.e. ComponentType is non-null. - private IDictionary BuildParameters(IReadOnlyDictionary attrs, string? childContentRaw) => - SlotRenderer.BuildParameters(Renderer, Preview!.ComponentType!, attrs, childContentRaw, Logger, razorChildren: true); } diff --git a/src/ShellDocs.Components/Content/SlotRenderer.cs b/src/ShellDocs.Components/Content/SlotRenderer.cs index 9ce258b..96a3a69 100644 --- a/src/ShellDocs.Components/Content/SlotRenderer.cs +++ b/src/ShellDocs.Components/Content/SlotRenderer.cs @@ -42,12 +42,36 @@ public static RenderFragment FromRazor(MarkdownRenderer renderer, string raw, IL } private static void Emit(RenderTreeBuilder builder, ref int seq, MarkdownRenderer renderer, ComponentSlot slot, ILogger? logger) + => builder.AddContent(seq++, Component(renderer, slot.ComponentType, slot.Parameters, slot.ChildContentRaw, logger)); + + /* One registered component from markdown. Generic definitions are closed from + their type-parameter attributes first; one that can't be closed renders a + visible error instead of throwing. */ + public static RenderFragment Component( + MarkdownRenderer renderer, + Type type, + IReadOnlyDictionary attrs, + string? childContentRaw, + ILogger? logger = null, + bool razorChildren = false) => builder => { - builder.OpenComponent(seq++); - builder.AddAttribute(seq++, "Type", slot.ComponentType); - builder.AddAttribute(seq++, "Parameters", BuildParameters(renderer, slot.ComponentType, slot.Parameters, slot.ChildContentRaw, logger)); + var closed = GenericComponents.Close(type, attrs, out var remaining, out var error, out var notes); + foreach (var note in notes) logger?.LogWarning("ShellDocs: {Note}", note); + if (closed is null) + { + logger?.LogWarning("ShellDocs: {Error}", error); + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "shelldocs-render-error"); + builder.AddAttribute(2, "role", "alert"); + builder.AddContent(3, error); + builder.CloseElement(); + return; + } + builder.OpenComponent(4); + builder.AddAttribute(5, "Type", closed); + builder.AddAttribute(6, "Parameters", BuildParameters(renderer, closed, remaining, childContentRaw, logger, razorChildren)); builder.CloseComponent(); - } + }; /* Elements are real render-tree elements, not markup strings, so wrappers keep their component children under interactive re-renders. One region per node @@ -67,10 +91,7 @@ private static void EmitNodes(RenderTreeBuilder builder, MarkdownRenderer render break; case PreviewComponentNode comp: - builder.OpenComponent(0); - builder.AddAttribute(1, "Type", comp.ComponentType); - builder.AddAttribute(2, "Parameters", BuildParameters(renderer, comp.ComponentType, comp.Parameters, comp.ChildContentRaw, logger, razorChildren: true)); - builder.CloseComponent(); + builder.AddContent(0, Component(renderer, comp.ComponentType, comp.Parameters, comp.ChildContentRaw, logger, razorChildren: true)); break; case PreviewElementNode el: diff --git a/src/ShellDocs.Components/ShellDocsOptions.cs b/src/ShellDocs.Components/ShellDocsOptions.cs index 75d5fbd..d8d3a2d 100644 --- a/src/ShellDocs.Components/ShellDocsOptions.cs +++ b/src/ShellDocs.Components/ShellDocsOptions.cs @@ -76,7 +76,8 @@ public ShellDocsOptions RegisterComponent(Type type, string tagName) return this; } - // Registers every public, concrete, non-generic component in the assembly, skipping [ShellDocsIgnore]. + // Registers every public, concrete component in the assembly, skipping [ShellDocsIgnore]. + // Generic components register under their bare name and are closed per use (TItem="…"). public ShellDocsOptions RegisterComponentsFromAssembly(Func? filter = null) => RegisterComponentsFromAssembly(typeof(TMarker).Assembly, filter); @@ -123,7 +124,6 @@ private static IEnumerable DiscoverComponentTypes(Assembly assembly) if (t is null) continue; if (!t.IsClass || t.IsAbstract) continue; if (!t.IsPublic && !t.IsNestedPublic) continue; - if (t.IsGenericTypeDefinition) continue; if (!typeof(ComponentBase).IsAssignableFrom(t)) continue; if (t.IsDefined(typeof(ShellDocsIgnoreAttribute), inherit: false)) continue; yield return t; @@ -196,8 +196,16 @@ internal TypeRegistry BuildTypeRegistry() registry.Register(type); registry.Register(BuiltInAliasPrefix + type.Name, type); } + // A library shipping both Select and Select keeps