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
2 changes: 2 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ Wikilinks (`[[target]]`) render with a dashed underline via the `.md-wikilink` c

Markdown links (`[text](url)`) render the label as an `<a href>` with the `.md-link` class, colored by `--meowdown-accent`; the `[`, `]`, and `(url)` syntax dims in show mode and hides in hide and focus modes. Wire click handling with `defineLinkClickHandler(({ href, event }) => ...)` (or `@meowdown/react`'s `onLinkClick` prop). A plain click inside a link the caret already sits in just places the caret; `Mod`-click always fires.

Bare URLs autolink without `[text](url)` brackets and share the same `.md-link` rendering and click handling: a scheme URL (`https://example.com`), an angle autolink (`<https://example.com>`), a `www.` host (`www.example.com`), an email (`me@example.com`), and a bare domain (`google.com`, `sub.domain.io/path`). Bare domains are matched against a curated list of common TLDs, so file names and prose keep their dots without linkifying (`README.md`, `node.js`, `i.e.` stay plain text); reach for `[text](url)` or `<url>` to link anything off that list. Autolinks are derived live from the text, so editing one re-evaluates it; the caret sitting inside a link never un-links it.

Inline images (`![alt](src)`) stay literal text and render in place via a mark view, with the raw `![alt](src)` hidden in hide and focus modes. Add it with `defineImage({ resolveImageUrl, onImagePaste })` (or `@meowdown/react`'s image props). `resolveImageUrl` is optional and defaults to showing http(s) URLs as-is. Wire click handling with `defineImageClickHandler(({ src, alt, event }) => ...)` (or `@meowdown/react`'s `onImageClick` prop).

Pasting a lone tweet or YouTube link can auto-embed it. `defineEmbedPaste()` (or `@meowdown/react`'s `embedPaste` prop) rewrites the pasted link to `![](url)` so it renders as an embed; one undo turns the embed back into the raw link. It is not part of `defineEditorExtension`; add it explicitly.
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/converters/roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ describe('markdown round-trip is byte-identical', () => {
'mail me@example.com ok',
'a <https://example.com> b',
'end https://example.com.',
// Bare domains autolink too, but stay plain text to the converters
'see google.com here',
'paths sub.domain.net/a/b?x=1 end',
'not a link README.md here',
'![cat](https://example.com/cat.png)',
'a ![one](https://example.com/1.png) b ![two](https://example.com/2.png) c',
'![](https://www.youtube.com/watch?v=dQw4w9WgXcQ)',
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/extensions/autolink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { page } from 'vitest/browser'

import { setupFixture } from '../testing/index.ts'

const pmRoot = page.locate('.ProseMirror')

describe('autolink rendering', () => {
it('renders a scheme autolink as a link', async () => {
using fixture = setupFixture()
const { n } = fixture
fixture.set(n.doc(n.paragraph('see https://example.com here')))
await expect
.element(pmRoot.getByRole('link', { name: 'https://example.com' }))
.toBeInTheDocument()
})

it('renders a bare domain as a link', async () => {
using fixture = setupFixture()
const { n } = fixture
fixture.set(n.doc(n.paragraph('go to google.com now')))
await expect.element(pmRoot.getByRole('link', { name: 'google.com' })).toBeInTheDocument()
})

// Locks the product decision: a link is never un-linked by moving the caret
// into it. The link keeps its blue `<a>` and stays editable.
it('keeps a scheme autolink a link when the caret is inside it', async () => {
using fixture = setupFixture()
const { n } = fixture
fixture.set(n.doc(n.paragraph('see https://exa<a>mple.com here')))
await expect
.element(pmRoot.getByRole('link', { name: 'https://example.com' }))
.toBeInTheDocument()
})

it('keeps a bare domain a link when the caret is inside it', async () => {
using fixture = setupFixture()
const { n } = fixture
fixture.set(n.doc(n.paragraph('go to goo<a>gle.com now')))
await expect.element(pmRoot.getByRole('link', { name: 'google.com' })).toBeInTheDocument()
})
})
23 changes: 23 additions & 0 deletions packages/core/src/extensions/inline-mark-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,29 @@ describe('inlineMarkPlugin', () => {
expect(linkText!.attrs.href).toBe('https://example.com')
})

it('applies mdLinkText with an https href on a bare domain', () => {
using fixture = setupFixture()
const { n } = fixture
const doc = n.doc(n.paragraph('visit google.com now'))
fixture.set(doc)

const pos = findText(fixture.doc, 'google.com')
const $pos = fixture.doc.resolve(pos + 1)
const linkText = $pos.marks().find((m) => m.type.name === 'mdLinkText')
expect(linkText).toBeTruthy()
expect(linkText!.attrs.href).toBe('https://google.com')
})

it('leaves a bare host off the TLD list as plain text', () => {
using fixture = setupFixture()
const { n } = fixture
const doc = n.doc(n.paragraph('open README.md now'))
fixture.set(doc)

const pos = findText(fixture.doc, 'README.md')
expect(marksAt(fixture.doc, pos + 1)).toEqual([])
})

it('marks `*foo*` inside headings as well', () => {
using fixture = setupFixture()
const { n } = fixture
Expand Down
106 changes: 104 additions & 2 deletions packages/core/src/extensions/inline-text-to-mark-chunks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,11 +315,113 @@ describe('inlineTextToMarkChunks', () => {
`)
})

it('does not autolink a schemeless host', () => {
it('autolinks a bare domain on the curated TLD list', () => {
const chunks = inlineTextToMarkChunks(markBuilders, 'a example.com b')
expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(`
"
0-15: -
0-2: -
2-13: mdLinkText(href=https://example.com)
13-15: -
"
`)
})

it('does not autolink a bare host whose TLD is off the list', () => {
const chunks = inlineTextToMarkChunks(markBuilders, 'a README.md b')
expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(`
"
0-13: -
"
`)
})

it('bare-autolinks a domain that starts the text', () => {
const chunks = inlineTextToMarkChunks(markBuilders, 'google.com')
expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(`
"
0-10: mdLinkText(href=https://google.com)
"
`)
})

it('bare-autolinks a domain with a path, keeping the path in the href', () => {
const chunks = inlineTextToMarkChunks(markBuilders, 'sub.domain.com/path?q=1')
expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(`
"
0-23: mdLinkText(href=https://sub.domain.com/path?q=1)
"
`)
})

it('preserves case in the bare-autolink href', () => {
const chunks = inlineTextToMarkChunks(markBuilders, 'GOOGLE.COM')
expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(`
"
0-10: mdLinkText(href=https://GOOGLE.COM)
"
`)
})

it('excludes a trailing period from a bare autolink', () => {
const chunks = inlineTextToMarkChunks(markBuilders, 'Visit google.com.')
expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(`
"
0-6: -
6-16: mdLinkText(href=https://google.com)
16-17: -
"
`)
})

it('does not bare-autolink a code-file name', () => {
const chunks = inlineTextToMarkChunks(markBuilders, 'edit node.js then')
expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(`
"
0-17: -
"
`)
})

it('claims a www. autolink as one chunk, not a nested bare domain', () => {
const chunks = inlineTextToMarkChunks(markBuilders, 'www.example.com')
expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(`
"
0-15: mdLinkText(href=https://www.example.com)
"
`)
})

it('does not bare-autolink the label of an explicit link', () => {
const chunks = inlineTextToMarkChunks(markBuilders, '[google.com](http://x)')
expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(`
"
0-1: mdLinkText(href=http://x) + mdMark
1-11: mdLinkText(href=http://x)
11-13: mdMark
13-21: mdLinkUri
21-22: mdMark
"
`)
})

it('does not bare-autolink inside inline code', () => {
const chunks = inlineTextToMarkChunks(markBuilders, '`see google.com`')
expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(`
"
0-1: mdCode + mdMark
1-15: mdCode
15-16: mdCode + mdMark
"
`)
})

it('does not bare-autolink a domain after an @ (it is an email)', () => {
const chunks = inlineTextToMarkChunks(markBuilders, 'mail a@google.com here')
expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(`
"
0-5: -
5-17: mdLinkText(href=mailto:a@google.com)
17-22: -
"
`)
})
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/extensions/inline-text-to-mark-chunks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Mark } from '@prosekit/pm/model'

import { hostFromUrl, isLinkableBareHost } from '../lezer/autolink-tld.ts'
import type { InlineElement } from '../lezer/inline.ts'
import { parseInline } from '../lezer/inline.ts'
import { LEZER_NODE_IDS } from '../lezer/node-ids.ts'
Expand Down Expand Up @@ -59,12 +60,14 @@ export function inlineTextToMarkChunks(
* - a URL with a scheme is used as-is
* - an email becomes `mailto:`
* - a `www.` URL gets an implied `https://`
* - a bare domain on the curated TLD list gets an implied `https://`
* - anything else returns `undefined`
*/
function getAutolinkHref(urlText: string): string | undefined {
if (/^[a-z][a-z0-9+.-]*:/i.test(urlText)) return urlText
if (/^[^\s@]+@[^\s@]+$/.test(urlText)) return `mailto:${urlText}`
if (/^www\./i.test(urlText)) return `https://${urlText}`
if (isLinkableBareHost(hostFromUrl(urlText))) return `https://${urlText}`
return undefined
}

Expand Down
56 changes: 56 additions & 0 deletions packages/core/src/lezer/autolink-tld.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'

import { hostFromUrl, isLinkableBareHost } from './autolink-tld.ts'

describe('hostFromUrl', () => {
it('returns the whole string when there is no path', () => {
expect(hostFromUrl('google.com')).toBe('google.com')
})

it('strips the path', () => {
expect(hostFromUrl('sub.domain.com/path?q=1')).toBe('sub.domain.com')
})
})

describe('isLinkableBareHost', () => {
const linkable = [
'google.com',
'example.org',
'cdn.example.net',
'a-b.example.com',
'GOOGLE.COM',
'm.google.com',
]
for (const host of linkable) {
it(`links ${host}`, () => {
expect(isLinkableBareHost(host)).toBe(true)
})
}

const rejected = [
'README.md', // md excluded
'deploy.sh', // sh excluded
'main.rs', // rs excluded
'script.pl', // pl excluded
'node.js', // js not a tld
'index.html', // html not a tld
'file.txt', // txt not a tld
'Cargo.toml', // toml not a tld
'package.json', // json not a tld
'etc', // single label
'page.io', // io is a real TLD but not in the curated list
'corp.co', // co is a real TLD but excluded on purpose
'ab.com', // 2-char registrable host (com is in the list)
'x.org', // 1-char registrable host (org is in the list)
'1.2.3.4', // last label not a tld
'v1.2', // last label not a tld
'192.168.0.1', // last label not a tld
'-bad.com', // leading hyphen label
'bad-.com', // trailing hyphen label
]
for (const host of rejected) {
it(`rejects ${host}`, () => {
expect(isLinkableBareHost(host)).toBe(false)
})
}
})
55 changes: 55 additions & 0 deletions packages/core/src/lezer/autolink-tld.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Allowed TLDs when they appear in a bare domain (no scheme, no `www.`).
*
* The 10 most-visited TLDs by real Chrome traffic.
* Source: Chrome UX Report https://github.com/zakird/crux-top-lists
*/
const BARE_AUTOLINK_TLDS: ReadonlySet<string> = new Set([
'com',
'br',
'net',
'jp',
'org',
'in',
'de',
'ru',
'it',
'fr',
])

// A single DNS label: alphanumeric, hyphens allowed inside but not at the edges.
const DNS_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i

/** The host portion of a bare candidate: everything before the first `/`. */
export function hostFromUrl(text: string): string {
const slash = text.indexOf('/')
return slash === -1 ? text : text.slice(0, slash)
}

/**
* True when `host` (no scheme, no `@`, path already stripped) is a bare domain
* meowdown links. Rules:
*
* - at least two dot-separated labels (host + tld)
* - the last label is in `BARE_AUTOLINK_TLDS` (matched case-insensitively)
* - the registrable label (the one before the tld) is at least 3 chars, so
* `t.co` / `x.io` / `do.so` stay plain text
* - every label is a valid DNS label (alphanumeric, inner hyphens only, <= 63
* chars), which also rejects IP-like input such as `1.2.3.4` because its last
* label is not a known tld
*/
export function isLinkableBareHost(host: string): boolean {
const labels = host.split('.')
if (labels.length < 2) return false

const tld = labels[labels.length - 1].toLowerCase()
if (!BARE_AUTOLINK_TLDS.has(tld)) return false

const registrable = labels[labels.length - 2]
if (registrable.length < 3) return false

for (const label of labels) {
if (label.length > 63 || !DNS_LABEL_RE.test(label)) return false
}
return true
}
Loading
Loading