diff --git a/package.json b/package.json index 78056bf..f212c8e 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,8 @@ "version": "0.1.0", "private": true, "scripts": { + "prebuild": "node scripts/generate-sitemap-config.mjs", + "generate:sitemap-config": "node scripts/generate-sitemap-config.mjs", "dev": "next dev", "build": "next build", "start": "next start", diff --git a/public/fonts/iran-yekan-black.ttf b/public/fonts/iran-yekan-black.ttf new file mode 100644 index 0000000..ec2da33 Binary files /dev/null and b/public/fonts/iran-yekan-black.ttf differ diff --git a/public/fonts/iran-yekan-bold.ttf b/public/fonts/iran-yekan-bold.ttf new file mode 100644 index 0000000..6d98764 Binary files /dev/null and b/public/fonts/iran-yekan-bold.ttf differ diff --git a/public/fonts/iran-yekan-extrabold.ttf b/public/fonts/iran-yekan-extrabold.ttf new file mode 100644 index 0000000..a2810c5 Binary files /dev/null and b/public/fonts/iran-yekan-extrabold.ttf differ diff --git a/public/fonts/iran-yekan-light.ttf b/public/fonts/iran-yekan-light.ttf new file mode 100644 index 0000000..b6114ee Binary files /dev/null and b/public/fonts/iran-yekan-light.ttf differ diff --git a/public/fonts/iran-yekan-medium.ttf b/public/fonts/iran-yekan-medium.ttf new file mode 100644 index 0000000..cc9d9b4 Binary files /dev/null and b/public/fonts/iran-yekan-medium.ttf differ diff --git a/public/fonts/iran-yekan-regular.ttf b/public/fonts/iran-yekan-regular.ttf new file mode 100644 index 0000000..c662408 Binary files /dev/null and b/public/fonts/iran-yekan-regular.ttf differ diff --git a/public/fonts/iran-yekan-thin.ttf b/public/fonts/iran-yekan-thin.ttf new file mode 100644 index 0000000..93a53a1 Binary files /dev/null and b/public/fonts/iran-yekan-thin.ttf differ diff --git a/public/fonts/montserrat-bold.ttf b/public/fonts/montserrat-bold.ttf new file mode 100644 index 0000000..9a425b9 Binary files /dev/null and b/public/fonts/montserrat-bold.ttf differ diff --git a/public/fonts/montserrat-medium.ttf b/public/fonts/montserrat-medium.ttf new file mode 100644 index 0000000..db5b1af Binary files /dev/null and b/public/fonts/montserrat-medium.ttf differ diff --git a/public/fonts/montserrat-regular.ttf b/public/fonts/montserrat-regular.ttf new file mode 100644 index 0000000..2a2b2aa Binary files /dev/null and b/public/fonts/montserrat-regular.ttf differ diff --git a/public/fonts/montserrat-semibold.ttf b/public/fonts/montserrat-semibold.ttf new file mode 100644 index 0000000..0ecc667 Binary files /dev/null and b/public/fonts/montserrat-semibold.ttf differ diff --git a/scripts/generate-sitemap-config.mjs b/scripts/generate-sitemap-config.mjs new file mode 100644 index 0000000..445c84d --- /dev/null +++ b/scripts/generate-sitemap-config.mjs @@ -0,0 +1,119 @@ +/** + * 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", + "customer", + // Legacy aliases that only redirect + "blogs", + "projects", + "article-details", + "portfolio-details", +]); + +const PRIORITY = { + "/": { changefreq: "daily", priority: 1.0 }, + "/products": { changefreq: "weekly", priority: 0.9 }, + "/solutions": { changefreq: "weekly", priority: 0.9 }, + "/portfolios": { changefreq: "weekly", priority: 0.8 }, + "/articles": { changefreq: "weekly", priority: 0.8 }, + "/documents": { changefreq: "monthly", priority: 0.7 }, + "/catalog": { changefreq: "monthly", priority: 0.7 }, + "/about": { changefreq: "monthly", priority: 0.7 }, + "/contact": { changefreq: "monthly", priority: 0.7 }, + "/ims-policy": { changefreq: "yearly", priority: 0.5 }, +}; + +function websiteDomain() { + const fromEnv = ( + process.env.NEXT_PUBLIC_SITE_URL || + process.env.NEXT_PUBLIC_WEBSITE_DOMAIN || + process.env.WEBSITE_DOMAIN || + "www.havaran.com" + ) + .replace(/^https?:\/\//, "") + .replace(/\/+$/, ""); + return fromEnv || "www.havaran.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] ?? { changefreq: "monthly", priority: 0.5 }; + return { path: routePath, ...meta }; + }); + + const config = { + baseUrl: `https://${websiteDomain()}`, + staticPages, + // Blog list lives at /articles (not Meshkee default /blog). + // Portfolio uses Meshkee default /portfolios/{id}/{titleFaSlug} — omit template. + templates: { + blog: "/articles/{id}/{titleSlug}", + }, + }; + + 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/article-details/[id]/page.tsx b/src/app/article-details/[id]/page.tsx index 54606f5..b563ba3 100644 --- a/src/app/article-details/[id]/page.tsx +++ b/src/app/article-details/[id]/page.tsx @@ -1,4 +1,6 @@ -import { redirect } from "next/navigation"; +import { permanentRedirect } from "next/navigation"; +import { getBlog, getBlogById } from "@/lib/meshkee"; +import { blogPath } from "@/lib/urls"; type PageProps = { params: Promise<{ id: string }>; @@ -6,5 +8,11 @@ type PageProps = { export default async function ArticleDetailsAliasPage({ params }: PageProps) { const { id } = await params; - redirect(`/articles/${id}`); + const byId = await getBlogById(id); + if (byId.item) permanentRedirect(blogPath(byId.item)); + + const bySlug = await getBlog(id); + if (bySlug.item) permanentRedirect(blogPath(bySlug.item)); + + permanentRedirect("/articles"); } diff --git a/src/app/articles/[id]/[slug]/page.tsx b/src/app/articles/[id]/[slug]/page.tsx new file mode 100644 index 0000000..8430bd1 --- /dev/null +++ b/src/app/articles/[id]/[slug]/page.tsx @@ -0,0 +1,64 @@ +import { notFound, permanentRedirect } from "next/navigation"; +import { Footer } from "@/components/Footer"; +import { Header } from "@/components/Header"; +import { PageHero } from "@/components/PageShared"; +import { ArticleDetailView } from "@/components/ArticleDetail"; +import { getBlogById } from "@/lib/meshkee"; +import { pageMetadata } from "@/lib/seo"; +import { blogPath, blogTitleSlug } from "@/lib/urls"; + +type PageProps = { + params: Promise<{ id: string; slug: string }>; +}; + +export async function generateMetadata({ params }: PageProps) { + const { id } = await params; + const { item } = await getBlogById(id); + if (!item) { + return pageMetadata({ + title: "مقاله", + description: "مقاله مورد نظر یافت نشد.", + path: `/articles/${id}`, + noIndex: true, + }); + } + + const description = item.excerpt || item.title; + return pageMetadata({ + title: item.title, + description, + path: blogPath(item), + image: item.image || undefined, + }); +} + +export default async function ArticleDetailPage({ params }: PageProps) { + const { id, slug: slugParam } = await params; + const slug = decodeURIComponent(slugParam); + const { item } = await getBlogById(id); + if (!item) notFound(); + + const canonicalSlug = blogTitleSlug(item); + if (slug !== canonicalSlug) { + permanentRedirect(blogPath(item)); + } + + return ( + <> +
+
+ + +
+