Add Schema.org JSON-LD, SEO meta overrides, and resilient bulk SMS.
Expose Organization/LocalBusiness and Product/BlogPosting jsonLd, optional seoMeta fields on content/categories, and continue mass SMS after per-recipient failures. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
db305c58a4
commit
aaf7a83742
@@ -0,0 +1,23 @@
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import {
|
||||
SEO_META_DESCRIPTION_MAX,
|
||||
SEO_META_TITLE_MAX,
|
||||
} from '../common/seo-meta.util';
|
||||
|
||||
/** Optional mixin fields — copy onto Create/Update DTOs (Nest does not support multi-inheritance well). */
|
||||
export const SEO_META_DTO_DECORATORS = {
|
||||
titleMax: SEO_META_TITLE_MAX,
|
||||
descriptionMax: SEO_META_DESCRIPTION_MAX,
|
||||
} as const;
|
||||
|
||||
export class SeoMetaDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(SEO_META_TITLE_MAX)
|
||||
seoMetaTitle?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(SEO_META_DESCRIPTION_MAX)
|
||||
seoMetaDescription?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Per-page SEO overrides stored in entity `metadata` JSON.
|
||||
* Empty / null → websites fall back to title / summary / about fields.
|
||||
*/
|
||||
|
||||
export const SEO_META_TITLE_MAX = 70;
|
||||
export const SEO_META_DESCRIPTION_MAX = 160;
|
||||
|
||||
export type SeoMetaFields = {
|
||||
seoMetaTitle: string | null;
|
||||
seoMetaDescription: string | null;
|
||||
};
|
||||
|
||||
export function emptySeoMeta(): SeoMetaFields {
|
||||
return { seoMetaTitle: null, seoMetaDescription: null };
|
||||
}
|
||||
|
||||
export function readSeoMetaFromMetadata(metadata: Record<string, unknown>): SeoMetaFields {
|
||||
const title =
|
||||
typeof metadata.seoMetaTitle === 'string' ? metadata.seoMetaTitle.trim() : '';
|
||||
const description =
|
||||
typeof metadata.seoMetaDescription === 'string'
|
||||
? metadata.seoMetaDescription.trim()
|
||||
: '';
|
||||
return {
|
||||
seoMetaTitle: title ? title.slice(0, SEO_META_TITLE_MAX) : null,
|
||||
seoMetaDescription: description
|
||||
? description.slice(0, SEO_META_DESCRIPTION_MAX)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Normalize optional DTO strings (undefined = leave unchanged when merging). */
|
||||
export function normalizeSeoMetaInput(
|
||||
seoMetaTitle?: string | null,
|
||||
seoMetaDescription?: string | null,
|
||||
): Partial<SeoMetaFields> {
|
||||
const out: Partial<SeoMetaFields> = {};
|
||||
if (seoMetaTitle !== undefined) {
|
||||
const trimmed = seoMetaTitle?.trim() ?? '';
|
||||
out.seoMetaTitle = trimmed ? trimmed.slice(0, SEO_META_TITLE_MAX) : null;
|
||||
}
|
||||
if (seoMetaDescription !== undefined) {
|
||||
const trimmed = seoMetaDescription?.trim() ?? '';
|
||||
out.seoMetaDescription = trimmed
|
||||
? trimmed.slice(0, SEO_META_DESCRIPTION_MAX)
|
||||
: null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Merge SEO keys into a metadata object (preserves tags and other keys). */
|
||||
export function applySeoMetaToMetadata(
|
||||
metadata: Record<string, unknown>,
|
||||
seo: Partial<SeoMetaFields>,
|
||||
): Record<string, unknown> {
|
||||
const next = { ...metadata };
|
||||
if (seo.seoMetaTitle !== undefined) {
|
||||
if (seo.seoMetaTitle) next.seoMetaTitle = seo.seoMetaTitle;
|
||||
else delete next.seoMetaTitle;
|
||||
}
|
||||
if (seo.seoMetaDescription !== undefined) {
|
||||
if (seo.seoMetaDescription) next.seoMetaDescription = seo.seoMetaDescription;
|
||||
else delete next.seoMetaDescription;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
Reference in New Issue
Block a user