Skip to content
← Public packages

@kentcdodds/friction-log

Qualify and file friction papercuts to GitHub (any owner/repo) or wake Patch for Kody packages; daily sweep of kody-hq/kody friction issues.

FollowFork

src/mention-safe.ts

202 lines · 7.2 KB · TypeScript
Raw
/**
 * 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)(?<!^[ \\t]*(?:[-*+>]|\\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 | null | undefined>): 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<string | null | undefined>
	/** 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
}