fix: stop auto-linking plain filenames
This commit is contained in:
@@ -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,
|
||||
|
||||
132
src/components/useFilenameAutolinkGuard.ts
Normal file
132
src/components/useFilenameAutolinkGuard.ts
Normal 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])
|
||||
}
|
||||
62
src/utils/editorLinkAutolink.test.ts
Normal file
62
src/utils/editorLinkAutolink.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
143
src/utils/editorLinkAutolink.ts
Normal file
143
src/utils/editorLinkAutolink.ts
Normal 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
|
||||
}
|
||||
108
tests/smoke/plain-filename-autolink.spec.ts
Normal file
108
tests/smoke/plain-filename-autolink.spec.ts
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user