import { MAX_ARTICLE_AUTHOR_SIZE, MAX_ARTICLE_CATEGORIES_SIZE, MAX_ARTICLE_CATEGORY_COUNT, MAX_ARTICLE_CATEGORY_SIZE, MAX_ARTICLE_GUID_SIZE, MAX_ARTICLE_LINK_SIZE, MAX_ARTICLE_TITLE_SIZE, MAX_CONTENT_SIZE, MAX_DESCRIPTION_SIZE, MAX_FEED_CATEGORY_SIZE, MAX_FEED_DESCRIPTION_SIZE, MAX_FEED_TITLE_SIZE, } from './constants'; /** * Bound a string by UTF-16 code units without leaving a dangling high surrogate. * This preserves the existing JavaScript string-length semantics while keeping * complete astral characters at the storage seam. */ export function truncateUtf16(value: string | undefined, maxLength: number): string | undefined { if (value === undefined || value.length <= maxLength) return value; let end = maxLength; const finalCodeUnit = value.charCodeAt(end - 1); if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) { end--; } return value.slice(0, end); } export interface BoundedArticleText { guid?: string; title?: string; link?: string; description?: string; content?: string; author?: string; categories?: string[]; } /** * Bound publisher-supplied categories by field, count, and aggregate text. * Categories are display metadata, so deterministic prefix truncation is safe. */ export function boundArticleCategories(categories: string[] | undefined): string[] | undefined { if (categories === undefined) return undefined; const bounded: string[] = []; let remaining = MAX_ARTICLE_CATEGORIES_SIZE; for (const category of categories) { if (bounded.length >= MAX_ARTICLE_CATEGORY_COUNT || remaining <= 0) break; const fieldLimit = Math.min(MAX_ARTICLE_CATEGORY_SIZE, remaining); const value = truncateUtf16(category, fieldLimit) ?? ''; if (value.length === 0 && category.length > 0) break; bounded.push(value); remaining -= value.length; if (value.length < category.length) break; } return bounded; } /** Apply hard storage bounds to every publisher-supplied Article text field. */ export function boundArticleText(article: T): T { return { ...article, // Identity values must not be prefix-truncated because that can create // false deduplication collisions between distinct publisher resources. guid: article.guid !== undefined && article.guid.length <= MAX_ARTICLE_GUID_SIZE ? article.guid : undefined, title: truncateUtf16(article.title, MAX_ARTICLE_TITLE_SIZE), link: article.link !== undefined && article.link.length <= MAX_ARTICLE_LINK_SIZE ? article.link : undefined, description: truncateUtf16(article.description, MAX_DESCRIPTION_SIZE), content: truncateUtf16(article.content, MAX_CONTENT_SIZE), author: truncateUtf16(article.author, MAX_ARTICLE_AUTHOR_SIZE), categories: boundArticleCategories(article.categories), }; } export interface BoundedFeedText { title?: string; description?: string; category?: string | null; } /** Apply hard storage bounds to publisher-supplied Feed display metadata. */ export function boundFeedText(feed: T): T { return { ...feed, title: truncateUtf16(feed.title, MAX_FEED_TITLE_SIZE), description: truncateUtf16(feed.description, MAX_FEED_DESCRIPTION_SIZE), category: feed.category === null ? null : truncateUtf16(feed.category, MAX_FEED_CATEGORY_SIZE), }; }