mirror of
https://git.meshkee.com/novintrades/website.git
synced 2026-08-11 20:50:58 +04:30
Add SEO meta descriptions and a dynamic sitemap.
Expose optional metaDescription for blogs, reportages, and brands in admin/API, apply it on detail pages, and serve an up-to-date /sitemap.xml for approved content plus static site pages. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
5088e0f4ae
commit
16bc154fb0
@@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Blog" ADD COLUMN "metaDescription" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Reportage" ADD COLUMN "metaDescription" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Brand" ADD COLUMN "metaDescription" TEXT;
|
||||
@@ -49,6 +49,7 @@ model Blog {
|
||||
title String
|
||||
slug String @unique
|
||||
abstract String?
|
||||
metaDescription String?
|
||||
content String @default("")
|
||||
imageUrl String?
|
||||
tags String[] @default([])
|
||||
@@ -80,6 +81,7 @@ model Reportage {
|
||||
slug String @unique
|
||||
businessOwner String
|
||||
abstract String?
|
||||
metaDescription String?
|
||||
content String @default("")
|
||||
imageUrl String?
|
||||
tags String[] @default([])
|
||||
@@ -110,6 +112,7 @@ model Brand {
|
||||
title String
|
||||
slug String @unique
|
||||
abstract String?
|
||||
metaDescription String?
|
||||
content String @default("")
|
||||
imageUrl String?
|
||||
galleryUrls String[] @default([])
|
||||
|
||||
@@ -2,6 +2,7 @@ import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import { prisma } from "./lib/prisma.js";
|
||||
import { healthRoutes } from "./routes/health.js";
|
||||
import { sitemapRoutes } from "./routes/sitemap.js";
|
||||
import { categoryRoutes } from "./routes/categories.js";
|
||||
import { blogRoutes } from "./routes/blogs.js";
|
||||
import { reportageRoutes } from "./routes/reportages.js";
|
||||
@@ -21,6 +22,7 @@ export async function buildApp() {
|
||||
app.decorate("prisma", prisma);
|
||||
|
||||
await app.register(healthRoutes);
|
||||
await app.register(sitemapRoutes);
|
||||
await app.register(authRoutes, { prefix: "/api/auth" });
|
||||
await app.register(categoryRoutes, { prefix: "/api/categories" });
|
||||
await app.register(blogRoutes, { prefix: "/api/blogs" });
|
||||
|
||||
@@ -7,6 +7,7 @@ const createSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
slug: z.string().min(1).optional(),
|
||||
abstract: z.string().optional(),
|
||||
metaDescription: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
imageUrl: z.string().url().nullable().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
@@ -58,6 +59,7 @@ export const blogRoutes: FastifyPluginAsync = async (app) => {
|
||||
title: parsed.data.title,
|
||||
slug,
|
||||
abstract: parsed.data.abstract,
|
||||
metaDescription: parsed.data.metaDescription,
|
||||
content: parsed.data.content ?? "",
|
||||
imageUrl: parsed.data.imageUrl ?? null,
|
||||
tags: parsed.data.tags ?? [],
|
||||
@@ -102,6 +104,9 @@ export const blogRoutes: FastifyPluginAsync = async (app) => {
|
||||
? { slug: slugify(data.slug ?? data.title ?? existing.title) }
|
||||
: {}),
|
||||
...(data.abstract !== undefined ? { abstract: data.abstract } : {}),
|
||||
...(data.metaDescription !== undefined
|
||||
? { metaDescription: data.metaDescription }
|
||||
: {}),
|
||||
...(data.content !== undefined ? { content: data.content } : {}),
|
||||
...(data.imageUrl !== undefined ? { imageUrl: data.imageUrl } : {}),
|
||||
...(data.tags !== undefined ? { tags: data.tags } : {}),
|
||||
|
||||
@@ -20,6 +20,7 @@ const createSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
slug: z.string().min(1).optional(),
|
||||
abstract: z.string().optional(),
|
||||
metaDescription: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
imageUrl: z.string().url().nullable().optional(),
|
||||
galleryUrls: z.array(z.string().url()).optional(),
|
||||
@@ -91,6 +92,7 @@ export const brandRoutes: FastifyPluginAsync = async (app) => {
|
||||
title: parsed.data.title,
|
||||
slug,
|
||||
abstract: parsed.data.abstract,
|
||||
metaDescription: parsed.data.metaDescription,
|
||||
content: parsed.data.content ?? "",
|
||||
imageUrl: parsed.data.imageUrl ?? null,
|
||||
galleryUrls,
|
||||
@@ -144,6 +146,9 @@ export const brandRoutes: FastifyPluginAsync = async (app) => {
|
||||
? { slug: slugify(data.slug ?? data.title ?? existing.title) }
|
||||
: {}),
|
||||
...(data.abstract !== undefined ? { abstract: data.abstract } : {}),
|
||||
...(data.metaDescription !== undefined
|
||||
? { metaDescription: data.metaDescription }
|
||||
: {}),
|
||||
...(data.content !== undefined ? { content: data.content } : {}),
|
||||
...(data.imageUrl !== undefined ? { imageUrl: data.imageUrl } : {}),
|
||||
...(galleryUrls !== undefined ? { galleryUrls } : {}),
|
||||
|
||||
@@ -8,6 +8,7 @@ const createSchema = z.object({
|
||||
slug: z.string().min(1).optional(),
|
||||
businessOwner: z.string().min(1),
|
||||
abstract: z.string().optional(),
|
||||
metaDescription: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
imageUrl: z.string().url().nullable().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
@@ -60,6 +61,7 @@ export const reportageRoutes: FastifyPluginAsync = async (app) => {
|
||||
slug,
|
||||
businessOwner: parsed.data.businessOwner,
|
||||
abstract: parsed.data.abstract,
|
||||
metaDescription: parsed.data.metaDescription,
|
||||
content: parsed.data.content ?? "",
|
||||
imageUrl: parsed.data.imageUrl ?? null,
|
||||
tags: parsed.data.tags ?? [],
|
||||
@@ -107,6 +109,9 @@ export const reportageRoutes: FastifyPluginAsync = async (app) => {
|
||||
? { businessOwner: data.businessOwner }
|
||||
: {}),
|
||||
...(data.abstract !== undefined ? { abstract: data.abstract } : {}),
|
||||
...(data.metaDescription !== undefined
|
||||
? { metaDescription: data.metaDescription }
|
||||
: {}),
|
||||
...(data.content !== undefined ? { content: data.content } : {}),
|
||||
...(data.imageUrl !== undefined ? { imageUrl: data.imageUrl } : {}),
|
||||
...(data.tags !== undefined ? { tags: data.tags } : {}),
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
import { VerificationStatus } from "@prisma/client";
|
||||
|
||||
const DEFAULT_SITE_URL = "https://novintrades.com";
|
||||
|
||||
function siteOrigin(): string {
|
||||
const raw = process.env.SITE_URL?.trim() || DEFAULT_SITE_URL;
|
||||
return raw.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function toLastmod(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
type SitemapEntry = {
|
||||
path: string;
|
||||
lastmod?: Date;
|
||||
changefreq?: string;
|
||||
priority?: string;
|
||||
};
|
||||
|
||||
function renderUrl(origin: string, entry: SitemapEntry): string {
|
||||
const lines = [
|
||||
" <url>",
|
||||
` <loc>${escapeXml(`${origin}${entry.path}`)}</loc>`,
|
||||
];
|
||||
|
||||
if (entry.lastmod) {
|
||||
lines.push(` <lastmod>${toLastmod(entry.lastmod)}</lastmod>`);
|
||||
}
|
||||
if (entry.changefreq) {
|
||||
lines.push(` <changefreq>${entry.changefreq}</changefreq>`);
|
||||
}
|
||||
if (entry.priority) {
|
||||
lines.push(` <priority>${entry.priority}</priority>`);
|
||||
}
|
||||
|
||||
lines.push(" </url>");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export const sitemapRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get("/sitemap.xml", async (_request, reply) => {
|
||||
const origin = siteOrigin();
|
||||
const now = new Date();
|
||||
|
||||
const [blogs, reportages, brands] = await Promise.all([
|
||||
app.prisma.blog.findMany({
|
||||
where: { verificationStatus: VerificationStatus.APPROVED },
|
||||
select: { slug: true, updatedAt: true },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
}),
|
||||
app.prisma.reportage.findMany({
|
||||
where: { verificationStatus: VerificationStatus.APPROVED },
|
||||
select: { slug: true, updatedAt: true },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
}),
|
||||
app.prisma.brand.findMany({
|
||||
where: { verificationStatus: VerificationStatus.APPROVED },
|
||||
select: { slug: true, updatedAt: true },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const staticEntries: SitemapEntry[] = [
|
||||
{ path: "/", changefreq: "daily", priority: "1.0", lastmod: now },
|
||||
{ path: "/about", changefreq: "monthly", priority: "0.6", lastmod: now },
|
||||
{ path: "/contact", changefreq: "monthly", priority: "0.6", lastmod: now },
|
||||
{ path: "/blog", changefreq: "daily", priority: "0.8", lastmod: now },
|
||||
{
|
||||
path: "/reportages",
|
||||
changefreq: "daily",
|
||||
priority: "0.8",
|
||||
lastmod: now,
|
||||
},
|
||||
{ path: "/brands", changefreq: "daily", priority: "0.8", lastmod: now },
|
||||
];
|
||||
|
||||
const dynamicEntries: SitemapEntry[] = [
|
||||
...blogs.map((item) => ({
|
||||
path: `/blog/${item.slug}`,
|
||||
lastmod: item.updatedAt,
|
||||
changefreq: "weekly",
|
||||
priority: "0.7",
|
||||
})),
|
||||
...reportages.map((item) => ({
|
||||
path: `/reportages/${item.slug}`,
|
||||
lastmod: item.updatedAt,
|
||||
changefreq: "weekly",
|
||||
priority: "0.7",
|
||||
})),
|
||||
...brands.map((item) => ({
|
||||
path: `/brands/${item.slug}`,
|
||||
lastmod: item.updatedAt,
|
||||
changefreq: "weekly",
|
||||
priority: "0.7",
|
||||
})),
|
||||
];
|
||||
|
||||
const body = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
...staticEntries.map((entry) => renderUrl(origin, entry)),
|
||||
...dynamicEntries.map((entry) => renderUrl(origin, entry)),
|
||||
"</urlset>",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
return reply
|
||||
.type("application/xml; charset=utf-8")
|
||||
.header("Cache-Control", "public, max-age=300")
|
||||
.send(body);
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user