Use /{id}/{fa-slug} detail URLs in sitemaps and add public by-id lookups.
Default sitemap templates now emit SEO-friendly Farsi path segments with stable CMS ids; storefronts resolve content via new by-id public endpoints. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
1b16e79d3f
commit
7cef24336a
@@ -262,8 +262,11 @@ 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 pages from synced manifest + published products/blogs/portfolios) |
|
||||
| GET | `/tenants/:host/sitemap.xml` | XML sitemap (static pages + `/products|blog|portfolios/{id}/{faSlug}`) |
|
||||
| GET | `/tenants/:host/robots.txt` | robots.txt pointing to apex `/sitemap.xml` |
|
||||
| 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 |
|
||||
| POST | `/businesses/:id/website/sitemap/sync` | Import manifest from `GET https://{domain}/meshkee/sitemap-config.json` |
|
||||
| GET | `/tenants/:host/store-specials` | Active store specials (`source` is `product` or `store_item`) |
|
||||
| GET | `/tenants/:host/website/category-groups` | Homepage category rows |
|
||||
|
||||
@@ -114,9 +114,14 @@ Minimal example:
|
||||
```
|
||||
|
||||
- Include public static routes automatically; exclude login, checkout, cart, account, and admin paths.
|
||||
- Omit `templates` unless this site uses non-default detail URLs (Meshkee defaults: `/products/{slug}`, `/blog/{slug}`, `/portfolios/{slug}`).
|
||||
- After deploy, business owner: **Website → Settings → Sync sitemap config** (imports the manifest once; does not rebuild XML by itself).
|
||||
- Dynamic URLs (products, blogs, portfolios, …) come from the CMS automatically. Hitting `/sitemap.xml` serves a Redis-cached XML that regenerates after CMS publish/update/delete (or when cache expires).
|
||||
- **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).
|
||||
- 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 (slugify Farsi title the same way: spaces → `-`, keep Persian letters).
|
||||
- Omit `templates` in `sitemap-config.json` unless this site uses non-default paths (Meshkee sitemap defaults already use `{id}/{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).
|
||||
|
||||
### On-page SEO (every public page)
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@
|
||||
"SEO"
|
||||
],
|
||||
"summary": "XML sitemap for search engines",
|
||||
"description": "Published products, 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 paths when not synced: `/`, `/products/{slug}`, `/blog/{slug}`, `/portfolios/{slug}`. Proxied from `https://{domain}/sitemap.xml` on the storefront. Cached in Redis; refreshed after CMS writes and manifest sync.",
|
||||
"description": "Published products, 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}`, `/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.",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/domain"
|
||||
@@ -514,6 +514,84 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/products/by-id/{productId}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Products"
|
||||
],
|
||||
"summary": "Product by id (preferred for /products/{id}/{nameFaSlug} pages)",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/domain"
|
||||
},
|
||||
{
|
||||
"name": "productId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "{ product, relatedProducts } — same shape as product-by-slug"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/products/by-id/{productId}/variations": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Products"
|
||||
],
|
||||
"summary": "Product variations by id",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/domain"
|
||||
},
|
||||
{
|
||||
"name": "productId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Variation tree for the product"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/products/by-id/{productId}/technical-info": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Products"
|
||||
],
|
||||
"summary": "Product technical info by id",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/domain"
|
||||
},
|
||||
{
|
||||
"name": "productId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Technical form + values"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/products/{slug}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -950,6 +1028,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/blogs/by-id/{blogId}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Blogs"
|
||||
],
|
||||
"summary": "Blog by id (preferred for /blog/{id}/{titleSlug} pages)",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/domain"
|
||||
},
|
||||
{
|
||||
"name": "blogId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "{ blog }"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/blogs/{slug}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -1277,6 +1381,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/portfolios/by-id/{portfolioId}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Portfolios"
|
||||
],
|
||||
"summary": "Portfolio by id (preferred for /portfolios/{id}/{titleFaSlug} pages)",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/domain"
|
||||
},
|
||||
{
|
||||
"name": "portfolioId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "{ portfolio }"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/portfolios/{slug}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
@@ -99,6 +99,11 @@ export class PublicBlogsController {
|
||||
return this.service.listPublic(host, query);
|
||||
}
|
||||
|
||||
@Get('by-id/:blogId')
|
||||
getById(@Param('host') host: string, @Param('blogId') blogId: string) {
|
||||
return this.service.getPublicById(host, blogId);
|
||||
}
|
||||
|
||||
@Get(':blogId/comments')
|
||||
listComments(@Param('host') host: string, @Param('blogId') blogId: string) {
|
||||
return this.service.listCommentsPublic(host, blogId);
|
||||
|
||||
@@ -381,6 +381,37 @@ export class BlogsService {
|
||||
};
|
||||
}
|
||||
|
||||
async getPublicById(host: string, blogIdRaw: string) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
let blogId: bigint;
|
||||
try {
|
||||
blogId = BigInt(blogIdRaw);
|
||||
} catch {
|
||||
throw new NotFoundException('Blog post not found');
|
||||
}
|
||||
|
||||
const blog = await this.prisma.blogs.findFirst({
|
||||
where: {
|
||||
business_id: businessId,
|
||||
id: blogId,
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
include: blogInclude,
|
||||
});
|
||||
|
||||
if (!blog) {
|
||||
throw new NotFoundException('Blog post not found');
|
||||
}
|
||||
|
||||
return {
|
||||
blog: await this.serializeBlog(blog, {
|
||||
includeComments: true,
|
||||
approvedCommentsOnly: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async listCommentsPublic(host: string, blogIdRaw: string) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
|
||||
@@ -99,6 +99,14 @@ export class PublicPortfoliosController {
|
||||
return this.service.listPublic(host, query);
|
||||
}
|
||||
|
||||
@Get('by-id/:portfolioId')
|
||||
getById(
|
||||
@Param('host') host: string,
|
||||
@Param('portfolioId') portfolioId: string,
|
||||
) {
|
||||
return this.service.getPublicById(host, portfolioId);
|
||||
}
|
||||
|
||||
@Get(':portfolioId/comments')
|
||||
listComments(
|
||||
@Param('host') host: string,
|
||||
|
||||
@@ -468,6 +468,37 @@ export class PortfoliosService {
|
||||
};
|
||||
}
|
||||
|
||||
async getPublicById(host: string, portfolioIdRaw: string) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
let portfolioId: bigint;
|
||||
try {
|
||||
portfolioId = BigInt(portfolioIdRaw);
|
||||
} catch {
|
||||
throw new NotFoundException('Portfolio not found');
|
||||
}
|
||||
|
||||
const portfolio = await this.prisma.portfolios.findFirst({
|
||||
where: {
|
||||
business_id: businessId,
|
||||
id: portfolioId,
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
include: portfolioInclude,
|
||||
});
|
||||
|
||||
if (!portfolio) {
|
||||
throw new NotFoundException('Portfolio not found');
|
||||
}
|
||||
|
||||
return {
|
||||
portfolio: await this.serializePortfolio(portfolio, {
|
||||
includeComments: true,
|
||||
approvedCommentsOnly: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async listCommentsPublic(host: string, portfolioIdRaw: string) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
|
||||
@@ -171,6 +171,35 @@ export class PublicProductsController {
|
||||
return this.service.listPublic(host, query);
|
||||
}
|
||||
|
||||
@Get('by-id/:productId/variations')
|
||||
async getVariationsById(
|
||||
@Param('host') host: string,
|
||||
@Param('productId') productId: string,
|
||||
) {
|
||||
const detail = await this.service.getPublicById(host, productId);
|
||||
return this.variationValuesService.getPublicForProduct(
|
||||
detail.product.businessId,
|
||||
detail.product.id,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('by-id/:productId/technical-info')
|
||||
async getTechnicalInfoById(
|
||||
@Param('host') host: string,
|
||||
@Param('productId') productId: string,
|
||||
) {
|
||||
const detail = await this.service.getPublicById(host, productId);
|
||||
return this.technicalInfoService.getPublicForProduct(
|
||||
detail.product.businessId,
|
||||
detail.product.id,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('by-id/:productId')
|
||||
getById(@Param('host') host: string, @Param('productId') productId: string) {
|
||||
return this.service.getPublicById(host, productId);
|
||||
}
|
||||
|
||||
@Get(':slug/variations')
|
||||
async getVariations(@Param('host') host: string, @Param('slug') slug: string) {
|
||||
const { businessId, productId } = await this.service.assertPublishedProductBySlug(
|
||||
|
||||
@@ -175,6 +175,39 @@ export class ProductsService {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
|
||||
return this.serializePublicDetail(businessId, product);
|
||||
}
|
||||
|
||||
async getPublicById(host: string, productIdRaw: string) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
let productId: bigint;
|
||||
try {
|
||||
productId = BigInt(productIdRaw);
|
||||
} catch {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
|
||||
const product = await this.prisma.product.findFirst({
|
||||
where: {
|
||||
businessId,
|
||||
id: productId,
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
include: productListInclude,
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
|
||||
return this.serializePublicDetail(businessId, product);
|
||||
}
|
||||
|
||||
private async serializePublicDetail(
|
||||
businessId: bigint,
|
||||
product: Prisma.ProductGetPayload<{ include: typeof productListInclude }>,
|
||||
) {
|
||||
const [serializedList, relatedProducts] = await Promise.all([
|
||||
this.serializePublicListItems(businessId, [product]),
|
||||
this.loadRelatedPublicProducts(businessId, product),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Build a URL path segment from a title (supports Persian / Arabic / Latin).
|
||||
* Keeps letters and digits; spaces → hyphens.
|
||||
*/
|
||||
export function slugifyForUrl(value: string, fallback = 'item'): string {
|
||||
const slug = value
|
||||
.trim()
|
||||
.normalize('NFC')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(
|
||||
/[^\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFFa-zA-Z0-9-]+/g,
|
||||
'',
|
||||
)
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
|
||||
return slug || fallback;
|
||||
}
|
||||
|
||||
export function encodePathSegment(value: string): string {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
@@ -42,7 +42,11 @@ function normalizeStoredTemplates(
|
||||
const templates: NonNullable<WebsiteSitemapConfig['templates']> = {};
|
||||
|
||||
for (const [key, template] of Object.entries(raw)) {
|
||||
if (typeof template === 'string' && template.includes('{slug}')) {
|
||||
if (
|
||||
typeof template === 'string' &&
|
||||
template.includes('{slug}') &&
|
||||
template.trim().startsWith('/')
|
||||
) {
|
||||
templates[key as keyof typeof templates] = template.trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,8 +51,13 @@ export function buildSitemapXml(entries: SitemapUrlEntry[]): string {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function applyPathTemplate(template: string, slug: string): string {
|
||||
return template.replace('{slug}', encodeURIComponent(slug));
|
||||
export function applyPathTemplate(
|
||||
template: string,
|
||||
vars: { id?: string; slug: string },
|
||||
): string {
|
||||
return template
|
||||
.replaceAll('{id}', vars.id != null ? encodeURIComponent(vars.id) : '')
|
||||
.replaceAll('{slug}', encodeURIComponent(vars.slug));
|
||||
}
|
||||
|
||||
export function buildAbsoluteUrl(baseUrl: string, path: string): string {
|
||||
|
||||
@@ -5,9 +5,9 @@ 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/{slug}',
|
||||
blog: '/blog/{slug}',
|
||||
portfolio: '/portfolios/{slug}',
|
||||
product: '/products/{id}/{slug}',
|
||||
blog: '/blog/{id}/{slug}',
|
||||
portfolio: '/portfolios/{id}/{slug}',
|
||||
} as const;
|
||||
|
||||
export const WEBSITE_SITEMAP_CONFIG_PATH = '/meshkee/sitemap-config.json';
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ContentStatus } from '@prisma/client';
|
||||
import { ContentStatus, Prisma } from '@prisma/client';
|
||||
import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
|
||||
import type { BusinessModuleId } from '../business-settings/business-settings.types';
|
||||
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 { slugifyForUrl } from './seo-slug.util';
|
||||
import { SITEMAP_CACHE_TTL_SECONDS } from './sitemap.constants';
|
||||
import {
|
||||
applyPathTemplate,
|
||||
@@ -14,7 +15,8 @@ import {
|
||||
type SitemapUrlEntry,
|
||||
} from './sitemap-xml.util';
|
||||
|
||||
type PublishedSlugRow = {
|
||||
type PublishedDetailRow = {
|
||||
id: bigint;
|
||||
slug: string;
|
||||
updatedAt: Date;
|
||||
};
|
||||
@@ -152,13 +154,25 @@ export class SitemapService {
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
|
||||
return rows.map((row) => this.toEntry(baseUrl, template, row));
|
||||
return rows.map((row) => {
|
||||
const content = this.asRecord(row.content);
|
||||
const nameFa =
|
||||
typeof content.nameFa === 'string' ? content.nameFa.trim() : '';
|
||||
const pathSlug = slugifyForUrl(nameFa || row.title, 'product');
|
||||
return this.toEntry(baseUrl, template, {
|
||||
id: row.id,
|
||||
slug: pathSlug,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async loadPublishedBlogs(
|
||||
@@ -172,7 +186,8 @@ export class SitemapService {
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
id: true,
|
||||
title: true,
|
||||
updated_at: true,
|
||||
},
|
||||
orderBy: [{ updated_at: 'desc' }, { id: 'desc' }],
|
||||
@@ -180,7 +195,8 @@ export class SitemapService {
|
||||
|
||||
return rows.map((row) =>
|
||||
this.toEntry(baseUrl, template, {
|
||||
slug: row.slug,
|
||||
id: row.id,
|
||||
slug: slugifyForUrl(row.title, 'blog'),
|
||||
updatedAt: row.updated_at,
|
||||
}),
|
||||
);
|
||||
@@ -197,7 +213,9 @@ export class SitemapService {
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
id: true,
|
||||
title: true,
|
||||
title_fa: true,
|
||||
updated_at: true,
|
||||
},
|
||||
orderBy: [{ updated_at: 'desc' }, { id: 'desc' }],
|
||||
@@ -205,7 +223,8 @@ export class SitemapService {
|
||||
|
||||
return rows.map((row) =>
|
||||
this.toEntry(baseUrl, template, {
|
||||
slug: row.slug,
|
||||
id: row.id,
|
||||
slug: slugifyForUrl(row.title_fa || row.title, 'portfolio'),
|
||||
updatedAt: row.updated_at,
|
||||
}),
|
||||
);
|
||||
@@ -214,9 +233,12 @@ export class SitemapService {
|
||||
private toEntry(
|
||||
baseUrl: string,
|
||||
template: string,
|
||||
row: PublishedSlugRow,
|
||||
row: PublishedDetailRow,
|
||||
): SitemapUrlEntry {
|
||||
const path = applyPathTemplate(template, row.slug);
|
||||
const path = applyPathTemplate(template, {
|
||||
id: row.id.toString(),
|
||||
slug: row.slug,
|
||||
});
|
||||
return {
|
||||
loc: buildAbsoluteUrl(baseUrl, path),
|
||||
lastmod: row.updatedAt,
|
||||
@@ -224,4 +246,11 @@ export class SitemapService {
|
||||
priority: 0.7,
|
||||
};
|
||||
}
|
||||
|
||||
private asRecord(value: Prisma.JsonValue): Record<string, unknown> {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,9 +114,14 @@ Minimal example:
|
||||
```
|
||||
|
||||
- Include public static routes automatically; exclude login, checkout, cart, account, and admin paths.
|
||||
- Omit `templates` unless this site uses non-default detail URLs (Meshkee defaults: `/products/{slug}`, `/blog/{slug}`, `/portfolios/{slug}`).
|
||||
- After deploy, business owner: **Website → Settings → Sync sitemap config** (imports the manifest once; does not rebuild XML by itself).
|
||||
- Dynamic URLs (products, blogs, portfolios, …) come from the CMS automatically. Hitting `/sitemap.xml` serves a Redis-cached XML that regenerates after CMS publish/update/delete (or when cache expires).
|
||||
- **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).
|
||||
- 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 (slugify Farsi title the same way: spaces → `-`, keep Persian letters).
|
||||
- Omit `templates` in `sitemap-config.json` unless this site uses non-default paths (Meshkee sitemap defaults already use `{id}/{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).
|
||||
|
||||
### On-page SEO (every public page)
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@
|
||||
"SEO"
|
||||
],
|
||||
"summary": "XML sitemap for search engines",
|
||||
"description": "Published products, 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 paths when not synced: `/`, `/products/{slug}`, `/blog/{slug}`, `/portfolios/{slug}`. Proxied from `https://{domain}/sitemap.xml` on the storefront. Cached in Redis; refreshed after CMS writes and manifest sync.",
|
||||
"description": "Published products, 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}`, `/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.",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/domain"
|
||||
@@ -514,6 +514,84 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/products/by-id/{productId}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Products"
|
||||
],
|
||||
"summary": "Product by id (preferred for /products/{id}/{nameFaSlug} pages)",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/domain"
|
||||
},
|
||||
{
|
||||
"name": "productId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "{ product, relatedProducts } — same shape as product-by-slug"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/products/by-id/{productId}/variations": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Products"
|
||||
],
|
||||
"summary": "Product variations by id",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/domain"
|
||||
},
|
||||
{
|
||||
"name": "productId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Variation tree for the product"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/products/by-id/{productId}/technical-info": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Products"
|
||||
],
|
||||
"summary": "Product technical info by id",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/domain"
|
||||
},
|
||||
{
|
||||
"name": "productId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Technical form + values"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/products/{slug}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -950,6 +1028,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/blogs/by-id/{blogId}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Blogs"
|
||||
],
|
||||
"summary": "Blog by id (preferred for /blog/{id}/{titleSlug} pages)",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/domain"
|
||||
},
|
||||
{
|
||||
"name": "blogId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "{ blog }"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/blogs/{slug}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -1277,6 +1381,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/portfolios/by-id/{portfolioId}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Portfolios"
|
||||
],
|
||||
"summary": "Portfolio by id (preferred for /portfolios/{id}/{titleFaSlug} pages)",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/domain"
|
||||
},
|
||||
{
|
||||
"name": "portfolioId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "{ portfolio }"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/portfolios/{slug}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
Reference in New Issue
Block a user