Skip to content
Merged
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
175 changes: 175 additions & 0 deletions e2e/tests/input/email-autolink.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { expect, test } from '@playwright/test'

import { focusEditor } from '../misc'

const email = 'test@example.com'

test.beforeEach(async ({ page }) => {
await page.goto('/plugin-automd/')
await focusEditor(page)
})

test('auto link email', async ({ page }) => {
await page.keyboard.type('test@example.c', { delay: 10 })
await page.keyboard.press('o')
await page.waitForTimeout(100)
await page.keyboard.press('m')
await page.waitForTimeout(100)

const link = page.locator('a').first()
await expect(link).toBeVisible()
await expect(link).toHaveText(email)

const href = await link.getAttribute('href')

expect(href).toContain(email)
})

test('auto link email backspace', async ({ page }) => {
await page.keyboard.type(email, { delay: 10 })
await page.waitForTimeout(100)

// Delete "om"
await page.keyboard.press('Backspace')
await page.keyboard.press('Backspace')

// Check intermediate state
await page.waitForTimeout(100)
const link = page.locator('a').first()
const href = await link.getAttribute('href')
expect(href).toContain('test@example.c') // Should match truncated text

// Delete everything
for (let i = 0; i < 'test@example.c'.length; i++) {
await page.keyboard.press('Backspace')
}

// Type new email
const newEmail = 'new@test.com'
await page.keyboard.type(newEmail)
await page.waitForTimeout(100)

const linkNew = page.locator('a').first()
await expect(linkNew).toHaveText(newEmail)
const hrefNew = await linkNew.getAttribute('href')
expect(hrefNew).toContain(newEmail)
})

test('phantom link check', async ({ page }) => {
await page.keyboard.type(email, { delay: 5 })
await page.waitForTimeout(50)

// Delete everything
for (const _ of email) {
await page.keyboard.press('Backspace')
}
await page.waitForTimeout(50)

// Type non-email text
await page.keyboard.type('hello world')
await page.waitForTimeout(50)

// Should NOT be a link
const link = page.locator('a').first()
await expect(link).not.toBeVisible()
})

test('trailing space should not remove link', async ({ page }) => {
await page.keyboard.type(email, { delay: 10 })
await page.waitForTimeout(100)

// Add a space
await page.keyboard.press('Space')
await page.waitForTimeout(100)

const link = page.locator('a').first()
// Link should still exist and point to the email
await expect(link).toBeVisible()
await expect(link).toHaveText(email + ' ')

// Check href doesn't become broken or vanish
const href = await link.getAttribute('href')
expect(href).toContain(email)
})

test('trailing dot should be excluded', async ({ page }) => {
// Type email ending with dot
await page.keyboard.type('test@example.com.', { delay: 10 })
await page.waitForTimeout(100)

const link = page.locator('a').first()

// Valid part 'test@example.com' should be linked
await expect(link).toHaveText(email)

const paragraph = page.locator('.editor p').last() // assuming it's in a paragraph
await expect(paragraph).toHaveText(email + '.')

const href = await link.getAttribute('href')
expect(href).toContain(email)
expect(href).not.toContain(email + '.')
})

test('backspace to dot', async ({ page }) => {
await page.keyboard.type(email)
await page.waitForTimeout(100)

// Backspace 3 times to 'test@example.'
await page.keyboard.press('Backspace')
await page.keyboard.press('Backspace')
await page.keyboard.press('Backspace')
await page.waitForTimeout(100)

// Type 'c' -> 'test@example.c'
await page.keyboard.type('c')
await page.waitForTimeout(100)

const link = page.locator('a').first()
await expect(link).toHaveText('test@example.c')

const href = await link.getAttribute('href')
expect(href).toBe('mailto:test@example.c')
})

test('typing after email', async ({ page }) => {
await page.keyboard.type(email)
await page.waitForTimeout(100)

// Type space and text
await page.keyboard.type(' hello')
await page.waitForTimeout(100)

const link = page.locator('a').first()
await expect(link).toHaveText(email)

const href = await link.getAttribute('href')
expect(href).toBe('mailto:test@example.com')
})

test('leaking char check', async ({ page }) => {
await page.keyboard.type(email)
await page.waitForTimeout(100)

// Delete '.com' (4 chars)
for (let i = 0; i < 3; i++) {
await page.keyboard.press('Backspace')
}
await page.waitForTimeout(100)
// Now 'test@example.com' -> 'test@example.'

// "add spaces"
await page.keyboard.type(' ')

// "type again"
await page.keyboard.type('hello')
await page.waitForTimeout(100)

const paragraph = page.locator('.editor p').last()
const text = await paragraph.innerText()

expect(text).not.toContain('<')
expect(text).not.toContain('>')
expect(text).toContain('test@example.')
const linkCount = await page.locator('a').count()
expect(linkCount).toBe(0)
})
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@milkdown/monorepo",
"version": "0.0.0",
"private": true,
"packageManager": "pnpm@10.26.0",
"packageManager": "pnpm@10.26.2",
"engines": {
"node": ">=22"
},
Expand Down
156 changes: 154 additions & 2 deletions packages/plugins/plugin-automd/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,18 @@ import type { Node } from '@milkdown/prose/model'
import type { EditorState } from '@milkdown/prose/state'

import { parserCtx, serializerCtx } from '@milkdown/core'
import { Fragment } from '@milkdown/prose/model'
import { pipe } from '@milkdown/utils'

import { inlineSyncConfig } from './config'
import { asterisk, asteriskHolder, underline, underlineHolder } from './regexp'
import {
asterisk,
asteriskHolder,
emailCandidateRegexp,
underline,
underlineHolder,
trailingPunctuationRegexp,
} from './regexp'
import {
calculatePlaceholder,
keepLink,
Expand Down Expand Up @@ -108,13 +116,136 @@ export function getContextByState(
const markdown = getMarkdown(ctx, state, node, globalNode)
const [text, placeholder] = addPlaceholder(ctx, markdown)

const newNode = getNewNode(ctx, text)
let newNode = getNewNode(ctx, text)

if (!newNode || node.type !== newNode.type || onlyHTML(newNode)) return null

// @ts-expect-error hijack the node attribute
newNode.attrs = { ...node.attrs }

let modified = false
const children: Node[] = []

// Pass 1: Merge adjacent Link + Text nodes
const rawNodes: Node[] = []
newNode.content.forEach((c) => rawNodes.push(c))

const mergedNodes: Node[] = []
for (const curr of rawNodes) {
if (!curr) continue

const prev = mergedNodes[mergedNodes.length - 1]

const prevIsLink = prev && prev.marks.some((m) => m.type.name === 'link')
const currIsText =
curr.isText && !curr.marks.some((m) => m.type.name === 'link')

if (prevIsLink && currIsText && prev.text && curr.text) {
const combined = prev.text + curr.text
mergedNodes[mergedNodes.length - 1] = state.schema.text(
combined,
prev.marks
)
} else {
mergedNodes.push(curr)
}
}
if (mergedNodes.length !== rawNodes.length) {
modified = true
}
mergedNodes.forEach((child) => {
const linkMark = child.marks.find((m) => m.type.name === 'link')
if (!linkMark || !child.text) {
children.push(child)
return
}

const text = child.text
const match = text.match(trailingPunctuationRegexp)
const suffix = match ? match[0] : ''
const trimmed = text.slice(0, text.length - suffix.length)

// Strategy 1: Suffix split (existing)
if (emailCandidateRegexp.test(trimmed)) {
if (suffix.length > 0) modified = true
const newMarks = child.marks.map((m) => {
if (m.type.name === 'link') {
return m.type.create({ ...m.attrs, href: `mailto:${trimmed}` })
}
return m
})

if (trimmed.length > 0) {
children.push(state.schema.text(trimmed, newMarks))
}
if (suffix.length > 0) {
children.push(
state.schema.text(
suffix,
child.marks.filter((m) => m.type.name !== 'link')
)
)
}
return
}

// Strategy 2: Prefix split (fallback)
const splitMatch = text.match(/^([a-zA-Z0-9._%+\-@]+)(.*)$/)
if (splitMatch) {
const possibleEmail = splitMatch[1]
const rest = splitMatch[2]

if (
possibleEmail &&
rest &&
emailCandidateRegexp.test(possibleEmail) &&
rest.length > 0
) {
modified = true
const newMarks = child.marks.map((m) => {
if (m.type.name === 'link') {
return m.type.create({
...m.attrs,
href: `mailto:${possibleEmail}`,
})
}
return m
})
children.push(state.schema.text(possibleEmail, newMarks))
children.push(
state.schema.text(
rest,
child.marks.filter((m) => m.type.name !== 'link')
)
)
return
}
}

// Fallback: If it's a mailto link and looks like an autolink (href contains text),
const href = linkMark.attrs.href
if (
typeof href === 'string' &&
href.startsWith('mailto:') &&
href.includes(text)
) {
modified = true
children.push(
state.schema.text(
text,
child.marks.filter((m) => m.type.name !== 'link')
)
)
return
}

children.push(child)
})

if (modified) {
newNode = newNode.copy(Fragment.from(children))
}

newNode.descendants((node) => {
const marks = node.marks
const link = marks.find((mark) => mark.type.name === 'link')
Expand All @@ -126,6 +257,27 @@ export function getContextByState(
// @ts-expect-error hijack the mark attribute
link.attrs.href = link.attrs.href.replace(placeholder, '')
}

const href = link?.attrs.href
const text = node.text?.replace(placeholder, '') || ''
if (link && href?.startsWith('mailto:')) {
const address = href.slice(7)
const isValidCandidate = emailCandidateRegexp.test(text.trim())
const isAddressIncomplete = !address.includes('@')
const isTextSimple = !text.includes(' ') && text.length > 0
const shouldSync =
text.startsWith(address) ||
address.startsWith(text) ||
(isAddressIncomplete && isTextSimple)

if (isValidCandidate && shouldSync) {
// @ts-expect-error hijack the mark attribute
link.attrs.href = `mailto:${text.trim()}`
} else if (!isValidCandidate) {
// @ts-expect-error hijack mark
node.marks = node.marks.filter((mark) => mark.type.name !== 'link')
}
}
if (
node.text?.includes(asteriskHolder) ||
node.text?.includes(underlineHolder)
Expand Down
5 changes: 5 additions & 0 deletions packages/plugins/plugin-automd/src/regexp.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
export const linkRegexp = /\[([^\]]+)]\([^\s\]]+\)/

export const emailCandidateRegexp =
/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z0-9.-]+$/

export const trailingPunctuationRegexp = /[.,:;!?\s\u2042\u2234\u2205]+$/

export const keepLinkRegexp =
/\[(?<span>((www|https:\/\/|http:\/\/)[^\s\]]+))]\((?<url>[^\s\]]+)\)/

Expand Down
Loading
Loading