Split product sitemap and use Farsi category URLs with ids.
sitemap.xml is now an index of main + products; category loc paths use /products/category/{id}/{nameFaSlug} with a public by-id lookup for storefronts.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
c4367d8eb0
commit
ea40ae40d8
@@ -7,6 +7,11 @@ export type SitemapUrlEntry = {
|
||||
priority?: number;
|
||||
};
|
||||
|
||||
export type SitemapIndexEntry = {
|
||||
loc: string;
|
||||
lastmod?: Date | null;
|
||||
};
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
@@ -51,6 +56,26 @@ export function buildSitemapXml(entries: SitemapUrlEntry[]): string {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildSitemapIndexXml(entries: SitemapIndexEntry[]): string {
|
||||
const sitemaps = entries
|
||||
.map((entry) => {
|
||||
const parts = [` <loc>${escapeXml(entry.loc)}</loc>`];
|
||||
if (entry.lastmod) {
|
||||
parts.push(` <lastmod>${formatLastmod(entry.lastmod)}</lastmod>`);
|
||||
}
|
||||
return ` <sitemap>\n${parts.join('\n')}\n </sitemap>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
`<sitemapindex xmlns="${SITEMAP_URLSET_NS}">`,
|
||||
sitemaps,
|
||||
'</sitemapindex>',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a path from a template. Keep Unicode (e.g. Farsi) readable in sitemap
|
||||
* `<loc>` — do not percent-encode letters. XML escaping happens in buildSitemapXml.
|
||||
|
||||
@@ -6,7 +6,7 @@ export const SITEMAP_URLSET_NS = 'http://www.sitemaps.org/schemas/sitemap/0.9';
|
||||
/** Default storefront path templates (overridable via /meshkee/sitemap-config.json). */
|
||||
export const DEFAULT_SITEMAP_PATH_TEMPLATES = {
|
||||
product: '/products/{id}/{slug}',
|
||||
productCategory: '/products/category/{slug}',
|
||||
productCategory: '/products/category/{id}/{slug}',
|
||||
blog: '/blog/{id}/{slug}',
|
||||
portfolio: '/portfolios/{id}/{slug}',
|
||||
} as const;
|
||||
|
||||
@@ -14,6 +14,22 @@ export class SitemapController {
|
||||
res.send(xml);
|
||||
}
|
||||
|
||||
@Get('sitemap-main.xml')
|
||||
@Header('Cache-Control', 'public, max-age=900')
|
||||
async getMainSitemap(@Param('host') host: string, @Res() res: Response) {
|
||||
const xml = await this.sitemap.getMainXmlForHost(host);
|
||||
res.setHeader('Content-Type', 'application/xml; charset=utf-8');
|
||||
res.send(xml);
|
||||
}
|
||||
|
||||
@Get('sitemap-products.xml')
|
||||
@Header('Cache-Control', 'public, max-age=900')
|
||||
async getProductsSitemap(@Param('host') host: string, @Res() res: Response) {
|
||||
const xml = await this.sitemap.getProductsXmlForHost(host);
|
||||
res.setHeader('Content-Type', 'application/xml; charset=utf-8');
|
||||
res.send(xml);
|
||||
}
|
||||
|
||||
@Get('robots.txt')
|
||||
@Header('Cache-Control', 'public, max-age=900')
|
||||
async getRobots(@Param('host') host: string, @Res() res: Response) {
|
||||
|
||||
@@ -6,11 +6,13 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import { resolveSitemapConfig } from './sitemap-config.util';
|
||||
import type { ResolvedSitemapConfig } from './sitemap-config.types';
|
||||
import { slugifyForUrl } from './seo-slug.util';
|
||||
import { SITEMAP_CACHE_TTL_SECONDS } from './sitemap.constants';
|
||||
import {
|
||||
applyPathTemplate,
|
||||
buildAbsoluteUrl,
|
||||
buildSitemapIndexXml,
|
||||
buildSitemapXml,
|
||||
type SitemapUrlEntry,
|
||||
} from './sitemap-xml.util';
|
||||
@@ -21,6 +23,8 @@ type PublishedDetailRow = {
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type SitemapSection = 'index' | 'main' | 'products';
|
||||
|
||||
@Injectable()
|
||||
export class SitemapService {
|
||||
private readonly logger = new Logger(SitemapService.name);
|
||||
@@ -31,14 +35,19 @@ export class SitemapService {
|
||||
private readonly tenant: TenantService,
|
||||
) {}
|
||||
|
||||
private cacheKey(businessId: bigint): string {
|
||||
return `sitemap:${businessId.toString()}`;
|
||||
private cacheKey(businessId: bigint, section: SitemapSection): string {
|
||||
return `sitemap:${businessId.toString()}:${section}`;
|
||||
}
|
||||
|
||||
async invalidateForBusiness(businessId: bigint | string): Promise<void> {
|
||||
const id = typeof businessId === 'bigint' ? businessId : BigInt(businessId);
|
||||
try {
|
||||
await this.redis.client.del(this.cacheKey(id));
|
||||
await this.redis.client.del(
|
||||
this.cacheKey(id, 'index'),
|
||||
this.cacheKey(id, 'main'),
|
||||
this.cacheKey(id, 'products'),
|
||||
`sitemap:${id.toString()}`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to invalidate sitemap cache for business ${id.toString()}: ${String(err)}`,
|
||||
@@ -47,8 +56,20 @@ export class SitemapService {
|
||||
}
|
||||
|
||||
async getXmlForHost(host: string): Promise<string> {
|
||||
return this.getSectionXml(host, 'index');
|
||||
}
|
||||
|
||||
async getMainXmlForHost(host: string): Promise<string> {
|
||||
return this.getSectionXml(host, 'main');
|
||||
}
|
||||
|
||||
async getProductsXmlForHost(host: string): Promise<string> {
|
||||
return this.getSectionXml(host, 'products');
|
||||
}
|
||||
|
||||
private async getSectionXml(host: string, section: SitemapSection): Promise<string> {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const cacheKey = this.cacheKey(business.id);
|
||||
const cacheKey = this.cacheKey(business.id, section);
|
||||
|
||||
try {
|
||||
const cached = await this.redis.client.get(cacheKey);
|
||||
@@ -58,7 +79,13 @@ export class SitemapService {
|
||||
}
|
||||
|
||||
const apexHost = await this.resolveApexHost(business.id, host);
|
||||
const xml = await this.buildXml(business.id, apexHost);
|
||||
const ctx = await this.loadContext(business.id, apexHost);
|
||||
const xml =
|
||||
section === 'index'
|
||||
? this.buildIndexXml(ctx)
|
||||
: section === 'products'
|
||||
? await this.buildProductsXml(ctx)
|
||||
: await this.buildMainXml(ctx);
|
||||
|
||||
try {
|
||||
await this.redis.client.set(cacheKey, xml, 'EX', SITEMAP_CACHE_TTL_SECONDS);
|
||||
@@ -102,16 +129,43 @@ export class SitemapService {
|
||||
return enabledModules.includes(moduleId);
|
||||
}
|
||||
|
||||
private async buildXml(businessId: bigint, apexHost: string): Promise<string> {
|
||||
private async loadContext(businessId: bigint, apexHost: string) {
|
||||
const business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
select: { settings: true },
|
||||
});
|
||||
|
||||
const settings = normalizeBusinessSettings(business?.settings);
|
||||
const enabledModules = settings.modules.enabled;
|
||||
const config = resolveSitemapConfig(settings.website.sitemapConfig, apexHost);
|
||||
return {
|
||||
businessId,
|
||||
enabledModules: settings.modules.enabled,
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
private buildIndexXml(ctx: {
|
||||
enabledModules: readonly BusinessModuleId[];
|
||||
config: ResolvedSitemapConfig;
|
||||
}): string {
|
||||
const entries = [
|
||||
{ loc: buildAbsoluteUrl(ctx.config.baseUrl, '/sitemap-main.xml') },
|
||||
];
|
||||
|
||||
if (this.isModuleEnabled(ctx.enabledModules, 'products')) {
|
||||
entries.push({
|
||||
loc: buildAbsoluteUrl(ctx.config.baseUrl, '/sitemap-products.xml'),
|
||||
});
|
||||
}
|
||||
|
||||
return buildSitemapIndexXml(entries);
|
||||
}
|
||||
|
||||
private async buildMainXml(ctx: {
|
||||
businessId: bigint;
|
||||
enabledModules: readonly BusinessModuleId[];
|
||||
config: ResolvedSitemapConfig;
|
||||
}): Promise<string> {
|
||||
const { businessId, enabledModules, config } = ctx;
|
||||
const entries: SitemapUrlEntry[] = config.staticPages.map((page) => ({
|
||||
loc: buildAbsoluteUrl(config.baseUrl, page.path),
|
||||
...(page.changefreq ? { changefreq: page.changefreq } : {}),
|
||||
@@ -126,9 +180,6 @@ export class SitemapService {
|
||||
config.templates.productCategory,
|
||||
)),
|
||||
);
|
||||
entries.push(
|
||||
...(await this.loadPublishedProducts(businessId, config.baseUrl, config.templates.product)),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.isModuleEnabled(enabledModules, 'blog')) {
|
||||
@@ -150,6 +201,24 @@ export class SitemapService {
|
||||
return buildSitemapXml(entries);
|
||||
}
|
||||
|
||||
private async buildProductsXml(ctx: {
|
||||
businessId: bigint;
|
||||
enabledModules: readonly BusinessModuleId[];
|
||||
config: ResolvedSitemapConfig;
|
||||
}): Promise<string> {
|
||||
if (!this.isModuleEnabled(ctx.enabledModules, 'products')) {
|
||||
return buildSitemapXml([]);
|
||||
}
|
||||
|
||||
return buildSitemapXml(
|
||||
await this.loadPublishedProducts(
|
||||
ctx.businessId,
|
||||
ctx.config.baseUrl,
|
||||
ctx.config.templates.product,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async loadProductCategories(
|
||||
businessId: bigint,
|
||||
baseUrl: string,
|
||||
@@ -163,7 +232,8 @@ export class SitemapService {
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
nameFa: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
@@ -172,7 +242,7 @@ export class SitemapService {
|
||||
return rows.map((row) =>
|
||||
this.toEntry(baseUrl, template, {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
slug: slugifyForUrl(row.nameFa?.trim() || row.name, 'category'),
|
||||
updatedAt: row.updatedAt,
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user