Publish Meshkee sitemap-config and harden on-page SEO.

Hand sitemap/robots to the Meshkee API proxy and expose build-time static routes plus clearer titles, canonicals, and image alts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-23 22:42:28 +03:30
co-authored by Cursor
parent 5d7da0f268
commit c588b249cc
18 changed files with 279 additions and 53 deletions
+18
View File
@@ -0,0 +1,18 @@
### Sitemap + robots.txt
- Do NOT ship public/sitemap.xml or public/robots.txt (nginx proxies both to Meshkee API).
- Live sitemap is an index: /sitemap.xml → /sitemap-main.xml + /sitemap-products.xml.
- Publish GET /meshkee/sitemap-config.json generated at build time from app routes.
- Include public static routes automatically; exclude auth/checkout/cart/admin paths.
- Canonical detail URLs (Meshkee defaults — omit templates unless customized):
- Product: /products/{id}/{nameFaSlug} → GET /tenants/{domain}/products/by-id/{id}
- Category: /products/category/{id}/{nameFaSlug} → GET /tenants/{domain}/categories/by-id/{id} (then list products with categoryId)
- Blog: /blog/{id}/{titleSlug} → GET /tenants/{domain}/blogs/by-id/{id}
- Portfolio: /portfolios/{id}/{titleFaSlug} → GET /tenants/{domain}/portfolios/by-id/{id}
- Slugify: Farsi letters kept, spaces → -, strip other punctuation. Do not use legacy DB slug in public URLs.
- After deploy: business dashboard → Website → Settings → Sync sitemap config (optional for CMS URLs; needed for static pages).
### On-page SEO (every public page)
- Unique <title> and meta description on every crawlable page
- Exactly one H1; H2/H3 for real sections
- All content images must have meaningful alt (CMS title when available)
- Prefer CMS fields for titles/descriptions; never leave Next defaults
+2
View File
@@ -4,6 +4,8 @@
"private": true,
"scripts": {
"dev": "next dev",
"prebuild": "node scripts/generate-sitemap-config.mjs",
"generate:sitemap-config": "node scripts/generate-sitemap-config.mjs",
"build": "next build",
"start": "next start",
"lint": "eslint"
+110
View File
@@ -0,0 +1,110 @@
/**
* Scans app page routes and writes src/lib/sitemap-config.generated.json
* for GET /meshkee/sitemap-config.json (Meshkee sitemap sync).
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const appDir = path.join(root, "src", "app");
const outFile = path.join(root, "src", "lib", "sitemap-config.generated.json");
const EXCLUDE_SEGMENTS = new Set([
"api",
"meshkee",
"admin",
"auth",
"login",
"checkout",
"cart",
"account",
]);
const PRIORITY = {
"/": { changefreq: "weekly", priority: 1.0 },
"/cms-website": { changefreq: "monthly", priority: 0.9 },
"/website": { changefreq: "monthly", priority: 0.9 },
"/website-qom": { changefreq: "monthly", priority: 0.9 },
"/seo": { changefreq: "monthly", priority: 0.8 },
"/dedicated-application": { changefreq: "monthly", priority: 0.8 },
"/meshkee-cms": { changefreq: "monthly", priority: 0.8 },
"/graphic-designer": { changefreq: "monthly", priority: 0.7 },
"/portfolio": { changefreq: "weekly", priority: 0.8 },
"/blog": { changefreq: "weekly", priority: 0.8 },
};
function websiteDomain() {
const fromEnv =
process.env.NEXT_PUBLIC_WEBSITE_DOMAIN ||
process.env.WEBSITE_DOMAIN ||
process.env.NEXT_PUBLIC_MESHKEE_DOMAIN ||
process.env.NEXT_PUBLIC_SITE_URL;
if (fromEnv) {
return fromEnv.replace(/^https?:\/\//, "").replace(/\/$/, "");
}
return "www.meshkee.com";
}
function walkPages(dir, segments = []) {
/** @type {string[]} */
const pages = [];
if (!fs.existsSync(dir)) return pages;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (EXCLUDE_SEGMENTS.has(entry.name)) continue;
if (/^\[.+\]$/.test(entry.name) || /^\(.+\)$/.test(entry.name)) {
if (/^\(.+\)$/.test(entry.name)) {
pages.push(...walkPages(full, segments));
}
continue;
}
pages.push(...walkPages(full, [...segments, entry.name]));
continue;
}
if (
entry.name === "page.tsx" ||
entry.name === "page.ts" ||
entry.name === "page.jsx" ||
entry.name === "page.js"
) {
const routePath = segments.length ? `/${segments.join("/")}` : "/";
pages.push(routePath);
}
}
return pages;
}
function main() {
const paths = [...new Set(walkPages(appDir))].sort((a, b) => {
if (a === "/") return -1;
if (b === "/") return 1;
return a.localeCompare(b);
});
const staticPages = paths.map((routePath) => {
const meta = PRIORITY[routePath] ?? { priority: 0.5 };
return { path: routePath, ...meta };
});
const config = {
baseUrl: `https://${websiteDomain()}`,
staticPages,
};
fs.mkdirSync(path.dirname(outFile), { recursive: true });
fs.writeFileSync(outFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
console.log(
`Wrote ${path.relative(root, outFile)} (${staticPages.length} static pages)`,
);
}
main();
+11 -1
View File
@@ -48,6 +48,12 @@ export async function generateMetadata({
return {
title: `${blog.title} | مشکی`,
description: blog.abstract ?? undefined,
alternates: { canonical: `/blog/${blog.slug}` },
openGraph: {
title: blog.title,
description: blog.abstract ?? undefined,
type: "article",
},
};
} catch {
return { title: "مقاله | مشکی" };
@@ -126,7 +132,11 @@ export default async function BlogDetailPage({ params }: BlogDetailPageProps) {
<div className="mx-auto max-w-3xl px-5 lg:px-8">
{cover ? (
<div className="mb-5 overflow-hidden rounded-2xl">
<BlogCoverImage src={cover} className="mx-auto max-h-[28rem]" />
<BlogCoverImage
src={cover}
alt={blog.title}
className="mx-auto max-h-[28rem]"
/>
</div>
) : null}
+1 -1
View File
@@ -69,7 +69,7 @@ export default async function BlogListPage({ searchParams }: BlogPageProps) {
>
<Link href={`/blog/${post.slug}`} className="block">
<div className="relative mb-4 overflow-hidden rounded-2xl">
<BlogCoverImage src={image} />
<BlogCoverImage src={image} alt={post.title} />
</div>
<h2 className="group-text-hover mb-2 line-clamp-2 whitespace-normal text-lg font-normal leading-8 text-ink">
{post.title}
@@ -0,0 +1,17 @@
import sitemapConfig from "@/lib/sitemap-config.generated.json";
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cache-Control": "public, max-age=60",
"Content-Type": "application/json; charset=utf-8",
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers });
}
export async function GET() {
return Response.json(sitemapConfig, { headers });
}
+10
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { AppSection } from "@/components/AppSection";
import { BlogSection } from "@/components/BlogSection";
import { CmsSection } from "@/components/CmsSection";
@@ -10,6 +11,15 @@ import { Portfolio } from "@/components/Portfolio";
import { RevealObserver } from "@/components/RevealObserver";
import { Services } from "@/components/Services";
export const metadata: Metadata = {
title: {
absolute: "تیم برنامه‌نویسی و تجارت الکترونیک مشکی | Meshkee",
},
description:
"راهکارهای مدرن برای تجارت شما — طراحی وبسایت، اپلیکیشن، سیستم‌های مدیریتی، سئو و گرافیک توسط تیم مشکی.",
alternates: { canonical: "/" },
};
export default function HomePage() {
return (
<>
+7
View File
@@ -66,6 +66,12 @@ export async function generateMetadata({
return {
title: `${title} | مشکی`,
description: portfolio.abstract ?? undefined,
alternates: { canonical: `/portfolio/${portfolio.slug}` },
openGraph: {
title,
description: portfolio.abstract ?? undefined,
type: "article",
},
};
} catch {
return { title: "نمونه‌کار | مشکی" };
@@ -180,6 +186,7 @@ export default async function PortfolioDetailPage({
category={categoryFa}
date={date}
dateTime={portfolio.publishedAt}
title={title}
/>
{portfolio.mainTextHtml ? (
+1 -1
View File
@@ -181,7 +181,7 @@ export default async function PortfolioListPage({
{image ? (
<img
src={image}
alt=""
alt={item.titleFa || item.title}
className="h-auto w-full transition duration-700 group-hover:scale-105"
/>
) : null}
-14
View File
@@ -1,14 +0,0 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.meshkee.com";
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: `${siteUrl}/sitemap.xml`,
host: siteUrl,
};
}
-27
View File
@@ -1,27 +0,0 @@
import type { MetadataRoute } from "next";
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.meshkee.com";
const routes = [
"/",
"/cms-website",
"/website-qom",
"/website",
"/seo",
"/dedicated-application",
"/meshkee-cms",
"/graphic-designer",
"/portfolio",
"/blog",
];
export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = new Date();
return routes.map((path) => ({
url: `${siteUrl}${path === "/" ? "" : path}`,
lastModified,
changeFrequency: path === "/" ? "weekly" : "monthly",
priority: path === "/" ? 1 : path === "/website-qom" || path === "/cms-website" ? 0.9 : 0.7,
}));
}
+7 -2
View File
@@ -4,10 +4,15 @@ import { useState } from "react";
type BlogCoverImageProps = {
src: string | null;
alt?: string;
className?: string;
};
export function BlogCoverImage({ src, className = "" }: BlogCoverImageProps) {
export function BlogCoverImage({
src,
alt = "",
className = "",
}: BlogCoverImageProps) {
const [failed, setFailed] = useState(false);
if (!src || failed) {
@@ -25,7 +30,7 @@ export function BlogCoverImage({ src, className = "" }: BlogCoverImageProps) {
return (
<img
src={src}
alt=""
alt={alt}
onError={() => setFailed(true)}
className={`h-auto w-full object-contain transition duration-700 group-hover:scale-105 ${className}`.trim()}
/>
+1 -1
View File
@@ -53,7 +53,7 @@ export async function BlogSection() {
>
<Link href={`/blog/${post.slug}`} className="block">
<div className="relative mb-4 overflow-hidden rounded-2xl">
<BlogCoverImage src={image} />
<BlogCoverImage src={image} alt={post.title} />
</div>
<h3 className="group-text-hover mb-2 line-clamp-2 text-lg font-normal leading-8 text-ink">
{post.title}
+13 -3
View File
@@ -13,6 +13,7 @@ type PortfolioGalleryProps = {
category?: string | null;
date?: string | null;
dateTime?: string | null;
title?: string | null;
};
export function PortfolioGallery({
@@ -21,6 +22,7 @@ export function PortfolioGallery({
category,
date,
dateTime,
title,
}: PortfolioGalleryProps) {
const slides: GalleryImage[] = [
...(cover ? [{ id: "cover", url: cover }] : []),
@@ -77,7 +79,7 @@ export function PortfolioGallery({
>
<img
src={cover}
alt=""
alt={title || "تصویر نمونه‌کار"}
className="h-auto max-h-[28rem] w-auto max-w-full object-contain transition duration-500 group-hover:scale-[1.01]"
/>
<span className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-ink/55 to-transparent px-4 py-3 text-xs text-white opacity-0 transition group-hover:opacity-100">
@@ -118,7 +120,11 @@ export function PortfolioGallery({
>
<img
src={image.url}
alt=""
alt={
title
? `${title} — تصویر ${index + 1}`
: `تصویر گالری ${index + 1}`
}
className="aspect-square h-auto w-full object-cover transition duration-500 group-hover:scale-[1.03]"
/>
</button>
@@ -175,7 +181,11 @@ export function PortfolioGallery({
<img
src={activeImage.url}
alt=""
alt={
title
? `${title} — تصویر ${(activeIndex ?? 0) + 1}`
: `تصویر ${(activeIndex ?? 0) + 1}`
}
className="max-h-[90vh] max-w-[min(96vw,72rem)] rounded-xl object-contain shadow-[0_24px_80px_rgba(0,0,0,0.35)]"
onClick={(event) => event.stopPropagation()}
/>
+2 -2
View File
@@ -48,7 +48,7 @@ function SolutionFeatureCard({ feature }: { feature: Feature }) {
{feature.icon ? (
<img
src={feature.icon}
alt=""
alt={feature.title}
width={36}
height={36}
className="mt-0.5 h-9 w-9 shrink-0 object-contain"
@@ -78,7 +78,7 @@ function TeamFeatureCard({ feature }: { feature: Feature }) {
{feature.icon ? (
<img
src={feature.icon}
alt=""
alt={feature.title}
width={43}
height={43}
className="h-[43px] w-[43px] shrink-0 object-contain"
+1 -1
View File
@@ -30,7 +30,7 @@ export function Services() {
<div className="mb-5 flex items-center gap-3">
<img
src={service.icon}
alt=""
alt={service.title}
width={48}
height={48}
className="h-12 w-12 shrink-0 object-contain"
+23
View File
@@ -0,0 +1,23 @@
/**
* Build a URL path segment from a title (Persian / Arabic / Latin).
* Keeps letters and digits; spaces → hyphens; strips other punctuation.
*/
export function slugifyForUrl(value: string, fallback = "item"): string {
const slug = value
.trim()
.normalize("NFC")
.replace(/\s+/g, "-")
.replace(
/[^\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFFa-zA-Z0-9-]+/g,
"",
)
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return slug || fallback;
}
export function detailPath(base: string, id: string | number, title: string) {
const slug = slugifyForUrl(title);
return `${base}/${encodeURIComponent(String(id))}/${encodeURIComponent(slug)}`;
}
+55
View File
@@ -0,0 +1,55 @@
{
"baseUrl": "https://www.meshkee.com",
"staticPages": [
{
"path": "/",
"changefreq": "weekly",
"priority": 1
},
{
"path": "/blog",
"changefreq": "weekly",
"priority": 0.8
},
{
"path": "/cms-website",
"changefreq": "monthly",
"priority": 0.9
},
{
"path": "/dedicated-application",
"changefreq": "monthly",
"priority": 0.8
},
{
"path": "/graphic-designer",
"changefreq": "monthly",
"priority": 0.7
},
{
"path": "/meshkee-cms",
"changefreq": "monthly",
"priority": 0.8
},
{
"path": "/portfolio",
"changefreq": "weekly",
"priority": 0.8
},
{
"path": "/seo",
"changefreq": "monthly",
"priority": 0.8
},
{
"path": "/website",
"changefreq": "monthly",
"priority": 0.9
},
{
"path": "/website-qom",
"changefreq": "monthly",
"priority": 0.9
}
]
}