diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 243e932..8376ef2 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -262,9 +262,12 @@ 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` | XML sitemap (static + categories `/products/category/{slug}` + `/products|blog|portfolios/{id}/{faSlug}`) | +| 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-products.xml` | Published products `/products/{id}/{faSlug}` | | GET | `/tenants/:host/robots.txt` | robots.txt pointing to apex `/sitemap.xml` | -| GET | `/tenants/:host/categories/by-slug/:slug` | Public category by slug (`?entityType=product` default) | +| 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 | | GET | `/tenants/:host/products/by-id/:productId` | Public product detail by id (for `{id}/{nameFaSlug}` storefront routes) | | GET | `/tenants/:host/blogs/by-id/:blogId` | Public blog by id | | GET | `/tenants/:host/portfolios/by-id/:portfolioId` | Public portfolio by id | diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md index 983217c..c4fa320 100644 --- a/docs/website-api/AI_PROMPT.md +++ b/docs/website-api/AI_PROMPT.md @@ -116,13 +116,16 @@ Minimal example: - Include public static routes automatically; exclude login, checkout, cart, account, and admin paths. - **Canonical detail URLs (Meshkee default for all sites):** - Product: `/products/{id}/{nameFaSlug}` — build `nameFaSlug` from `nameFa` (fallback `title`); resolve page via `GET /tenants/{domain}/products/by-id/{id}` (slug segment is SEO-only; redirect to canonical if it drifts). - - Product category: `/products/category/{categorySlug}` — use category `slug` from `GET /tenants/{domain}/categories?entityType=product`; resolve via `GET /tenants/{domain}/categories/by-slug/{slug}?entityType=product`, then list products with `categoryId`. + - Product category: `/products/category/{categoryId}/{nameFaSlug}` — build slug from `nameFa` (fallback `name`); resolve via `GET /tenants/{domain}/categories/by-id/{id}`, then list products with `categoryId`. - Blog: `/blog/{id}/{titleSlug}` — `GET /tenants/{domain}/blogs/by-id/{id}` - Portfolio: `/portfolios/{id}/{titleFaSlug}` — `GET /tenants/{domain}/portfolios/by-id/{id}` -- 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/{slug}`. -- Omit `templates` in `sitemap-config.json` unless this site uses non-default paths (Meshkee sitemap defaults already use `{id}/{slug}` and `/products/category/{slug}`). -- After deploy, business owner: **Website → Settings → Sync sitemap config** (imports static pages; does not rebuild XML by itself). -- Dynamic URLs come from the CMS automatically. Hitting `/sitemap.xml` serves a Redis-cached XML that regenerates after CMS publish/update/delete (or when cache expires). +- 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-products.xml` — published products (when products module is enabled) + - `/robots.txt` — points at `/sitemap.xml` ### On-page SEO (every public page) diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index 0094c8c..485585f 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -195,7 +195,7 @@ "SEO" ], "summary": "XML sitemap for search engines", - "description": "Published products, product categories, blogs, and portfolios for this tenant (respects enabled modules), plus static pages and URL templates from the synced website manifest (`GET https://{domain}/meshkee/sitemap-config.json`). Default detail paths: `/products/{id}/{nameFaSlug}`, `/products/category/{categorySlug}`, `/blog/{id}/{titleSlug}`, `/portfolios/{id}/{titleFaSlug}`. Proxied from `https://{domain}/sitemap.xml` on the storefront. 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, 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.", "parameters": [ { "$ref": "#/components/parameters/domain" @@ -226,6 +226,56 @@ } } }, + "/tenants/{domain}/sitemap-main.xml": { + "get": { + "tags": [ + "SEO" + ], + "summary": "Main sitemap urlset (static + categories + blogs + portfolios)", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "Sitemap XML (urlset)", + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/tenants/{domain}/sitemap-products.xml": { + "get": { + "tags": [ + "SEO" + ], + "summary": "Products-only sitemap urlset", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "Sitemap XML (urlset) of published products", + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/tenants/{domain}/robots.txt": { "get": { "tags": [ @@ -446,12 +496,55 @@ } } }, + "/tenants/{domain}/categories/by-id/{categoryId}": { + "get": { + "tags": [ + "Categories" + ], + "summary": "Category by id (preferred for /products/category/{id}/{nameFaSlug} pages)", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "categoryId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "entityType", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "product", + "blog", + "portfolio", + "video" + ], + "default": "product" + } + } + ], + "responses": { + "200": { + "description": "{ category } — use category.id as categoryId when listing products" + }, + "404": { + "description": "Category not found" + } + } + } + }, "/tenants/{domain}/categories/by-slug/{slug}": { "get": { "tags": [ "Categories" ], - "summary": "Category by slug (for /products/category/{categorySlug} pages)", + "summary": "Category by CMS slug (legacy / lookup helper)", "parameters": [ { "$ref": "#/components/parameters/domain" diff --git a/scripts/websites-agent/patch-nginx-seo.sh b/scripts/websites-agent/patch-nginx-seo.sh index 5892412..0e64767 100755 --- a/scripts/websites-agent/patch-nginx-seo.sh +++ b/scripts/websites-agent/patch-nginx-seo.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# Insert Meshkee SEO proxy locations (/sitemap.xml, /robots.txt) into an existing storefront nginx site. +# Insert / update Meshkee SEO proxy locations on an existing storefront nginx site. +# Covers: /sitemap.xml (index), /sitemap-main.xml, /sitemap-products.xml, /robots.txt set -euo pipefail HOST="${1:-}" @@ -17,25 +18,12 @@ if [[ ! -f "$NGINX_AVAILABLE" ]]; then exit 1 fi -if grep -q 'location = /sitemap.xml' "$NGINX_AVAILABLE"; then - echo "nginx SEO locations already present for $HOST" - exit 0 -fi - -TMP="$(mktemp)" -SEO_BLOCK="$(cat <"$TMP" +ensure_location() { + local path="$1" + local upstream="$2" + if grep -q "location = ${path}" "$NGINX_AVAILABLE"; then + echo "already present: ${path}" + return 0 + fi + + local block + block="$(proxy_location "$path" "$upstream")" + local tmp + tmp="$(mktemp)" + awk -v block="$block" ' + /location \/ \{/ && !done { + print block + done = 1 + } + { print } + ' "$NGINX_AVAILABLE" >"$tmp" + mv "$tmp" "$NGINX_AVAILABLE" + echo "added: ${path}" +} + +ensure_location /sitemap.xml sitemap.xml +ensure_location /sitemap-main.xml sitemap-main.xml +ensure_location /sitemap-products.xml sitemap-products.xml +ensure_location /robots.txt robots.txt -mv "$TMP" "$NGINX_AVAILABLE" nginx -t systemctl reload nginx echo "patched nginx SEO locations for $HOST" diff --git a/scripts/websites-agent/provision.sh b/scripts/websites-agent/provision.sh index 879d0bd..e73b4f3 100755 --- a/scripts/websites-agent/provision.sh +++ b/scripts/websites-agent/provision.sh @@ -132,6 +132,26 @@ server { add_header Cache-Control "public, max-age=900"; } + location = /sitemap-main.xml { + proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap-main.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-products.xml { + proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap-products.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; diff --git a/src/categories/categories.controller.ts b/src/categories/categories.controller.ts index 27d4178..3ae81f0 100644 --- a/src/categories/categories.controller.ts +++ b/src/categories/categories.controller.ts @@ -174,6 +174,15 @@ export class PublicCategoriesController { return this.service.listPublic(host, query); } + @Get('by-id/:categoryId') + getById( + @Param('host') host: string, + @Param('categoryId') categoryId: string, + @Query() query: ListCategoriesDto, + ) { + return this.service.getPublicById(host, categoryId, query.entityType); + } + @Get('by-slug/:slug') getBySlug( @Param('host') host: string, diff --git a/src/categories/categories.service.ts b/src/categories/categories.service.ts index e8eac9f..a8f9c18 100644 --- a/src/categories/categories.service.ts +++ b/src/categories/categories.service.ts @@ -87,6 +87,32 @@ export class CategoriesService { return { category: this.serialize(category) }; } + async getPublicById(host: string, categoryIdRaw: string, entityType?: MediaEntityType) { + const business = await this.tenant.resolveBusinessByDomain(host); + const resolvedType = entityType ?? MediaEntityType.product; + let categoryId: bigint; + try { + categoryId = BigInt(categoryIdRaw); + } catch { + throw new NotFoundException('Category not found'); + } + + const category = await this.prisma.category.findFirst({ + where: { + businessId: business.id, + entityType: resolvedType, + id: categoryId, + isActive: true, + }, + }); + + if (!category) { + throw new NotFoundException('Category not found'); + } + + return { category: this.serialize(category) }; + } + async create(businessIdRaw: string, dto: CreateCategoryDto, actor: AuthUser) { const businessId = BigInt(businessIdRaw); await this.assertPermission(businessId, actor.id, 'categories.create'); diff --git a/src/sitemap/sitemap-xml.util.ts b/src/sitemap/sitemap-xml.util.ts index ec672e5..d56d1d8 100644 --- a/src/sitemap/sitemap-xml.util.ts +++ b/src/sitemap/sitemap-xml.util.ts @@ -7,6 +7,11 @@ export type SitemapUrlEntry = { priority?: number; }; +export type SitemapIndexEntry = { + loc: string; + lastmod?: Date | null; +}; + function escapeXml(value: string): string { return value .replace(/&/g, '&') @@ -51,6 +56,26 @@ export function buildSitemapXml(entries: SitemapUrlEntry[]): string { ].join('\n'); } +export function buildSitemapIndexXml(entries: SitemapIndexEntry[]): string { + const sitemaps = entries + .map((entry) => { + const parts = [` ${escapeXml(entry.loc)}`]; + if (entry.lastmod) { + parts.push(` ${formatLastmod(entry.lastmod)}`); + } + return ` \n${parts.join('\n')}\n `; + }) + .join('\n'); + + return [ + '', + ``, + sitemaps, + '', + '', + ].join('\n'); +} + /** * Build a path from a template. Keep Unicode (e.g. Farsi) readable in sitemap * `` — do not percent-encode letters. XML escaping happens in buildSitemapXml. diff --git a/src/sitemap/sitemap.constants.ts b/src/sitemap/sitemap.constants.ts index 6d38892..1ff7656 100644 --- a/src/sitemap/sitemap.constants.ts +++ b/src/sitemap/sitemap.constants.ts @@ -6,7 +6,7 @@ export const SITEMAP_URLSET_NS = 'http://www.sitemaps.org/schemas/sitemap/0.9'; /** Default storefront path templates (overridable via /meshkee/sitemap-config.json). */ export const DEFAULT_SITEMAP_PATH_TEMPLATES = { product: '/products/{id}/{slug}', - productCategory: '/products/category/{slug}', + productCategory: '/products/category/{id}/{slug}', blog: '/blog/{id}/{slug}', portfolio: '/portfolios/{id}/{slug}', } as const; diff --git a/src/sitemap/sitemap.controller.ts b/src/sitemap/sitemap.controller.ts index b83db7a..cda4341 100644 --- a/src/sitemap/sitemap.controller.ts +++ b/src/sitemap/sitemap.controller.ts @@ -14,6 +14,22 @@ export class SitemapController { res.send(xml); } + @Get('sitemap-main.xml') + @Header('Cache-Control', 'public, max-age=900') + async getMainSitemap(@Param('host') host: string, @Res() res: Response) { + const xml = await this.sitemap.getMainXmlForHost(host); + res.setHeader('Content-Type', 'application/xml; charset=utf-8'); + res.send(xml); + } + + @Get('sitemap-products.xml') + @Header('Cache-Control', 'public, max-age=900') + async getProductsSitemap(@Param('host') host: string, @Res() res: Response) { + const xml = await this.sitemap.getProductsXmlForHost(host); + res.setHeader('Content-Type', 'application/xml; charset=utf-8'); + res.send(xml); + } + @Get('robots.txt') @Header('Cache-Control', 'public, max-age=900') async getRobots(@Param('host') host: string, @Res() res: Response) { diff --git a/src/sitemap/sitemap.service.ts b/src/sitemap/sitemap.service.ts index f71288c..90dd510 100644 --- a/src/sitemap/sitemap.service.ts +++ b/src/sitemap/sitemap.service.ts @@ -6,11 +6,13 @@ import { PrismaService } from '../prisma/prisma.service'; import { RedisService } from '../redis/redis.service'; import { TenantService } from '../tenant/tenant.service'; import { resolveSitemapConfig } from './sitemap-config.util'; +import type { ResolvedSitemapConfig } from './sitemap-config.types'; import { slugifyForUrl } from './seo-slug.util'; import { SITEMAP_CACHE_TTL_SECONDS } from './sitemap.constants'; import { applyPathTemplate, buildAbsoluteUrl, + buildSitemapIndexXml, buildSitemapXml, type SitemapUrlEntry, } from './sitemap-xml.util'; @@ -21,6 +23,8 @@ type PublishedDetailRow = { updatedAt: Date; }; +type SitemapSection = 'index' | 'main' | 'products'; + @Injectable() export class SitemapService { private readonly logger = new Logger(SitemapService.name); @@ -31,14 +35,19 @@ export class SitemapService { private readonly tenant: TenantService, ) {} - private cacheKey(businessId: bigint): string { - return `sitemap:${businessId.toString()}`; + private cacheKey(businessId: bigint, section: SitemapSection): string { + return `sitemap:${businessId.toString()}:${section}`; } async invalidateForBusiness(businessId: bigint | string): Promise { const id = typeof businessId === 'bigint' ? businessId : BigInt(businessId); try { - await this.redis.client.del(this.cacheKey(id)); + await this.redis.client.del( + this.cacheKey(id, 'index'), + this.cacheKey(id, 'main'), + this.cacheKey(id, 'products'), + `sitemap:${id.toString()}`, + ); } catch (err) { this.logger.warn( `Failed to invalidate sitemap cache for business ${id.toString()}: ${String(err)}`, @@ -47,8 +56,20 @@ export class SitemapService { } async getXmlForHost(host: string): Promise { + return this.getSectionXml(host, 'index'); + } + + async getMainXmlForHost(host: string): Promise { + return this.getSectionXml(host, 'main'); + } + + async getProductsXmlForHost(host: string): Promise { + return this.getSectionXml(host, 'products'); + } + + private async getSectionXml(host: string, section: SitemapSection): Promise { const business = await this.tenant.resolveBusinessByDomain(host); - const cacheKey = this.cacheKey(business.id); + const cacheKey = this.cacheKey(business.id, section); try { const cached = await this.redis.client.get(cacheKey); @@ -58,7 +79,13 @@ export class SitemapService { } const apexHost = await this.resolveApexHost(business.id, host); - const xml = await this.buildXml(business.id, apexHost); + const ctx = await this.loadContext(business.id, apexHost); + const xml = + section === 'index' + ? this.buildIndexXml(ctx) + : section === 'products' + ? await this.buildProductsXml(ctx) + : await this.buildMainXml(ctx); try { await this.redis.client.set(cacheKey, xml, 'EX', SITEMAP_CACHE_TTL_SECONDS); @@ -102,16 +129,43 @@ export class SitemapService { return enabledModules.includes(moduleId); } - private async buildXml(businessId: bigint, apexHost: string): Promise { + private async loadContext(businessId: bigint, apexHost: string) { const business = await this.prisma.business.findUnique({ where: { id: businessId }, select: { settings: true }, }); - const settings = normalizeBusinessSettings(business?.settings); - const enabledModules = settings.modules.enabled; const config = resolveSitemapConfig(settings.website.sitemapConfig, apexHost); + return { + businessId, + enabledModules: settings.modules.enabled, + config, + }; + } + private buildIndexXml(ctx: { + enabledModules: readonly BusinessModuleId[]; + config: ResolvedSitemapConfig; + }): string { + const entries = [ + { loc: buildAbsoluteUrl(ctx.config.baseUrl, '/sitemap-main.xml') }, + ]; + + if (this.isModuleEnabled(ctx.enabledModules, 'products')) { + entries.push({ + loc: buildAbsoluteUrl(ctx.config.baseUrl, '/sitemap-products.xml'), + }); + } + + return buildSitemapIndexXml(entries); + } + + private async buildMainXml(ctx: { + businessId: bigint; + enabledModules: readonly BusinessModuleId[]; + config: ResolvedSitemapConfig; + }): Promise { + const { businessId, enabledModules, config } = ctx; const entries: SitemapUrlEntry[] = config.staticPages.map((page) => ({ loc: buildAbsoluteUrl(config.baseUrl, page.path), ...(page.changefreq ? { changefreq: page.changefreq } : {}), @@ -126,9 +180,6 @@ export class SitemapService { config.templates.productCategory, )), ); - entries.push( - ...(await this.loadPublishedProducts(businessId, config.baseUrl, config.templates.product)), - ); } if (this.isModuleEnabled(enabledModules, 'blog')) { @@ -150,6 +201,24 @@ export class SitemapService { return buildSitemapXml(entries); } + private async buildProductsXml(ctx: { + businessId: bigint; + enabledModules: readonly BusinessModuleId[]; + config: ResolvedSitemapConfig; + }): Promise { + if (!this.isModuleEnabled(ctx.enabledModules, 'products')) { + return buildSitemapXml([]); + } + + return buildSitemapXml( + await this.loadPublishedProducts( + ctx.businessId, + ctx.config.baseUrl, + ctx.config.templates.product, + ), + ); + } + private async loadProductCategories( businessId: bigint, baseUrl: string, @@ -163,7 +232,8 @@ export class SitemapService { }, select: { id: true, - slug: true, + name: true, + nameFa: true, updatedAt: true, }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], @@ -172,7 +242,7 @@ export class SitemapService { return rows.map((row) => this.toEntry(baseUrl, template, { id: row.id, - slug: row.slug, + slug: slugifyForUrl(row.nameFa?.trim() || row.name, 'category'), updatedAt: row.updatedAt, }), ); diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md index 983217c..c4fa320 100644 --- a/src/website-docs/static/AI_PROMPT.md +++ b/src/website-docs/static/AI_PROMPT.md @@ -116,13 +116,16 @@ Minimal example: - Include public static routes automatically; exclude login, checkout, cart, account, and admin paths. - **Canonical detail URLs (Meshkee default for all sites):** - Product: `/products/{id}/{nameFaSlug}` — build `nameFaSlug` from `nameFa` (fallback `title`); resolve page via `GET /tenants/{domain}/products/by-id/{id}` (slug segment is SEO-only; redirect to canonical if it drifts). - - Product category: `/products/category/{categorySlug}` — use category `slug` from `GET /tenants/{domain}/categories?entityType=product`; resolve via `GET /tenants/{domain}/categories/by-slug/{slug}?entityType=product`, then list products with `categoryId`. + - Product category: `/products/category/{categoryId}/{nameFaSlug}` — build slug from `nameFa` (fallback `name`); resolve via `GET /tenants/{domain}/categories/by-id/{id}`, then list products with `categoryId`. - Blog: `/blog/{id}/{titleSlug}` — `GET /tenants/{domain}/blogs/by-id/{id}` - Portfolio: `/portfolios/{id}/{titleFaSlug}` — `GET /tenants/{domain}/portfolios/by-id/{id}` -- 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/{slug}`. -- Omit `templates` in `sitemap-config.json` unless this site uses non-default paths (Meshkee sitemap defaults already use `{id}/{slug}` and `/products/category/{slug}`). -- After deploy, business owner: **Website → Settings → Sync sitemap config** (imports static pages; does not rebuild XML by itself). -- Dynamic URLs come from the CMS automatically. Hitting `/sitemap.xml` serves a Redis-cached XML that regenerates after CMS publish/update/delete (or when cache expires). +- 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-products.xml` — published products (when products module is enabled) + - `/robots.txt` — points at `/sitemap.xml` ### On-page SEO (every public page) diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index 0094c8c..485585f 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -195,7 +195,7 @@ "SEO" ], "summary": "XML sitemap for search engines", - "description": "Published products, product categories, blogs, and portfolios for this tenant (respects enabled modules), plus static pages and URL templates from the synced website manifest (`GET https://{domain}/meshkee/sitemap-config.json`). Default detail paths: `/products/{id}/{nameFaSlug}`, `/products/category/{categorySlug}`, `/blog/{id}/{titleSlug}`, `/portfolios/{id}/{titleFaSlug}`. Proxied from `https://{domain}/sitemap.xml` on the storefront. 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, 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.", "parameters": [ { "$ref": "#/components/parameters/domain" @@ -226,6 +226,56 @@ } } }, + "/tenants/{domain}/sitemap-main.xml": { + "get": { + "tags": [ + "SEO" + ], + "summary": "Main sitemap urlset (static + categories + blogs + portfolios)", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "Sitemap XML (urlset)", + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/tenants/{domain}/sitemap-products.xml": { + "get": { + "tags": [ + "SEO" + ], + "summary": "Products-only sitemap urlset", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "Sitemap XML (urlset) of published products", + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/tenants/{domain}/robots.txt": { "get": { "tags": [ @@ -446,12 +496,55 @@ } } }, + "/tenants/{domain}/categories/by-id/{categoryId}": { + "get": { + "tags": [ + "Categories" + ], + "summary": "Category by id (preferred for /products/category/{id}/{nameFaSlug} pages)", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "categoryId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "entityType", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "product", + "blog", + "portfolio", + "video" + ], + "default": "product" + } + } + ], + "responses": { + "200": { + "description": "{ category } — use category.id as categoryId when listing products" + }, + "404": { + "description": "Category not found" + } + } + } + }, "/tenants/{domain}/categories/by-slug/{slug}": { "get": { "tags": [ "Categories" ], - "summary": "Category by slug (for /products/category/{categorySlug} pages)", + "summary": "Category by CMS slug (legacy / lookup helper)", "parameters": [ { "$ref": "#/components/parameters/domain"