Skip to content

Commit 562bdf6

Browse files
committed
add metadata
1 parent af75ae6 commit 562bdf6

File tree

2 files changed

+276
-1
lines changed

2 files changed

+276
-1
lines changed

docs/llm/gemini.md

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4180,6 +4180,206 @@ else
41804180

41814181

41824182

4183+
4184+
4185+
4186+
4187+
4188+
4189+
4190+
4191+
4192+
4193+
4194+
4195+
4196+
4197+
4198+
4199+
4200+
4201+
You are absolutely right. While we fixed the content rendering and real-time updates, the **Post Detail page is missing critical Metadata (SEO) tags**.
4202+
4203+
Currently, search engines and social media platforms (Twitter, LinkedIn, Slack) see a generic page without a description, image, or proper title because the `<head>` section is not being populated with the post's specific data.
4204+
4205+
Here is the **complete solution** to inject SEO and Social Sharing metadata into the page head.
4206+
4207+
### Changes Summary
4208+
4209+
1. **Modify `PostDetail.razor**`:
4210+
* Add `<HeadContent>` to inject tags into the HTML `<head>`.
4211+
* Add **Standard SEO** tags (`description`, `author`, `canonical`).
4212+
* Add **Open Graph (OG)** tags for Facebook/LinkedIn/Slack (`og:title`, `og:image`, etc.).
4213+
* Add **Twitter Card** tags for large image previews.
4214+
* Add logic to **extract the first image** from the Markdown content to use as the preview image.
4215+
4216+
4217+
4218+
---
4219+
4220+
### **1. Update PostDetail.razor**
4221+
4222+
**File:** `src/MyBlog.Web/Components/Pages/PostDetail.razor`
4223+
4224+
```razor
4225+
@page "/post/{Slug}"
4226+
@using System.Text.RegularExpressions
4227+
@inject IPostRepository PostRepository
4228+
@inject NavigationManager Navigation
4229+
@inject IConfiguration Configuration
4230+
4231+
@if (_post is null)
4232+
{
4233+
@if (_notFound)
4234+
{
4235+
<PageTitle>Not Found</PageTitle>
4236+
<h1>Post Not Found</h1>
4237+
<p>The post you're looking for doesn't exist.</p>
4238+
<a href="/">← Back to Home</a>
4239+
}
4240+
else
4241+
{
4242+
<p>Loading...</p>
4243+
}
4244+
}
4245+
else
4246+
{
4247+
@* --- 1. Browser Tab Title --- *@
4248+
<PageTitle>@_post.Title</PageTitle>
4249+
4250+
@* --- 2. SEO & Social Metadata (Injected into <head>) --- *@
4251+
<HeadContent>
4252+
@* Standard SEO *@
4253+
<meta name="description" content="@_post.Summary" />
4254+
<meta name="author" content="@_post.AuthorDisplayName" />
4255+
<link rel="canonical" href="@_currentUrl" />
4256+
4257+
@* Open Graph / Facebook / LinkedIn *@
4258+
<meta property="og:type" content="article" />
4259+
<meta property="og:site_name" content="@(Configuration["Application:Title"] ?? "MyBlog")" />
4260+
<meta property="og:title" content="@_post.Title" />
4261+
<meta property="og:description" content="@_post.Summary" />
4262+
<meta property="og:url" content="@_currentUrl" />
4263+
4264+
@if (!string.IsNullOrEmpty(_previewImage))
4265+
{
4266+
<meta property="og:image" content="@_previewImage" />
4267+
<meta property="og:image:alt" content="@_post.Title" />
4268+
}
4269+
4270+
@* Twitter Cards *@
4271+
<meta name="twitter:card" content="@(string.IsNullOrEmpty(_previewImage) ? "summary" : "summary_large_image")" />
4272+
<meta name="twitter:title" content="@_post.Title" />
4273+
<meta name="twitter:description" content="@_post.Summary" />
4274+
@if (!string.IsNullOrEmpty(_previewImage))
4275+
{
4276+
<meta name="twitter:image" content="@_previewImage" />
4277+
}
4278+
</HeadContent>
4279+
4280+
@* --- 3. Visible Page Content --- *@
4281+
<article class="post-detail">
4282+
<header class="post-header">
4283+
<h1>@_post.Title</h1>
4284+
<div class="post-meta">
4285+
<span class="author">By @_post.AuthorDisplayName</span>
4286+
@if (_post.PublishedAtUtc.HasValue)
4287+
{
4288+
<span class="date">Published @_post.PublishedAtUtc.Value.ToString("MMMM d, yyyy")</span>
4289+
}
4290+
4291+
@* Real-time Reader Badge *@
4292+
<ReaderBadge Slug="@Slug" />
4293+
4294+
@* Social Share Button (Visible UI) *@
4295+
<button type="button" class="btn-link share-btn" data-title="@_post.Title" onclick="sharePost(this.dataset.title)">
4296+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
4297+
<circle cx="18" cy="5" r="3"></circle>
4298+
<circle cx="6" cy="12" r="3"></circle>
4299+
<circle cx="18" cy="19" r="3"></circle>
4300+
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line>
4301+
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line>
4302+
</svg>
4303+
Share
4304+
</button>
4305+
</div>
4306+
</header>
4307+
4308+
<div class="post-content">
4309+
<MarkdownRenderer Content="@_post.Content" />
4310+
</div>
4311+
</article>
4312+
4313+
<a href="/" class="back-link">← Back to Home</a>
4314+
}
4315+
4316+
@code {
4317+
[Parameter]
4318+
public string Slug { get; set; } = "";
4319+
4320+
private PostDetailDto? _post;
4321+
private bool _notFound;
4322+
private string _currentUrl = "";
4323+
private string? _previewImage;
4324+
4325+
protected override async Task OnParametersSetAsync()
4326+
{
4327+
// 1. Load Data
4328+
_post = await PostRepository.GetBySlugAsync(Slug);
4329+
_notFound = _post is null;
4330+
4331+
// 2. Validate visibility
4332+
if (_post is not null && !_post.IsPublished)
4333+
{
4334+
_post = null;
4335+
_notFound = true;
4336+
}
4337+
4338+
// 3. Prepare Metadata
4339+
if (_post is not null)
4340+
{
4341+
// Get Absolute URL for Canonical/OG tags
4342+
_currentUrl = Navigation.Uri;
4343+
4344+
// Extract the first image from Markdown to use as the preview image
4345+
// Regex matches: ![alt](url)
4346+
var imageMatch = Regex.Match(_post.Content, @"!\[.*?\]\((.*?)\)");
4347+
if (imageMatch.Success)
4348+
{
4349+
var url = imageMatch.Groups[1].Value;
4350+
// If it's a relative URL (like /api/images/...), make it absolute
4351+
if (url.StartsWith("/"))
4352+
{
4353+
_previewImage = Navigation.ToAbsoluteUri(url).ToString();
4354+
}
4355+
else
4356+
{
4357+
_previewImage = url;
4358+
}
4359+
}
4360+
}
4361+
}
4362+
}
4363+
4364+
```
4365+
4366+
---
4367+
4368+
### **How to Verify**
4369+
4370+
1. **Rebuild and Run**: `dotnet run --project src/MyBlog.Web`.
4371+
2. **Navigate** to a blog post.
4372+
3. **View Source (Ctrl+U)**: You will now see proper `<meta>` tags in the `<head>` section.
4373+
4. **Test Sharing**:
4374+
* If running locally (`localhost`), social media scrapers (Twitter/LinkedIn) **cannot** see your site.
4375+
* To test properly, use **ngrok** to tunnel your local site: `ngrok http 5000` (or your port).
4376+
* Or check the tags in the browser's "Elements" inspector to confirm they are populated.
4377+
4378+
4379+
4380+
### **Why this was happening**
4381+
4382+
Blazor components render into the `<body>` by default. To affect the `<head>` (which is defined in `App.razor` outside the router), we must use the `<HeadContent>` component. This component "teleports" the content inside it up to the `<head>` of the document during rendering.
41834383

41844384

41854385

src/MyBlog.Web/Components/Pages/PostDetail.razor

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
@page "/post/{Slug}"
2+
@using System.Text.RegularExpressions
23
@inject IPostRepository PostRepository
34
@inject NavigationManager Navigation
5+
@inject IConfiguration Configuration
46

57
@if (_post is null)
68
{
@@ -18,8 +20,40 @@
1820
}
1921
else
2022
{
23+
@* --- 1. Browser Tab Title --- *@
2124
<PageTitle>@_post.Title</PageTitle>
2225

26+
@* --- 2. SEO & Social Metadata (Injected into <head>) --- *@
27+
<HeadContent>
28+
@* Standard SEO *@
29+
<meta name="description" content="@_post.Summary" />
30+
<meta name="author" content="@_post.AuthorDisplayName" />
31+
<link rel="canonical" href="@_currentUrl" />
32+
33+
@* Open Graph / Facebook / LinkedIn *@
34+
<meta property="og:type" content="article" />
35+
<meta property="og:site_name" content="@(Configuration["Application:Title"] ?? "MyBlog")" />
36+
<meta property="og:title" content="@_post.Title" />
37+
<meta property="og:description" content="@_post.Summary" />
38+
<meta property="og:url" content="@_currentUrl" />
39+
40+
@if (!string.IsNullOrEmpty(_previewImage))
41+
{
42+
<meta property="og:image" content="@_previewImage" />
43+
<meta property="og:image:alt" content="@_post.Title" />
44+
}
45+
46+
@* Twitter Cards *@
47+
<meta name="twitter:card" content="@(string.IsNullOrEmpty(_previewImage) ? "summary" : "summary_large_image")" />
48+
<meta name="twitter:title" content="@_post.Title" />
49+
<meta name="twitter:description" content="@_post.Summary" />
50+
@if (!string.IsNullOrEmpty(_previewImage))
51+
{
52+
<meta name="twitter:image" content="@_previewImage" />
53+
}
54+
</HeadContent>
55+
56+
@* --- 3. Visible Page Content --- *@
2357
<article class="post-detail">
2458
<header class="post-header">
2559
<h1>@_post.Title</h1>
@@ -29,7 +63,21 @@ else
2963
{
3064
<span class="date">Published @_post.PublishedAtUtc.Value.ToString("MMMM d, yyyy")</span>
3165
}
66+
67+
@* Real-time Reader Badge *@
3268
<ReaderBadge Slug="@Slug" />
69+
70+
@* Social Share Button (Visible UI) *@
71+
<button type="button" class="btn-link share-btn" data-title="@_post.Title" onclick="sharePost(this.dataset.title)">
72+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
73+
<circle cx="18" cy="5" r="3"></circle>
74+
<circle cx="6" cy="12" r="3"></circle>
75+
<circle cx="18" cy="19" r="3"></circle>
76+
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line>
77+
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line>
78+
</svg>
79+
Share
80+
</button>
3381
</div>
3482
</header>
3583

@@ -47,17 +95,44 @@ else
4795

4896
private PostDetailDto? _post;
4997
private bool _notFound;
98+
private string _currentUrl = "";
99+
private string? _previewImage;
50100

51101
protected override async Task OnParametersSetAsync()
52102
{
103+
// 1. Load Data
53104
_post = await PostRepository.GetBySlugAsync(Slug);
54105
_notFound = _post is null;
55106

56-
// Don't show unpublished posts to public
107+
// 2. Validate visibility
57108
if (_post is not null && !_post.IsPublished)
58109
{
59110
_post = null;
60111
_notFound = true;
61112
}
113+
114+
// 3. Prepare Metadata
115+
if (_post is not null)
116+
{
117+
// Get Absolute URL for Canonical/OG tags
118+
_currentUrl = Navigation.Uri;
119+
120+
// Extract the first image from Markdown to use as the preview image
121+
// Regex matches: ![alt](url)
122+
var imageMatch = Regex.Match(_post.Content, @"!\[.*?\]\((.*?)\)");
123+
if (imageMatch.Success)
124+
{
125+
var url = imageMatch.Groups[1].Value;
126+
// If it's a relative URL (like /api/images/...), make it absolute
127+
if (url.StartsWith("/"))
128+
{
129+
_previewImage = Navigation.ToAbsoluteUri(url).ToString();
130+
}
131+
else
132+
{
133+
_previewImage = url;
134+
}
135+
}
136+
}
62137
}
63138
}

0 commit comments

Comments
 (0)