Skip to content

Commit 538fafb

Browse files
First attempt at structured data in SeoToolkit (#538)
* Starting on schema implementation * Some starting points * chore: outline schema resolver integration plan Agent-Logs-Url: https://github.com/patrickdemooij9/SeoToolkit.Umbraco/sessions/51b4741c-0cf6-4891-8dd7-82877e03c18b Co-authored-by: patrickdemooij9 <11466511+patrickdemooij9@users.noreply.github.com> * feat: wire schema editor persistence and schema.net resolution Agent-Logs-Url: https://github.com/patrickdemooij9/SeoToolkit.Umbraco/sessions/51b4741c-0cf6-4891-8dd7-82877e03c18b Co-authored-by: patrickdemooij9 <11466511+patrickdemooij9@users.noreply.github.com> * Implement dedicated schema entry DB table with CRUD service, migration, and UI Agent-Logs-Url: https://github.com/patrickdemooij9/SeoToolkit.Umbraco/sessions/d9bbc074-03ce-475d-bffa-6fb46a2ac480 Co-authored-by: patrickdemooij9 <11466511+patrickdemooij9@users.noreply.github.com> * Fix merge conflicts * Fix schema editor: immediate picker submit, entry loading on refresh, property value input Agent-Logs-Url: https://github.com/patrickdemooij9/SeoToolkit.Umbraco/sessions/b2c4c012-d7fc-452d-a6f2-9c5277d2c42d Co-authored-by: patrickdemooij9 <11466511+patrickdemooij9@users.noreply.github.com> * Fix trailing whitespace in SchemaPickerModal Agent-Logs-Url: https://github.com/patrickdemooij9/SeoToolkit.Umbraco/sessions/b2c4c012-d7fc-452d-a6f2-9c5277d2c42d Co-authored-by: patrickdemooij9 <11466511+patrickdemooij9@users.noreply.github.com> * Fix schema editor feedback: displayName, pick existing names, reference dropdown, doc type ownerKey, remove edit dropdown Agent-Logs-Url: https://github.com/patrickdemooij9/SeoToolkit.Umbraco/sessions/2a667a7c-49cf-4667-b2aa-2d8b3cdc5c40 Co-authored-by: patrickdemooij9 <11466511+patrickdemooij9@users.noreply.github.com> * Clean up: add using directives, extract PropertyValue import, fix code review feedback Agent-Logs-Url: https://github.com/patrickdemooij9/SeoToolkit.Umbraco/sessions/2a667a7c-49cf-4667-b2aa-2d8b3cdc5c40 Co-authored-by: patrickdemooij9 <11466511+patrickdemooij9@users.noreply.github.com> * Additive schemas with doc type inheritance, media picker for logo field Agent-Logs-Url: https://github.com/patrickdemooij9/SeoToolkit.Umbraco/sessions/87519cb0-70d5-4ac1-a816-8ee35e9fac41 Co-authored-by: patrickdemooij9 <11466511+patrickdemooij9@users.noreply.github.com> * Add Raw JSON schema type with allowReference support Agent-Logs-Url: https://github.com/patrickdemooij9/SeoToolkit.Umbraco/sessions/eab47e70-5014-40a2-a50a-9b9f85c37ea3 Co-authored-by: patrickdemooij9 <11466511+patrickdemooij9@users.noreply.github.com> * Fix schema logo media picker persistence Agent-Logs-Url: https://github.com/patrickdemooij9/SeoToolkit.Umbraco/sessions/97b788e3-258e-4b77-b106-e3f0e189e350 Co-authored-by: patrickdemooij9 <11466511+patrickdemooij9@users.noreply.github.com> * Some fixes * Add value converter pipeline for schema property values Agent-Logs-Url: https://github.com/patrickdemooij9/SeoToolkit.Umbraco/sessions/e4c902cd-0f56-4c10-8e09-1069869d4025 Co-authored-by: patrickdemooij9 <11466511+patrickdemooij9@users.noreply.github.com> * More fixes * Schema config passthrough, allowedSchemas filtering, and ownerKey fix - Add Config dictionary to SchemaProperty for passing editor config to umb-property - Add allowedSchemas config support to SchemaEditorPropertyEditor for filtering schema types - Fix ownerKey for nested schema editors by pre-generating GUID client-side - Fix 'Address Country' display name in PostalAddressSchemaResolver - Wrap JsonDocument.Parse in try-catch in RawJsonSchemaResolver * Bit of cleanup * Fix some typings * Some feedback things * Add website as ownerType and some bugfixes * Small fixes * Fix with the migration * Up versions * Some fixes * Add developmentmode package * Up versions * Fix tests * Add way to reference context on the page itself * Add more schema fields --------- Co-authored-by: patrickdemooij9 <11466511+patrickdemooij9@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 2df0be4 commit 538fafb

71 files changed

Lines changed: 4553 additions & 31 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
using Moq;
2+
using Schema.NET;
3+
using SeoToolkit.Umbraco.Common.Core.Services.SettingsService;
4+
using SeoToolkit.Umbraco.MetaFields.Core.Config.Models;
5+
using SeoToolkit.Umbraco.MetaFields.Core.Providers;
6+
using Umbraco.Cms.Core.Models.PublishedContent;
7+
using Umbraco.Cms.Core.Services.Navigation;
8+
9+
namespace SeoToolkit.Tests
10+
{
11+
[TestFixture]
12+
public class BreadcrumbSchemaProviderTests
13+
{
14+
[Test]
15+
public void Get_BuildsTheBreadcrumbFromTheRootToTheCurrentPage()
16+
{
17+
var home = CreateContent("Home", "https://example.com/");
18+
var blog = CreateContent("Blog", "https://example.com/blog/");
19+
var post = CreateContent("My post", "https://example.com/blog/my-post/");
20+
21+
var provider = CreateProvider(enabled: true, post, blog, home);
22+
23+
var json = provider.Get(post)!.ToString();
24+
25+
Assert.That(json, Is.EqualTo(
26+
"{\"@context\":\"https://schema.org\",\"@type\":\"BreadcrumbList\",\"itemListElement\":[" +
27+
"{\"@type\":\"ListItem\",\"name\":\"Home\",\"item\":{\"@type\":\"WebPage\",\"@id\":\"https://example.com/\"},\"position\":1}," +
28+
"{\"@type\":\"ListItem\",\"name\":\"Blog\",\"item\":{\"@type\":\"WebPage\",\"@id\":\"https://example.com/blog/\"},\"position\":2}," +
29+
"{\"@type\":\"ListItem\",\"name\":\"My post\",\"item\":{\"@type\":\"WebPage\",\"@id\":\"https://example.com/blog/my-post/\"},\"position\":3}]}"));
30+
}
31+
32+
[Test]
33+
public void Get_ReturnsNullWhenDisabled()
34+
{
35+
var home = CreateContent("Home", "https://example.com/");
36+
var blog = CreateContent("Blog", "https://example.com/blog/");
37+
38+
var provider = CreateProvider(enabled: false, blog, home);
39+
40+
Assert.That(provider.Get(blog), Is.Null);
41+
}
42+
43+
[Test]
44+
public void Get_ReturnsNullForASinglePage()
45+
{
46+
var home = CreateContent("Home", "https://example.com/");
47+
48+
var provider = CreateProvider(enabled: true, home);
49+
50+
Assert.That(provider.Get(home), Is.Null);
51+
}
52+
53+
[Test]
54+
public void Get_SkipsAncestorsThatCannotBeVisited()
55+
{
56+
var home = CreateContent("Home", "https://example.com/");
57+
var folder = CreateContent("Folder", "https://example.com/folder/", templateId: null);
58+
var noUrl = CreateContent("No url", "#");
59+
var page = CreateContent("Page", "https://example.com/folder/no-url/page/");
60+
61+
var provider = CreateProvider(enabled: true, page, noUrl, folder, home);
62+
63+
var breadcrumb = (BreadcrumbList)provider.Get(page)!;
64+
var names = breadcrumb.ItemListElement.Value1.Select(it => ((ListItem)it).Name.First()).ToArray();
65+
var positions = breadcrumb.ItemListElement.Value1.Select(it => ((ListItem)it).Position.Value1.First()).ToArray();
66+
67+
Assert.That(names, Is.EqualTo(new[] { "Home", "Page" }));
68+
Assert.That(positions, Is.EqualTo(new int?[] { 1, 2 }));
69+
}
70+
71+
[Test]
72+
public void Get_KeepsAncestorsWithoutTemplateWhenTheCurrentPageHasNoTemplate()
73+
{
74+
var root = CreateContent("Root", "https://example.com/", templateId: null);
75+
var page = CreateContent("Page", "https://example.com/page/", templateId: null);
76+
77+
var provider = CreateProvider(enabled: true, page, root);
78+
79+
Assert.That(provider.Get(page), Is.Not.Null);
80+
}
81+
82+
private static TestBreadcrumbSchemaProvider CreateProvider(bool enabled, params TestContent[] ancestorsOrSelf)
83+
{
84+
var settingsService = new Mock<ISettingsService<MetaFieldsConfigModel>>();
85+
settingsService.Setup(x => x.GetSettings()).Returns(new MetaFieldsConfigModel { EnableBreadcrumbSchema = enabled });
86+
87+
return new TestBreadcrumbSchemaProvider(settingsService.Object, ancestorsOrSelf);
88+
}
89+
90+
private static TestContent CreateContent(string name, string url, int? templateId = 1)
91+
{
92+
var content = new Mock<IPublishedContent>();
93+
content.Setup(x => x.Name).Returns(name);
94+
content.Setup(x => x.ItemType).Returns(PublishedItemType.Content);
95+
content.Setup(x => x.TemplateId).Returns(templateId);
96+
return new TestContent(content.Object, url);
97+
}
98+
99+
private record TestContent(IPublishedContent Content, string Url);
100+
101+
private class TestBreadcrumbSchemaProvider : DefaultBreadcrumbSchemaProvider
102+
{
103+
private readonly TestContent[] _ancestorsOrSelf;
104+
105+
public TestBreadcrumbSchemaProvider(ISettingsService<MetaFieldsConfigModel> settingsService, TestContent[] ancestorsOrSelf)
106+
: base(settingsService, Mock.Of<IDocumentNavigationQueryService>(), Mock.Of<IPublishedContentStatusFilteringService>())
107+
{
108+
_ancestorsOrSelf = ancestorsOrSelf;
109+
}
110+
111+
public IThing? Get(TestContent content) => Get(content.Content);
112+
113+
protected override IEnumerable<IPublishedContent> GetAncestorsOrSelf(IPublishedContent content)
114+
=> _ancestorsOrSelf.Select(it => it.Content);
115+
116+
protected override Uri? GetUrl(IPublishedContent content)
117+
{
118+
var url = _ancestorsOrSelf.First(it => it.Content == content).Url;
119+
return Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri : null;
120+
}
121+
}
122+
}
123+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
using Moq;
2+
using SeoToolkit.Umbraco.MetaFields.Core.Common.SchemaResolvers;
3+
using Umbraco.Cms.Core.Models.PublishedContent;
4+
5+
namespace SeoToolkit.Tests
6+
{
7+
[TestFixture]
8+
public class SchemaReferenceResolverTests
9+
{
10+
[Test]
11+
public void ResolveReference_ResolvesPageName()
12+
{
13+
var content = CreateContent("Home");
14+
15+
Assert.That(SchemaReferenceResolver.ResolveReference("[PageName]", content), Is.EqualTo("Home"));
16+
}
17+
18+
[Test]
19+
public void ResolveReference_ResolvesContentProperty()
20+
{
21+
var content = CreateContent("Home", ("title", "Welcome home"));
22+
23+
Assert.That(SchemaReferenceResolver.ResolveReference("[Property:title]", content), Is.EqualTo("Welcome home"));
24+
}
25+
26+
[Test]
27+
public void ResolveReference_ReturnsEmptyForAPropertyTheContentDoesNotHave()
28+
{
29+
var content = CreateContent("Home");
30+
31+
Assert.That(SchemaReferenceResolver.ResolveReference("[Property:title]", content), Is.Empty);
32+
}
33+
34+
[Test]
35+
public void ResolveReference_ReturnsEmptyWithoutContent()
36+
{
37+
Assert.That(SchemaReferenceResolver.ResolveReference("[PageName]", null), Is.Empty);
38+
}
39+
40+
[Test]
41+
public void ResolveTokens_CombinesTextWithAContextToken()
42+
{
43+
var content = CreateContent("Home");
44+
45+
Assert.That(SchemaReferenceResolver.ResolveTokens("Hello {pageName}", content), Is.EqualTo("Hello Home"));
46+
}
47+
48+
[Test]
49+
public void ResolveTokens_ResolvesAContentPropertyByItsAlias()
50+
{
51+
var content = CreateContent("Home", ("title", "Welcome home"));
52+
53+
Assert.That(SchemaReferenceResolver.ResolveTokens("Hello {title}!", content), Is.EqualTo("Hello Welcome home!"));
54+
Assert.That(SchemaReferenceResolver.ResolveTokens("Hello {property:title}!", content), Is.EqualTo("Hello Welcome home!"));
55+
}
56+
57+
[Test]
58+
public void ResolveTokens_ResolvesEveryTokenInTheValue()
59+
{
60+
var content = CreateContent("Home", ("title", "Welcome home"));
61+
62+
Assert.That(SchemaReferenceResolver.ResolveTokens("{pageName} - {title}", content), Is.EqualTo("Home - Welcome home"));
63+
}
64+
65+
[Test]
66+
public void ResolveTokens_PrefersAContextTokenOverAPropertyOfTheSameName()
67+
{
68+
var content = CreateContent("Home", ("pageName", "Some property value"));
69+
70+
Assert.That(SchemaReferenceResolver.ResolveTokens("{pageName}", content), Is.EqualTo("Home"));
71+
}
72+
73+
[Test]
74+
public void ResolveTokens_LeavesUnknownTokensUntouched()
75+
{
76+
var content = CreateContent("Home");
77+
78+
Assert.That(SchemaReferenceResolver.ResolveTokens("Hello {notAToken}", content), Is.EqualTo("Hello {notAToken}"));
79+
}
80+
81+
[Test]
82+
public void ResolveTokens_LeavesTextThatOnlyLooksLikeATokenUntouched()
83+
{
84+
var content = CreateContent("Home");
85+
86+
const string json = "{\"@type\": \"Thing\", \"name\": \"Test\"}";
87+
Assert.That(SchemaReferenceResolver.ResolveTokens(json, content), Is.EqualTo(json));
88+
}
89+
90+
[Test]
91+
public void ResolveTokens_ResolvesAnEmptyPropertyToNothing()
92+
{
93+
var content = CreateContent("Home", ("title", null));
94+
95+
Assert.That(SchemaReferenceResolver.ResolveTokens("Hello {title}", content), Is.EqualTo("Hello "));
96+
}
97+
98+
[Test]
99+
public void ResolveTokens_ReturnsTheValueUnchangedWithoutContent()
100+
{
101+
Assert.That(SchemaReferenceResolver.ResolveTokens("Hello {pageName}", null), Is.EqualTo("Hello {pageName}"));
102+
}
103+
104+
[Test]
105+
public void ResolveReference_ResolvesPageDatesAsIsoDates()
106+
{
107+
var content = Mock.Get(CreateContent("Home"));
108+
content.Setup(x => x.CreateDate).Returns(new DateTime(2024, 3, 1, 9, 30, 0));
109+
content.Setup(x => x.UpdateDate).Returns(new DateTime(2024, 4, 2, 17, 0, 5));
110+
111+
Assert.Multiple(() =>
112+
{
113+
Assert.That(SchemaReferenceResolver.ResolveReference("[PageCreateDate]", content.Object), Is.EqualTo("2024-03-01T09:30:00"));
114+
Assert.That(SchemaReferenceResolver.ResolveTokens("{pageUpdateDate}", content.Object), Is.EqualTo("2024-04-02T17:00:05"));
115+
});
116+
}
117+
118+
[Test]
119+
public void ResolveReference_ResolvesADatePropertyAsAnIsoDate()
120+
{
121+
var content = CreateContent("Home", ("publishDate", new DateTime(2024, 3, 1, 9, 30, 0)));
122+
123+
Assert.That(SchemaReferenceResolver.ResolveReference("[Property:publishDate]", content), Is.EqualTo("2024-03-01T09:30:00"));
124+
}
125+
126+
private static IPublishedContent CreateContent(string name, params (string Alias, object? Value)[] properties)
127+
{
128+
var content = new Mock<IPublishedContent>();
129+
content.Setup(x => x.Name).Returns(name);
130+
131+
foreach (var (alias, value) in properties)
132+
{
133+
var property = new Mock<IPublishedProperty>();
134+
property.Setup(x => x.GetValue(null, null)).Returns(value);
135+
content.Setup(x => x.GetProperty(alias)).Returns(property.Object);
136+
}
137+
138+
return content.Object;
139+
}
140+
}
141+
}

0 commit comments

Comments
 (0)