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
@@ -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 {};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user