fix: stop auto-linking plain filenames

This commit is contained in:
lucaronin
2026-04-17 22:20:48 +02:00
parent 6f8810146e
commit b2122ac24c
5 changed files with 447 additions and 0 deletions

View File

@@ -20,6 +20,7 @@ import {
resolveRawModeContent,
} from './editorRawModeSync'
import { useRawModeWithFlush } from './useRawModeWithFlush'
import { useFilenameAutolinkGuard } from './useFilenameAutolinkGuard'
import './Editor.css'
import './EditorTheme.css'
@@ -167,6 +168,7 @@ function useEditorSetup({
schema,
uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current),
})
useFilenameAutolinkGuard(editor)
const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null
const {
rawMode,

View File

@@ -0,0 +1,132 @@
import { useEffect } from 'react'
import type { useCreateBlockNote } from '@blocknote/react'
import { shouldStripAutoLinkedLocalFileMark } from '../utils/editorLinkAutolink'
const FILENAME_AUTOLINK_GUARD_META = 'tolaria-filename-autolink-guard'
type TiptapLinkMark = {
attrs?: {
href?: string
}
type: unknown
}
type TiptapTextNode = {
isText?: boolean
marks?: TiptapLinkMark[]
nodeSize: number
text?: string | null
}
function hasTextContent(node: TiptapTextNode) {
return Boolean(node.isText && node.text && node.marks?.length)
}
function isAccidentalFilenameLinkMark(
mark: TiptapLinkMark,
linkMarkType: unknown,
text: string,
) {
if (mark.type !== linkMarkType) {
return false
}
if (typeof mark.attrs?.href !== 'string') {
return false
}
return shouldStripAutoLinkedLocalFileMark({
href: { raw: mark.attrs.href },
text: { raw: text },
})
}
function hasEditorEventApi(
tiptap: ReturnType<typeof useCreateBlockNote>['_tiptapEditor'] | undefined,
) {
return Boolean(tiptap && typeof tiptap.on === 'function' && typeof tiptap.off === 'function')
}
function collectAccidentalFilenameLinkRanges(editor: ReturnType<typeof useCreateBlockNote>) {
const linkMarkType = editor._tiptapEditor?.schema?.marks?.link
if (!linkMarkType) {
return []
}
const ranges: Array<{ from: number; to: number }> = []
editor._tiptapEditor.state.doc.descendants((node: TiptapTextNode, pos: number) => {
if (!hasTextContent(node)) {
return
}
const hasAccidentalFilenameLink = node.marks.some((mark) => (
isAccidentalFilenameLinkMark(mark, linkMarkType, node.text ?? '')
))
if (hasAccidentalFilenameLink) {
ranges.push({ from: pos, to: pos + node.nodeSize })
}
})
return ranges
}
function shouldSkipGuardRun(
applyingGuard: boolean,
transaction: { docChanged?: boolean; getMeta: (key: string) => unknown },
) {
return (
applyingGuard
|| !transaction.docChanged
|| transaction.getMeta(FILENAME_AUTOLINK_GUARD_META)
)
}
function removeLinkRanges(
editor: ReturnType<typeof useCreateBlockNote>,
ranges: Array<{ from: number; to: number }>,
) {
const tr = editor._tiptapEditor.state.tr
for (const range of ranges) {
tr.removeMark(range.from, range.to, editor._tiptapEditor.schema.marks.link)
}
return tr
}
export function useFilenameAutolinkGuard(editor: ReturnType<typeof useCreateBlockNote>) {
useEffect(() => {
const tiptap = editor._tiptapEditor
if (!hasEditorEventApi(tiptap)) {
return
}
let applyingGuard = false
const stripAccidentalFilenameAutolinks = ({ transaction }: { transaction: { docChanged?: boolean; getMeta: (key: string) => unknown } }) => {
if (shouldSkipGuardRun(applyingGuard, transaction)) {
return
}
const ranges = collectAccidentalFilenameLinkRanges(editor)
if (ranges.length === 0) {
return
}
const tr = removeLinkRanges(editor, ranges)
if (!tr.docChanged) {
return
}
tr.setMeta(FILENAME_AUTOLINK_GUARD_META, true)
applyingGuard = true
tiptap.view.dispatch(tr)
applyingGuard = false
}
tiptap.on('update', stripAccidentalFilenameAutolinks)
return () => {
tiptap.off('update', stripAccidentalFilenameAutolinks)
}
}, [editor])
}

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import {
looksLikeLocalFileReference,
shouldStripAutoLinkedLocalFileMark,
shouldAutoLinkTolariaHref,
} from './editorLinkAutolink'
describe('looksLikeLocalFileReference', () => {
it('treats bare filenames with file extensions as local file references', () => {
expect(looksLikeLocalFileReference({ raw: 'AGENTS.md' })).toBe(true)
expect(looksLikeLocalFileReference({ raw: 'README.txt' })).toBe(true)
})
it('treats local path-like filenames as local file references', () => {
expect(looksLikeLocalFileReference({ raw: 'docs/README.md' })).toBe(true)
expect(looksLikeLocalFileReference({ raw: './docs/README.md' })).toBe(true)
expect(looksLikeLocalFileReference({ raw: '/vault/README.md' })).toBe(true)
})
it('does not classify domain-based urls as local file references', () => {
expect(looksLikeLocalFileReference({ raw: 'https://example.com/README.md' })).toBe(false)
expect(looksLikeLocalFileReference({ raw: 'example.com/README.md' })).toBe(false)
expect(looksLikeLocalFileReference({ raw: 'www.example.com/README.md' })).toBe(false)
})
})
describe('shouldAutoLinkTolariaHref', () => {
it('rejects plain filename-like text', () => {
expect(shouldAutoLinkTolariaHref({ raw: 'AGENTS.md' })).toBe(false)
expect(shouldAutoLinkTolariaHref({ raw: 'docs/README.md' })).toBe(false)
})
it('keeps normal url-like values eligible for autolinking', () => {
expect(shouldAutoLinkTolariaHref({ raw: 'https://example.com/docs' })).toBe(true)
expect(shouldAutoLinkTolariaHref({ raw: 'example.com' })).toBe(true)
expect(shouldAutoLinkTolariaHref({ raw: 'example.com/README.md' })).toBe(true)
})
})
describe('shouldStripAutoLinkedLocalFileMark', () => {
it('strips accidental link marks that mirror local file text', () => {
expect(shouldStripAutoLinkedLocalFileMark({
href: { raw: 'https://AGENTS.md' },
text: { raw: 'AGENTS.md' },
})).toBe(true)
expect(shouldStripAutoLinkedLocalFileMark({
href: { raw: 'https://docs/README.md' },
text: { raw: 'docs/README.md' },
})).toBe(true)
})
it('keeps intentional external links', () => {
expect(shouldStripAutoLinkedLocalFileMark({
href: { raw: 'https://example.com/docs' },
text: { raw: 'Tolaria Docs' },
})).toBe(false)
expect(shouldStripAutoLinkedLocalFileMark({
href: { raw: 'https://example.com/agents' },
text: { raw: 'AGENTS.md' },
})).toBe(false)
})
})

View File

@@ -0,0 +1,143 @@
const FILE_LIKE_EXTENSION_PATTERN =
/\.(?:md|markdown|txt|ya?ml|json|toml|csv|tsv|pdf|png|jpe?g|gif|svg|webp|avif|mp3|wav|ogg|mp4|mov|zip|tar|gz|tsx?|jsx?|cjs|mjs|rs|py|sh|css|html?)$/i
const EXPLICIT_PROTOCOL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i
const MAYBE_PROTOCOL_PATTERN = /^[a-z][a-z0-9+.-]*:/i
const LOCAL_PATH_PREFIX_PATTERN = /^(?:\.{1,2}\/|~\/|\/)/
const IPV4_HOST_PATTERN = /^\d{1,3}(\.\d{1,3}){3}$/
const WINDOWS_PATH_SEPARATOR = '\\'
const WWW_PREFIX = 'www.'
export type LinkValue = {
raw: string
}
export type LinkMarkCandidate = {
href: LinkValue
text: LinkValue
}
function withRaw(raw: string): LinkValue {
return { raw }
}
function stripUrlDecorators(value: LinkValue) {
return withRaw(value.raw.split(/[?#]/, 1)[0] ?? value.raw)
}
function stripProtocol(value: LinkValue) {
return withRaw(value.raw.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ''))
}
function isExplicitProtocolUrl(value: LinkValue) {
return EXPLICIT_PROTOCOL_PATTERN.test(value.raw)
}
function isMaybeProtocolUrl(value: LinkValue) {
return MAYBE_PROTOCOL_PATTERN.test(value.raw)
}
function hasWindowsPathSeparator(value: LinkValue) {
return value.raw.includes(WINDOWS_PATH_SEPARATOR)
}
function hasPathSeparator(value: LinkValue) {
return value.raw.includes('/')
}
function startsWithWebHostPrefix(value: LinkValue) {
return value.raw.startsWith(WWW_PREFIX)
}
function firstPathSegment(value: LinkValue) {
return value.raw.split('/', 1)[0] ?? value.raw
}
function isDomainLikePath(value: LinkValue) {
return firstPathSegment(value).includes('.')
}
function hostnameFromUrlLikeValue(value: LinkValue) {
const withoutUserinfo = value.raw.includes('@') ? value.raw.split('@').pop() ?? value.raw : value.raw
return withoutUserinfo.split(/[/?#:]/, 1)[0] ?? withoutUserinfo
}
function isExplicitWebUrl(value: LinkValue) {
return isExplicitProtocolUrl(value) || startsWithWebHostPrefix(value)
}
function isSingleSegmentValue(value: LinkValue) {
return !hasPathSeparator(value)
}
function isLocalPathPrefix(value: LinkValue) {
return LOCAL_PATH_PREFIX_PATTERN.test(value.raw)
}
function isPathLikeFileReference(value: LinkValue) {
return hasPathSeparator(value) && !isDomainLikePath(value)
}
function hasBareProtocolPrefix(value: LinkValue) {
return isMaybeProtocolUrl(value) && !value.raw.includes('@')
}
function hostnameHasTld(hostname: string) {
return hostname.includes('.')
}
function normalizeInput(value: LinkValue) {
return stripUrlDecorators(withRaw(value.raw.trim()))
}
export function looksLikeLocalFileReference(value: LinkValue) {
const normalized = normalizeInput(value)
if (!normalized.raw) return false
if (isLocalPathPrefix(normalized) || hasWindowsPathSeparator(normalized)) {
return true
}
if (!FILE_LIKE_EXTENSION_PATTERN.test(normalized.raw)) {
return false
}
if (isExplicitWebUrl(normalized)) {
return false
}
if (isSingleSegmentValue(normalized)) {
return true
}
return isPathLikeFileReference(normalized)
}
export function shouldAutoLinkTolariaHref(url: LinkValue) {
if (looksLikeLocalFileReference(url)) {
return false
}
if (isExplicitProtocolUrl(url) || hasBareProtocolPrefix(url)) {
return true
}
const hostname = hostnameFromUrlLikeValue(url)
if (IPV4_HOST_PATTERN.test(hostname)) {
return false
}
return hostnameHasTld(hostname)
}
export function shouldStripAutoLinkedLocalFileMark(mark: LinkMarkCandidate) {
const normalizedText = normalizeInput(mark.text)
if (!looksLikeLocalFileReference(normalizedText)) {
return false
}
const normalizedHref = stripUrlDecorators(
stripProtocol(normalizeInput(mark.href)),
)
return normalizedHref.raw === normalizedText.raw
}

View File

@@ -0,0 +1,108 @@
import { expect, test, type Page } from '@playwright/test'
import {
createFixtureVaultCopy,
openFixtureVault,
removeFixtureVaultCopy,
} from '../helpers/fixtureVault'
import { executeCommand, openCommandPalette } from './helpers'
let tempVaultDir: string
async function openNote(page: Page, title: string) {
await page.locator('[data-testid="note-list-container"]').getByText(title, { exact: true }).click()
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}
async function openRawMode(page: Page) {
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5_000 })
}
async function openBlockNoteMode(page: Page) {
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}
async function getRawEditorContent(page: Page): Promise<string> {
return page.evaluate(() => {
const el = document.querySelector('.cm-content')
if (!el) return ''
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- CodeMirror view is attached to the DOM node.
const view = (el as any).cmTile?.view
if (!view) return el.textContent ?? ''
return view.state.doc.toString() as string
})
}
async function setRawEditorContent(page: Page, content: string) {
await page.evaluate((nextContent) => {
const el = document.querySelector('.cm-content')
if (!el) return
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- CodeMirror view is attached to the DOM node.
const view = (el as any).cmTile?.view
if (!view) return
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: nextContent,
},
})
}, content)
}
async function appendPlainFilenameParagraph(page: Page, value: string) {
const lastBlock = page.locator('.bn-block-content').last()
await expect(lastBlock).toBeVisible({ timeout: 5_000 })
await lastBlock.click()
await page.keyboard.press('End')
await page.keyboard.press('Enter')
await page.keyboard.type(`${value} `)
await page.waitForTimeout(700)
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(90_000)
tempVaultDir = createFixtureVaultCopy()
await openFixtureVault(page, tempVaultDir)
})
test.afterEach(async () => {
removeFixtureVaultCopy(tempVaultDir)
})
test('plain filename-like text typed in BlockNote stays plain through raw-mode round trips', async ({ page }) => {
await openNote(page, 'Note B')
await appendPlainFilenameParagraph(page, 'AGENTS.md')
await appendPlainFilenameParagraph(page, 'docs/README.md')
await openRawMode(page)
const raw = await getRawEditorContent(page)
expect(raw).toContain('AGENTS.md')
expect(raw).toContain('docs/README.md')
expect(raw).not.toMatch(/\[AGENTS\.md\]\(/)
expect(raw).not.toMatch(/\[docs\/README\.md\]\(/)
})
test('explicit markdown links still render and round-trip intentionally', async ({ page }) => {
await openNote(page, 'Note B')
await openRawMode(page)
const raw = await getRawEditorContent(page)
const intentionalLink = '[Tolaria Docs](https://example.com/docs)'
await setRawEditorContent(page, `${raw}\n\n${intentionalLink}\n`)
await page.waitForTimeout(700)
await openBlockNoteMode(page)
await expect(page.locator('.bn-editor a[href="https://example.com/docs"]').last()).toContainText('Tolaria Docs')
await openRawMode(page)
await expect.poll(() => getRawEditorContent(page)).toContain(intentionalLink)
})