Skip to content

Commit 3a36350

Browse files
committed
Custom metadata on content items
Key/value pairs Unit tests, too
1 parent fbd513f commit 3a36350

File tree

9 files changed

+323
-16
lines changed

9 files changed

+323
-16
lines changed

MoonPress.BlazorDesktop/Components/Pages/Content/ContentItemForm.razor

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,22 @@
4747
<ValidationMessage For="@(() => Model.Summary)" />
4848
</div>
4949

50+
<div class="mb-3">
51+
<label>Custom Fields</label>
52+
@foreach (var pair in CustomFieldsList)
53+
{
54+
<div class="input-group mb-2">
55+
<input class="form-control" placeholder="Key"
56+
@bind="pair.Key" @bind:event="oninput" />
57+
<input class="form-control" placeholder="Value"
58+
@bind="pair.Value" @bind:event="oninput" />
59+
<button type="button" class="btn btn-danger"
60+
@onclick="() => RemoveCustomField(pair)">Remove</button>
61+
</div>
62+
}
63+
<button type="button" class="btn btn-secondary" @onclick="AddCustomField">Add Custom Field</button>
64+
</div>
65+
5066
<button type="submit" class="btn btn-primary">Save</button>
5167
<button type="button" class="btn btn-secondary" @onclick="OnCancel">Cancel</button>
5268
</EditForm>

MoonPress.BlazorDesktop/Components/Pages/Content/ContentItemForm.razor.cs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ public partial class ContentItemForm : ComponentBase
99
public ContentItem Model { get; set; } = default!;
1010

1111
[Parameter]
12-
public EventCallback OnValidSubmit { get; set; } = default!;
12+
public EventCallback OnValidSubmitCallback { get; set; } = default!;
1313

1414
[Parameter]
1515
public EventCallback OnCancel { get; set; } = default!;
@@ -21,4 +21,52 @@ private void OnSummaryChanged(ChangeEventArgs e)
2121
Model.Summary = e.Value?.ToString();
2222
StateHasChanged();
2323
}
24+
25+
#region custom fields
26+
27+
// Helper class for binding
28+
public class CustomFieldPair
29+
{
30+
public string Key { get; set; } = "";
31+
public string Value { get; set; } = "";
32+
}
33+
34+
// List for UI binding
35+
private List<CustomFieldPair> CustomFieldsList = new();
36+
37+
protected override void OnInitialized()
38+
{
39+
// Populate from model
40+
if (Model.CustomFields != null)
41+
{
42+
CustomFieldsList = Model.CustomFields
43+
.Select(kvp => new CustomFieldPair { Key = kvp.Key, Value = kvp.Value })
44+
.ToList();
45+
}
46+
}
47+
48+
private void AddCustomField()
49+
{
50+
CustomFieldsList.Add(new CustomFieldPair());
51+
}
52+
53+
private void RemoveCustomField(CustomFieldPair pair)
54+
{
55+
CustomFieldsList.Remove(pair);
56+
}
57+
58+
private async Task OnValidSubmit()
59+
{
60+
// Sync back to model
61+
Model.CustomFields = CustomFieldsList
62+
.Where(p => !string.IsNullOrWhiteSpace(p.Key))
63+
.ToDictionary(p => p.Key, p => p.Value ?? "");
64+
// Call the callback if set
65+
if (OnValidSubmitCallback.HasDelegate)
66+
{
67+
await OnValidSubmitCallback.InvokeAsync();
68+
}
69+
}
70+
71+
#endregion
2472
}

MoonPress.BlazorDesktop/Components/Pages/Content/EditContentItem.razor

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
<h3>Edit Page</h3>
44

5-
<ContentItemForm Model="Model" OnValidSubmit="Save" OnCancel="Cancel" />
5+
<ContentItemForm Model="Model" OnValidSubmitCallback="Save" OnCancel="Cancel" />

MoonPress.BlazorDesktop/Components/Pages/Content/NewContentItem.razor

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
<h3>New Page</h3>
44

5-
<ContentItemForm Model="_item" OnValidSubmit="Save" OnCancel="Cancel" />
5+
<ContentItemForm Model="_item" OnValidSubmitCallback="Save" OnCancel="Cancel" />

MoonPress.Core.Tests/Content/ContentItemFetcherTests.cs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,4 +167,78 @@ public void UpdateCache_ThrowsIfNull()
167167
// Act & Assert
168168
Assert.Throws<ArgumentNullException>(() => ContentItemFetcher.UpdateCache(null!));
169169
}
170+
171+
[Test]
172+
public void GetContentItems_ParsesCustomFields()
173+
{
174+
// Arrange
175+
var yaml = @"
176+
id: test2
177+
title: Custom Fields Test
178+
datePublished: 2023-02-01 10:00:00
179+
dateUpdated: 2023-02-02 11:00:00
180+
category: CatB
181+
tags: tag3, tag4
182+
isDraft: true
183+
summary: Has custom fields
184+
customField1: value1
185+
customField2: value2";
186+
WriteMarkdown("item2.md", yaml);
187+
188+
// Act
189+
var items = ContentItemFetcher.GetContentItems(_testRoot);
190+
191+
// Assert
192+
var item = items.Values.First(i => i.Id == "test2");
193+
Assert.That(item.CustomFields, Is.Not.Null);
194+
Assert.That(item.CustomFields.ContainsKey("customField1"));
195+
Assert.That(item.CustomFields["customField1"], Is.EqualTo("value1"));
196+
Assert.That(item.CustomFields.ContainsKey("customField2"));
197+
Assert.That(item.CustomFields["customField2"], Is.EqualTo("value2"));
198+
}
199+
200+
[Test]
201+
public void GetContentItems_ParsesTagsWithSpacesAndQuotes()
202+
{
203+
// Arrange
204+
var yaml = @"
205+
id: test3
206+
title: Tag Quotes
207+
datePublished: 2023-03-01 09:00:00
208+
dateUpdated: 2023-03-02 10:00:00
209+
category: CatC
210+
tags: tag1, ""tag 2"", tag3
211+
isDraft: false
212+
summary: Tags with spaces and quotes";
213+
WriteMarkdown("item3.md", yaml);
214+
215+
// Act
216+
var items = ContentItemFetcher.GetContentItems(_testRoot);
217+
218+
// Assert
219+
var item = items.Values.First(i => i.Id == "test3");
220+
Assert.That(item.Tags, Is.EqualTo("tag1, \"tag 2\", tag3"));
221+
}
222+
223+
[Test]
224+
public void GetContentItems_EmptyOrMissingTags_ResultsInEmptyTags()
225+
{
226+
// Arrange
227+
var yaml = @"
228+
id: test4
229+
title: No Tags
230+
datePublished: 2023-04-01 08:00:00
231+
dateUpdated: 2023-04-02 09:00:00
232+
category: CatD
233+
isDraft: false
234+
summary: No tags field";
235+
WriteMarkdown("item4.md", yaml);
236+
237+
// Act
238+
var items = ContentItemFetcher.GetContentItems(_testRoot);
239+
240+
// Assert
241+
var item = items.Values.First(i => i.Id == "test4");
242+
Assert.That(item.Tags, Is.EqualTo(string.Empty));
243+
}
170244
}

MoonPress.Core/Content/ContentItem.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ public static string Sanitize(string? input)
4141
public bool IsDraft { get; set; }
4242
public string Category { get; set; } = string.Empty;
4343
public string Tags { get; set; } = string.Empty; // comma separated list of tags
44+
// Custom key/value pairs
45+
public Dictionary<string, string> CustomFields { get; set; } = new();
4446

4547
/// <summary>
4648
/// Used to generate the OpenGraph og:description meta tag.

MoonPress.Core/Content/ContentItemFetcher.cs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,27 @@ public static void UpdateCache(ContentItem newOrExistingItem)
131131
var contentStartIndex = yamlMatch.Index + yamlMatch.Length;
132132
var bodyContent = content.Substring(contentStartIndex).Trim();
133133

134+
// Extract all YAML key-value pairs for custom fields
135+
var customFields = new Dictionary<string, string>();
136+
var yamlLines = yamlContent.Split('\n');
137+
var knownKeys = new HashSet<string>
138+
{
139+
"id", "title", "datePublished", "dateUpdated", "category", "tags", "isDraft", "summary"
140+
};
141+
foreach (var line in yamlLines)
142+
{
143+
var match = Regex.Match(line, @"^(?<key>[^:]+):\s*(?<value>.+)$");
144+
if (match.Success)
145+
{
146+
var key = match.Groups["key"].Value.Trim();
147+
var value = match.Groups["value"].Value.Trim().Trim('"');
148+
if (!knownKeys.Contains(key))
149+
{
150+
customFields[key] = value;
151+
}
152+
}
153+
}
154+
134155
return new ContentItem
135156
{
136157
Id = ExtractYamlValue(yamlContent, "id")!,
@@ -142,7 +163,8 @@ public static void UpdateCache(ContentItem newOrExistingItem)
142163
Tags = ExtractYamlValue(yamlContent, "tags") ?? string.Empty,
143164
IsDraft = isDraft,
144165
Summary = ExtractYamlValue(yamlContent, "summary"),
145-
Contents = bodyContent
166+
Contents = bodyContent,
167+
CustomFields = customFields
146168
};
147169
}
148170
catch

MoonPress.Rendering.Tests/MarkdownRendererTests.cs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,5 +98,126 @@ public void RenderMarkdown_MultilineContent_RendersAllLines()
9898
Assert.That(result, Does.Contain("Line2"));
9999
Assert.That(result, Does.Contain("Line3"));
100100
}
101+
102+
[Test]
103+
public void RenderMarkdown_WithTagsAndCategory_RendersTagsAndCategory()
104+
{
105+
var contentItem = new ContentItem
106+
{
107+
Id = "tags1",
108+
Title = "Tags Test",
109+
DatePublished = DateTime.UtcNow,
110+
DateUpdated = DateTime.UtcNow,
111+
Category = "Tech",
112+
Tags = "csharp, dotnet, unit test ",
113+
IsDraft = false,
114+
Summary = "Testing tags and category.",
115+
Contents = "Body."
116+
};
117+
118+
var result = new ContentItemMarkdownRenderer().RenderMarkdown(contentItem);
119+
120+
Assert.That(result, Does.Contain("category: Tech"));
121+
Assert.That(result, Does.Contain("tags: csharp, dotnet, unit test"));
122+
}
123+
124+
[Test]
125+
public void RenderMarkdown_WithCustomFields_RendersCustomFields()
126+
{
127+
var contentItem = new ContentItem
128+
{
129+
Id = "custom1",
130+
Title = "Custom Fields",
131+
DatePublished = DateTime.UtcNow,
132+
DateUpdated = DateTime.UtcNow,
133+
Category = "",
134+
Tags = "",
135+
IsDraft = false,
136+
Summary = "",
137+
Contents = "",
138+
CustomFields = new Dictionary<string, string>
139+
{
140+
{ "custom1", "value1" },
141+
{ "custom2", "value2" }
142+
}
143+
};
144+
145+
var result = new ContentItemMarkdownRenderer().RenderMarkdown(contentItem);
146+
147+
Assert.That(result, Does.Contain("custom1: value1"));
148+
Assert.That(result, Does.Contain("custom2: value2"));
149+
}
150+
151+
[Test]
152+
public void RenderMarkdown_EscapesQuotesInFields()
153+
{
154+
var contentItem = new ContentItem
155+
{
156+
Id = "quotes1",
157+
Title = "Title with \"quotes\"",
158+
DatePublished = DateTime.UtcNow,
159+
DateUpdated = DateTime.UtcNow,
160+
Category = "Cat\"egory",
161+
Tags = "tag1, \"tag2\"",
162+
IsDraft = false,
163+
Summary = "Summary with \"quotes\"",
164+
Contents = "Content.",
165+
CustomFields = new Dictionary<string, string>
166+
{
167+
{ "field", "value with \"quotes\"" }
168+
}
169+
};
170+
171+
var result = new ContentItemMarkdownRenderer().RenderMarkdown(contentItem);
172+
173+
Assert.That(result, Does.Contain("title: Title with \\\"quotes\\\""));
174+
Assert.That(result, Does.Contain("category: Cat\\\"egory"));
175+
Assert.That(result, Does.Contain("tags: tag1, \\\"tag2\\\""));
176+
Assert.That(result, Does.Contain("summary: Summary with \\\"quotes\\\""));
177+
Assert.That(result, Does.Contain("field: value with \\\"quotes\\\""));
178+
}
179+
180+
[Test]
181+
public void RenderMarkdown_EmptyOrNullTags_RendersTagsAsEmpty()
182+
{
183+
var contentItem = new ContentItem
184+
{
185+
Id = "tags2",
186+
Title = "No Tags",
187+
DatePublished = DateTime.UtcNow,
188+
DateUpdated = DateTime.UtcNow,
189+
Category = "",
190+
Tags = null,
191+
IsDraft = false,
192+
Summary = "",
193+
Contents = ""
194+
};
195+
196+
var result = new ContentItemMarkdownRenderer().RenderMarkdown(contentItem);
197+
198+
Assert.That(result, Does.Contain("tags: "));
199+
}
200+
201+
[Test]
202+
public void RenderMarkdown_IncludesDateUpdated()
203+
{
204+
var contentItem = new ContentItem
205+
{
206+
Id = "dateupd",
207+
Title = "Date Updated",
208+
DatePublished = new DateTime(2024, 5, 1, 10, 0, 0),
209+
DateUpdated = new DateTime(2024, 5, 2, 12, 0, 0),
210+
Category = "",
211+
Tags = "",
212+
IsDraft = false,
213+
Summary = "",
214+
Contents = ""
215+
};
216+
217+
var result = new ContentItemMarkdownRenderer().RenderMarkdown(contentItem);
218+
219+
Assert.That(result, Does.Contain("datePublished: 2024-05-01 10:00:00"));
220+
Assert.That(result, Does.Contain("dateUpdated: 2024-05-02 12:00:00"));
221+
}
101222
}
102223
}

0 commit comments

Comments
 (0)