Split blogs and other modules into child sitemaps.

Move blogs, portfolios, videos, instructions, and workshops out of sitemap-main into dedicated files like products, and allow saving migrated Farsi category color labels without wiping store-item links.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-26 19:02:38 +03:30
co-authored by Cursor
parent 5850c90d84
commit 730407ec3a
14 changed files with 839 additions and 113 deletions
+7 -1
View File
@@ -11,7 +11,13 @@ export type ResolvedSitemapConfig = {
templates: Required<
Pick<
import('../business-settings/business-settings.types').SitemapPathTemplatesConfig,
'product' | 'productCategory' | 'blog' | 'portfolio'
| 'product'
| 'productCategory'
| 'blog'
| 'portfolio'
| 'video'
| 'instruction'
| 'workshop'
>
>;
};
+6
View File
@@ -105,6 +105,12 @@ export function resolveSitemapConfig(
portfolio:
stored?.templates?.portfolio ??
DEFAULT_SITEMAP_PATH_TEMPLATES.portfolio,
video: stored?.templates?.video ?? DEFAULT_SITEMAP_PATH_TEMPLATES.video,
instruction:
stored?.templates?.instruction ??
DEFAULT_SITEMAP_PATH_TEMPLATES.instruction,
workshop:
stored?.templates?.workshop ?? DEFAULT_SITEMAP_PATH_TEMPLATES.workshop,
},
};
}
+17 -1
View File
@@ -1,4 +1,3 @@
/** Safety-net TTL when invalidation is missed; normal freshness is event-driven. */
export const SITEMAP_CACHE_TTL_SECONDS = 3600;
export const SITEMAP_URLSET_NS = 'http://www.sitemaps.org/schemas/sitemap/0.9';
@@ -9,6 +8,9 @@ export const DEFAULT_SITEMAP_PATH_TEMPLATES = {
productCategory: '/products/category/{id}/{slug}',
blog: '/blog/{id}/{slug}',
portfolio: '/portfolios/{id}/{slug}',
video: '/videos/{slug}',
instruction: '/instructions/{slug}',
workshop: '/workshops/{slug}',
} as const;
export const WEBSITE_SITEMAP_CONFIG_PATH = '/meshkee/sitemap-config.json';
@@ -16,3 +18,17 @@ export const WEBSITE_SITEMAP_CONFIG_PATH = '/meshkee/sitemap-config.json';
export const DEFAULT_SITEMAP_STATIC_PAGES = [
{ path: '/', changefreq: 'daily' as const, priority: 1.0 },
] as const;
/** Child sitemap sections listed from the index (excluding main). */
export const SITEMAP_DYNAMIC_SECTIONS = [
'products',
'blogs',
'portfolios',
'videos',
'instructions',
'workshops',
] as const;
export type SitemapDynamicSection = (typeof SITEMAP_DYNAMIC_SECTIONS)[number];
export type SitemapSection = 'index' | 'main' | SitemapDynamicSection;
+46 -3
View File
@@ -1,5 +1,6 @@
import { Controller, Get, Header, Param, Res } from '@nestjs/common';
import type { Response } from 'express';
import { SITEMAP_DYNAMIC_SECTIONS, type SitemapDynamicSection } from './sitemap.constants';
import { SitemapService } from './sitemap.service';
@Controller('tenants/:host')
@@ -25,9 +26,37 @@ export class SitemapController {
@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);
await this.sendDynamic(host, 'products', res);
}
@Get('sitemap-blogs.xml')
@Header('Cache-Control', 'public, max-age=900')
async getBlogsSitemap(@Param('host') host: string, @Res() res: Response) {
await this.sendDynamic(host, 'blogs', res);
}
@Get('sitemap-portfolios.xml')
@Header('Cache-Control', 'public, max-age=900')
async getPortfoliosSitemap(@Param('host') host: string, @Res() res: Response) {
await this.sendDynamic(host, 'portfolios', res);
}
@Get('sitemap-videos.xml')
@Header('Cache-Control', 'public, max-age=900')
async getVideosSitemap(@Param('host') host: string, @Res() res: Response) {
await this.sendDynamic(host, 'videos', res);
}
@Get('sitemap-instructions.xml')
@Header('Cache-Control', 'public, max-age=900')
async getInstructionsSitemap(@Param('host') host: string, @Res() res: Response) {
await this.sendDynamic(host, 'instructions', res);
}
@Get('sitemap-workshops.xml')
@Header('Cache-Control', 'public, max-age=900')
async getWorkshopsSitemap(@Param('host') host: string, @Res() res: Response) {
await this.sendDynamic(host, 'workshops', res);
}
@Get('robots.txt')
@@ -37,4 +66,18 @@ export class SitemapController {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(text);
}
private async sendDynamic(
host: string,
section: SitemapDynamicSection,
res: Response,
) {
if (!SITEMAP_DYNAMIC_SECTIONS.includes(section)) {
res.status(404).send('Not found');
return;
}
const xml = await this.sitemap.getSectionXmlForHost(host, section);
res.setHeader('Content-Type', 'application/xml; charset=utf-8');
res.send(xml);
}
}
+174 -51
View File
@@ -8,7 +8,12 @@ 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 {
SITEMAP_CACHE_TTL_SECONDS,
SITEMAP_DYNAMIC_SECTIONS,
type SitemapDynamicSection,
type SitemapSection,
} from './sitemap.constants';
import {
applyPathTemplate,
buildAbsoluteUrl,
@@ -23,7 +28,29 @@ type PublishedDetailRow = {
updatedAt: Date;
};
type SitemapSection = 'index' | 'main' | 'products';
type SitemapContext = {
businessId: bigint;
enabledModules: readonly BusinessModuleId[];
config: ResolvedSitemapConfig;
};
const SECTION_MODULE: Record<SitemapDynamicSection, BusinessModuleId> = {
products: 'products',
blogs: 'blog',
portfolios: 'portfolio',
videos: 'videos',
instructions: 'instructions',
workshops: 'workshops',
};
const SECTION_FILE: Record<SitemapDynamicSection, string> = {
products: '/sitemap-products.xml',
blogs: '/sitemap-blogs.xml',
portfolios: '/sitemap-portfolios.xml',
videos: '/sitemap-videos.xml',
instructions: '/sitemap-instructions.xml',
workshops: '/sitemap-workshops.xml',
};
@Injectable()
export class SitemapService {
@@ -45,7 +72,7 @@ export class SitemapService {
await this.redis.client.del(
this.cacheKey(id, 'index'),
this.cacheKey(id, 'main'),
this.cacheKey(id, 'products'),
...SITEMAP_DYNAMIC_SECTIONS.map((section) => this.cacheKey(id, section)),
`sitemap:${id.toString()}`,
);
} catch (err) {
@@ -63,8 +90,8 @@ export class SitemapService {
return this.getSectionXml(host, 'main');
}
async getProductsXmlForHost(host: string): Promise<string> {
return this.getSectionXml(host, 'products');
async getSectionXmlForHost(host: string, section: SitemapDynamicSection): Promise<string> {
return this.getSectionXml(host, section);
}
private async getSectionXml(host: string, section: SitemapSection): Promise<string> {
@@ -83,9 +110,9 @@ export class SitemapService {
const xml =
section === 'index'
? this.buildIndexXml(ctx)
: section === 'products'
? await this.buildProductsXml(ctx)
: await this.buildMainXml(ctx);
: section === 'main'
? await this.buildMainXml(ctx)
: await this.buildDynamicXml(ctx, section);
try {
await this.redis.client.set(cacheKey, xml, 'EX', SITEMAP_CACHE_TTL_SECONDS);
@@ -129,7 +156,7 @@ export class SitemapService {
return enabledModules.includes(moduleId);
}
private async loadContext(businessId: bigint, apexHost: string) {
private async loadContext(businessId: bigint, apexHost: string): Promise<SitemapContext> {
const business = await this.prisma.business.findUnique({
where: { id: businessId },
select: { settings: true },
@@ -143,28 +170,23 @@ export class SitemapService {
};
}
private buildIndexXml(ctx: {
enabledModules: readonly BusinessModuleId[];
config: ResolvedSitemapConfig;
}): string {
private buildIndexXml(ctx: SitemapContext): 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'),
});
for (const section of SITEMAP_DYNAMIC_SECTIONS) {
if (this.isModuleEnabled(ctx.enabledModules, SECTION_MODULE[section])) {
entries.push({
loc: buildAbsoluteUrl(ctx.config.baseUrl, SECTION_FILE[section]),
});
}
}
return buildSitemapIndexXml(entries);
}
private async buildMainXml(ctx: {
businessId: bigint;
enabledModules: readonly BusinessModuleId[];
config: ResolvedSitemapConfig;
}): Promise<string> {
private async buildMainXml(ctx: SitemapContext): Promise<string> {
const { businessId, enabledModules, config } = ctx;
const entries: SitemapUrlEntry[] = config.staticPages.map((page) => ({
loc: buildAbsoluteUrl(config.baseUrl, page.path),
@@ -182,41 +204,61 @@ export class SitemapService {
);
}
if (this.isModuleEnabled(enabledModules, 'blog')) {
entries.push(
...(await this.loadPublishedBlogs(businessId, config.baseUrl, config.templates.blog)),
);
}
if (this.isModuleEnabled(enabledModules, 'portfolio')) {
entries.push(
...(await this.loadPublishedPortfolios(
businessId,
config.baseUrl,
config.templates.portfolio,
)),
);
}
return buildSitemapXml(entries);
}
private async buildProductsXml(ctx: {
businessId: bigint;
enabledModules: readonly BusinessModuleId[];
config: ResolvedSitemapConfig;
}): Promise<string> {
if (!this.isModuleEnabled(ctx.enabledModules, 'products')) {
private async buildDynamicXml(
ctx: SitemapContext,
section: SitemapDynamicSection,
): Promise<string> {
if (!this.isModuleEnabled(ctx.enabledModules, SECTION_MODULE[section])) {
return buildSitemapXml([]);
}
return buildSitemapXml(
await this.loadPublishedProducts(
ctx.businessId,
ctx.config.baseUrl,
ctx.config.templates.product,
),
);
const { businessId, config } = ctx;
switch (section) {
case 'products':
return buildSitemapXml(
await this.loadPublishedProducts(
businessId,
config.baseUrl,
config.templates.product,
),
);
case 'blogs':
return buildSitemapXml(
await this.loadPublishedBlogs(businessId, config.baseUrl, config.templates.blog),
);
case 'portfolios':
return buildSitemapXml(
await this.loadPublishedPortfolios(
businessId,
config.baseUrl,
config.templates.portfolio,
),
);
case 'videos':
return buildSitemapXml(
await this.loadPublishedVideos(businessId, config.baseUrl, config.templates.video),
);
case 'instructions':
return buildSitemapXml(
await this.loadPublishedInstructions(
businessId,
config.baseUrl,
config.templates.instruction,
),
);
case 'workshops':
return buildSitemapXml(
await this.loadPublishedWorkshops(
businessId,
config.baseUrl,
config.templates.workshop,
),
);
}
}
private async loadProductCategories(
@@ -335,6 +377,87 @@ export class SitemapService {
);
}
private async loadPublishedVideos(
businessId: bigint,
baseUrl: string,
template: string,
): Promise<SitemapUrlEntry[]> {
const rows = await this.prisma.videos.findMany({
where: {
business_id: businessId,
status: ContentStatus.published,
},
select: {
id: true,
slug: true,
updated_at: true,
},
orderBy: [{ updated_at: 'desc' }, { id: 'desc' }],
});
return rows.map((row) =>
this.toEntry(baseUrl, template, {
id: row.id,
slug: row.slug,
updatedAt: row.updated_at,
}),
);
}
private async loadPublishedInstructions(
businessId: bigint,
baseUrl: string,
template: string,
): Promise<SitemapUrlEntry[]> {
const rows = await this.prisma.instructions.findMany({
where: {
business_id: businessId,
status: ContentStatus.published,
},
select: {
id: true,
slug: true,
updated_at: true,
},
orderBy: [{ updated_at: 'desc' }, { id: 'desc' }],
});
return rows.map((row) =>
this.toEntry(baseUrl, template, {
id: row.id,
slug: row.slug,
updatedAt: row.updated_at,
}),
);
}
private async loadPublishedWorkshops(
businessId: bigint,
baseUrl: string,
template: string,
): Promise<SitemapUrlEntry[]> {
const rows = await this.prisma.workshops.findMany({
where: {
business_id: businessId,
status: ContentStatus.published,
},
select: {
id: true,
slug: true,
updated_at: true,
},
orderBy: [{ updated_at: 'desc' }, { id: 'desc' }],
});
return rows.map((row) =>
this.toEntry(baseUrl, template, {
id: row.id,
slug: row.slug,
updatedAt: row.updated_at,
}),
);
}
private toEntry(
baseUrl: string,
template: string,