diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index c8402f95..6bb3300c 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -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, diff --git a/src/components/useFilenameAutolinkGuard.ts b/src/components/useFilenameAutolinkGuard.ts new file mode 100644 index 00000000..d7e5a960 --- /dev/null +++ b/src/components/useFilenameAutolinkGuard.ts @@ -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['_tiptapEditor'] | undefined, +) { + return Boolean(tiptap && typeof tiptap.on === 'function' && typeof tiptap.off === 'function') +} + +function collectAccidentalFilenameLinkRanges(editor: ReturnType) { + 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, + 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) { + 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]) +} diff --git a/src/utils/editorLinkAutolink.test.ts b/src/utils/editorLinkAutolink.test.ts new file mode 100644 index 00000000..cd0fe68a --- /dev/null +++ b/src/utils/editorLinkAutolink.test.ts @@ -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) + }) +}) diff --git a/src/utils/editorLinkAutolink.ts b/src/utils/editorLinkAutolink.ts new file mode 100644 index 00000000..4baa7e83 --- /dev/null +++ b/src/utils/editorLinkAutolink.ts @@ -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 +} diff --git a/tests/smoke/plain-filename-autolink.spec.ts b/tests/smoke/plain-filename-autolink.spec.ts new file mode 100644 index 00000000..ef659e1e --- /dev/null +++ b/tests/smoke/plain-filename-autolink.spec.ts @@ -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 { + 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) +})