feat(seo): complete OG/Twitter/structured-data coverage + clean encoding
CI/CD / CI · dotnet build (push) Successful in 2m1s
CI/CD / Deploy · drsousan (push) Successful in 35s

Blog list (/blog): add robots, full Open Graph + Twitter, Blog +
BreadcrumbList JSON-LD, per-page self-canonical, and rel=prev/next for
paginated pages.

Blog post: add robots, og:site_name, article:published_time /
modified_time / author / section, twitter:image, og:image:alt, and a
BreadcrumbList JSON-LD (Home → Blog → Category → Post).

Gallery (/gallery): add robots, full OG + Twitter (with first image as
og:image), ImageGallery + BreadcrumbList JSON-LD.

Encoding: register HtmlEncoder.Create(UnicodeRanges.All) so Persian text
in meta tags and JSON-LD renders literally instead of &#xXXXX; entities
(smaller, cleaner output; friendlier to SEO validators).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-25 13:44:37 +03:30
parent 5a1f1a8ccb
commit 00a138fe46
4 changed files with 140 additions and 5 deletions
+50 -2
View File
@@ -1,10 +1,58 @@
@page "/blog"
@model DrSousan.Api.Pages.Blog.BlogIndexModel
@{
var blogBase = Environment.GetEnvironmentVariable("SITE_BASE_URL")?.TrimEnd('/') ?? (Request.Scheme + "://" + Request.Host);
var catQs = string.IsNullOrEmpty(Model.ActiveCat) ? "" : "&category=" + Model.ActiveCat;
string PageUrl(int p) => p <= 1
? blogBase + "/blog" + (string.IsNullOrEmpty(Model.ActiveCat) ? "" : "?category=" + Model.ActiveCat)
: blogBase + "/blog?pg=" + p + catQs;
var blogDesc = "مقالات تخصصی دکتر سوسن آل‌طه درباره زیبایی پوست، بوتاکس، فیلر، لیزر و مراقبت از پوست.";
}
@section Head {
<title>@ViewData["Title"]</title>
<meta name="description" content="مقالات تخصصی دکتر سوسن آل‌طه درباره زیبایی پوست، بوتاکس، فیلر، لیزر و مراقبت از پوست." />
<link rel="canonical" href="@((Environment.GetEnvironmentVariable("SITE_BASE_URL")?.TrimEnd('/') ?? (Request.Scheme + "://" + Request.Host)) + "/blog")" />
<meta name="description" content="@blogDesc" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1" />
<meta name="author" content="@ViewData["SiteName"]" />
<meta name="theme-color" content="#B8955A" />
<link rel="canonical" href="@PageUrl(Model.CurrentPage)" />
@if (Model.CurrentPage > 1) { <link rel="prev" href="@PageUrl(Model.CurrentPage - 1)" /> }
@if (Model.CurrentPage < Model.TotalPages) { <link rel="next" href="@PageUrl(Model.CurrentPage + 1)" /> }
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="@ViewData["SiteName"]" />
<meta property="og:title" content="@ViewData["Title"]" />
<meta property="og:description" content="@blogDesc" />
<meta property="og:url" content="@PageUrl(Model.CurrentPage)" />
<meta property="og:locale" content="fa_IR" />
<!-- Twitter -->
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="@ViewData["Title"]" />
<meta name="twitter:description" content="@blogDesc" />
<!-- Structured data: Blog + breadcrumb -->
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "Blog",
"name": "@ViewData["Title"]",
"description": "@blogDesc",
"url": "@(blogBase)/blog",
"inLanguage": "fa-IR",
"publisher": { "@@type": "Organization", "name": "@ViewData["SiteName"]", "url": "@blogBase" }
}
</script>
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "BreadcrumbList",
"itemListElement": [
{ "@@type": "ListItem", "position": 1, "name": "خانه", "item": "@blogBase/" },
{ "@@type": "ListItem", "position": 2, "name": "وبلاگ", "item": "@blogBase/blog" }
]
}
</script>
<style>
/* ─── Blog Hero ─────────────────────────────────────────────── */
.blog-hero{background:linear-gradient(135deg,var(--gold-pale) 0%,#EDE0CA 100%);padding:6rem 2rem 3rem;text-align:center}
+30 -1
View File
@@ -18,17 +18,28 @@
@if (!string.IsNullOrEmpty(ViewData["Keywords"]?.ToString())) {
<meta name="keywords" content="@ViewData["Keywords"]" />
}
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1" />
<meta name="author" content="@post.Author" />
<meta property="og:type" content="article" />
<meta property="og:site_name" content="@ViewData["SiteName"]" />
<meta property="og:title" content="@ViewData["Title"]" />
<meta property="og:description" content="@ViewData["MetaDesc"]" />
<meta property="og:url" content="@canonicalUrl" />
<meta property="og:locale" content="fa_IR" />
@if (!string.IsNullOrEmpty(pubDate)) { <meta property="article:published_time" content="@pubDate" /> }
<meta property="article:modified_time" content="@updDate" />
<meta property="article:author" content="@post.Author" />
@if (post.Category != null) { <meta property="article:section" content="@post.Category.Name" /> }
@if (!string.IsNullOrEmpty(ogImage)) {
<meta property="og:image" content="@(ogImage.StartsWith("http") ? ogImage : baseUrl + ogImage)" />
<meta property="og:image" content="@(ogImage.StartsWith("http") ? ogImage : baseUrl + ogImage)" />
<meta property="og:image:alt" content="@post.Title" />
}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="@ViewData["Title"]" />
<meta name="twitter:description" content="@ViewData["MetaDesc"]" />
@if (!string.IsNullOrEmpty(ogImage)) {
<meta name="twitter:image" content="@(ogImage.StartsWith("http") ? ogImage : baseUrl + ogImage)" />
}
<link rel="canonical" href="@canonicalUrl" />
<script type="application/ld+json">
@@ -52,6 +63,24 @@
}
</script>
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "BreadcrumbList",
"itemListElement": [
{ "@@type": "ListItem", "position": 1, "name": "خانه", "item": "@baseUrl/" },
{ "@@type": "ListItem", "position": 2, "name": "وبلاگ", "item": "@baseUrl/blog" }@(post.Category != null ? "," : "")
@if (post.Category != null) {
@:{ "@@type": "ListItem", "position": 3, "name": "@J(post.Category.Name)", "item": "@baseUrl/blog?category=@post.Category.Slug" },
@:{ "@@type": "ListItem", "position": 4, "name": "@J(post.Title)", "item": "@canonicalUrl" }
}
else {
@:{ "@@type": "ListItem", "position": 3, "name": "@J(post.Title)", "item": "@canonicalUrl" }
}
]
}
</script>
<style>
/* ─── Post Layout ──────────────────────────────────────────────── */
.post-layout{max-width:1100px;margin:0 auto;padding:5rem 2rem 3rem;display:grid;grid-template-columns:1fr 320px;gap:3rem;align-items:start}
+54 -2
View File
@@ -1,10 +1,62 @@
@page "/gallery"
@model DrSousan.Api.Pages.GalleryModel
@{
var galBase = Environment.GetEnvironmentVariable("SITE_BASE_URL")?.TrimEnd('/') ?? (Request.Scheme + "://" + Request.Host);
var galUrl = galBase + "/gallery";
var galDesc = "گالری نتایج واقعی قبل و بعد درمان‌های زیبایی پوست دکتر سوسن آل‌طه — بوتاکس، فیلر، لیزر، مزوتراپی و پاکسازی پوست.";
var firstImg = Model.Items
.Select(i => !string.IsNullOrEmpty(i.BeforeImageUrl) ? i.BeforeImageUrl : i.ImageUrl)
.FirstOrDefault(u => !string.IsNullOrEmpty(u)) ?? "";
var absFirstImg = string.IsNullOrEmpty(firstImg) ? "" : (firstImg.StartsWith("http") ? firstImg : galBase + firstImg);
}
@section Head {
<title>@ViewData["Title"]</title>
<meta name="description" content="گالری نتایج واقعی قبل و بعد درمان‌های زیبایی پوست دکتر سوسن آل‌طه — بوتاکس، فیلر، لیزر، مزوتراپی و پاکسازی پوست." />
<link rel="canonical" href="@((Environment.GetEnvironmentVariable("SITE_BASE_URL")?.TrimEnd('/') ?? (Request.Scheme + "://" + Request.Host)) + "/gallery")" />
<meta name="description" content="@galDesc" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1" />
<meta name="author" content="@ViewData["SiteName"]" />
<meta name="theme-color" content="#B8955A" />
<link rel="canonical" href="@galUrl" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="@ViewData["SiteName"]" />
<meta property="og:title" content="@ViewData["Title"]" />
<meta property="og:description" content="@galDesc" />
<meta property="og:url" content="@galUrl" />
<meta property="og:locale" content="fa_IR" />
@if (!string.IsNullOrEmpty(absFirstImg)) {
<meta property="og:image" content="@absFirstImg" />
}
<!-- Twitter -->
<meta name="twitter:card" content="@(string.IsNullOrEmpty(absFirstImg) ? "summary" : "summary_large_image")" />
<meta name="twitter:title" content="@ViewData["Title"]" />
<meta name="twitter:description" content="@galDesc" />
@if (!string.IsNullOrEmpty(absFirstImg)) {
<meta name="twitter:image" content="@absFirstImg" />
}
<!-- Structured data: ImageGallery + breadcrumb -->
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "ImageGallery",
"name": "@ViewData["Title"]",
"description": "@galDesc",
"url": "@galUrl",
"inLanguage": "fa-IR"
}
</script>
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "BreadcrumbList",
"itemListElement": [
{ "@@type": "ListItem", "position": 1, "name": "خانه", "item": "@galBase/" },
{ "@@type": "ListItem", "position": 2, "name": "گالری", "item": "@galUrl" }
]
}
</script>
<style>
.gal-hero{background:linear-gradient(135deg,var(--gold-pale) 0%,#EDE0CA 100%);padding:6rem 2rem 3rem;text-align:center}
.gal-hero h1{font-size:clamp(1.8rem,4vw,2.5rem);font-weight:700;color:var(--dark);margin-bottom:.6rem}
+6
View File
@@ -1,6 +1,8 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Unicode;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
@@ -49,6 +51,10 @@ builder.Services.AddCors(opt =>
// Razor Pages for SSR public pages
builder.Services.AddRazorPages();
// Don't entity-encode non-ASCII (Persian) or chars like '+' in markup output.
// Default encoder turns "application/ld+json" into "application/ld&#x2B;json" and
// Persian text into \&#x06XX; entities — valid but bloated and trips some validators.
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
// Fix circular JSON references (BlogPost ↔ BlogCategory)
builder.Services.ConfigureHttpJsonOptions(opts =>