/** * Slack delivers mrkdwn, not plain text. Link syntax and HTML entities that * survive into a note degrade embedding quality and read as noise on a sticky, * so they are unwrapped here. Pure and dependency-free so both the normalizer * and the API client can use it. */ const ENTITIES: Record = { '&': '&', '<': '<', '>': '>', }; const unescapeEntities = (text: string): string => text.replace(/&(?:amp|lt|gt);/g, (match) => ENTITIES[match] ?? match); const splitOnce = (value: string, separator: string): [string, string | undefined] => { const index = value.indexOf(separator); if (index === -1) return [value, undefined]; return [value.slice(0, index), value.slice(index + separator.length)]; }; const BROADCASTS: readonly string[] = ['here', 'channel', 'everyone']; const MARKUP = /<([^<>]*)>/g; const unwrap = (inner: string): string => { const [target, rawLabel] = splitOnce(inner, '|'); const label = rawLabel !== undefined && rawLabel.length > 0 ? rawLabel : undefined; if (target.startsWith('@')) return `@${label ?? target.slice(1)}`; if (target.startsWith('#')) return `#${label ?? target.slice(1)}`; if (target.startsWith('!')) { const special = target.slice(1); if (BROADCASTS.includes(special)) return `@${special}`; return label ?? `@${special}`; } if (target.startsWith('mailto:')) return label ?? target.slice('mailto:'.length); return label ?? target; }; /** * Entities are unescaped only after markup is unwrapped. Slack escapes a * literal `<` as `<` precisely so it is not read as markup, and reversing * that order would turn user text into parsed link syntax. */ export const cleanSlackText = (input: string): string => unescapeEntities(input.replace(MARKUP, (_match, inner: string) => unwrap(inner))) .replace(/[ \t]+$/gm, '') .trim();