Fix Linear issue reference links in descriptions (#4955)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
351e4508de
commit
400f3d7eaa
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue