Skip to main content
Glama
title-validator.ts6.01 kB
/** * Title validation utility for XHS MCP Server * Validates title width according to XiaoHongShu's display width rules * * XHS Rules: * - Max width: 40 units * - CJK characters (Chinese/Japanese/Korean): 2 units each * - Other characters (English/Numbers): 1 unit each */ import stringWidth from 'string-width'; import { PublishError } from './errors'; export interface TitleValidationResult { valid: boolean; width: number; maxWidth: number; message?: string; suggestion?: string; } /** * XiaoHongShu title constraints */ export const XHS_TITLE_CONSTRAINTS = { MAX_WIDTH: 40, // Maximum display width in units MAX_LENGTH: 20, // Approximate max character count (for reference) } as const; /** * Validate title width according to XHS display rules * * @param title - The title to validate * @returns Validation result with width information * * @example * ```typescript * const result = validateTitleWidth('Hello世界'); * console.log(result.width); // 10 (5 + 2*2 + 1 = 10) * console.log(result.valid); // true * ``` */ export function validateTitleWidth(title: string): TitleValidationResult { if (!title) { return { valid: false, width: 0, maxWidth: XHS_TITLE_CONSTRAINTS.MAX_WIDTH, message: 'Title cannot be empty', suggestion: 'Please provide a valid title', }; } // Calculate display width using string-width // This correctly handles CJK characters, emoji, and other Unicode const width = stringWidth(title); if (width > XHS_TITLE_CONSTRAINTS.MAX_WIDTH) { return { valid: false, width, maxWidth: XHS_TITLE_CONSTRAINTS.MAX_WIDTH, message: `Title width exceeds limit: ${width} units (max: ${XHS_TITLE_CONSTRAINTS.MAX_WIDTH} units)`, suggestion: `Current title is too long. CJK characters count as 2 units, English/numbers as 1 unit. Please shorten your title.`, }; } return { valid: true, width, maxWidth: XHS_TITLE_CONSTRAINTS.MAX_WIDTH, }; } /** * Validate and throw error if title width is invalid * * @param title - The title to validate * @throws PublishError if title width is invalid * * @example * ```typescript * try { * assertTitleWidthValid('很长很长的标题'.repeat(10)); * } catch (error) { * console.error(error.message); * } * ``` */ export function assertTitleWidthValid(title: string): void { const result = validateTitleWidth(title); if (!result.valid) { throw new PublishError(result.message!, { title, width: result.width, maxWidth: result.maxWidth, suggestion: result.suggestion, details: { titleLength: title.length, displayWidth: result.width, maxDisplayWidth: result.maxWidth, exceeded: result.width - result.maxWidth, }, }); } } /** * Get the display width of a title * * @param title - The title to measure * @returns Display width in units * * @example * ```typescript * console.log(getTitleWidth('Hello')); // 5 * console.log(getTitleWidth('你好')); // 4 (2*2) * console.log(getTitleWidth('Hello世界')); // 10 (5 + 2*2 + 1) * console.log(getTitleWidth('👋Hello')); // 7 (2 + 5) * ``` */ export function getTitleWidth(title: string): number { return stringWidth(title); } /** * Calculate how many characters can be added to the title * * @param title - Current title * @returns Remaining width units available * * @example * ```typescript * const remaining = getRemainingTitleWidth('Hello'); // 35 * console.log(`You can add ${remaining} more units`); * ``` */ export function calculateRemainingTitleWidth(title: string): number { const currentWidth = getTitleWidth(title); return Math.max(0, XHS_TITLE_CONSTRAINTS.MAX_WIDTH - currentWidth); } /** * Truncate title to fit within width limit * * @param title - The title to truncate * @param maxWidth - Maximum width (default: XHS_TITLE_CONSTRAINTS.MAX_WIDTH) * @returns Truncated title that fits within width limit * * @example * ```typescript * const long = '这是一个很长很长的标题'.repeat(5); * const truncated = truncateTitleToWidth(long); * console.log(getTitleWidth(truncated)); // <= 40 * ``` */ export function truncateTitleToWidth( title: string, maxWidth: number = XHS_TITLE_CONSTRAINTS.MAX_WIDTH ): string { if (getTitleWidth(title) <= maxWidth) { return title; } let truncated = ''; let currentWidth = 0; for (const char of title) { const charWidth = stringWidth(char); if (currentWidth + charWidth > maxWidth) { break; } truncated += char; currentWidth += charWidth; } return truncated; } /** * Get human-readable width breakdown * Useful for debugging and user feedback * * @param title - The title to analyze * @returns Width breakdown information */ export function getTitleWidthBreakdown(title: string): { title: string; totalWidth: number; totalChars: number; maxWidth: number; remaining: number; valid: boolean; breakdown: Array<{ char: string; width: number; type: 'CJK' | 'ASCII' | 'Emoji' | 'Other'; }>; } { const totalWidth = getTitleWidth(title); const breakdown: Array<{ char: string; width: number; type: 'CJK' | 'ASCII' | 'Emoji' | 'Other'; }> = []; for (const char of title) { const charWidth = stringWidth(char); let type: 'CJK' | 'ASCII' | 'Emoji' | 'Other' = 'Other'; const code = char.charCodeAt(0); if (char.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/)) { type = 'CJK'; } else if (code < 128) { type = 'ASCII'; } else if (char.match(/[\u{1F300}-\u{1F9FF}]/u)) { type = 'Emoji'; } breakdown.push({ char, width: charWidth, type }); } return { title, totalWidth, totalChars: title.length, maxWidth: XHS_TITLE_CONSTRAINTS.MAX_WIDTH, remaining: calculateRemainingTitleWidth(title), valid: totalWidth <= XHS_TITLE_CONSTRAINTS.MAX_WIDTH, breakdown, }; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Algovate/xhs-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server