← 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.
src/mention-safe.ts
202 lines · 7.2 KB · TypeScript/**
* 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
}