Fix Linear issue reference links in descriptions (#4955)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-09 00:59:29 -04:00 committed by GitHub
parent 351e4508de
commit 400f3d7eaa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 229 additions and 21 deletions

View File

@ -0,0 +1,188 @@
type ReferenceLinkDefinition = {
label: string
title: string | null
url: string
}
const REFERENCE_DEFINITION_PATTERN =
/^ {0,3}\[([^\]]+)\]:[ \t]*(<[^>\n]+>|[^\s]+)(?:[ \t]+(?:"([^"]*)"|'([^']*)'|\(([^)]*)\)))?[ \t]*$/
function normalizeReferenceLabel(label: string): string {
return label.trim().replace(/\s+/g, ' ').toLowerCase()
}
function unwrapReferenceUrl(rawUrl: string): string {
return rawUrl.startsWith('<') && rawUrl.endsWith('>') ? rawUrl.slice(1, -1) : rawUrl
}
function parseReferenceDefinition(line: string): ReferenceLinkDefinition | null {
const match = line.match(REFERENCE_DEFINITION_PATTERN)
if (!match) {
return null
}
return {
label: normalizeReferenceLabel(match[1]),
url: unwrapReferenceUrl(match[2]),
title: match[3] ?? match[4] ?? match[5] ?? null
}
}
function splitReferenceDefinitions(content: string): {
definitions: Map<string, ReferenceLinkDefinition>
markdown: string
} {
const definitions = new Map<string, ReferenceLinkDefinition>()
const lines = content.split(/(\n)/)
let activeFence: '`' | '~' | null = null
let activeFenceLength = 0
let markdown = ''
for (let index = 0; index < lines.length; index += 2) {
const line = lines[index] ?? ''
const newline = lines[index + 1] ?? ''
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/)
if (fenceMatch) {
const fenceChar = fenceMatch[1][0] as '`' | '~'
const fenceLength = fenceMatch[1].length
if (activeFence === null) {
activeFence = fenceChar
activeFenceLength = fenceLength
} else if (activeFence === fenceChar && fenceLength >= activeFenceLength) {
activeFence = null
activeFenceLength = 0
}
}
const definition = activeFence === null ? parseReferenceDefinition(line) : null
if (definition) {
definitions.set(definition.label, definition)
continue
}
markdown += line + newline
}
return { definitions, markdown }
}
function isEscaped(content: string, index: number): boolean {
let backslashCount = 0
for (let cursor = index - 1; cursor >= 0 && content[cursor] === '\\'; cursor -= 1) {
backslashCount += 1
}
return backslashCount % 2 === 1
}
function findClosingBracket(content: string, start: number): number {
for (let index = start; index < content.length; index += 1) {
if (content[index] === ']' && !isEscaped(content, index)) {
return index
}
}
return -1
}
function formatInlineReferenceLink(text: string, definition: ReferenceLinkDefinition): string {
const escapedUrl = definition.url.replace(/[()\\]/g, '\\$&')
if (!definition.title) {
return `[${text}](${escapedUrl})`
}
const escapedTitle = definition.title.replace(/["\\]/g, '\\$&')
return `[${text}](${escapedUrl} "${escapedTitle}")`
}
function replaceReferenceLinks(
markdown: string,
definitions: Map<string, ReferenceLinkDefinition>
): string {
let result = ''
let index = 0
let activeFence: '`' | '~' | null = null
let activeFenceLength = 0
let isLineStart = true
while (index < markdown.length) {
const lineRest = markdown.slice(index)
if (isLineStart) {
const fenceMatch = lineRest.match(/^\s*(`{3,}|~{3,})/)
if (fenceMatch) {
const fenceChar = fenceMatch[1][0] as '`' | '~'
const fenceLength = fenceMatch[1].length
if (activeFence === null) {
activeFence = fenceChar
activeFenceLength = fenceLength
} else if (activeFence === fenceChar && fenceLength >= activeFenceLength) {
activeFence = null
activeFenceLength = 0
}
}
}
if (activeFence || markdown[index] !== '[' || isEscaped(markdown, index)) {
const nextChar = markdown[index]
result += nextChar
isLineStart = nextChar === '\n'
index += 1
continue
}
const closingTextIndex = findClosingBracket(markdown, index + 1)
if (closingTextIndex === -1) {
result += markdown[index]
isLineStart = false
index += 1
continue
}
const text = markdown.slice(index + 1, closingTextIndex)
const afterText = markdown[closingTextIndex + 1]
if (afterText === '(') {
result += markdown[index]
isLineStart = false
index += 1
continue
}
if (afterText === '[') {
const closingLabelIndex = findClosingBracket(markdown, closingTextIndex + 2)
if (closingLabelIndex !== -1) {
const rawLabel = markdown.slice(closingTextIndex + 2, closingLabelIndex)
const label = normalizeReferenceLabel(rawLabel || text)
const definition = definitions.get(label)
if (definition) {
result += formatInlineReferenceLink(text, definition)
isLineStart = false
index = closingLabelIndex + 1
continue
}
}
} else {
const definition = definitions.get(normalizeReferenceLabel(text))
if (definition) {
result += formatInlineReferenceLink(text, definition)
isLineStart = false
index = closingTextIndex + 1
continue
}
}
result += markdown[index]
isLineStart = false
index += 1
}
return result
}
export function normalizeMarkdownReferenceLinks(content: string): string {
const { definitions, markdown } = splitReferenceDefinitions(content)
if (definitions.size === 0) {
return content
}
// Why: Tiptap's Markdown parser drops reference definitions but leaves
// shortcut references as plain text. Inline them before parsing so Linear
// issue mentions keep their links in the rich description editor.
return replaceReferenceLinks(markdown, definitions)
}

View File

@ -152,6 +152,24 @@ describe('rich markdown round trip', () => {
expect(roundTripMarkdown('| a | b |\n| - | - |\n| 1 | 2 |\n')).toContain('| a')
})
it('does not surface Linear issue reference definitions as description text', () => {
const input = [
'- [x] [H-279]',
'- [ ] [H-284]',
'',
'[H-279]: https://linear.app/acme/issue/H-279/child-one "Child one"',
'[H-284]: https://linear.app/acme/issue/H-284/child-two "Child two"',
''
].join('\n')
expect(roundTripMarkdown(input)).toBe(
[
'- [x] [H-279](https://linear.app/acme/issue/H-279/child-one "Child one")',
'- [ ] [H-284](https://linear.app/acme/issue/H-284/child-two "Child two")'
].join('\n')
)
})
it('preserves doc links', () => {
expect(roundTripMarkdown('See [[setup-guide]] for details\n')).toBe(
'See [[setup-guide]] for details'

View File

@ -1,6 +1,7 @@
import { Node, mergeAttributes } from '@tiptap/core'
import { isEditableDetailsHtmlBlock, matchDetailsHtmlBlock } from './details-markdown-html'
import { formatMarkdownDocLinkBody, parseMarkdownDocLink } from './markdown-doc-links'
import { normalizeMarkdownReferenceLinks } from './markdown-reference-link-normalization'
const INLINE_PLACEHOLDER_PREFIX = '[[ORCA_RAW_HTML_INLINE:'
const BLOCK_PLACEHOLDER_PREFIX = '[[ORCA_RAW_HTML_BLOCK:'
@ -90,14 +91,15 @@ function matchBlockHtml(content: string, start: number): string | null {
}
export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
const normalizedContent = normalizeMarkdownReferenceLinks(content)
let index = 0
let isLineStart = true
let activeFence: '`' | '~' | null = null
let activeFenceLength = 0
let result = ''
while (index < content.length) {
const lineRest = content.slice(index)
while (index < normalizedContent.length) {
const lineRest = normalizedContent.slice(index)
if (isLineStart) {
const fenceMatch = lineRest.match(/^\s*(`{3,}|~{3,})/)
@ -115,16 +117,16 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
}
if (activeFence) {
const nextChar = content[index]
const nextChar = normalizedContent[index]
result += nextChar
isLineStart = nextChar === '\n'
index += 1
continue
}
if (content[index] === '`') {
if (normalizedContent[index] === '`') {
let tickCount = 0
while (content[index + tickCount] === '`') {
while (normalizedContent[index + tickCount] === '`') {
tickCount += 1
}
@ -132,15 +134,15 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
// not a longer run. We scan forward to find the first exact match.
let searchFrom = index + tickCount
let closingIndex = -1
while (searchFrom < content.length) {
const candidate = content.indexOf('`'.repeat(tickCount), searchFrom)
while (searchFrom < normalizedContent.length) {
const candidate = normalizedContent.indexOf('`'.repeat(tickCount), searchFrom)
if (candidate === -1) {
break
}
// Verify the match is exactly tickCount backticks (no extra backtick before/after)
if (
(candidate === 0 || content[candidate - 1] !== '`') &&
content[candidate + tickCount] !== '`'
(candidate === 0 || normalizedContent[candidate - 1] !== '`') &&
normalizedContent[candidate + tickCount] !== '`'
) {
closingIndex = candidate
break
@ -149,7 +151,7 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
}
if (closingIndex !== -1) {
const rawSpan = content.slice(index, closingIndex + tickCount)
const rawSpan = normalizedContent.slice(index, closingIndex + tickCount)
result += rawSpan
isLineStart = rawSpan.endsWith('\n')
index = closingIndex + tickCount
@ -158,7 +160,7 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
}
if (isLineStart) {
const detailsHtml = matchDetailsHtmlBlock(content, index)
const detailsHtml = matchDetailsHtmlBlock(normalizedContent, index)
if (detailsHtml && isEditableDetailsHtmlBlock(detailsHtml)) {
// Why: <details>/<summary> is an editable rich-mode node; raw passthrough
// would make toggle blocks reopen as inert HTML instead.
@ -173,7 +175,7 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
continue
}
const blockHtml = matchBlockHtml(content, index)
const blockHtml = matchBlockHtml(normalizedContent, index)
if (blockHtml) {
result += createPlaceholder('block', blockHtml)
index += blockHtml.length
@ -181,8 +183,8 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
}
}
if (content[index] === '<' && !isEscaped(content, index)) {
const inlineHtml = matchInlineHtml(content.slice(index))
if (normalizedContent[index] === '<' && !isEscaped(normalizedContent, index)) {
const inlineHtml = matchInlineHtml(normalizedContent.slice(index))
if (inlineHtml) {
result += createPlaceholder('inline', inlineHtml)
index += inlineHtml.length
@ -195,14 +197,14 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
// skipped by the guards above. The [[ORCA_ prefix check prevents re-encoding
// sibling placeholders that were already emitted earlier in this pass.
if (
content[index] === '[' &&
content[index + 1] === '[' &&
!content.startsWith('[[ORCA_', index) &&
!isEscaped(content, index)
normalizedContent[index] === '[' &&
normalizedContent[index + 1] === '[' &&
!normalizedContent.startsWith('[[ORCA_', index) &&
!isEscaped(normalizedContent, index)
) {
const closingIndex = content.indexOf(']]', index + 2)
const closingIndex = normalizedContent.indexOf(']]', index + 2)
if (closingIndex !== -1) {
const rawTarget = content.slice(index + 2, closingIndex)
const rawTarget = normalizedContent.slice(index + 2, closingIndex)
const link = parseMarkdownDocLink(rawTarget)
if (link) {
result += `${DOC_LINK_PLACEHOLDER_PREFIX}${formatMarkdownDocLinkBody(
@ -215,7 +217,7 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
}
}
const nextChar = content[index]
const nextChar = normalizedContent[index]
result += nextChar
isLineStart = nextChar === '\n'
index += 1