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:
co-authored by
Cursor
parent
5d7da0f268
commit
c588b249cc
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -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,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()}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user