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
2 changes: 1 addition & 1 deletion samples/Graph/Data/ACME/Article.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"$type":"MeshNode","id":"Article","namespace":"ACME","path":"ACME/Article","mainNode":"ACME/Article","name":"Article","description":"An Software content article with metadata","nodeType":"NodeType","category":"Types","icon":"/static/NodeTypeIcons/document.svg","lastModified":"2026-06-26T09:24:11.064611+00:00","lastModifiedBy":"system-security","version":12,"state":"Active","content":{"$type":"NodeTypeDefinition","description":"An Software content article with metadata","configuration":"config =\u003E config.WithContentType\u003CMeshWeaver.Markdown.MarkdownContent\u003E().AddDefaultLayoutAreas().AddLayout(layout =\u003E layout.WithDefaultArea(\u0022Overview\u0022).AddArticleLayoutAreas())","showChildrenInDetails":true,"detailsChildrenLimit":10,"includeGlobalTypes":true}}
{"$type":"MeshNode","id":"Article","namespace":"ACME","path":"ACME/Article","mainNode":"ACME/Article","name":"Article","description":"An Software content article with metadata","nodeType":"NodeType","category":"Types","icon":"/static/NodeTypeIcons/document.svg","lastModified":"2026-06-26T09:24:11.064611+00:00","lastModifiedBy":"system-security","version":12,"state":"Active","content":{"$type":"NodeTypeDefinition","description":"An Software content article with metadata","configuration":"config =\u003E config.WithContentType\u003CMeshWeaver.Markdown.MarkdownContent\u003E().AddDefaultLayoutAreas().AddLayout(layout =\u003E layout.WithDefaultArea(\u0022Overview\u0022).AddArticleLayoutAreas().WithView(\u0022Tests\u0022, ArticleViewTests.Tests))","showChildrenInDetails":true,"detailsChildrenLimit":10,"includeGlobalTypes":true}}
186 changes: 96 additions & 90 deletions samples/Graph/Data/ACME/Article/Source/ArticleLayoutAreas.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,30 @@
// DisplayName: ACME Software Article Views
// </meshweaver>

using System.Reactive.Linq;
using System.Globalization;
using MeshWeaver.Graph;
using MeshWeaver.Layout;
using MeshWeaver.Layout.Composition;
using MeshWeaver.Markdown;
using MeshWeaver.Mesh;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

/// <summary>
/// Views for ACME Software Article nodes.
/// Views for ACME Software Article nodes — both TEMPLATES (Doc/GUI/DataBinding → "Templates first,
/// data later"): emitted whole on the first render, filled in as the article's node arrives, and
/// live after that.
/// </summary>
public static class ArticleLayoutAreas
{
/// <summary>The <c>/data</c> id the header feed writes to.</summary>
public const string HeaderDataId = "articleHeader";

/// <summary>
/// Registers article views with the layout definition.
/// </summary>
/// <param name="layout">The layout definition.</param>
/// <returns>The layout definition with the views added.</returns>
public static LayoutDefinition AddArticleLayoutAreas(this LayoutDefinition layout) =>
layout
.WithView("Overview", Overview)
Expand All @@ -26,107 +35,104 @@ public static LayoutDefinition AddArticleLayoutAreas(this LayoutDefinition layou
/// <summary>
/// Overview view showing article content with metadata header.
/// </summary>
public static IObservable<UiControl?> Overview(LayoutAreaHost host, RenderingContext _)
{
var hubPath = host.Hub.Address.ToString();
var nodeStream = host.Workspace.GetStream<MeshNode>()?.Select(nodes => nodes ?? Array.Empty<MeshNode>())
?? Observable.Return(Array.Empty<MeshNode>());
/// <param name="host">The area host; the header feed reads its node.</param>
/// <param name="_">The rendering context.</param>
/// <returns>The overview template.</returns>
public static UiControl Overview(LayoutAreaHost host, RenderingContext _)
=> OverviewTemplate(host.Hub.Address.ToString(), HeaderFeed(host));

return nodeStream.Select(nodes =>
{
var node = nodes.FirstOrDefault(n => n.Path == hubPath);
if (node == null)
return (UiControl?)Controls.Markdown("*Loading article...*");

return (UiControl?)BuildArticleOverview(host, node);
});
}

private static UiControl BuildArticleOverview(LayoutAreaHost host, MeshNode node)
/// <summary>
/// Thumbnail view for catalog display: the PATH alone — the thumbnail view reads the node's
/// name, abstract and image itself, live.
/// </summary>
/// <param name="host">The area host; only its address is read.</param>
/// <param name="_">The rendering context.</param>
/// <returns>The thumbnail, bound by path.</returns>
public static UiControl Thumbnail(LayoutAreaHost host, RenderingContext _)
{
var container = Controls.Stack.WithWidth("100%")
.WithStyle("max-width: 960px; margin: 0 auto; padding: 0 24px;");

// Title
container = container.WithView(
Controls.Html($"<h1 style=\"margin: 0 0 8px 0;\">{System.Web.HttpUtility.HtmlEncode(node.Name ?? "Article")}</h1>"));

// Metadata bar: authors, published date, tags.
// ContentAs, never `as`: a node whose content was stored as bare JSON (an import, an MCP
// create/patch carrying a raw body) has no $type for the polymorphic converter to resolve,
// so it arrives — and stays, even on this article's OWN hub — a raw JsonElement. `as` is
// then silently null and the whole header vanishes with no exception and no log line.
var mdContent = node.ContentAs<MarkdownContent>(host.Hub.JsonSerializerOptions);
var metaParts = new List<string>();

if (mdContent?.Authors?.Count > 0)
metaParts.Add(string.Join(", ", mdContent.Authors));

if (node.LastModified != default)
metaParts.Add(node.LastModified.ToString("MMMM d, yyyy"));

if (metaParts.Count > 0)
{
var metaHtml = string.Join(" &middot; ", metaParts);
var path = host.Hub.Address.ToString();
return new MeshNodeThumbnailControl(path, path);
}

// Add tags as styled badges
if (mdContent?.Tags?.Count > 0)
/// <summary>
/// The article page. The title and the body are pointers into the node (its <c>name</c>, its
/// <see cref="MarkdownContent"/>'s <c>content</c>), resolved by the GUI; the metadata line —
/// authors, date, tags, the thumbnail image — has to be COMPOSED, so it is a markdown control
/// bound to <c>/data/articleHeader</c>, which <paramref name="header"/> fills.
/// </summary>
/// <param name="nodePath">The article node.</param>
/// <param name="header">The composed metadata markdown, as it changes.</param>
/// <returns>The complete control tree — it never waits on data.</returns>
public static UiControl OverviewTemplate(string nodePath, IObservable<string> header) =>
Controls.Stack.WithWidth("100%")
.WithStyle("max-width: 960px; margin: 0 auto; padding: 0 24px;")
.WithView(Controls.H1(new JsonPointerReference("name")) with
{
var tagBadges = string.Join(" ", mdContent.Tags.Select(t =>
$"<span style=\"background: var(--neutral-fill-secondary-rest); padding: 2px 8px; border-radius: 4px; font-size: 0.85em;\">{System.Web.HttpUtility.HtmlEncode(t)}</span>"));
metaHtml += $" &middot; {tagBadges}";
}

container = container.WithView(Controls.Html(
$"<div style=\"color: var(--neutral-foreground-hint); margin-bottom: 24px; font-size: 0.9em; display: flex; align-items: center; gap: 8px; flex-wrap: wrap;\">{metaHtml}</div>"));
}

// Thumbnail image
if (!string.IsNullOrEmpty(mdContent?.Thumbnail))
{
var thumbnail = mdContent.Thumbnail;
string imgSrc;
if (thumbnail.StartsWith("/") || thumbnail.StartsWith("http"))
imgSrc = thumbnail;
else
DataContext = LayoutAreaReference.GetMeshNodeDataContext(nodePath, bindContent: false)
})
.WithView(header.Bind(markdown => Controls.Markdown(markdown), HeaderDataId))
.WithView(Controls.Markdown(new JsonPointerReference("content")) with
{
var ns = node.Namespace;
imgSrc = !string.IsNullOrEmpty(ns)
? $"/api/content/{ns}/{thumbnail}"
: thumbnail;
}
container = container.WithView(Controls.Html(
$"<img src=\"{imgSrc}\" alt=\"\" style=\"max-width: 100%; border-radius: 8px; margin-bottom: 24px;\" />"));
}

// Markdown body content. MarkdownBody.Of is the framework's single reader — shared with the
// export templates (ExportSource.MarkdownOf) precisely so this extractor is not hand-copied
// into every sample space again.
var rawContent = MarkdownBody.Of(node, host.Hub.JsonSerializerOptions);
if (!string.IsNullOrEmpty(rawContent))
{
container = container.WithView(Controls.Markdown(rawContent));
}

DataContext = LayoutAreaReference.GetMeshNodeDataContext(nodePath)
});
// No children section — children are injected inline with the @@(query) operator, or
// browsed via the Catalog / Search areas (the framework dropped its hardcoded one too).
// browsed via the Catalog / Search areas.

return container;
/// <summary>
/// The FEED half of the overview: the metadata markdown on every emission of the node. Builds
/// no control. A failure is logged and shown as text — reported, never swallowed.
/// </summary>
/// <param name="host">The area host whose node is read.</param>
/// <returns>The metadata markdown.</returns>
public static IObservable<string> HeaderFeed(LayoutAreaHost host)
{
var culture = CultureInfo.GetCultureInfo(host.ViewerLocale());
return host.Workspace.GetMeshNodeStream()
.Select(node => Header(node, node.ContentAs<MarkdownContent>(host.Hub.JsonSerializerOptions), culture))
.DistinctUntilChanged()
.Catch<string, Exception>(ex =>
{
host.Hub.ServiceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger(nameof(ArticleLayoutAreas))
.LogWarning(ex, "The header of article {Path} could not be read", host.Hub.Address);
return Observable.Return("*The article's details could not be read.*");
});
}

/// <summary>
/// Thumbnail view for catalog display.
/// The metadata markdown — authors · date · tags, then the thumbnail image. Pure.
/// <c>ContentAs</c>, never <c>as</c>, reads the content: a node stored as bare JSON stays a raw
/// <c>JsonElement</c>, and <c>as</c> would silently drop the whole header.
/// </summary>
public static IObservable<UiControl?> Thumbnail(LayoutAreaHost host, RenderingContext _)
/// <param name="node">The article node, or <c>null</c> while there is none.</param>
/// <param name="content">Its markdown content.</param>
/// <param name="culture">The viewer's culture, for the date.</param>
/// <returns>The markdown; empty when there is nothing to show.</returns>
public static string Header(MeshNode? node, MarkdownContent? content, CultureInfo culture)
{
var hubPath = host.Hub.Address.ToString();
var nodeStream = host.Workspace.GetStream<MeshNode>()?.Select(nodes => nodes ?? Array.Empty<MeshNode>())
?? Observable.Return(Array.Empty<MeshNode>());
if (node is null)
return string.Empty;

return nodeStream.Select(nodes =>
var parts = new List<string>();
if (content?.Authors?.Count > 0)
parts.Add(string.Join(", ", content.Authors));
if (node.LastModified != default)
parts.Add(node.LastModified.ToString("D", culture));
if (parts.Count > 0 && content?.Tags?.Count > 0)
parts.Add(string.Join(" ", content.Tags.Select(t => $"`{t}`")));

var lines = new List<string>();
if (parts.Count > 0)
lines.Add(string.Join(" · ", parts));

if (!string.IsNullOrEmpty(content?.Thumbnail))
{
var node = nodes.FirstOrDefault(n => n.Path == hubPath);
return (UiControl?)MeshNodeThumbnailControl.FromNode(node, hubPath);
});
var thumbnail = content.Thumbnail;
var src = thumbnail.StartsWith("/") || thumbnail.StartsWith("http") || string.IsNullOrEmpty(node.Namespace)
? thumbnail
: $"/api/content/{node.Namespace}/{thumbnail}";
lines.Add($"![]({src})");
}
return string.Join("\n\n", lines);
}
}
117 changes: 117 additions & 0 deletions samples/Graph/Data/ACME/Article/Test/ArticleViewTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// <meshweaver>
// Id: ArticleViewTests
// DisplayName: Article Views Tests — the views are templates
// </meshweaver>
#nullable enable
using System;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Reactive.Linq;
using MeshWeaver.Data;
using MeshWeaver.Layout;
using MeshWeaver.Layout.Composition;
using MeshWeaver.Layout.DataGrid;
using MeshWeaver.Markdown;
using MeshWeaver.Mesh;

/// <summary>
/// The cases of <c>ACME/Article</c>'s views: each view is a TEMPLATE (Doc/GUI/DataBinding →
/// "Templates first, data later") — built without the node, its first emission is the whole page,
/// with no view deferred until data arrives — and it binds the node by PATH. Pure cases: they
/// build the templates from a path and THROW on failure. <see cref="Tests"/> runs them; a case not
/// listed there runs nowhere.
/// </summary>
public static class ArticleViewTests
{
private const string NodePath = "ACME/Articles/Launch";

/// <summary>The Tests view — registered in the NodeType's configuration as <c>WithView("Tests", …)</c>.</summary>
/// <param name="host">The layout area host.</param>
/// <param name="context">The rendering context.</param>
/// <returns>One verdict frame: the <c>N/M passed</c> title and a row per case.</returns>
public static UiControl Tests(LayoutAreaHost host, RenderingContext context)
{
var cases = new (string Name, Action Body)[]
{
("The overview is whole while its header feed is silent; title and body are pointers into the node.", Overview_IsATemplateBoundByPath),
("The metadata line: authors, the date in the viewer's format, tags and the thumbnail.", Header_ComposesTheMetadata),
};
var rows = cases.Select(c => Run(c.Name, c.Body)).ToImmutableList();
return Controls.Stack
.WithView(Controls.Markdown($"### Article views tests — {rows.Count(r => r.Passed)}/{rows.Count} passed"))
.WithView(host.ToDataGrid(rows));
}

/// <summary>The overview is whole while its header feed is silent; title and body are pointers into the node.</summary>
public static void Overview_IsATemplateBoundByPath()
{
var template = ArticleLayoutAreas.OverviewTemplate(NodePath, Observable.Never<string>());
ExpectTemplate(template, "Overview");
Expect(Bindings(template).Single() == ("name", LayoutAreaReference.GetMeshNodeDataContext(NodePath, bindContent: false)),
"the title binds the node's own name");
var markdown = LayoutTemplate.Descendants(template).OfType<MarkdownControl>().ToList();
Expect(markdown.Any(m => m.Markdown is JsonPointerReference { Pointer: "content" }
&& m.DataContext == LayoutAreaReference.GetMeshNodeDataContext(NodePath)),
"the body is a pointer into the node's MarkdownContent, never its text");
Expect(markdown.Any(m => m.DataContext == LayoutAreaReference.GetDataPointer(ArticleLayoutAreas.HeaderDataId)),
"the metadata line is declared up front and fed through /data");
}

/// <summary>The metadata line: authors, the date in the viewer's format, tags and the thumbnail.</summary>
public static void Header_ComposesTheMetadata()
{
var node = new MeshNode("Launch", "ACME/Articles") { LastModified = new DateTimeOffset(2026, 6, 1, 0, 0, 0, TimeSpan.Zero) };
var content = new MarkdownContent { Content = "# Launch", Authors = ["Ada", "Alan"], Tags = ["news"], Thumbnail = "cover.png" };
var header = ArticleLayoutAreas.Header(node, content, CultureInfo.GetCultureInfo("en"));
Expect(header.Contains("Ada, Alan", StringComparison.Ordinal), header);
Expect(header.Contains("June 1, 2026", StringComparison.Ordinal) || header.Contains("1 June 2026", StringComparison.Ordinal), header);
Expect(header.Contains("`news`", StringComparison.Ordinal), header);
Expect(header.Contains("![](/api/content/ACME/Articles/cover.png)", StringComparison.Ordinal), header);
Expect(ArticleLayoutAreas.Header(null, null, CultureInfo.GetCultureInfo("en")) == "", "no node, no header");
}

/// <summary>The node-bound pointers of the tree's labels, as <c>(pointer, data context)</c>.</summary>
private static ImmutableList<(string Pointer, string? Context)> Bindings(UiControl root) =>
LayoutTemplate.Descendants(root)
.OfType<LabelControl>()
.Where(label => label.Data is JsonPointerReference)
.Select(label => (((JsonPointerReference)label.Data).Pointer, label.DataContext))
.ToImmutableList();

private static void ExpectTemplate(UiControl template, string view)
{
var deferred = LayoutTemplate.DeferredViews(template);
Expect(deferred.IsEmpty, $"{view} defers {deferred.Count} view(s) until data arrives: {string.Join(", ", deferred)}");
Expect(!LayoutTemplate.Descendants(template).OfType<HtmlControl>().Any(),
$"{view} renders hand-built HTML; compose platform controls instead");
}

private static ArticleViewTestsRow Run(string name, Action body)
{
try
{
body();
return new(name, "✅ pass");
}
catch (Exception ex)
{
return new(name, "❌ " + ex.Message.Replace('\n', ' '));
}
}

private static void Expect(bool condition, string because)
{
if (!condition)
throw new InvalidOperationException(because);
}
}

/// <summary>One executed case, as a grid row.</summary>
/// <param name="Case">What the case asserts.</param>
/// <param name="Result">✅, or ❌ with the failure message.</param>
public sealed record ArticleViewTestsRow(string Case, string Result)
{
/// <summary>Whether the case passed.</summary>
public bool Passed => Result.StartsWith("✅", StringComparison.Ordinal);
}
Loading