File size: 4,957 Bytes
dbb1bf9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 | import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
export interface BriefSource {
title: string;
source: string;
url: string;
publishedAt?: string;
}
export interface BriefSourceCandidate {
title?: unknown;
primaryTitle?: unknown;
source?: unknown;
primarySource?: unknown;
url?: unknown;
link?: unknown;
primaryLink?: unknown;
publishedAt?: unknown;
pubDate?: unknown;
}
const DEFAULT_MAX_SOURCES = 6;
const MAX_TITLE_LEN = 160;
const MAX_SOURCE_LEN = 80;
const BRIEF_SOURCES_METHODOLOGY_HREF = '/docs/methodology/news-digest-and-briefing';
function clipText(value: unknown, maxLen: number): string {
const text = typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
return text.length > maxLen ? `${text.slice(0, maxLen - 1).trim()}...` : text;
}
export function normalizeBriefSourceUrl(value: unknown): string {
if (typeof value !== 'string') return '';
const trimmed = value.trim();
if (!trimmed) return '';
try {
const parsed = new URL(trimmed);
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.toString() : '';
} catch {
return '';
}
}
function normalizePublishedAt(value: unknown): string | undefined {
if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.toISOString() : undefined;
if (typeof value === 'number' && Number.isFinite(value)) return new Date(value).toISOString();
if (typeof value === 'string' && value.trim()) {
const ms = new Date(value).getTime();
return Number.isFinite(ms) ? new Date(ms).toISOString() : undefined;
}
return undefined;
}
export function normalizeBriefSource(candidate: BriefSourceCandidate): BriefSource | null {
const url = normalizeBriefSourceUrl(candidate.url ?? candidate.link ?? candidate.primaryLink);
if (!url) return null;
const title = clipText(candidate.title ?? candidate.primaryTitle, MAX_TITLE_LEN);
const source = clipText(candidate.source ?? candidate.primarySource, MAX_SOURCE_LEN);
if (!title || !source) return null;
const publishedAt = normalizePublishedAt(candidate.publishedAt ?? candidate.pubDate);
return publishedAt ? { title, source, url, publishedAt } : { title, source, url };
}
export function collectBriefSources(
candidates: BriefSourceCandidate[],
maxSources = DEFAULT_MAX_SOURCES,
): BriefSource[] {
const out: BriefSource[] = [];
const seen = new Set<string>();
for (const candidate of candidates) {
const source = normalizeBriefSource(candidate);
if (!source || seen.has(source.url)) continue;
out.push(source);
seen.add(source.url);
if (out.length >= maxSources) break;
}
return out;
}
export function normalizeCachedBriefSources(
cacheData: { sources?: BriefSourceCandidate[] } | undefined,
maxSources = DEFAULT_MAX_SOURCES,
): { sources: BriefSource[]; legacySourceShape: boolean } {
const legacySourceShape = !cacheData || !Object.prototype.hasOwnProperty.call(cacheData, 'sources');
return {
sources: collectBriefSources(cacheData?.sources ?? [], maxSources),
legacySourceShape,
};
}
export function buildBriefSourceContextLines(sources: BriefSource[]): string[] {
return sources.map((source, index) => {
const payload = source.publishedAt
? { title: source.title, source: source.source, url: source.url, publishedAt: source.publishedAt }
: { title: source.title, source: source.source, url: source.url };
return `Source [${index + 1}]: ${JSON.stringify(payload)}`;
});
}
export function renderBriefSourcesFooter(
sources: BriefSource[] | undefined,
options: { className?: string; methodologyHref?: string; maxSources?: number } = {},
): string {
const normalized = collectBriefSources(sources ?? [], options.maxSources ?? DEFAULT_MAX_SOURCES);
if (normalized.length === 0) return '';
const className = options.className ?? 'brief-sources';
const methodologyHref = sanitizeUrl(options.methodologyHref ?? BRIEF_SOURCES_METHODOLOGY_HREF);
const sourceWord = normalized.length === 1 ? 'source' : 'sources';
const items = normalized.map((source) => {
const href = sanitizeUrl(source.url);
const when = source.publishedAt ? ` <span class="brief-source-date">${escapeHtml(source.publishedAt.slice(0, 10))}</span>` : '';
return `
<li>
<a href="${href}" target="_blank" rel="noopener noreferrer">${escapeHtml(source.title)}</a>
<span class="brief-source-meta">${escapeHtml(source.source)}${when}</span>
</li>`;
}).join('');
const methodology = methodologyHref
? ` · <a href="${methodologyHref}" target="_blank" rel="noopener noreferrer">Methodology</a>`
: '';
return `
<details class="${escapeHtml(className)}">
<summary>Sources (${normalized.length})</summary>
<div class="brief-sources-note">AI-synthesized from ${normalized.length} ${sourceWord} · may contain errors${methodology}</div>
<ol>${items}</ol>
</details>`;
}
|