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
+206 -46
View File
@@ -4,28 +4,70 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { MediaEntityType, VariationType } from '@prisma/client';
import { MediaEntityType, Prisma, VariationType } from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import {
COLOR_PRESETS,
getColorHex,
isColorPreset,
} from './color-presets';
import { COLOR_PRESETS, isHexColor, resolveColorHex } from './color-presets';
import {
CategoryVariationInputDto,
ReplaceCategoryVariationsDto,
} from './dto/category-variation.dto';
function slugifyValue(value: string): string {
return value
const ascii = value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
if (ascii) {
return ascii;
}
// Keep letters/numbers from any script (e.g. Farsi labels) when ASCII strip is empty
const unicode = value
.trim()
.toLowerCase()
.normalize('NFKC')
.replace(/\s+/g, '-')
.replace(/[^\p{L}\p{N}-]+/gu, '')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
return unicode;
}
function uniqueSlug(base: string, used: Set<string>, fallbackIndex: number): string {
let value = base || `option-${fallbackIndex}`;
if (!used.has(value)) {
used.add(value);
return value;
}
let suffix = 2;
while (used.has(`${value}-${suffix}`)) {
suffix += 1;
}
const next = `${value}-${suffix}`;
used.add(next);
return next;
}
type ExistingVariation = {
id: bigint;
name: string;
variationType: VariationType;
sortOrder: number;
options: {
id: bigint;
label: string;
value: string;
colorHex: string | null;
sortOrder: number;
}[];
};
@Injectable()
export class CategoryVariationsService {
constructor(
@@ -70,32 +112,57 @@ export class CategoryVariationsService {
this.validateVariationInputs(dto.variations);
await this.prisma.$transaction(async (tx) => {
await tx.categoryVariation.deleteMany({
const existing = await tx.categoryVariation.findMany({
where: { businessId, categoryId },
include: { options: { orderBy: { sortOrder: 'asc' } } },
orderBy: { sortOrder: 'asc' },
});
const matchedExistingIds = new Set<bigint>();
for (const [index, variation] of dto.variations.entries()) {
const created = await tx.categoryVariation.create({
data: {
businessId,
categoryId,
name: this.resolveVariationName(variation),
variationType: variation.type as VariationType,
sortOrder: index,
},
const current = this.matchExistingVariation(existing, variation, matchedExistingIds);
const name = this.resolveVariationName(variation);
const uniqueValues = [
...new Set(variation.values.map((v) => v.trim()).filter(Boolean)),
];
if (current) {
matchedExistingIds.add(current.id);
await tx.categoryVariation.update({
where: { id: current.id },
data: {
name,
variationType: variation.type as VariationType,
sortOrder: index,
},
});
await this.syncOptions(tx, current, variation.type, uniqueValues);
} else {
const created = await tx.categoryVariation.create({
data: {
businessId,
categoryId,
name,
variationType: variation.type as VariationType,
sortOrder: index,
},
});
await this.createOptions(tx, created.id, variation.type, uniqueValues);
}
}
const removed = existing.filter((item) => !matchedExistingIds.has(item.id));
if (removed.length) {
const removedIds = removed.map((item) => item.id);
await tx.storeItemVariantSelection.deleteMany({
where: { variationId: { in: removedIds } },
});
const uniqueValues = [...new Set(variation.values.map((v) => v.trim()).filter(Boolean))];
await tx.categoryVariationOption.createMany({
data: uniqueValues.map((label, optionIndex) => ({
variationId: created.id,
label,
value: slugifyValue(label) || `option-${optionIndex + 1}`,
colorHex:
variation.type === 'color' ? getColorHex(label) : null,
sortOrder: optionIndex,
})),
await tx.productVariationValue.deleteMany({
where: { variationId: { in: removedIds } },
});
await tx.categoryVariation.deleteMany({
where: { id: { in: removedIds } },
});
}
});
@@ -113,6 +180,109 @@ export class CategoryVariationsService {
return variations.map((item) => this.serialize(item));
}
private matchExistingVariation(
existing: ExistingVariation[],
incoming: CategoryVariationInputDto,
alreadyMatched: Set<bigint>,
): ExistingVariation | undefined {
const candidates = existing.filter((item) => !alreadyMatched.has(item.id));
if (incoming.type === 'color' || incoming.type === 'size') {
return candidates.find((item) => item.variationType === incoming.type);
}
const name = incoming.name.trim().toLowerCase();
return candidates.find(
(item) =>
item.variationType === 'custom' && item.name.trim().toLowerCase() === name,
);
}
private async syncOptions(
tx: Prisma.TransactionClient,
current: ExistingVariation,
type: CategoryVariationInputDto['type'],
labels: string[],
) {
const usedSlugs = new Set<string>();
const matchedOptionIds = new Set<bigint>();
for (const [optionIndex, label] of labels.entries()) {
const existingOption = current.options.find(
(option) =>
!matchedOptionIds.has(option.id) && option.label.trim() === label,
);
const colorHex =
type === 'color'
? resolveColorHex(label) ??
existingOption?.colorHex ??
(existingOption && isHexColor(existingOption.value)
? existingOption.value.trim()
: null)
: null;
if (existingOption) {
matchedOptionIds.add(existingOption.id);
usedSlugs.add(existingOption.value);
await tx.categoryVariationOption.update({
where: { id: existingOption.id },
data: {
label,
colorHex,
sortOrder: optionIndex,
},
});
continue;
}
const value = uniqueSlug(slugifyValue(label), usedSlugs, optionIndex + 1);
await tx.categoryVariationOption.create({
data: {
variationId: current.id,
label,
value,
colorHex,
sortOrder: optionIndex,
},
});
}
const removedOptions = current.options.filter(
(option) => !matchedOptionIds.has(option.id),
);
if (removedOptions.length) {
const removedOptionIds = removedOptions.map((option) => option.id);
await tx.storeItemVariantSelection.deleteMany({
where: { optionId: { in: removedOptionIds } },
});
await tx.productVariationValue.deleteMany({
where: { optionId: { in: removedOptionIds } },
});
await tx.categoryVariationOption.deleteMany({
where: { id: { in: removedOptionIds } },
});
}
}
private async createOptions(
tx: Prisma.TransactionClient,
variationId: bigint,
type: CategoryVariationInputDto['type'],
labels: string[],
) {
const usedSlugs = new Set<string>();
await tx.categoryVariationOption.createMany({
data: labels.map((label, optionIndex) => ({
variationId,
label,
value: uniqueSlug(slugifyValue(label), usedSlugs, optionIndex + 1),
colorHex: type === 'color' ? resolveColorHex(label) : null,
sortOrder: optionIndex,
})),
});
}
private validateVariationInputs(variations: CategoryVariationInputDto[]) {
const colorCount = variations.filter((v) => v.type === 'color').length;
const sizeCount = variations.filter((v) => v.type === 'size').length;
@@ -128,21 +298,9 @@ export class CategoryVariationsService {
const customNames = new Set<string>();
for (const variation of variations) {
if (variation.type === 'color') {
for (const value of variation.values) {
if (!isColorPreset(value.trim())) {
throw new BadRequestException(
`Invalid color "${value}". Choose from the predefined color palette.`,
);
}
}
}
if (variation.type === 'size' || variation.type === 'custom') {
const values = variation.values.map((v) => v.trim()).filter(Boolean);
if (!values.length) {
throw new BadRequestException('Each variation must have at least one value');
}
const values = variation.values.map((v) => v.trim()).filter(Boolean);
if (!values.length) {
throw new BadRequestException('Each variation must have at least one value');
}
if (variation.type === 'custom') {
@@ -162,10 +320,10 @@ export class CategoryVariationsService {
private resolveVariationName(variation: CategoryVariationInputDto): string {
if (variation.type === 'color') {
return 'Color';
return variation.name.trim() || 'Color';
}
if (variation.type === 'size') {
return 'Size';
return variation.name.trim() || 'Size';
}
return variation.name.trim();
}
@@ -226,7 +384,9 @@ export class CategoryVariationsService {
id: option.id.toString(),
label: option.label,
value: option.value,
colorHex: option.colorHex,
colorHex:
option.colorHex ??
(isHexColor(option.value) ? option.value.trim() : null),
sortOrder: option.sortOrder,
})),
};
+52
View File
@@ -16,6 +16,34 @@ export const COLOR_PRESETS = [
{ name: 'Beige', hex: '#D4C4A8' },
] as const;
/** Common Farsi labels from legacy migrations → English preset name */
const COLOR_ALIASES: Record<string, (typeof COLOR_PRESETS)[number]['name']> = {
قرمز: 'Red',
آبی: 'Blue',
سبز: 'Green',
مشکی: 'Black',
سیاه: 'Black',
سفید: 'White',
نقرهای: 'Silver',
'نقره ای': 'Silver',
نقره‌ای: 'Silver',
طلایی: 'Gold',
طلائی: 'Gold',
سرمهای: 'Navy',
'سرمه ای': 'Navy',
سرمه‌ای: 'Navy',
زرد: 'Yellow',
نارنجی: 'Orange',
بنفش: 'Purple',
صورتی: 'Pink',
قهوهای: 'Brown',
'قهوه ای': 'Brown',
قهوه‌ای: 'Brown',
خاکستری: 'Gray',
طوسی: 'Gray',
بژ: 'Beige',
};
export const COLOR_PRESET_NAMES = COLOR_PRESETS.map((color) => color.name);
export function isColorPreset(value: string): boolean {
@@ -25,3 +53,27 @@ export function isColorPreset(value: string): boolean {
export function getColorHex(name: string): string | null {
return COLOR_PRESETS.find((color) => color.name === name)?.hex ?? null;
}
function normalizeColorKey(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/\u200c/g, '') // ZWNJ
.replace(/\s+/g, ' ');
}
/** Resolve hex from English preset name or known Farsi alias. */
export function resolveColorHex(label: string): string | null {
const trimmed = label.trim();
if (!trimmed) return null;
const fromPreset = getColorHex(trimmed);
if (fromPreset) return fromPreset;
const alias = COLOR_ALIASES[trimmed] ?? COLOR_ALIASES[normalizeColorKey(trimmed)];
return alias ? getColorHex(alias) : null;
}
export function isHexColor(value: string): boolean {
return /^#[0-9a-fA-F]{6}$/.test(value.trim());
}
+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,
+7 -2
View File
@@ -138,9 +138,14 @@ Minimal example:
- When linking from lists/cards, use the same `{id}/{slug}` shape for details (slugify Farsi title: spaces → `-`, keep Persian letters). Category links use `/products/category/{id}/{nameFaSlug}`.
- Omit `templates` in `sitemap-config.json` unless this site uses non-default paths.
- **Sitemap files (proxied by nginx — do not ship local copies):**
- `/sitemap.xml` — sitemap **index**
- `/sitemap-main.xml` — static pages + categories + blogs + portfolios
- `/sitemap.xml` — sitemap **index** (lists child sitemaps for enabled modules)
- `/sitemap-main.xml` — static pages + product categories
- `/sitemap-products.xml` — published products (when products module is enabled)
- `/sitemap-blogs.xml` — published blogs (when blog module is enabled)
- `/sitemap-portfolios.xml` — published portfolios (when portfolio module is enabled)
- `/sitemap-videos.xml` — published videos (when videos module is enabled)
- `/sitemap-instructions.xml` — published instructions (when instructions module is enabled)
- `/sitemap-workshops.xml` — published workshops (when workshops module is enabled)
- `/robots.txt` — points at `/sitemap.xml`
### On-page SEO (every public page)
+127 -2
View File
@@ -201,7 +201,7 @@
"SEO"
],
"summary": "XML sitemap for search engines",
"description": "Sitemap **index** for this tenant. Points to `/sitemap-main.xml` (static pages, product categories, blogs, portfolios) and `/sitemap-products.xml` (published products when the products module is enabled). Category paths: `/products/category/{id}/{nameFaSlug}`. Product paths: `/products/{id}/{nameFaSlug}`. Proxied from `https://{domain}/sitemap.xml`. Cached in Redis; refreshed after CMS writes and manifest sync.",
"description": "Sitemap **index** for this tenant. Points to `/sitemap-main.xml` (static pages + product categories) plus module child sitemaps when enabled: `/sitemap-products.xml`, `/sitemap-blogs.xml`, `/sitemap-portfolios.xml`, `/sitemap-videos.xml`, `/sitemap-instructions.xml`, `/sitemap-workshops.xml`. Proxied from `https://{domain}/sitemap.xml`. Cached in Redis; refreshed after CMS writes and manifest sync.",
"parameters": [
{
"$ref": "#/components/parameters/domain"
@@ -237,7 +237,7 @@
"tags": [
"SEO"
],
"summary": "Main sitemap urlset (static + categories + blogs + portfolios)",
"summary": "Main sitemap urlset (static pages + product categories)",
"parameters": [
{
"$ref": "#/components/parameters/domain"
@@ -282,6 +282,131 @@
}
}
},
"/tenants/{domain}/sitemap-blogs.xml": {
"get": {
"tags": [
"SEO"
],
"summary": "Blogs-only sitemap urlset",
"parameters": [
{
"$ref": "#/components/parameters/domain"
}
],
"responses": {
"200": {
"description": "Sitemap XML (urlset) of published blogs",
"content": {
"application/xml": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/tenants/{domain}/sitemap-portfolios.xml": {
"get": {
"tags": [
"SEO"
],
"summary": "Portfolios-only sitemap urlset",
"parameters": [
{
"$ref": "#/components/parameters/domain"
}
],
"responses": {
"200": {
"description": "Sitemap XML (urlset) of published portfolios",
"content": {
"application/xml": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/tenants/{domain}/sitemap-videos.xml": {
"get": {
"tags": [
"SEO"
],
"summary": "Videos-only sitemap urlset",
"parameters": [
{
"$ref": "#/components/parameters/domain"
}
],
"responses": {
"200": {
"description": "Sitemap XML (urlset) of published videos",
"content": {
"application/xml": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/tenants/{domain}/sitemap-instructions.xml": {
"get": {
"tags": [
"SEO"
],
"summary": "Instructions-only sitemap urlset",
"parameters": [
{
"$ref": "#/components/parameters/domain"
}
],
"responses": {
"200": {
"description": "Sitemap XML (urlset) of published instructions",
"content": {
"application/xml": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/tenants/{domain}/sitemap-workshops.xml": {
"get": {
"tags": [
"SEO"
],
"summary": "Workshops-only sitemap urlset",
"parameters": [
{
"$ref": "#/components/parameters/domain"
}
],
"responses": {
"200": {
"description": "Sitemap XML (urlset) of published workshops",
"content": {
"application/xml": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/tenants/{domain}/robots.txt": {
"get": {
"tags": [