Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion mobile/src/components/MobileMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
isFilePathCodeSpan,
normalizeFilePath
} from './markdown-file-path-detection'
import { trimFileLinkNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary'
import { routeMarkdownHref } from './markdown-href-routing'
import {
isIntrawordUnderscoreToken,
Expand Down Expand Up @@ -132,7 +133,7 @@ function renderInline(text: string, onOpenFile?: (pathText: string) => void): Re
<Text
key={key}
style={[styles.inlineCode, styles.inlineCodeLink]}
onPress={() => onOpenFile(normalizeFilePath(code.trim()))}
onPress={() => onOpenFile(normalizeFilePath(trimFileLinkNonAsciiProse(code.trim())))}
>
{code}
</Text>
Expand Down
28 changes: 28 additions & 0 deletions mobile/src/components/markdown-file-path-detection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,28 @@ describe('detectFilePathSegments', () => {
{ type: 'file', value: 'src/app/Main.tsx', path: 'src/app/Main.tsx' }
])
})

it('detects CJK directory segments and trims trailing particles', () => {
expect(detectFilePathSegments('開く /Users/me/docs/日本語フォルダ/ファイル.mdへ 完了')).toEqual(
[
{ type: 'text', value: '開く ' },
{
type: 'file',
value: '/Users/me/docs/日本語フォルダ/ファイル.md',
path: '/Users/me/docs/日本語フォルダ/ファイル.md'
},
{ type: 'text', value: 'へ 完了' }
]
)
})

it('detects ASCII separator paths with glued CJK particles in prose', () => {
expect(detectFilePathSegments('保存先は plans/foo.mdへ 確定')).toEqual([
{ type: 'text', value: '保存先は ' },
{ type: 'file', value: 'plans/foo.md', path: 'plans/foo.md' },
{ type: 'text', value: 'へ 確定' }
])
})
})

describe('isFilePathCodeSpan', () => {
Expand Down Expand Up @@ -256,6 +278,12 @@ describe('isFilePathCodeSpan', () => {
expect(isFilePathCodeSpan('git@github.com:user/repo.git')).toBe(false)
expect(isFilePathCodeSpan('user@host.com/path/file.txt')).toBe(false)
})

it('accepts code spans with CJK particles glued after the extension', () => {
expect(isFilePathCodeSpan('src/foo.tsへ')).toBe(true)
expect(isFilePathCodeSpan('plans/foo.mdに')).toBe(true)
expect(isFilePathCodeSpan('package.jsonです')).toBe(true)
})
})

describe('splitFilePathLineSuffix', () => {
Expand Down
18 changes: 11 additions & 7 deletions mobile/src/components/markdown-file-path-detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// annoyance, but a false positive on prose or a version number is a broken tap.

import { parseFileLinkLocation } from '../../../src/shared/file-link-location'
import { trimFileLinkNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary'

export type FilePathSegment =
| { type: 'text'; value: string }
Expand Down Expand Up @@ -88,8 +89,10 @@ const EXTENSION_SET = new Set<string>(FILE_EXTENSIONS)
// connected runtime, which may be Windows even when the phone is not. Leading
// alternatives cover Windows drives, UNC, and POSIX absolute roots; the optional
// tail captures only bounded agent-style :line(:col) citations.
// Why \p{L}\p{M}\p{N}: ASCII-only segments truncated CJK directory names; the
// extension stays ASCII so particles after `.md` fall outside the match.
const CANDIDATE_PATTERN =
/(?:(?:[A-Za-z]:[\\/]|\\\\|[\\/]|\.{1,2}[\\/])(?:[\w.@~+-]+[\\/])*|(?:[\w.@~+-]+[\\/])+)[\w.@+-]+\.[A-Za-z0-9]+(?::[1-9]\d*(?::[1-9]\d*)?(?![\w@%]))?/g
/(?:(?:[A-Za-z]:[\\/]|\\\\|[\\/]|\.{1,2}[\\/])(?:[\p{L}\p{M}\p{N}_.@~+-]+[\\/])*|(?:[\p{L}\p{M}\p{N}_.@~+-]+[\\/])+)[\p{L}\p{M}\p{N}_.@+-]+\.[A-Za-z0-9]+(?::[1-9]\d*(?::[1-9]\d*)?(?![A-Za-z0-9_@%]))?/gu

// A path candidate in chat prose is short; a much longer run can't hold one worth
// linkifying but can push CANDIDATE_PATTERN into super-linear backtracking, so we
Expand Down Expand Up @@ -124,8 +127,9 @@ export function splitFilePathLineSuffix(pathText: string): {
}

function isOpenablePath(pathText: string): boolean {
// A :line(:col) tail is part of the citation, not the file name.
const { path: candidate } = splitFilePathLineSuffix(pathText)
// A :line(:col) tail is part of the citation, not the file name. Strip CJK
// particles glued after an ASCII extension before extension lookup.
const { path: candidate } = splitFilePathLineSuffix(trimFileLinkNonAsciiProse(pathText))
// Reject anything URL-ish or scheme-bearing — those are handled as web links.
if (candidate.includes('://') || hasMidTokenAt(candidate)) {
return false
Expand Down Expand Up @@ -178,12 +182,12 @@ export function detectFilePathSegments(text: string): FilePathSegment[] {
CANDIDATE_PATTERN.lastIndex = 0

while ((match = CANDIDATE_PATTERN.exec(text))) {
const candidate = match[0]
const candidate = trimFileLinkNonAsciiProse(match[0])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trimFileLinkNonAsciiProse trims leading prose too, but match.index and lastIndex below are computed from the untrimmed match, so the reassembled text is corrupted when prose is glued on the left. For 参照docs/文書.md the candidate becomes docs/文書.md while the segment is still emitted at match.index, dropping the leading prose and duplicating the tail. Use the trailing-only trim here, or shift the indices by the trimmed prefix length.

Technical details
# Markdown left-glued prose misalignment

## Affected sites
- mobile/src/components/markdown-file-path-detection.ts:185 — `trimFileLinkNonAsciiProse(match[0])` strips a leading prefix, but `prev` (line 189), `text.slice(lastIndex, match.index)` (line 197), and `lastIndex = match.index + candidate.length` (line 200) still use the untrimmed `match.index`.

## Required outcome
- `detectFilePathSegments('参照docs/文書.md を見て')` must emit a text segment for `参照` plus a file segment `docs/文書.md` whose positions reconstruct the original string exactly.

## Suggested approach
- Compute the leading-trimmed text separately and advance the segment start by the stripped prefix length (then apply the trailing trim), or call `trimFileLinkTrailingNonAsciiProse` instead of `trimFileLinkNonAsciiProse` — the candidate pattern never captures a trailing non-ASCII run, so the trailing trim is a no-op here and only the harmful leading trim fires.
- Add a `detectFilePathSegments` case for left-glued prose so the positions are pinned by test.

// Skip candidates that are part of a URL: a scheme colon, a domain tail, or
// a preceding slash (the leading slash of an absolute path is part of the
// match itself, so prev '/' means a '://' or '//' remainder, not a path).
const prev = match.index > 0 ? text[match.index - 1]! : ''
if (prev === ':' || prev === '/' || prev === '\\' || /[\w.@]/.test(prev)) {
if (prev === ':' || prev === '/' || prev === '\\' || /[A-Za-z0-9_.@]/.test(prev)) {
continue
}
if (!isOpenablePath(candidate)) {
Expand Down Expand Up @@ -212,7 +216,7 @@ export function detectFilePathSegments(text: string): FilePathSegment[] {
* bare `file.ts` here even without a slash.
*/
export function isFilePathCodeSpan(code: string): boolean {
const trimmed = code.trim()
const trimmed = trimFileLinkNonAsciiProse(code.trim())
if (!trimmed || /\s/.test(trimmed)) {
return false
}
Expand All @@ -234,7 +238,7 @@ export function isFilePathCodeSpan(code: string): boolean {
}
const name = path.slice(0, dot)
const ext = path.slice(dot + 1).toLowerCase()
if (/[^\w.@+-]/.test(name)) {
if (/[^\p{L}\p{M}\p{N}_.@+-]/u.test(name)) {
return false
}
return EXTENSION_SET.has(ext)
Expand Down
40 changes: 33 additions & 7 deletions mobile/src/terminal/terminal-path-tap-injected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@
// often print a bare filename (the markdown link target is consumed, leaving
// only the label text), so requiring a slash would miss the common case.
export const TERMINAL_PATH_TAP_JS = String.raw`
var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g;
var SPACED_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|\`\r\n]+(?::\d+)?(?::\d+)?/g;
var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[\p{L}\p{M}\p{N}._-]+[\\/]|(?=[\p{L}\p{M}\p{N}._-]*\.[A-Za-z0-9]))[\p{L}\p{M}\p{N}._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/gu;
var SPACED_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[\p{L}\p{M}\p{N}._-]+[\\/])[^()[\]{}'",;<>|\x60\r\n]+(?::\d+)?(?::\d+)?/gu;
var PATH_LEADING_TRIM = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 };
var PATH_TRAILING_TRIM = { ')': 1, ']': 1, '}': 1, '"': 1, "'": 1, ',': 1, ';': 1, '.': 1 };
var ASCII_EXT_THEN_NON_ASCII = /^(.*\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?)(\P{ASCII}[\s\S]*)$/u;
// Leading prose only when the path resumes in ASCII, so a wholly non-Latin name survives.
var LEADING_NON_ASCII_THEN_ASCII = /^(\P{ASCII}+)([A-Za-z0-9_][\s\S]*)$/u;

function parsePathLineCol(value) {
var m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value);
Expand All @@ -39,6 +42,22 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
return { text: raw.slice(start, end), startIndex: rawStart + start, endIndex: rawStart + end };
}

function trimTrailingNonAsciiProse(range) {
var match = ASCII_EXT_THEN_NON_ASCII.exec(range.text);
if (!match) return range;
return { text: match[1], startIndex: range.startIndex, endIndex: range.startIndex + match[1].length };
}

function trimLeadingNonAsciiProse(range) {
var match = LEADING_NON_ASCII_THEN_ASCII.exec(range.text);
if (!match) return range;
return { text: match[2], startIndex: range.endIndex - match[2].length, endIndex: range.endIndex };
}

function trimNonAsciiProse(range) {
return trimTrailingNonAsciiProse(trimLeadingNonAsciiProse(range));
}

function hasSeparatorAfterWhitespace(text) {
var sawWhitespace = false;
for (var i = 0; i < text.length; i++) {
Expand All @@ -53,12 +72,15 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
// A line-end extension token only extends the span when the added segment
// is path-like (contains a separator) — prose must not be swallowed.
var selected = null;
var extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g;
// Also stop before any non-ASCII char (particles and CJK brackets glued to the ext).
var extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$|\P{ASCII})/gu;
var match;
while ((match = extensionPrefixPattern.exec(range.text)) !== null) {
var end = match.index + match[0].length;
var text = range.text.slice(0, end);
if (countPathStarts(text) > 1) continue;
// A finished path plus another hop across whitespace is a second path.
if (selected !== null && COMPLETE_PATH.test(selected)) break;
if (end < range.text.length || selected === null || /[\\/]/.test(range.text.slice(selected.length, end))) {
selected = text;
}
Expand All @@ -71,6 +93,8 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
return { text: text, startIndex: range.startIndex, endIndex: range.startIndex + text.length };
}

var COMPLETE_PATH = /[\\/].*\.[A-Za-z][A-Za-z0-9_+-]*$/;

function countPathStarts(text) {
var count = 0;
var pathStartPattern = /(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g;
Expand All @@ -91,8 +115,9 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
while ((match = SPACED_PATH_RE.exec(lineText)) !== null) {
var trimmed = trimPathBoundaryPunctuation(match[0], match.index);
if (!trimmed || (!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))) continue;
var candidate = trimSpacedPathTrailingProse(trimmed, col);
if (!candidate) continue;
var proseTrimmed = trimSpacedPathTrailingProse(trimmed, col);
if (!proseTrimmed) continue;
var candidate = trimNonAsciiProse(proseTrimmed);
if (col < candidate.startIndex || col >= candidate.endIndex) continue;
var parsed = parsePathLineCol(candidate.text);
if (parsed) return parsed;
Expand All @@ -110,8 +135,9 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
if (raw.length === 0) { FILE_PATH_RE.lastIndex += 1; continue; }
var trimmed = trimPathBoundaryPunctuation(raw, match.index);
if (!trimmed) continue;
if (col < trimmed.startIndex || col >= trimmed.endIndex) continue;
var parsed = parsePathLineCol(trimmed.text);
var candidate = trimNonAsciiProse(trimmed);
if (col < candidate.startIndex || col >= candidate.endIndex) continue;
var parsed = parsePathLineCol(candidate.text);
if (parsed) return parsed;
}
return null;
Expand Down
21 changes: 21 additions & 0 deletions mobile/src/terminal/terminal-path-tap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,27 @@ describe('matchFilePathAtColumn', () => {
const line = '• Here you go: README.md'
expect(matchFilePathAtColumn(line, colOf(line, 'Here'))).toBeNull()
})

it('trims CJK particles from bare filenames under the tap', () => {
const line = 'See README.mdへ for details'
expect(matchFilePathAtColumn(line, colOf(line, 'README'))).toEqual({
pathText: 'README.md',
line: null,
column: null
})
expect(matchFilePathAtColumn(line, colOf(line, 'へ'))).toBeNull()
})

it('matches non-Latin separator paths and trims trailing particles', () => {
const path = '/Users/me/docs/日本語フォルダ/ファイル.md'
const line = `${path}へ 開きました`
expect(matchFilePathAtColumn(line, colOf(line, 'ファイル'))).toEqual({
pathText: path,
line: null,
column: null
})
expect(matchFilePathAtColumn(line, colOf(line, 'へ'))).toBeNull()
})
})

describe('injected matchFilePathAtColumn', () => {
Expand Down
28 changes: 21 additions & 7 deletions mobile/src/terminal/terminal-path-tap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
parseFileLinkLocation,
type ParsedFileLinkLocation
} from '../../../src/shared/file-link-location'
import { trimFileLinkRangeNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary'

export type TappedFilePath = ParsedFileLinkLocation

Expand All @@ -15,10 +16,13 @@ export type TappedFilePath = ParsedFileLinkLocation
// with :line or :line:col. Like desktop, we propose candidates and let the host
// existence-check reject non-files — agents often print a bare filename, so
// requiring a slash would miss the common case.
// Why \p{L}\p{M}\p{N}: ASCII-only classes truncated CJK path segments (#13396).
// Pair with trimFileLinkRangeNonAsciiProse so glued particles after
// an extension are not part of the tap target.
const LOCAL_PATH_REGEX =
/(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g
/(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[\p{L}\p{M}\p{N}._-]+[\\/]|(?=[\p{L}\p{M}\p{N}._-]*\.[A-Za-z0-9]))[\p{L}\p{M}\p{N}._~\-/%+@\\()[\]]*(?::\d+)?(?::\d+)?/gu
const SPACED_PATH_REGEX =
/(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|`\r\n]+(?::\d+)?(?::\d+)?/g
/(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[\p{L}\p{M}\p{N}._-]+[\\/])[^()[\]{}'",;<>|`\r\n]+(?::\d+)?(?::\d+)?/gu

const LEADING_TRIM_CHARS = new Set(['(', '[', '{', '"', "'"])
const TRAILING_TRIM_CHARS = new Set([')', ']', '}', '"', "'", ',', ';', '.'])
Expand Down Expand Up @@ -82,14 +86,18 @@ function trimSpacedPathTrailingProse(
// segment is path-like (contains a separator) — "v1.2 reports/result.json"
// extends, prose like "failed to start app.py" must not be swallowed.
let selected: string | null = null
const extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g
// Also stop at non-ASCII: a CJK bracket closes a citation as often as a particle.
const extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$|\P{ASCII})/gu
let match: RegExpExecArray | null
while ((match = extensionPrefixPattern.exec(range.text)) !== null) {
const end = match.index + match[0].length
const text = range.text.slice(0, end)
if (countPathStarts(text) > 1) {
continue
}
if (selected !== null && COMPLETE_PATH.test(selected)) {
break
}
if (
end < range.text.length ||
selected === null ||
Expand All @@ -116,6 +124,10 @@ function trimSpacedPathTrailingProse(
}
}

// Mirror of the renderer rule: a separator plus an alphabetic extension means the
// span is already a finished path, so a further hop across whitespace is a second one.
const COMPLETE_PATH = /[\\/].*\.[A-Za-z][A-Za-z0-9_+-]*$/

function countPathStarts(text: string): number {
let count = 0
for (const match of text.matchAll(/(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g)) {
Expand Down Expand Up @@ -148,10 +160,11 @@ function matchSpacedFilePathAtColumn(lineText: string, col: number): TappedFileP
) {
continue
}
const candidate = trimSpacedPathTrailingProse(trimmed, col)
if (!candidate) {
const proseTrimmed = trimSpacedPathTrailingProse(trimmed, col)
if (!proseTrimmed) {
continue
}
const candidate = trimFileLinkRangeNonAsciiProse(proseTrimmed)
if (col < candidate.startIndex || col >= candidate.endIndex) {
continue
}
Expand Down Expand Up @@ -181,10 +194,11 @@ export function matchFilePathAtColumn(lineText: string, col: number): TappedFile
if (!trimmed) {
continue
}
if (col < trimmed.startIndex || col >= trimmed.endIndex) {
const candidate = trimFileLinkRangeNonAsciiProse(trimmed)
if (col < candidate.startIndex || col >= candidate.endIndex) {
continue
}
const parsed = parsePathWithOptionalLineColumn(trimmed.text)
const parsed = parsePathWithOptionalLineColumn(candidate.text)
if (parsed) {
return parsed
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits'
import { isNonAsciiProseBoundary } from '../../../../shared/non-ascii-terminal-text-boundary'

type ParsedTerminalHttpLink = {
url: string
Expand Down Expand Up @@ -98,7 +99,7 @@ function trimHttpUrlTrailingPunctuation(

function isHttpUrlBodyTerminator(code: number): boolean {
return (
isNonAsciiUrlTerminator(code) ||
isNonAsciiProseBoundary(code) ||
isAsciiWhitespace(code) ||
code === 0x22 ||
code === 0x27 ||
Expand Down Expand Up @@ -143,14 +144,6 @@ function isHttpUrlTrailingPunctuation(code: number): boolean {
)
}

// Why not all non-ascii: a url path may legitimately carry unencoded CJK (see the wrapped-url
// tests), so only punctuation and spaces end it - full-width brackets, an ideographic space, `\u30fb`.
const NON_ASCII_URL_TERMINATOR = /[\p{P}\p{S}\p{Z}]/u

function isNonAsciiUrlTerminator(code: number): boolean {
return code > 0x7e && NON_ASCII_URL_TERMINATOR.test(String.fromCharCode(code))
}

function isAsciiWhitespace(code: number): boolean {
return code === 9 || code === 10 || code === 11 || code === 12 || code === 13 || code === 32
}
Expand Down
13 changes: 10 additions & 3 deletions src/renderer/src/lib/terminal-bare-file-link-detection.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { trimFileLinkRangeNonAsciiProse } from '../../../shared/non-ascii-terminal-text-boundary'
import {
detectTerminalFileLinkRanges,
terminalFileLinkRangesOverlap,
Expand All @@ -23,7 +24,12 @@ const EXTENSIONLESS_FILENAMES = new Set([
'CONTRIBUTING'
])

const BARE_FILENAME_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._+-]*$/
// Why \p{L}\p{M}\p{N}: an ASCII-only name never matched `文書.md`, so a bare
// non-Latin filename was not a link at all (#13396).
const BARE_FILENAME_PATTERN = /^[\p{L}\p{M}\p{N}_][\p{L}\p{M}\p{N}._+-]*$/u
// Why an ASCII extension: with the wider name class, every sentence ending in `.`
// would otherwise reach the filesystem probe.
const BARE_FILENAME_EXTENSION = /\.[A-Za-z0-9_+-]+$/
const MAX_BARE_FILENAME_TOKEN_LENGTH = 120

function looksLikeFilename(token: string): boolean {
Expand All @@ -37,7 +43,7 @@ function looksLikeFilename(token: string): boolean {
return false
}
if (token.includes('.')) {
return !/^\.+$/.test(token)
return BARE_FILENAME_EXTENSION.test(token)
}
return EXTENSIONLESS_FILENAMES.has(token)
}
Expand All @@ -58,7 +64,8 @@ export function detectBareFilenameLinks(
if (range.text.length > MAX_BARE_FILENAME_TOKEN_LENGTH) {
continue
}
const link = toParsedTerminalFileLink(range)
// Trim first: `README.mdへ` fails looksLikeFilename and never becomes a link.
const link = toParsedTerminalFileLink(trimFileLinkRangeNonAsciiProse(range))
if (!link || !looksLikeFilename(link.pathText)) {
continue
}
Expand Down
Loading
Loading