mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(editor): share fence boundaries for Markdown links
This commit is contained in:
@@ -1,6 +1,21 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { marked } from 'marked'
|
||||
import { normalizeMarkdownReferenceLinks } from './markdown-reference-link-normalization'
|
||||
|
||||
// Offsets marked itself treats as fenced code, so the sweep below compares the
|
||||
// normalizer against the parser that decides what the file really means.
|
||||
function markedCodeRanges(markdown: string): [number, number][] {
|
||||
const ranges: [number, number][] = []
|
||||
let offset = 0
|
||||
for (const token of marked.lexer(markdown.replace(/\r\n|\r/g, '\n'))) {
|
||||
if (token.type === 'code') {
|
||||
ranges.push([offset, offset + token.raw.length])
|
||||
}
|
||||
offset += token.raw.length
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
@@ -26,6 +41,25 @@ describe('normalizeMarkdownReferenceLinks', () => {
|
||||
expect(normalizeMarkdownReferenceLinks(markdown)).toBe(markdown)
|
||||
})
|
||||
|
||||
it('never removes a definition marked keeps inside a fenced code block', () => {
|
||||
const definition = '[docs]: https://example.com/docs'
|
||||
const survivors: string[] = []
|
||||
for (const outer of ['```', '````', '~~~', '~~~~']) {
|
||||
// Fence-shaped lines that do and do not close `outer`.
|
||||
for (const inner of ['```', '````', '~~~', '````js', '``` ', '```` trailing', '~~~a`b']) {
|
||||
const markdown = `${outer}\ncode\n${inner}\n${definition}\n[Docs]\n${outer}\n`
|
||||
const offset = markdown.indexOf(definition)
|
||||
const insideCode = markedCodeRanges(markdown).some(
|
||||
([start, end]) => offset >= start && offset < end
|
||||
)
|
||||
if (insideCode && !normalizeMarkdownReferenceLinks(markdown).includes(definition)) {
|
||||
survivors.push(markdown)
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(survivors).toEqual([])
|
||||
})
|
||||
|
||||
it('scans newline-heavy documents without splitting into line arrays', () => {
|
||||
const split = vi.spyOn(String.prototype, 'split')
|
||||
const body = Array.from({ length: 5000 }, (_, index) => `line ${index + 1}`).join('\n')
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
createMarkdownFenceRangeCursor,
|
||||
createMarkdownFenceTracker,
|
||||
getMarkdownFenceRanges
|
||||
} from './markdown-fence-scanner'
|
||||
|
||||
type ReferenceLinkDefinition = {
|
||||
label: string
|
||||
title: string | null
|
||||
@@ -65,25 +71,12 @@ function splitReferenceDefinitions(content: string): {
|
||||
markdown: string
|
||||
} {
|
||||
const definitions = new Map<string, ReferenceLinkDefinition>()
|
||||
let activeFence: '`' | '~' | null = null
|
||||
let activeFenceLength = 0
|
||||
const fence = createMarkdownFenceTracker()
|
||||
let markdown = ''
|
||||
|
||||
forEachReferenceDefinitionLine(content, (line, newline) => {
|
||||
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
|
||||
const isFenceLine = fence.consume(line)
|
||||
const definition = isFenceLine || fence.insideFence ? null : parseReferenceDefinition(line)
|
||||
if (definition) {
|
||||
definitions.set(definition.label, definition)
|
||||
return
|
||||
@@ -148,40 +141,11 @@ function replaceReferenceLinks(
|
||||
): string {
|
||||
let result = ''
|
||||
let index = 0
|
||||
let activeFence: '`' | '~' | null = null
|
||||
let activeFenceLength = 0
|
||||
let isLineStart = true
|
||||
const nonWhitespace = /\S/g
|
||||
const fencePrefix = /(`{3,}|~{3,})/y
|
||||
let fenceProbe = -1
|
||||
let fenceMatch: RegExpExecArray | null = null
|
||||
const isInsideFence = createMarkdownFenceRangeCursor(getMarkdownFenceRanges(markdown))
|
||||
|
||||
while (index < markdown.length) {
|
||||
if (isLineStart) {
|
||||
// Reuse the lookahead across blank lines, preserving cross-line fence semantics.
|
||||
if (index > fenceProbe) {
|
||||
nonWhitespace.lastIndex = index
|
||||
fenceProbe = nonWhitespace.exec(markdown)?.index ?? markdown.length
|
||||
fencePrefix.lastIndex = fenceProbe
|
||||
fenceMatch = fencePrefix.exec(markdown)
|
||||
}
|
||||
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'
|
||||
if (isInsideFence(index) || markdown[index] !== '[' || isEscaped(markdown, index)) {
|
||||
result += markdown[index]
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
@@ -189,7 +153,6 @@ function replaceReferenceLinks(
|
||||
const closingTextIndex = findClosingBracket(markdown, index + 1)
|
||||
if (closingTextIndex === -1) {
|
||||
result += markdown[index]
|
||||
isLineStart = false
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
@@ -198,7 +161,6 @@ function replaceReferenceLinks(
|
||||
const afterText = markdown[closingTextIndex + 1]
|
||||
if (afterText === '(') {
|
||||
result += markdown[index]
|
||||
isLineStart = false
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
@@ -211,7 +173,6 @@ function replaceReferenceLinks(
|
||||
const definition = definitions.get(label)
|
||||
if (definition) {
|
||||
result += formatInlineReferenceLink(text, definition)
|
||||
isLineStart = false
|
||||
index = closingLabelIndex + 1
|
||||
continue
|
||||
}
|
||||
@@ -220,14 +181,12 @@ function replaceReferenceLinks(
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+6
-8
@@ -1,10 +1,12 @@
|
||||
import type { IRange } from 'monaco-editor'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdownDocLinkTarget } from './markdown-doc-links'
|
||||
import { createMarkdownFenceTracker } from './markdown-fence-scanner'
|
||||
import { getMarkdownDocLinkDecorationRanges } from './monaco-markdown-doc-link-decorations'
|
||||
|
||||
// Why: the pre-offset implementation, kept verbatim as the equivalence oracle
|
||||
// for the allocation-free scan that replaced it.
|
||||
// Why: the pre-offset implementation, kept as the equivalence oracle for the
|
||||
// allocation-free scan that replaced it. Fence detection is the shared
|
||||
// scanner's contract, not this file's, so the oracle calls into it.
|
||||
function referenceDecorationRanges(content: string): IRange[] {
|
||||
const getInlineCodeSpans = (line: string): { start: number; end: number }[] => {
|
||||
const spans: { start: number; end: number }[] = []
|
||||
@@ -26,7 +28,7 @@ function referenceDecorationRanges(content: string): IRange[] {
|
||||
spans.some((span) => index >= span.start && index < span.end)
|
||||
|
||||
const ranges: IRange[] = []
|
||||
let insideFence = false
|
||||
const fence = createMarkdownFenceTracker()
|
||||
let lineStart = 0
|
||||
let lineNumber = 1
|
||||
for (let index = 0; index <= content.length; index += 1) {
|
||||
@@ -39,11 +41,7 @@ function referenceDecorationRanges(content: string): IRange[] {
|
||||
const currentLineNumber = lineNumber
|
||||
lineNumber += 1
|
||||
|
||||
if (/^\s*(```|~~~)/.test(line)) {
|
||||
insideFence = !insideFence
|
||||
continue
|
||||
}
|
||||
if (insideFence) {
|
||||
if (fence.consume(line) || fence.insideFence) {
|
||||
continue
|
||||
}
|
||||
const inlineCodeSpans = getInlineCodeSpans(line)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { editor, IDisposable, IRange } from 'monaco-editor'
|
||||
import { getMarkdownDocLinkTarget } from './markdown-doc-links'
|
||||
import { createMarkdownFenceRangeCursor, getMarkdownFenceRanges } from './markdown-fence-scanner'
|
||||
import { forEachLine } from './text-line-offsets'
|
||||
|
||||
const BACKTICK = 96
|
||||
@@ -41,18 +42,10 @@ function isInsideSpan(index: number, spans: number[]): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
const FENCE_PREFIX_RE = /[^\S\n]*(?:```|~~~)/y
|
||||
|
||||
function startsCodeFence(content: string, lineStart: number, lineEnd: number): boolean {
|
||||
FENCE_PREFIX_RE.lastIndex = lineStart
|
||||
// Bound whitespace to this line so blank runs cannot trigger repeated suffix scans.
|
||||
return FENCE_PREFIX_RE.test(content) && FENCE_PREFIX_RE.lastIndex <= lineEnd
|
||||
}
|
||||
|
||||
export function getMarkdownDocLinkDecorationRanges(content: string): IRange[] {
|
||||
const ranges: IRange[] = []
|
||||
const inlineCodeSpans: number[] = []
|
||||
let insideFence = false
|
||||
const isInsideFence = createMarkdownFenceRangeCursor(getMarkdownFenceRanges(content))
|
||||
// Why: `indexOf` on the whole document would rescan the tail once per line.
|
||||
// Both cursors only ever move forward, and every probe position is
|
||||
// monotonic, so the delimiter search stays linear in document length.
|
||||
@@ -60,11 +53,7 @@ export function getMarkdownDocLinkDecorationRanges(content: string): IRange[] {
|
||||
let nextClose = content.indexOf(']]')
|
||||
|
||||
forEachLine(content, (lineStart, lineEnd, lineNumber) => {
|
||||
if (startsCodeFence(content, lineStart, lineEnd)) {
|
||||
insideFence = !insideFence
|
||||
return
|
||||
}
|
||||
if (insideFence) {
|
||||
if (isInsideFence(lineStart)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
cleanupMarkdownFixture,
|
||||
createMarkdownFixture,
|
||||
getActiveWorktreeContext,
|
||||
openMarkdownFixture
|
||||
} from './helpers/markdown-editor-fixture'
|
||||
|
||||
test('source links stay outside mixed code fences', async ({ orcaPage }, testInfo) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
const context = await getActiveWorktreeContext(orcaPage)
|
||||
const file = await createMarkdownFixture(
|
||||
context,
|
||||
'markdown-fences',
|
||||
'links',
|
||||
testInfo.workerIndex,
|
||||
'# Fence boundaries\n\n~~~text\n```\n[[inside-code]]\n~~~\n\n[[outside-code]]\n'
|
||||
)
|
||||
try {
|
||||
await openMarkdownFixture(orcaPage, context, file)
|
||||
await orcaPage.evaluate(() => {
|
||||
const state = window.__store!.getState()
|
||||
if (!state.activeFileId) {
|
||||
throw new Error('missing active file')
|
||||
}
|
||||
state.setMarkdownViewMode(state.activeFileId, 'source')
|
||||
})
|
||||
const links = orcaPage.locator('.monaco-editor .view-lines .monaco-markdown-doc-link')
|
||||
await expect(links).toHaveCount(1)
|
||||
await expect(links).toHaveText('[[outside-code]]')
|
||||
await testInfo.attach('fence-links', {
|
||||
body: await orcaPage.screenshot({ path: testInfo.outputPath('fence-links.png') }),
|
||||
contentType: 'image/png'
|
||||
})
|
||||
} finally {
|
||||
await cleanupMarkdownFixture(file)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user