From c328086b82810cbc5ccb5d75ab8a9b3c8163743a Mon Sep 17 00:00:00 2001 From: Chris Hanna Date: Thu, 2 Apr 2026 15:08:03 -0400 Subject: [PATCH 1/2] docs work --- .../PortalExtensionConstants.cs | 1 + cli/cli/App.cs | 1 + .../Docs/GenerateWebComponentDocsCommand.cs | 541 ++++++++++++++++++ .../Project/NewPortalExtensionCommand.cs | 3 + cli/cli/Services/BeamoLocalSystem.cs | 2 +- .../BeamoLocalSystem_PortalExtension.cs | 13 + .../PortalExtensionDiscoveryService.cs | 8 +- cli/cli/Services/ProjectContextUtil.cs | 32 +- .../Microservice/EmbeddedVersionUtil.cs | 33 ++ .../portal-toolkit-version.json | 3 + .../beamable.tooling.common.csproj | 1 + 11 files changed, 632 insertions(+), 6 deletions(-) create mode 100644 cli/cli/Commands/Docs/GenerateWebComponentDocsCommand.cs create mode 100644 microservice/beamable.tooling.common/Microservice/EmbeddedVersionUtil.cs create mode 100644 microservice/beamable.tooling.common/Microservice/VersionManagement/portal-toolkit-version.json diff --git a/cli/beamable.common/Runtime/Constants/Implementations/PortalExtensionConstants.cs b/cli/beamable.common/Runtime/Constants/Implementations/PortalExtensionConstants.cs index 76b73cbf56..2eacd67e22 100644 --- a/cli/beamable.common/Runtime/Constants/Implementations/PortalExtensionConstants.cs +++ b/cli/beamable.common/Runtime/Constants/Implementations/PortalExtensionConstants.cs @@ -7,6 +7,7 @@ public static partial class Features public static partial class PortalExtension { public const string EXTENSION_DEPENDENCIES_PROPERTY_NAME = "microserviceDependencies"; + public const string PORTAL_TOOLKIT_PACKAGE_NAME = "@beamable/portal-toolkit"; } } } diff --git a/cli/cli/App.cs b/cli/cli/App.cs index 8186c1ddc2..012eb55f0e 100644 --- a/cli/cli/App.cs +++ b/cli/cli/App.cs @@ -607,6 +607,7 @@ public virtual void Configure( Commands.AddRootCommand(); Commands.AddRootCommand(); Commands.AddRootCommand(); + Commands.AddRootCommand(); // FEDERATION COMMANDS Commands.AddRootCommand(); diff --git a/cli/cli/Commands/Docs/GenerateWebComponentDocsCommand.cs b/cli/cli/Commands/Docs/GenerateWebComponentDocsCommand.cs new file mode 100644 index 0000000000..77127bed94 --- /dev/null +++ b/cli/cli/Commands/Docs/GenerateWebComponentDocsCommand.cs @@ -0,0 +1,541 @@ +using System.CommandLine; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Beamable.Server; + +namespace cli.Docs; + +public class GenerateWebComponentDocsCommandArgs : CommandArgs +{ + public string webTypesFile; + public string outputDirectory; + public string snippetsDirectory; +} + +public class GenerateWebComponentDocsCommandResult +{ + public List generatedFiles = new(); +} + +public class GenerateWebComponentDocsCommand + : AtomicCommand + , IStandaloneCommand + , ISkipManifest +{ + public override bool IsForInternalUse => true; + + public GenerateWebComponentDocsCommand() : base("web-components", "Generate the docs for web components") + { + } + + public override void Configure() + { + AddOption(new Option("--components", "Path to a web-types.json file describing the web components"), + (args, v) => args.webTypesFile = v); + AddOption(new Option(new[] { "--output-dir", "-o" }, () => "web-component-docs", + "A folder where the output markdown files will be written"), + (args, v) => args.outputDirectory = v); + AddOption(new Option("--snippets", + "Optional path to a folder containing {tag-name}.md snippet files"), + (args, v) => args.snippetsDirectory = v); + } + + public override async Task GetResult(GenerateWebComponentDocsCommandArgs args) + { + if (string.IsNullOrWhiteSpace(args.webTypesFile)) + throw new CliException("--web-types is required"); + + if (!File.Exists(args.webTypesFile)) + throw new CliException($"web-types file not found: {args.webTypesFile}"); + + var json = await File.ReadAllTextAsync(args.webTypesFile); + var manifest = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + var elements = manifest?.Modules + ?.SelectMany(m => m.Declarations ?? new List()) + .Where(d => d.Kind == "custom-element" && !string.IsNullOrWhiteSpace(d.TagName)) + .ToList(); + + if (elements is not { Count: > 0 }) + { + Log.Warning("No custom-element declarations found in manifest"); + return new GenerateWebComponentDocsCommandResult(); + } + + Directory.CreateDirectory(args.outputDirectory); + + var result = new GenerateWebComponentDocsCommandResult(); + var summarySb = new StringBuilder(); + + foreach (var element in elements) + { + var fileName = $"{element.TagName}.md"; + var filePath = Path.Combine(args.outputDirectory, fileName); + var snippet = LoadComponentSnippet(element.TagName, args.snippetsDirectory); + var markdown = RenderComponentDoc(element, snippet); + await File.WriteAllTextAsync(filePath, markdown); + result.generatedFiles.Add(filePath); + Log.Information("Generated {File}", filePath); + + var label = TagNameToLabel(element.TagName); + summarySb.AppendLine($"- [{label}]({fileName})"); + } + + var summaryPath = Path.Combine(args.outputDirectory, "SUMMARY.md"); + await File.WriteAllTextAsync(summaryPath, summarySb.ToString()); + result.generatedFiles.Add(summaryPath); + + return result; + } + + static string RenderComponentDoc(CemDeclaration element, ComponentSnippet snippet = null) + { + var sb = new StringBuilder(); + + sb.AppendLine($"# `<{element.TagName}>`"); + sb.AppendLine(); + + if (!string.IsNullOrWhiteSpace(element.Description)) + { + sb.AppendLine(element.Description.Trim()); + sb.AppendLine(); + } + + if (!string.IsNullOrWhiteSpace(snippet?.About)) + { + sb.AppendLine(snippet.About); + sb.AppendLine(); + } + + // Interactive demo — unified controls + attributes table + live preview + HTML snippet + sb.AppendLine("## Interactive Demo"); + sb.AppendLine(); + sb.AppendLine(RenderInteractiveDemo(element)); + sb.AppendLine(); + + // Events + if (element.Events is { Count: > 0 }) + { + sb.AppendLine("## Events"); + sb.AppendLine(); + sb.AppendLine("| Event | Description |"); + sb.AppendLine("|-------|-------------|"); + foreach (var evt in element.Events) + { + var desc = EscapeMarkdownCell(evt.Description ?? ""); + sb.AppendLine($"| `{evt.Name}` | {desc} |"); + } + sb.AppendLine(); + } + + // Slots + if (element.Slots is { Count: > 0 }) + { + sb.AppendLine("## Slots"); + sb.AppendLine(); + sb.AppendLine("| Slot | Description |"); + sb.AppendLine("|------|-------------|"); + foreach (var slot in element.Slots) + { + var slotName = string.IsNullOrEmpty(slot.Name) ? "(default)" : slot.Name; + var desc = EscapeMarkdownCell(slot.Description ?? ""); + sb.AppendLine($"| `{slotName}` | {desc} |"); + } + sb.AppendLine(); + } + + if (!string.IsNullOrWhiteSpace(snippet?.Notes)) + { + sb.AppendLine("## Notes"); + sb.AppendLine(); + sb.AppendLine(snippet.Notes); + sb.AppendLine(); + } + + return sb.ToString(); + } + + static string RenderInteractiveDemo(CemDeclaration element) + { + var attrs = element.Attributes ?? new List(); + var demoAttrs = attrs.Where(a => a.Name != null).ToList(); + var demoId = $"demo-{element.TagName}"; + var compId = $"comp-{element.TagName}"; + var themeId = $"{demoId}-theme"; + var htmlSnippetId = $"html-{element.TagName}"; + var slotText = TagNameToLabel(element.TagName); + + var sb = new StringBuilder(); + + // IMPORTANT: Python-Markdown type-6 HTML blocks terminate at the first blank line. + // No blank lines and no 4-space-indented lines inside this block. + sb.AppendLine("
"); + sb.AppendLine($"
"); + // Preview + sb.AppendLine("
"); + sb.AppendLine($"<{element.TagName} id=\"{compId}\">{slotText}"); + sb.AppendLine("
"); + // HTML snippet + sb.AppendLine("
"); + sb.AppendLine($"
");
+        sb.AppendLine($"");
+        sb.AppendLine("
"); + // Controls table (3 columns: Attribute | Control | Description) + sb.AppendLine("
"); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($""); + + foreach (var attr in demoAttrs) + { + var inputId = $"{demoId}-{attr.Name}"; + var desc = EscapeHtml(attr.Description ?? ""); + var controlHtml = RenderControl(attr, inputId, element.Members); + sb.AppendLine($""); + } + + sb.AppendLine(""); + sb.AppendLine("
AttributeControlDescription
dark modeToggle dark/light theme for this preview.
{EscapeHtml(attr.Name)}{controlHtml}{desc}
"); + sb.AppendLine("
"); + sb.AppendLine("
"); + sb.AppendLine(""); + sb.AppendLine("
"); + + return sb.ToString(); + } + + /// Renders the appropriate HTML control for a CEM attribute. + static string RenderControl(CemAttribute attr, string inputId, List members) + { + var typeText = attr.Type?.Text ?? "string"; + var defaultVal = BuildDefaultValue(attr, members); + var inferredType = InferType(attr); + + if (inferredType == "boolean") + { + var checkedAttr = defaultVal == "true" ? " checked" : ""; + return $""; + } + + if (inferredType == "number") + { + var numDefault = string.IsNullOrWhiteSpace(defaultVal) ? "0" : defaultVal; + return $""; + } + + var enumValues = ExtractEnumValues(typeText); + var strDefault = defaultVal.Trim('\''); + + if (enumValues.Count > 0) + { + // All enums (open and closed) render as a {options}"; + } + + // Plain text input + return $""; + } + + /// Parses quoted union type text (e.g. `'a' | 'b' | string`) and returns the literal values. + static List ExtractEnumValues(string typeText) + { + if (string.IsNullOrWhiteSpace(typeText)) return new List(); + var values = new List(); + foreach (var part in typeText.Split('|')) + { + var trimmed = part.Trim(); + if (trimmed.Length >= 2 && trimmed.StartsWith("'") && trimmed.EndsWith("'")) + values.Add(trimmed.Substring(1, trimmed.Length - 2)); + } + return values; + } + + /// HTML-escapes a string for safe embedding in attribute values or text content. + static string EscapeHtml(string value) + { + if (string.IsNullOrEmpty(value)) return value; + return value + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">") + .Replace("\"", """); + } + + // attributes in CEM don't carry `default`; the matching field member does. + static string ResolveDefault(CemAttribute attr, List members) + { + var member = members?.FirstOrDefault(m => m.Kind == "field" && m.Name == attr.Name); + return member?.Default ?? attr.Default ?? ""; + } + + static string InferType(CemAttribute attr) + { + var typeStr = attr.Type?.Text?.ToLowerInvariant() ?? "string"; + if (typeStr.Contains("boolean") || typeStr == "bool") return "boolean"; + if (typeStr.Contains("number") || typeStr.Contains("int") || typeStr.Contains("float")) return "number"; + return "string"; + } + + static string BuildDefaultValue(CemAttribute attr, List members) + { + var raw = ResolveDefault(attr, members); + var type = InferType(attr); + if (!string.IsNullOrWhiteSpace(raw)) + { + if (type == "boolean") return raw.ToLower() is "true" or "1" ? "true" : "false"; + if (type == "number") return raw; + return $"'{raw}'"; + } + return type switch + { + "boolean" => "false", + "number" => "0", + _ => "''" + }; + } + + // "beam-btn" → "Button", "beam-data-table" → "Data Table" + static string TagNameToLabel(string tagName) + { + // strip a leading vendor prefix (everything up to and including the first hyphen) + var withoutPrefix = tagName.Contains('-') ? tagName[(tagName.IndexOf('-') + 1)..] : tagName; + // split on hyphens, title-case each word, join with spaces + return string.Join(" ", withoutPrefix.Split('-').Select(TitleCase)); + } + + static string TitleCase(string word) => + word.Length == 0 ? word : char.ToUpperInvariant(word[0]) + word[1..]; + + static string EscapeMarkdownCell(string value) => + value.Replace("\n", " ").Replace("|", "\\|").Trim(); + + // Loads About/Notes snippets from {snippetsDir}/{tagName}.md, if it exists. + static ComponentSnippet LoadComponentSnippet(string tagName, string snippetsDir) + { + if (string.IsNullOrWhiteSpace(snippetsDir)) return new ComponentSnippet(null, null); + var filePath = Path.Combine(snippetsDir, $"{tagName}.md"); + if (!File.Exists(filePath)) return new ComponentSnippet(null, null); + var content = File.ReadAllText(filePath); + var sections = ParseSnippetSections(content); + sections.TryGetValue("about", out var about); + sections.TryGetValue("notes", out var notes); + return new ComponentSnippet(about?.Trim(), notes?.Trim()); + } + + // Splits markdown on level-1 headings into a case-insensitive section dictionary. + static Dictionary ParseSnippetSections(string content) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var lines = content.Split('\n'); + string currentSection = null; + var sectionContent = new StringBuilder(); + foreach (var rawLine in lines) + { + var line = rawLine.TrimEnd('\r'); + if (line.StartsWith("# ")) + { + if (currentSection != null) + result[currentSection] = sectionContent.ToString().Trim(); + currentSection = line.Substring(2).Trim(); + sectionContent.Clear(); + } + else if (currentSection != null) + { + sectionContent.AppendLine(line); + } + } + if (currentSection != null) + result[currentSection] = sectionContent.ToString().Trim(); + return result; + } +} + +// ── Per-component supplemental docs (optional Docs/Components/{tagName}.md) ── + +record ComponentSnippet(string About, string Notes); + +// ── Custom Elements Manifest deserialization model ─────────────────────────── +// Schema: https://github.com/webcomponents/custom-elements-manifest + +public class CustomElementsManifest +{ + public string SchemaVersion { get; set; } + public List Modules { get; set; } +} + +public class CemModule +{ + public string Kind { get; set; } + public string Path { get; set; } + public List Declarations { get; set; } +} + +public class CemDeclaration +{ + public string Kind { get; set; } + + [JsonPropertyName("tagName")] + public string TagName { get; set; } + + public string Name { get; set; } + public string Description { get; set; } + public List Attributes { get; set; } + public List Members { get; set; } + public List Slots { get; set; } + public List Events { get; set; } +} + +public class CemAttribute +{ + public string Name { get; set; } + public string Description { get; set; } + public string Default { get; set; } + public CemType Type { get; set; } +} + +public class CemMember +{ + public string Kind { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string Default { get; set; } + public CemType Type { get; set; } +} + +public class CemType +{ + public string Text { get; set; } +} + +public class CemSlot +{ + public string Name { get; set; } + public string Description { get; set; } +} + +public class CemEvent +{ + public string Name { get; set; } + public string Description { get; set; } +} diff --git a/cli/cli/Commands/Project/NewPortalExtensionCommand.cs b/cli/cli/Commands/Project/NewPortalExtensionCommand.cs index fe7986b444..d45bcff00e 100644 --- a/cli/cli/Commands/Project/NewPortalExtensionCommand.cs +++ b/cli/cli/Commands/Project/NewPortalExtensionCommand.cs @@ -1,6 +1,7 @@ using System.CommandLine; using cli.Portal; using cli.Services; +using cli.Services.PortalExtension; using cli.Utils; using Newtonsoft.Json.Linq; using Spectre.Console; @@ -146,6 +147,8 @@ public override async Task Handle(NewPortalExtensionCommandArgs args) } File.WriteAllText(def.AbsolutePackageJsonPath, jObj.ToString(Newtonsoft.Json.Formatting.Indented)); + + PortalExtensionObserver.InstallDeps(def.AbsolutePath); } private static void BuildMountSiteIndex( diff --git a/cli/cli/Services/BeamoLocalSystem.cs b/cli/cli/Services/BeamoLocalSystem.cs index a484431b38..40db8a017d 100644 --- a/cli/cli/Services/BeamoLocalSystem.cs +++ b/cli/cli/Services/BeamoLocalSystem.cs @@ -841,7 +841,7 @@ public string GetToolkitVersion() var json = File.ReadAllText(AbsolutePackageJsonPath); var root = Newtonsoft.Json.Linq.JObject.Parse(json); var depVersion = (root["devDependencies"] as Newtonsoft.Json.Linq.JObject) - ?["@beamable/portal-toolkit"]?.ToString(); + ?[Beamable.Common.Constants.Features.PortalExtension.PORTAL_TOOLKIT_PACKAGE_NAME]?.ToString(); // If the version is a file: reference or other non-semver, resolve from the installed package if (depVersion != null && !char.IsDigit(depVersion.TrimStart('^', '~')[0])) diff --git a/cli/cli/Services/BeamoLocalSystem_PortalExtension.cs b/cli/cli/Services/BeamoLocalSystem_PortalExtension.cs index 405efd1585..638212b497 100644 --- a/cli/cli/Services/BeamoLocalSystem_PortalExtension.cs +++ b/cli/cli/Services/BeamoLocalSystem_PortalExtension.cs @@ -1,3 +1,4 @@ +using System.Runtime.Serialization; using Beamable.Common.Content; using Beamable.Server; using Beamable.Server.Api.Notifications; @@ -5,6 +6,7 @@ using cli.Services.PortalExtension; using Microsoft.Extensions.Logging; using Newtonsoft.Json; +using Newtonsoft.Json.Converters; namespace cli.Services; @@ -152,6 +154,14 @@ public class PortalExtensionMountProperties [JsonProperty("args")] public Dictionary Args = new Dictionary(); } +[JsonConverter(typeof(StringEnumConverter))] +public enum AutoUpdateToolkitMode +{ + [EnumMember(Value = "none")] None, + [EnumMember(Value = "auto")] Auto, + [EnumMember(Value = "warn")] Warn, +} + [Serializable] public class PortalExtensionPackageProperties { @@ -161,6 +171,9 @@ public class PortalExtensionPackageProperties [JsonProperty("microserviceDependencies")] public List MicroserviceDependencies; + + [JsonProperty("autoUpdateToolkit")] + public AutoUpdateToolkitMode AutoUpdateToolkit = AutoUpdateToolkitMode.Auto; [JsonProperty("mount")] public PortalExtensionMountProperties Mount; } diff --git a/cli/cli/Services/PortalExtension/PortalExtensionDiscoveryService.cs b/cli/cli/Services/PortalExtension/PortalExtensionDiscoveryService.cs index f6bf0d38f6..4a938706d8 100644 --- a/cli/cli/Services/PortalExtension/PortalExtensionDiscoveryService.cs +++ b/cli/cli/Services/PortalExtension/PortalExtensionDiscoveryService.cs @@ -147,12 +147,14 @@ public void BuildExtension() } } - public void InstallDeps() + public void InstallDeps() => InstallDeps(AppFilesPath); + + public static void InstallDeps(string workingDirectoryPath) { var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); StartProcessResult result = isWindows - ? StartProcessUtil.Run("cmd.exe", "/c npm install", workingDirectoryPath: AppFilesPath): - StartProcessUtil.Run("npm", "install", workingDirectoryPath: AppFilesPath); + ? StartProcessUtil.Run("cmd.exe", "/c npm install", workingDirectoryPath: workingDirectoryPath) + : StartProcessUtil.Run("npm", "install", workingDirectoryPath: workingDirectoryPath); if (result.exit != 0) { throw new CliException($"Failed to generate portal extension dependencies. \nCheck errors: \n{result.stderr} \nAll logs: {result.stdout}" diff --git a/cli/cli/Services/ProjectContextUtil.cs b/cli/cli/Services/ProjectContextUtil.cs index 3a4fe2ac6d..4c30dad15d 100644 --- a/cli/cli/Services/ProjectContextUtil.cs +++ b/cli/cli/Services/ProjectContextUtil.cs @@ -417,13 +417,41 @@ public static List FindPortalExtensionProjects(string rootFo var dir = Path.GetDirectoryName(filePath); - projects.Add(new PortalExtensionDef() + var def = new PortalExtensionDef() { Name = info.Name, Properties = properties, RelativePath = Path.GetRelativePath(rootFolder, dir), AbsolutePath = Path.GetFullPath(dir), - }); + }; + + var embeddedVersion = EmbeddedVersionUtil.GetPortalToolkitVersion(); + var installedVersion = def.GetToolkitVersion(); + + if (installedVersion != null && installedVersion != embeddedVersion) + { + switch (properties.AutoUpdateToolkit) + { + case AutoUpdateToolkitMode.Auto: + var packageJson = File.ReadAllText(def.AbsolutePackageJsonPath); + var root = Newtonsoft.Json.Linq.JObject.Parse(packageJson); + if (root["devDependencies"] is Newtonsoft.Json.Linq.JObject devDeps) + { + devDeps[Beamable.Common.Constants.Features.PortalExtension.PORTAL_TOOLKIT_PACKAGE_NAME] = embeddedVersion; + File.WriteAllText(def.AbsolutePackageJsonPath, root.ToString(Newtonsoft.Json.Formatting.Indented)); + } + break; + case AutoUpdateToolkitMode.Warn: + Log.Warning("Portal extension {Name} has {Package}@{Installed} but the embedded version is {Embedded}. Update your package.json to match.", + def.Name, Beamable.Common.Constants.Features.PortalExtension.PORTAL_TOOLKIT_PACKAGE_NAME, installedVersion, embeddedVersion); + break; + case AutoUpdateToolkitMode.None: + default: + break; + } + } + + projects.Add(def); } catch (Exception e) { diff --git a/microservice/beamable.tooling.common/Microservice/EmbeddedVersionUtil.cs b/microservice/beamable.tooling.common/Microservice/EmbeddedVersionUtil.cs new file mode 100644 index 0000000000..e9b22d20e3 --- /dev/null +++ b/microservice/beamable.tooling.common/Microservice/EmbeddedVersionUtil.cs @@ -0,0 +1,33 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Beamable.Server.Common; + +public static class EmbeddedVersionUtil +{ + static string _cachedPortalToolkitVersion; + + public static string GetPortalToolkitVersion() + { + if (_cachedPortalToolkitVersion != null) + return _cachedPortalToolkitVersion; + + var resourceName = "beamable.tooling.common.Microservice.VersionManagement.portal-toolkit-version.json"; + var assembly = typeof(EmbeddedVersionUtil).Assembly; + using var stream = assembly.GetManifestResourceStream(resourceName); + if (stream == null) + throw new InvalidOperationException($"Embedded resource not found: {resourceName}"); + var toolkitVersion = JsonSerializer.Deserialize(stream); + _cachedPortalToolkitVersion = toolkitVersion.portalToolkitVersion; + return _cachedPortalToolkitVersion; + } + + [Serializable] + public class ToolkitVersion + { + [JsonPropertyName("portal-toolkit-version")] + public string portalToolkitVersion; + } + +} \ No newline at end of file diff --git a/microservice/beamable.tooling.common/Microservice/VersionManagement/portal-toolkit-version.json b/microservice/beamable.tooling.common/Microservice/VersionManagement/portal-toolkit-version.json new file mode 100644 index 0000000000..6e12f58981 --- /dev/null +++ b/microservice/beamable.tooling.common/Microservice/VersionManagement/portal-toolkit-version.json @@ -0,0 +1,3 @@ +{ + "portalToolkitVersion": "0.1.4" +} \ No newline at end of file diff --git a/microservice/beamable.tooling.common/beamable.tooling.common.csproj b/microservice/beamable.tooling.common/beamable.tooling.common.csproj index 8da8330e19..c48fa4a027 100644 --- a/microservice/beamable.tooling.common/beamable.tooling.common.csproj +++ b/microservice/beamable.tooling.common/beamable.tooling.common.csproj @@ -45,6 +45,7 @@ + From e8c905c451dfd89a53310bb03751c414b49795da Mon Sep 17 00:00:00 2001 From: Chris Hanna Date: Thu, 9 Apr 2026 17:13:43 -0400 Subject: [PATCH 2/2] checkpoint --- .../Docs/GenerateWebComponentDocsCommand.cs | 468 ++++++++++++++---- .../Microservice/EmbeddedVersionUtil.cs | 2 +- 2 files changed, 359 insertions(+), 111 deletions(-) diff --git a/cli/cli/Commands/Docs/GenerateWebComponentDocsCommand.cs b/cli/cli/Commands/Docs/GenerateWebComponentDocsCommand.cs index 77127bed94..053ef27971 100644 --- a/cli/cli/Commands/Docs/GenerateWebComponentDocsCommand.cs +++ b/cli/cli/Commands/Docs/GenerateWebComponentDocsCommand.cs @@ -73,15 +73,17 @@ public override async Task GetResult(Gene foreach (var element in elements) { + var snippet = LoadComponentSnippet(element.TagName, args.snippetsDirectory); + if (snippet?.Config?.Disabled == true) continue; + var fileName = $"{element.TagName}.md"; var filePath = Path.Combine(args.outputDirectory, fileName); - var snippet = LoadComponentSnippet(element.TagName, args.snippetsDirectory); var markdown = RenderComponentDoc(element, snippet); await File.WriteAllTextAsync(filePath, markdown); result.generatedFiles.Add(filePath); Log.Information("Generated {File}", filePath); - var label = TagNameToLabel(element.TagName); + var label = snippet?.Config?.Title ?? TagNameToLabel(element.TagName); summarySb.AppendLine($"- [{label}]({fileName})"); } @@ -96,25 +98,26 @@ static string RenderComponentDoc(CemDeclaration element, ComponentSnippet snippe { var sb = new StringBuilder(); - sb.AppendLine($"# `<{element.TagName}>`"); + sb.AppendLine("---"); + sb.AppendLine("beam_components: true"); + sb.AppendLine("---"); + var pageTitle = snippet?.Config?.Title ?? TagNameToLabel(element.TagName); + sb.AppendLine($"# {pageTitle}"); sb.AppendLine(); - if (!string.IsNullOrWhiteSpace(element.Description)) + if (!string.IsNullOrWhiteSpace(snippet?.About)) { - sb.AppendLine(element.Description.Trim()); + var aboutText = snippet.About.Replace("{{description}}", element.Description?.Trim() ?? ""); + sb.AppendLine(aboutText); sb.AppendLine(); } - - if (!string.IsNullOrWhiteSpace(snippet?.About)) + else if (!string.IsNullOrWhiteSpace(element.Description)) { - sb.AppendLine(snippet.About); + sb.AppendLine(element.Description.Trim()); sb.AppendLine(); } - // Interactive demo — unified controls + attributes table + live preview + HTML snippet - sb.AppendLine("## Interactive Demo"); - sb.AppendLine(); - sb.AppendLine(RenderInteractiveDemo(element)); + sb.AppendLine(RenderInteractiveDemo(element, snippet)); sb.AppendLine(); // Events @@ -132,22 +135,6 @@ static string RenderComponentDoc(CemDeclaration element, ComponentSnippet snippe sb.AppendLine(); } - // Slots - if (element.Slots is { Count: > 0 }) - { - sb.AppendLine("## Slots"); - sb.AppendLine(); - sb.AppendLine("| Slot | Description |"); - sb.AppendLine("|------|-------------|"); - foreach (var slot in element.Slots) - { - var slotName = string.IsNullOrEmpty(slot.Name) ? "(default)" : slot.Name; - var desc = EscapeMarkdownCell(slot.Description ?? ""); - sb.AppendLine($"| `{slotName}` | {desc} |"); - } - sb.AppendLine(); - } - if (!string.IsNullOrWhiteSpace(snippet?.Notes)) { sb.AppendLine("## Notes"); @@ -159,51 +146,139 @@ static string RenderComponentDoc(CemDeclaration element, ComponentSnippet snippe return sb.ToString(); } - static string RenderInteractiveDemo(CemDeclaration element) + static string RenderInteractiveDemo(CemDeclaration element, ComponentSnippet snippet = null) { + var config = snippet?.Config; + var componentStyle = snippet?.Style; var attrs = element.Attributes ?? new List(); - var demoAttrs = attrs.Where(a => a.Name != null).ToList(); + var hiddenSet = config?.Hidden != null + ? new HashSet(config.Hidden, StringComparer.OrdinalIgnoreCase) + : new HashSet(); + // Auto-hide properties that have explicit bindings (not editable via controls). + if (config?.Bindings != null) + foreach (var key in config.Bindings.Keys) + hiddenSet.Add(key); + var groupedAttrs = config?.Groups?.Values + .SelectMany(v => v) + .Select(a => a.ToLowerInvariant()) + .ToHashSet() ?? new HashSet(); + var demoAttrs = attrs.Where(a => a.Name != null && a.Name != "dark" && a.Name != "light" + && !hiddenSet.Contains(a.Name) + && !groupedAttrs.Contains(a.Name.ToLowerInvariant())).ToList(); var demoId = $"demo-{element.TagName}"; var compId = $"comp-{element.TagName}"; - var themeId = $"{demoId}-theme"; + var previewId = $"preview-{element.TagName}"; + var vappId = $"vapp-{element.TagName}"; + var lightToggleId = $"light-toggle-{element.TagName}"; var htmlSnippetId = $"html-{element.TagName}"; - var slotText = TagNameToLabel(element.TagName); + var rawSlot = snippet?.SlotHtml ?? config?.Slot ?? TagNameToLabel(element.TagName); + var previewSlot = string.Join(" ", rawSlot.Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0)); + var slotJs = JsonSerializer.Serialize(rawSlot); var sb = new StringBuilder(); // IMPORTANT: Python-Markdown type-6 HTML blocks terminate at the first blank line. // No blank lines and no 4-space-indented lines inside this block. sb.AppendLine("
"); - sb.AppendLine($"
"); - // Preview - sb.AppendLine("
"); - sb.AppendLine($"<{element.TagName} id=\"{compId}\">{slotText}"); + if (!string.IsNullOrWhiteSpace(componentStyle)) + { + var css = string.Join(" ", componentStyle.Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0)); + sb.AppendLine($""); + } + sb.AppendLine(""); + // Two-column grid: left = preview + snippet, right = controls + var previewWidth = string.IsNullOrWhiteSpace(config?.PreviewWidth) ? "60%" : config.PreviewWidth; + var gridCols = $"{previewWidth} 1fr"; + sb.AppendLine($"
"); + sb.AppendLine("
"); + // Preview: checkerboard background with dark/light toggle + sb.AppendLine("
"); + sb.AppendLine($"
"); + // v-application wrapper: registerVuetifyLayoutElement (beam-card etc.) uses closest('.v-application') for theming + sb.AppendLine($"
"); + if (!string.IsNullOrWhiteSpace(snippet?.Preview)) + { + var previewHtml = string.Join(" ", snippet.Preview.Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0)); + sb.AppendLine(previewHtml); + } + var componentHtml = $"<{element.TagName} id=\"{compId}\" dark>{previewSlot}"; + var wrapperTemplate = snippet?.Wrapper ?? "\n {{component}}\n"; + var wrappedHtml = wrapperTemplate.Replace("{{component}}", componentHtml); + var wrappedOneLine = string.Join(" ", wrappedHtml.Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0)); + sb.AppendLine(wrappedOneLine); sb.AppendLine("
"); - // HTML snippet - sb.AppendLine("
"); - sb.AppendLine($"
");
-        sb.AppendLine($"");
         sb.AppendLine("
"); - // Controls table (3 columns: Attribute | Control | Description) - sb.AppendLine("
"); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); + sb.AppendLine("
"); + sb.AppendLine("preview"); + sb.AppendLine($""); + sb.AppendLine("
"); + sb.AppendLine(""); + sb.AppendLine(""); + // Controls table: right column, scoped with .pico + sb.AppendLine("
"); + // Split attributes: non-booleans first in a table, booleans last in a two-column grid + var nonBoolAttrs = demoAttrs.Where(a => InferType(a) != "boolean").ToList(); + var boolAttrs = demoAttrs.Where(a => InferType(a) == "boolean").ToList(); + sb.AppendLine("
AttributeControlDescription
"); sb.AppendLine(""); - sb.AppendLine($""); - foreach (var attr in demoAttrs) + foreach (var attr in nonBoolAttrs) { var inputId = $"{demoId}-{attr.Name}"; var desc = EscapeHtml(attr.Description ?? ""); - var controlHtml = RenderControl(attr, inputId, element.Members); - sb.AppendLine($""); + string cfgDefault = null; + config?.Defaults?.TryGetValue(attr.Name, out cfgDefault); + var controlHtml = RenderControl(attr, inputId, element.Members, cfgDefault); + sb.AppendLine($""); + } + + if (config?.Groups != null) + { + foreach (var (groupName, groupMembers) in config.Groups) + { + var groupLabel = TitleCase(groupName); + var groupSelectId = $"{demoId}-group-{groupName}"; + string initialGroupVal = ""; + foreach (var ga in groupMembers) + { + string d = null; + config.Defaults?.TryGetValue(ga, out d); + if (d?.ToLower() is "true" or "1") { initialGroupVal = ga; break; } + } + var emptySelected = string.IsNullOrEmpty(initialGroupVal) ? " selected" : ""; + var groupOptions = string.Join("", groupMembers.Select(ga => + { + var sel = ga == initialGroupVal ? " selected" : ""; + return $""; + })); + var groupAttrsEsc = EscapeHtml(string.Join(",", groupMembers)); + sb.AppendLine($""); + } } sb.AppendLine(""); sb.AppendLine("
dark modeToggle dark/light theme for this preview.
{EscapeHtml(attr.Name)}{controlHtml}{desc}
{EscapeHtml(attr.Name)}{controlHtml}
{EscapeHtml(groupLabel)}
"); + // Boolean attributes: two-column grid of checkboxes + if (boolAttrs.Count > 0) + { + sb.AppendLine("
"); + foreach (var attr in boolAttrs) + { + var inputId = $"{demoId}-{attr.Name}"; + var desc = EscapeHtml(attr.Description ?? ""); + string cfgDefault = null; + config?.Defaults?.TryGetValue(attr.Name, out cfgDefault); + var controlHtml = RenderControl(attr, inputId, element.Members, cfgDefault); + sb.AppendLine($""); + } + sb.AppendLine("
"); + } + sb.AppendLine("
"); sb.AppendLine("
"); + // HTML snippet: full-width below the two-column grid + sb.AppendLine("
"); + sb.AppendLine($"
");
+        sb.AppendLine($"");
         sb.AppendLine("
"); sb.AppendLine(""); sb.AppendLine("
"); @@ -320,11 +530,18 @@ static string RenderInteractiveDemo(CemDeclaration element) } /// Renders the appropriate HTML control for a CEM attribute. - static string RenderControl(CemAttribute attr, string inputId, List members) + static string RenderControl(CemAttribute attr, string inputId, List members, string configDefault = null) { var typeText = attr.Type?.Text ?? "string"; var defaultVal = BuildDefaultValue(attr, members); var inferredType = InferType(attr); + // Config defaults override CEM defaults + if (configDefault != null) + { + defaultVal = inferredType == "boolean" + ? (configDefault.ToLower() is "true" or "1" ? "true" : "false") + : configDefault; + } if (inferredType == "boolean") { @@ -334,7 +551,7 @@ static string RenderControl(CemAttribute attr, string inputId, List m if (inferredType == "number") { - var numDefault = string.IsNullOrWhiteSpace(defaultVal) ? "0" : defaultVal; + var numDefault = string.IsNullOrWhiteSpace(defaultVal) ? "" : defaultVal; return $""; } @@ -350,7 +567,7 @@ static string RenderControl(CemAttribute attr, string inputId, List m var selected = v == strDefault ? " selected" : ""; return $""; })); - return $""; + return $""; } // Plain text input @@ -410,7 +627,7 @@ static string BuildDefaultValue(CemAttribute attr, List members) return type switch { "boolean" => "false", - "number" => "0", + "number" => "", _ => "''" }; } @@ -430,7 +647,7 @@ static string TitleCase(string word) => static string EscapeMarkdownCell(string value) => value.Replace("\n", " ").Replace("|", "\\|").Trim(); - // Loads About/Notes snippets from {snippetsDir}/{tagName}.md, if it exists. + // Loads snippet sections from {snippetsDir}/{tagName}.md, if it exists. static ComponentSnippet LoadComponentSnippet(string tagName, string snippetsDir) { if (string.IsNullOrWhiteSpace(snippetsDir)) return new ComponentSnippet(null, null); @@ -440,7 +657,18 @@ static ComponentSnippet LoadComponentSnippet(string tagName, string snippetsDir) var sections = ParseSnippetSections(content); sections.TryGetValue("about", out var about); sections.TryGetValue("notes", out var notes); - return new ComponentSnippet(about?.Trim(), notes?.Trim()); + sections.TryGetValue("style", out var style); + sections.TryGetValue("slot", out var slotHtml); + sections.TryGetValue("preview", out var preview); + sections.TryGetValue("script", out var script); + sections.TryGetValue("wrapper", out var wrapper); + ComponentConfig config = null; + if (sections.TryGetValue("config", out var configJson) && !string.IsNullOrWhiteSpace(configJson)) + { + try { config = JsonSerializer.Deserialize(configJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); } + catch { /* ignore malformed config */ } + } + return new ComponentSnippet(about?.Trim(), notes?.Trim(), config, style?.Trim(), slotHtml?.Trim(), preview?.Trim(), script?.Trim(), wrapper?.Trim()); } // Splits markdown on level-1 headings into a case-insensitive section dictionary. @@ -473,7 +701,27 @@ static Dictionary ParseSnippetSections(string content) // ── Per-component supplemental docs (optional Docs/Components/{tagName}.md) ── -record ComponentSnippet(string About, string Notes); +record ComponentSnippet(string About, string Notes, ComponentConfig Config = null, string Style = null, string SlotHtml = null, string Preview = null, string Script = null, string Wrapper = null); + +class ComponentConfig +{ + [JsonPropertyName("title")] + public string Title { get; set; } + [JsonPropertyName("hidden")] + public List Hidden { get; set; } = new(); + [JsonPropertyName("defaults")] + public Dictionary Defaults { get; set; } = new(); + [JsonPropertyName("slot")] + public string Slot { get; set; } + [JsonPropertyName("disabled")] + public bool Disabled { get; set; } + [JsonPropertyName("groups")] + public Dictionary> Groups { get; set; } = new(); + [JsonPropertyName("bindings")] + public Dictionary Bindings { get; set; } = new(); + [JsonPropertyName("preview-width")] + public string PreviewWidth { get; set; } +} // ── Custom Elements Manifest deserialization model ─────────────────────────── // Schema: https://github.com/webcomponents/custom-elements-manifest diff --git a/microservice/beamable.tooling.common/Microservice/EmbeddedVersionUtil.cs b/microservice/beamable.tooling.common/Microservice/EmbeddedVersionUtil.cs index e9b22d20e3..a13e62cc63 100644 --- a/microservice/beamable.tooling.common/Microservice/EmbeddedVersionUtil.cs +++ b/microservice/beamable.tooling.common/Microservice/EmbeddedVersionUtil.cs @@ -26,7 +26,7 @@ public static string GetPortalToolkitVersion() [Serializable] public class ToolkitVersion { - [JsonPropertyName("portal-toolkit-version")] + [JsonPropertyName("portalToolkitVersion")] public string portalToolkitVersion; }