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
@@ -262,9 +262,14 @@ All routes are prefixed with `/api/v1`.
|
||||
| POST | `/auth/verify-otp` | Verify OTP (marks cell verified; no tokens) |
|
||||
| POST | `/auth/handoff/consume` | One-time SSO ticket → tokens (customer → business dashboard) |
|
||||
| GET | `/tenants/:host` | Resolve business from domain (`specialProductsSource`, `enabledModules`, `homeCharts`, `ePayment`) |
|
||||
| GET | `/tenants/:host/sitemap.xml` | Sitemap **index** → main + products child sitemaps |
|
||||
| GET | `/tenants/:host/sitemap-main.xml` | Static pages + `/products/category/{id}/{faSlug}` + blogs/portfolios |
|
||||
| GET | `/tenants/:host/sitemap.xml` | Sitemap **index** → main + dynamic child sitemaps |
|
||||
| GET | `/tenants/:host/sitemap-main.xml` | Static pages + `/products/category/{id}/{faSlug}` |
|
||||
| GET | `/tenants/:host/sitemap-products.xml` | Published products `/products/{id}/{faSlug}` |
|
||||
| GET | `/tenants/:host/sitemap-blogs.xml` | Published blogs `/blog/{id}/{slug}` |
|
||||
| GET | `/tenants/:host/sitemap-portfolios.xml` | Published portfolios `/portfolios/{id}/{slug}` |
|
||||
| GET | `/tenants/:host/sitemap-videos.xml` | Published videos `/videos/{slug}` |
|
||||
| GET | `/tenants/:host/sitemap-instructions.xml` | Published instructions `/instructions/{slug}` |
|
||||
| GET | `/tenants/:host/sitemap-workshops.xml` | Published workshops `/workshops/{slug}` |
|
||||
| GET | `/tenants/:host/robots.txt` | robots.txt pointing to apex `/sitemap.xml` |
|
||||
| GET | `/tenants/:host/categories/by-id/:categoryId` | Public category by id (for category landing pages) |
|
||||
| GET | `/tenants/:host/categories/by-slug/:slug` | Public category by CMS slug |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Insert / update Meshkee SEO proxy locations on an existing storefront nginx site.
|
||||
# Covers: /sitemap.xml (index), /sitemap-main.xml, /sitemap-products.xml, /robots.txt
|
||||
# Covers: /sitemap.xml (index), child sitemaps, /robots.txt
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${1:-}"
|
||||
@@ -61,6 +61,11 @@ ensure_location() {
|
||||
ensure_location /sitemap.xml sitemap.xml
|
||||
ensure_location /sitemap-main.xml sitemap-main.xml
|
||||
ensure_location /sitemap-products.xml sitemap-products.xml
|
||||
ensure_location /sitemap-blogs.xml sitemap-blogs.xml
|
||||
ensure_location /sitemap-portfolios.xml sitemap-portfolios.xml
|
||||
ensure_location /sitemap-videos.xml sitemap-videos.xml
|
||||
ensure_location /sitemap-instructions.xml sitemap-instructions.xml
|
||||
ensure_location /sitemap-workshops.xml sitemap-workshops.xml
|
||||
ensure_location /robots.txt robots.txt
|
||||
|
||||
nginx -t
|
||||
|
||||
@@ -160,6 +160,56 @@ PROXY_COMMON=$(cat <<PROXY
|
||||
add_header Cache-Control "public, max-age=900";
|
||||
}
|
||||
|
||||
location = /sitemap-blogs.xml {
|
||||
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap-blogs.xml;
|
||||
proxy_set_header Host api.meshkee.com;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
add_header Cache-Control "public, max-age=900";
|
||||
}
|
||||
|
||||
location = /sitemap-portfolios.xml {
|
||||
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap-portfolios.xml;
|
||||
proxy_set_header Host api.meshkee.com;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
add_header Cache-Control "public, max-age=900";
|
||||
}
|
||||
|
||||
location = /sitemap-videos.xml {
|
||||
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap-videos.xml;
|
||||
proxy_set_header Host api.meshkee.com;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
add_header Cache-Control "public, max-age=900";
|
||||
}
|
||||
|
||||
location = /sitemap-instructions.xml {
|
||||
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap-instructions.xml;
|
||||
proxy_set_header Host api.meshkee.com;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
add_header Cache-Control "public, max-age=900";
|
||||
}
|
||||
|
||||
location = /sitemap-workshops.xml {
|
||||
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap-workshops.xml;
|
||||
proxy_set_header Host api.meshkee.com;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
add_header Cache-Control "public, max-age=900";
|
||||
}
|
||||
|
||||
location = /robots.txt {
|
||||
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/robots.txt;
|
||||
proxy_set_header Host api.meshkee.com;
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user