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:
co-authored by
Cursor
parent
5850c90d84
commit
730407ec3a
@@ -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'
|
||||
>
|
||||
>;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user