diff --git a/Ai-data/prompt-seo.txt b/Ai-data/prompt-seo.txt new file mode 100644 index 0000000..54a55b8 --- /dev/null +++ b/Ai-data/prompt-seo.txt @@ -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 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 diff --git a/package.json b/package.json index 9ad2872..ebd95b3 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/generate-sitemap-config.mjs b/scripts/generate-sitemap-config.mjs new file mode 100644 index 0000000..eb6e036 --- /dev/null +++ b/scripts/generate-sitemap-config.mjs @@ -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(); diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx index c860cb1..8334a61 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/blog/[slug]/page.tsx @@ -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} diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx index 721592c..265e8d4 100644 --- a/src/app/blog/page.tsx +++ b/src/app/blog/page.tsx @@ -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} diff --git a/src/app/meshkee/sitemap-config.json/route.ts b/src/app/meshkee/sitemap-config.json/route.ts new file mode 100644 index 0000000..998558b --- /dev/null +++ b/src/app/meshkee/sitemap-config.json/route.ts @@ -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 }); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 8333cff..99fa88c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( <> diff --git a/src/app/portfolio/[slug]/page.tsx b/src/app/portfolio/[slug]/page.tsx index 8a36d65..74e40d8 100644 --- a/src/app/portfolio/[slug]/page.tsx +++ b/src/app/portfolio/[slug]/page.tsx @@ -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 ? ( diff --git a/src/app/portfolio/page.tsx b/src/app/portfolio/page.tsx index ca2ae65..d7ac638 100644 --- a/src/app/portfolio/page.tsx +++ b/src/app/portfolio/page.tsx @@ -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} diff --git a/src/app/robots.ts b/src/app/robots.ts deleted file mode 100644 index b228014..0000000 --- a/src/app/robots.ts +++ /dev/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, - }; -} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts deleted file mode 100644 index d7db2c0..0000000 --- a/src/app/sitemap.ts +++ /dev/null @@ -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, - })); -} diff --git a/src/components/BlogCoverImage.tsx b/src/components/BlogCoverImage.tsx index 3e466ed..db3b6fe 100644 --- a/src/components/BlogCoverImage.tsx +++ b/src/components/BlogCoverImage.tsx @@ -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()} /> diff --git a/src/components/BlogSection.tsx b/src/components/BlogSection.tsx index 74dab8a..7841b66 100644 --- a/src/components/BlogSection.tsx +++ b/src/components/BlogSection.tsx @@ -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} diff --git a/src/components/PortfolioGallery.tsx b/src/components/PortfolioGallery.tsx index 602edf7..3fd62d4 100644 --- a/src/components/PortfolioGallery.tsx +++ b/src/components/PortfolioGallery.tsx @@ -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()} /> diff --git a/src/components/ServicePageLayout.tsx b/src/components/ServicePageLayout.tsx index 1687745..d367603 100644 --- a/src/components/ServicePageLayout.tsx +++ b/src/components/ServicePageLayout.tsx @@ -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" diff --git a/src/components/Services.tsx b/src/components/Services.tsx index 305ed11..ecd47b2 100644 --- a/src/components/Services.tsx +++ b/src/components/Services.tsx @@ -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" diff --git a/src/lib/seo-slug.ts b/src/lib/seo-slug.ts new file mode 100644 index 0000000..12e4c5c --- /dev/null +++ b/src/lib/seo-slug.ts @@ -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)}`; +} diff --git a/src/lib/sitemap-config.generated.json b/src/lib/sitemap-config.generated.json new file mode 100644 index 0000000..e5c5a1a --- /dev/null +++ b/src/lib/sitemap-config.generated.json @@ -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 + } + ] +}