/** * Deterministic mention-safety pass for text this package posts to GitHub. * * `@kentcdodds/github` refuses posts that @-mention anyone outside code. This * pass runs first so generated text does not depend on that guard alone: * scoped package names (`@scope/name`) and given phrases (such as test names) * go in inline code, and credit phrases like "by @someone" are dropped. Text * that is already in inline code, fenced code blocks, or indented code blocks * stays as it is. Bare handles that remain still reach the guard, which * refuses them unless the caller allows them. */ const HANDLE = '@[A-Za-z0-9](?:-?[A-Za-z0-9]){0,38}(?![A-Za-z0-9])' const PACKAGE_NAME_PATTERN = /(^|[^A-Za-z0-9_`:@])((?:[a-z][a-z0-9+.-]*:)?@[A-Za-z0-9][\w.-]*(?:\/[\w.-]*[A-Za-z0-9])+)/g const CREDIT_WORDS = '(?:by|thanks(?:\\s+to)?|thank\\s+you|props(?:\\s+to)?|h\\/t|hat\\s+tip(?:\\s+to)?|cc|credit(?:\\s+to)?|reported\\s+by|via)' const HANDLE_LIST = `${HANDLE}(?:\\s*(?:,|and|&)\\s*${HANDLE})*` const PAREN_CREDIT_PATTERN = new RegExp(`[ \\t]*\\(\\s*${CREDIT_WORDS}\\s+${HANDLE_LIST}\\s*\\)`, 'gi') // Trailing credits only ("Faster builds by @someone", "Ship it, cc @a"). A // credit that opens a line or list item stays, so list structure survives. const BARE_CREDIT_PATTERN = new RegExp( `(?<=\\S)(?]|\\d{1,9}[.)]))(?:[ \\t]*[,;]|[ \\t]+-)?[ \\t]+\\b${CREDIT_WORDS}\\s+${HANDLE_LIST}`, 'gim', ) /** Wrap text in an inline code span that survives backticks inside it. */ export function inlineCode(text: string): string { const value = String(text ?? '') const longest = Math.max(0, ...Array.from(value.matchAll(/`+/g), (m) => m[0].length)) const fence = '`'.repeat(longest + 1) const pad = value.startsWith('`') || value.endsWith('`') ? ' ' : '' return `${fence}${pad}${value}${pad}${fence}` } /** Apply `transform` to prose only, leaving code blocks and code spans alone. */ export function mapProse(markdown: string, transform: (prose: string) => string): string { const lines = String(markdown ?? '').split('\n') const out: string[] = [] let prose: string[] = [] let fence: { char: string; length: number } | null = null let previousBlank = true let previousIndentedCode = false let inList = false const flush = () => { if (prose.length > 0) out.push(mapInline(prose.join('\n'), transform)) prose = [] } for (const line of lines) { if (fence) { const close = line.match(/^ {0,3}(`{3,}|~{3,})\s*$/) if (close && close[1][0] === fence.char && close[1].length >= fence.length) fence = null out.push(line) previousBlank = false previousIndentedCode = false continue } const open = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/) if (open && !(open[1][0] === '`' && open[2].includes('`'))) { flush() fence = { char: open[1][0], length: open[1].length } out.push(line) previousBlank = false previousIndentedCode = false continue } const blank = line.trim().length === 0 const indented = /^(?: {4}|\t| {1,3}\t)/.test(line) if (!blank && indented && !inList && (previousBlank || previousIndentedCode)) { flush() out.push(line) previousBlank = false previousIndentedCode = true continue } if (!blank) { if (/^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:\s|$)/.test(line)) inList = true else if (!indented && previousBlank) inList = false } if (!(blank && previousIndentedCode)) previousIndentedCode = false if (previousIndentedCode && blank) { out.push(line) } else { prose.push(line) } previousBlank = blank } flush() return out.join('\n') } function mapInline(text: string, transform: (prose: string) => string): string { // Code spans become opaque placeholders so the transform sees one string // (word boundaries across a span still work) but never edits inside a span. const spans: string[] = [] let masked = '' let index = 0 while (index < text.length) { const char = text[index] if (char === '\\' && text[index + 1] === '`') { masked += '\\`' index += 2 continue } if (char !== '`') { masked += char index += 1 continue } let runEnd = index while (text[runEnd] === '`') runEnd += 1 const length = runEnd - index const close = findClosingRun(text, runEnd, length) if (close === -1) { masked += text.slice(index, runEnd) index = runEnd continue } masked += `\u0000${spans.length}\u0000` spans.push(text.slice(index, close + length)) index = close + length } return transform(masked).replace(/\u0000(\d+)\u0000/g, (_match, n: string) => spans[Number(n)] ?? '') } function findClosingRun(text: string, from: number, length: number): number { let index = from while (index < text.length) { if (text[index] !== '`') { if (text[index] === '\n' && /^\n[ \t]*(\n|$)/.test(text.slice(index))) return -1 index += 1 continue } let runEnd = index while (text[runEnd] === '`') runEnd += 1 if (runEnd - index === length) return index index = runEnd } return -1 } /** Put scoped package names (and `kody:@scope/name` specifiers) in inline code. */ export function backtickPackageNames(markdown: string): string { return mapProse(markdown, (prose) => prose.replace(PACKAGE_NAME_PATTERN, (_match, before: string, name: string) => before + inlineCode(name)), ) } /** Put every prose occurrence of each phrase (for example a test name) in inline code. */ export function backtickPhrases(markdown: string, phrases: Array): string { let result = String(markdown ?? '') const unique = Array.from(new Set(phrases.map((p) => String(p ?? '').trim()).filter(Boolean))) unique.sort((a, b) => b.length - a.length) for (const phrase of unique) { result = mapProse(result, (prose) => prose.split(phrase).join(inlineCode(phrase))) } return result } /** Drop credit phrases that mention handles ("by @someone", "(thanks @a and @b)"). */ export function dropMentionCredits(markdown: string): string { return mapProse(markdown, (prose) => prose.replace(PAREN_CREDIT_PATTERN, '').replace(BARE_CREDIT_PATTERN, ''), ) } const BARE_HANDLE_PATTERN = new RegExp( `(^|[^A-Za-z0-9_\`])(${HANDLE}(?:\\/[A-Za-z0-9](?:[A-Za-z0-9_-]*[A-Za-z0-9])?)?)`, 'g', ) /** * Put every remaining bare `@handle` or `@org/team` in inline code. Only for * text that never intends to notify anyone (for example release notes). */ export function backtickHandles(markdown: string): string { return mapProse(markdown, (prose) => prose.replace(BARE_HANDLE_PATTERN, (_match, before: string, handle: string) => before + inlineCode(handle)), ) } export type MentionSafeOptions = { /** Exact phrases (test names, titles) to put in inline code. */ phrases?: Array /** Drop "by @someone" style credits. Defaults to true. */ dropCredits?: boolean /** Also put remaining bare handles in inline code. Defaults to false. */ backtickHandles?: boolean } /** Run the full pass: phrases, credits, package names, then (opt-in) bare handles. */ export function makeMentionSafe(markdown: string, options: MentionSafeOptions = {}): string { let result = String(markdown ?? '') if (options.phrases?.length) result = backtickPhrases(result, options.phrases) if (options.dropCredits !== false) result = dropMentionCredits(result) result = backtickPackageNames(result) return options.backtickHandles ? backtickHandles(result) : result }