Split product sitemap and use Farsi category URLs with ids.
sitemap.xml is now an index of main + products; category loc paths use /products/category/{id}/{nameFaSlug} with a public by-id lookup for storefronts.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
c4367d8eb0
commit
ea40ae40d8
@@ -262,9 +262,12 @@ All routes are prefixed with `/api/v1`.
|
|||||||
| POST | `/auth/verify-otp` | Verify OTP (marks cell verified; no tokens) |
|
| POST | `/auth/verify-otp` | Verify OTP (marks cell verified; no tokens) |
|
||||||
| POST | `/auth/handoff/consume` | One-time SSO ticket → tokens (customer → business dashboard) |
|
| 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` | 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/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/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/blogs/by-id/:blogId` | Public blog by id |
|
||||||
| GET | `/tenants/:host/portfolios/by-id/:portfolioId` | Public portfolio by id |
|
| GET | `/tenants/:host/portfolios/by-id/:portfolioId` | Public portfolio by id |
|
||||||
|
|||||||
@@ -116,13 +116,16 @@ Minimal example:
|
|||||||
- Include public static routes automatically; exclude login, checkout, cart, account, and admin paths.
|
- Include public static routes automatically; exclude login, checkout, cart, account, and admin paths.
|
||||||
- **Canonical detail URLs (Meshkee default for all sites):**
|
- **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: `/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}`
|
- Blog: `/blog/{id}/{titleSlug}` — `GET /tenants/{domain}/blogs/by-id/{id}`
|
||||||
- Portfolio: `/portfolios/{id}/{titleFaSlug}` — `GET /tenants/{domain}/portfolios/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}`.
|
- 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 (Meshkee sitemap defaults already use `{id}/{slug}` and `/products/category/{slug}`).
|
- Omit `templates` in `sitemap-config.json` unless this site uses non-default paths.
|
||||||
- After deploy, business owner: **Website → Settings → Sync sitemap config** (imports static pages; does not rebuild XML by itself).
|
- **Sitemap files (proxied by nginx — do not ship local copies):**
|
||||||
- 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).
|
- `/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)
|
### On-page SEO (every public page)
|
||||||
|
|
||||||
|
|||||||
@@ -195,7 +195,7 @@
|
|||||||
"SEO"
|
"SEO"
|
||||||
],
|
],
|
||||||
"summary": "XML sitemap for search engines",
|
"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": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"$ref": "#/components/parameters/domain"
|
"$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": {
|
"/tenants/{domain}/robots.txt": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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}": {
|
"/tenants/{domain}/categories/by-slug/{slug}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"Categories"
|
"Categories"
|
||||||
],
|
],
|
||||||
"summary": "Category by slug (for /products/category/{categorySlug} pages)",
|
"summary": "Category by CMS slug (legacy / lookup helper)",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"$ref": "#/components/parameters/domain"
|
"$ref": "#/components/parameters/domain"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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
|
set -euo pipefail
|
||||||
|
|
||||||
HOST="${1:-}"
|
HOST="${1:-}"
|
||||||
@@ -17,25 +18,12 @@ if [[ ! -f "$NGINX_AVAILABLE" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if grep -q 'location = /sitemap.xml' "$NGINX_AVAILABLE"; then
|
proxy_location() {
|
||||||
echo "nginx SEO locations already present for $HOST"
|
local path="$1"
|
||||||
exit 0
|
local upstream="$2"
|
||||||
fi
|
cat <<NGINX
|
||||||
|
location = ${path} {
|
||||||
TMP="$(mktemp)"
|
proxy_pass https://${API_HOST}/api/v1/tenants/${HOST}/${upstream};
|
||||||
SEO_BLOCK="$(cat <<NGINX
|
|
||||||
location = /sitemap.xml {
|
|
||||||
proxy_pass https://${API_HOST}/api/v1/tenants/${HOST}/sitemap.xml;
|
|
||||||
proxy_set_header Host ${API_HOST};
|
|
||||||
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_HOST}/api/v1/tenants/${HOST}/robots.txt;
|
|
||||||
proxy_set_header Host ${API_HOST};
|
proxy_set_header Host ${API_HOST};
|
||||||
proxy_ssl_server_name on;
|
proxy_ssl_server_name on;
|
||||||
proxy_set_header X-Real-IP \$remote_addr;
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
@@ -45,17 +33,36 @@ SEO_BLOCK="$(cat <<NGINX
|
|||||||
}
|
}
|
||||||
|
|
||||||
NGINX
|
NGINX
|
||||||
)"
|
}
|
||||||
|
|
||||||
awk -v block="$SEO_BLOCK" '
|
ensure_location() {
|
||||||
/location \/ \{/ && !done {
|
local path="$1"
|
||||||
print block
|
local upstream="$2"
|
||||||
done = 1
|
if grep -q "location = ${path}" "$NGINX_AVAILABLE"; then
|
||||||
}
|
echo "already present: ${path}"
|
||||||
{ print }
|
return 0
|
||||||
' "$NGINX_AVAILABLE" >"$TMP"
|
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
|
nginx -t
|
||||||
systemctl reload nginx
|
systemctl reload nginx
|
||||||
echo "patched nginx SEO locations for $HOST"
|
echo "patched nginx SEO locations for $HOST"
|
||||||
|
|||||||
@@ -132,6 +132,26 @@ server {
|
|||||||
add_header Cache-Control "public, max-age=900";
|
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 {
|
location = /robots.txt {
|
||||||
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/robots.txt;
|
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/robots.txt;
|
||||||
proxy_set_header Host api.meshkee.com;
|
proxy_set_header Host api.meshkee.com;
|
||||||
|
|||||||
@@ -174,6 +174,15 @@ export class PublicCategoriesController {
|
|||||||
return this.service.listPublic(host, query);
|
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')
|
@Get('by-slug/:slug')
|
||||||
getBySlug(
|
getBySlug(
|
||||||
@Param('host') host: string,
|
@Param('host') host: string,
|
||||||
|
|||||||
@@ -87,6 +87,32 @@ export class CategoriesService {
|
|||||||
return { category: this.serialize(category) };
|
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) {
|
async create(businessIdRaw: string, dto: CreateCategoryDto, actor: AuthUser) {
|
||||||
const businessId = BigInt(businessIdRaw);
|
const businessId = BigInt(businessIdRaw);
|
||||||
await this.assertPermission(businessId, actor.id, 'categories.create');
|
await this.assertPermission(businessId, actor.id, 'categories.create');
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export type SitemapUrlEntry = {
|
|||||||
priority?: number;
|
priority?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SitemapIndexEntry = {
|
||||||
|
loc: string;
|
||||||
|
lastmod?: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
function escapeXml(value: string): string {
|
function escapeXml(value: string): string {
|
||||||
return value
|
return value
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -51,6 +56,26 @@ export function buildSitemapXml(entries: SitemapUrlEntry[]): string {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildSitemapIndexXml(entries: SitemapIndexEntry[]): string {
|
||||||
|
const sitemaps = entries
|
||||||
|
.map((entry) => {
|
||||||
|
const parts = [` <loc>${escapeXml(entry.loc)}</loc>`];
|
||||||
|
if (entry.lastmod) {
|
||||||
|
parts.push(` <lastmod>${formatLastmod(entry.lastmod)}</lastmod>`);
|
||||||
|
}
|
||||||
|
return ` <sitemap>\n${parts.join('\n')}\n </sitemap>`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
`<sitemapindex xmlns="${SITEMAP_URLSET_NS}">`,
|
||||||
|
sitemaps,
|
||||||
|
'</sitemapindex>',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a path from a template. Keep Unicode (e.g. Farsi) readable in sitemap
|
* Build a path from a template. Keep Unicode (e.g. Farsi) readable in sitemap
|
||||||
* `<loc>` — do not percent-encode letters. XML escaping happens in buildSitemapXml.
|
* `<loc>` — do not percent-encode letters. XML escaping happens in buildSitemapXml.
|
||||||
|
|||||||
@@ -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). */
|
/** Default storefront path templates (overridable via /meshkee/sitemap-config.json). */
|
||||||
export const DEFAULT_SITEMAP_PATH_TEMPLATES = {
|
export const DEFAULT_SITEMAP_PATH_TEMPLATES = {
|
||||||
product: '/products/{id}/{slug}',
|
product: '/products/{id}/{slug}',
|
||||||
productCategory: '/products/category/{slug}',
|
productCategory: '/products/category/{id}/{slug}',
|
||||||
blog: '/blog/{id}/{slug}',
|
blog: '/blog/{id}/{slug}',
|
||||||
portfolio: '/portfolios/{id}/{slug}',
|
portfolio: '/portfolios/{id}/{slug}',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -14,6 +14,22 @@ export class SitemapController {
|
|||||||
res.send(xml);
|
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')
|
@Get('robots.txt')
|
||||||
@Header('Cache-Control', 'public, max-age=900')
|
@Header('Cache-Control', 'public, max-age=900')
|
||||||
async getRobots(@Param('host') host: string, @Res() res: Response) {
|
async getRobots(@Param('host') host: string, @Res() res: Response) {
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { RedisService } from '../redis/redis.service';
|
import { RedisService } from '../redis/redis.service';
|
||||||
import { TenantService } from '../tenant/tenant.service';
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
import { resolveSitemapConfig } from './sitemap-config.util';
|
import { resolveSitemapConfig } from './sitemap-config.util';
|
||||||
|
import type { ResolvedSitemapConfig } from './sitemap-config.types';
|
||||||
import { slugifyForUrl } from './seo-slug.util';
|
import { slugifyForUrl } from './seo-slug.util';
|
||||||
import { SITEMAP_CACHE_TTL_SECONDS } from './sitemap.constants';
|
import { SITEMAP_CACHE_TTL_SECONDS } from './sitemap.constants';
|
||||||
import {
|
import {
|
||||||
applyPathTemplate,
|
applyPathTemplate,
|
||||||
buildAbsoluteUrl,
|
buildAbsoluteUrl,
|
||||||
|
buildSitemapIndexXml,
|
||||||
buildSitemapXml,
|
buildSitemapXml,
|
||||||
type SitemapUrlEntry,
|
type SitemapUrlEntry,
|
||||||
} from './sitemap-xml.util';
|
} from './sitemap-xml.util';
|
||||||
@@ -21,6 +23,8 @@ type PublishedDetailRow = {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SitemapSection = 'index' | 'main' | 'products';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SitemapService {
|
export class SitemapService {
|
||||||
private readonly logger = new Logger(SitemapService.name);
|
private readonly logger = new Logger(SitemapService.name);
|
||||||
@@ -31,14 +35,19 @@ export class SitemapService {
|
|||||||
private readonly tenant: TenantService,
|
private readonly tenant: TenantService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private cacheKey(businessId: bigint): string {
|
private cacheKey(businessId: bigint, section: SitemapSection): string {
|
||||||
return `sitemap:${businessId.toString()}`;
|
return `sitemap:${businessId.toString()}:${section}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async invalidateForBusiness(businessId: bigint | string): Promise<void> {
|
async invalidateForBusiness(businessId: bigint | string): Promise<void> {
|
||||||
const id = typeof businessId === 'bigint' ? businessId : BigInt(businessId);
|
const id = typeof businessId === 'bigint' ? businessId : BigInt(businessId);
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Failed to invalidate sitemap cache for business ${id.toString()}: ${String(err)}`,
|
`Failed to invalidate sitemap cache for business ${id.toString()}: ${String(err)}`,
|
||||||
@@ -47,8 +56,20 @@ export class SitemapService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getXmlForHost(host: string): Promise<string> {
|
async getXmlForHost(host: string): Promise<string> {
|
||||||
|
return this.getSectionXml(host, 'index');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMainXmlForHost(host: string): Promise<string> {
|
||||||
|
return this.getSectionXml(host, 'main');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProductsXmlForHost(host: string): Promise<string> {
|
||||||
|
return this.getSectionXml(host, 'products');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSectionXml(host: string, section: SitemapSection): Promise<string> {
|
||||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
const cacheKey = this.cacheKey(business.id);
|
const cacheKey = this.cacheKey(business.id, section);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cached = await this.redis.client.get(cacheKey);
|
const cached = await this.redis.client.get(cacheKey);
|
||||||
@@ -58,7 +79,13 @@ export class SitemapService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const apexHost = await this.resolveApexHost(business.id, host);
|
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 {
|
try {
|
||||||
await this.redis.client.set(cacheKey, xml, 'EX', SITEMAP_CACHE_TTL_SECONDS);
|
await this.redis.client.set(cacheKey, xml, 'EX', SITEMAP_CACHE_TTL_SECONDS);
|
||||||
@@ -102,16 +129,43 @@ export class SitemapService {
|
|||||||
return enabledModules.includes(moduleId);
|
return enabledModules.includes(moduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildXml(businessId: bigint, apexHost: string): Promise<string> {
|
private async loadContext(businessId: bigint, apexHost: string) {
|
||||||
const business = await this.prisma.business.findUnique({
|
const business = await this.prisma.business.findUnique({
|
||||||
where: { id: businessId },
|
where: { id: businessId },
|
||||||
select: { settings: true },
|
select: { settings: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const settings = normalizeBusinessSettings(business?.settings);
|
const settings = normalizeBusinessSettings(business?.settings);
|
||||||
const enabledModules = settings.modules.enabled;
|
|
||||||
const config = resolveSitemapConfig(settings.website.sitemapConfig, apexHost);
|
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<string> {
|
||||||
|
const { businessId, enabledModules, config } = ctx;
|
||||||
const entries: SitemapUrlEntry[] = config.staticPages.map((page) => ({
|
const entries: SitemapUrlEntry[] = config.staticPages.map((page) => ({
|
||||||
loc: buildAbsoluteUrl(config.baseUrl, page.path),
|
loc: buildAbsoluteUrl(config.baseUrl, page.path),
|
||||||
...(page.changefreq ? { changefreq: page.changefreq } : {}),
|
...(page.changefreq ? { changefreq: page.changefreq } : {}),
|
||||||
@@ -126,9 +180,6 @@ export class SitemapService {
|
|||||||
config.templates.productCategory,
|
config.templates.productCategory,
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
entries.push(
|
|
||||||
...(await this.loadPublishedProducts(businessId, config.baseUrl, config.templates.product)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isModuleEnabled(enabledModules, 'blog')) {
|
if (this.isModuleEnabled(enabledModules, 'blog')) {
|
||||||
@@ -150,6 +201,24 @@ export class SitemapService {
|
|||||||
return buildSitemapXml(entries);
|
return buildSitemapXml(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async buildProductsXml(ctx: {
|
||||||
|
businessId: bigint;
|
||||||
|
enabledModules: readonly BusinessModuleId[];
|
||||||
|
config: ResolvedSitemapConfig;
|
||||||
|
}): Promise<string> {
|
||||||
|
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(
|
private async loadProductCategories(
|
||||||
businessId: bigint,
|
businessId: bigint,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
@@ -163,7 +232,8 @@ export class SitemapService {
|
|||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
slug: true,
|
name: true,
|
||||||
|
nameFa: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
},
|
},
|
||||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
@@ -172,7 +242,7 @@ export class SitemapService {
|
|||||||
return rows.map((row) =>
|
return rows.map((row) =>
|
||||||
this.toEntry(baseUrl, template, {
|
this.toEntry(baseUrl, template, {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
slug: row.slug,
|
slug: slugifyForUrl(row.nameFa?.trim() || row.name, 'category'),
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -116,13 +116,16 @@ Minimal example:
|
|||||||
- Include public static routes automatically; exclude login, checkout, cart, account, and admin paths.
|
- Include public static routes automatically; exclude login, checkout, cart, account, and admin paths.
|
||||||
- **Canonical detail URLs (Meshkee default for all sites):**
|
- **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: `/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}`
|
- Blog: `/blog/{id}/{titleSlug}` — `GET /tenants/{domain}/blogs/by-id/{id}`
|
||||||
- Portfolio: `/portfolios/{id}/{titleFaSlug}` — `GET /tenants/{domain}/portfolios/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}`.
|
- 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 (Meshkee sitemap defaults already use `{id}/{slug}` and `/products/category/{slug}`).
|
- Omit `templates` in `sitemap-config.json` unless this site uses non-default paths.
|
||||||
- After deploy, business owner: **Website → Settings → Sync sitemap config** (imports static pages; does not rebuild XML by itself).
|
- **Sitemap files (proxied by nginx — do not ship local copies):**
|
||||||
- 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).
|
- `/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)
|
### On-page SEO (every public page)
|
||||||
|
|
||||||
|
|||||||
@@ -195,7 +195,7 @@
|
|||||||
"SEO"
|
"SEO"
|
||||||
],
|
],
|
||||||
"summary": "XML sitemap for search engines",
|
"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": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"$ref": "#/components/parameters/domain"
|
"$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": {
|
"/tenants/{domain}/robots.txt": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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}": {
|
"/tenants/{domain}/categories/by-slug/{slug}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"Categories"
|
"Categories"
|
||||||
],
|
],
|
||||||
"summary": "Category by slug (for /products/category/{categorySlug} pages)",
|
"summary": "Category by CMS slug (legacy / lookup helper)",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"$ref": "#/components/parameters/domain"
|
"$ref": "#/components/parameters/domain"
|
||||||
|
|||||||
Reference in New Issue
Block a user