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: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"guard:engine-registry": "node scripts/guard-engine-registry.mjs",
"lint": "biome lint .",
"lint:fix": "biome lint --write .",
"test": "node --import tsx --test \"server/src/__tests__/**/*.test.ts\" \"workers/**/*.test.ts\"",
"test": "node --import tsx --test \"server/src/__tests__/**/*.test.ts\" \"tests/**/*.test.ts\" \"workers/**/*.test.ts\"",
"test:integration": "node server/run-integration-tests.mjs",
"migrate": "tsx server/src/migrate-bin.ts",
"icons": "python3 scripts-make-mac-icon.py && npx -y electron-icon-builder -i build/icon.png -o build --flatten && node scripts-sync-electron-dev-icon.mjs",
Expand Down
16 changes: 5 additions & 11 deletions src/components/LinkPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useState, type ReactNode } from 'react'
import { type ReactNode, useEffect, useState } from 'react'
import { http } from '@/api/client'
import { firstHttpUrlInMarkdown } from '@/lib/markdownUrls'

/**
* Inline OG card rendered under a chat bubble when its body contains a URL.
Expand Down Expand Up @@ -221,15 +222,8 @@ export function LinkPreview({ url }: { url: string }) {

/** Pull the first http(s) URL out of a message body. Returns null when the
* body has no link; used by the bubble to decide whether to mount a
* LinkPreview at all. Mirrors the regex in `parseBody` so what we render
* as a link in the inline pass is the same thing we expand into a card. */
* LinkPreview at all. Uses the renderer's shared URL-boundary policy so the
* address we render is the same one we expand into a card. */
export function firstUrlInBody(body: string): string | null {
const m = body.match(/\bhttps?:\/\/[^\s<>"'`]+/)
if (!m) return null
// Trim sentence punctuation the same way parseBody does.
let url = m[0]
while (/[.,;:!?")\]}>'"]$/.test(url) && url.length > 'https://'.length) {
url = url.slice(0, -1)
}
return url
return firstHttpUrlInMarkdown(body)
}
78 changes: 78 additions & 0 deletions src/lib/markdownUrls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Shared URL-boundary handling for chat Markdown.
*
* GFM literal autolinks deliberately accept almost every non-whitespace
* character in a URL path. That is surprising in CJK prose, where a URL is
* commonly followed immediately by a full-width punctuation mark, and it can
* also swallow a Markdown closing delimiter when prose follows it without a
* space (for example `**https://example.com**(说明)`).
*
* Keep this policy in one place so the Markdown renderer and link-preview
* extractor agree on the URL that a user can actually open.
*/

/** Characters that conventionally end a URL in CJK prose. A literal URL
* that really needs one of these characters can still use percent encoding or
* explicit Markdown link syntax. */
const CJK_PROSE_BOUNDARY_RE = /[,。;:!?、()【】《》〈〉“”‘’「」『』]/u

const TRAILING_PUNCTUATION = new Set(['.', ',', ';', ':', '!', '?', '"', "'"])
const BRACKET_PAIRS: Record<string, string> = { ')': '(', ']': '[', '}': '{', '>': '<' }

const HTTP_URL_CANDIDATE_RE = /\bhttps?:\/\/[^\s<>"'`]+/g

export interface SplitHttpUrlOptions {
/** A Markdown delimiter known to close immediately after the URL. */
closingMarker?: string
}

/** Split a permissively captured HTTP(S) URL from prose that was swept into
* the same match. The returned `trail` is always the exact unused suffix. */
export function splitHttpUrlCandidate(
raw: string,
options: SplitHttpUrlOptions = {},
): { url: string; trail: string } {
let end = raw.length

if (options.closingMarker) {
const markerAt = raw.indexOf(options.closingMarker, 'https://'.length)
if (markerAt >= 0) end = Math.min(end, markerAt)
}

const cjkAt = raw.slice(0, end).search(CJK_PROSE_BOUNDARY_RE)
if (cjkAt >= 0) end = Math.min(end, cjkAt)

// Trim ordinary sentence punctuation. Keep balanced ASCII brackets inside
// the URL (for example a Wikipedia title ending in `)`), but remove a prose
// closer that has no matching opener in the captured URL.
while (end > 'https://'.length) {
const c = raw[end - 1]
if (TRAILING_PUNCTUATION.has(c)) {
end--
continue
}
const opener = BRACKET_PAIRS[c]
if (!opener) break
const inside = raw.slice(0, end - 1)
const opens = Array.from(inside).filter((value) => value === opener).length
const closes = Array.from(inside).filter((value) => value === c).length
if (closes >= opens) end--
else break
}

return { url: raw.slice(0, end), trail: raw.slice(end) }
}

const MARKDOWN_URL_WRAPPERS = ['**', '__', '~~', '*', '_'] as const

/** Return the first HTTP(S) URL represented by a chat Markdown body. */
export function firstHttpUrlInMarkdown(body: string): string | null {
HTTP_URL_CANDIDATE_RE.lastIndex = 0
const match = HTTP_URL_CANDIDATE_RE.exec(body)
if (!match) return null

const prefix = body.slice(0, match.index)
const closingMarker = MARKDOWN_URL_WRAPPERS.find((marker) => prefix.endsWith(marker))
const { url } = splitHttpUrlCandidate(match[0], { closingMarker })
return url || null
}
88 changes: 86 additions & 2 deletions src/lib/remarkCumora.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@
* (bold/italic/code/links), so in practice parseBody only finds the four custom
* kinds — but we map every kind it can emit, defensively.
*/
import { visit, SKIP } from 'unist-util-visit'
import type { Root, RootContent, Text } from 'mdast'
import type { Link, Parents, Root, RootContent, Text } from 'mdast'
import { SKIP, visit } from 'unist-util-visit'
import { splitHttpUrlCandidate } from './markdownUrls'
import { parseBody, type RichToken } from './utils'

/** A custom mdast node that mdast-util-to-hast renders as `<hName …>` because of
Expand Down Expand Up @@ -53,10 +54,93 @@ function tokenToNode(t: RichToken): RootContent {
}
}

const URL_WRAPPERS = [
{ marker: '**', type: 'strong' },
{ marker: '__', type: 'strong' },
{ marker: '~~', type: 'delete' },
{ marker: '*', type: 'emphasis' },
{ marker: '_', type: 'emphasis' },
] as const

function samePosition(a: RootContent['position'], b: RootContent['position']): boolean {
return !!a && !!b
&& a.start.offset === b.start.offset
&& a.end.offset === b.end.offset
}

/** GFM literal autolinks can absorb Markdown closers and CJK punctuation.
* Repair those nodes before applying Cumora's custom inline-token pass. */
function repairLiteralAutolinks(parent: Parents): void {
for (let index = 0; index < parent.children.length; index++) {
const node = parent.children[index]
if (node.type !== 'link') {
if ('children' in node && Array.isArray(node.children)) repairLiteralAutolinks(node as Parents)
continue
}

const label = node.children.length === 1 && node.children[0].type === 'text'
? node.children[0]
: null
// A literal autolink's link and label cover the same source span. This
// excludes `[label](url)` and `<url>`, which already have explicit bounds.
if (!label || label.value !== node.url || !samePosition(node.position, label.position)) continue

const previous = index > 0 ? parent.children[index - 1] : null
const wrapper = previous?.type === 'text'
? URL_WRAPPERS.find(({ marker }) => (
previous.value.endsWith(marker)
&& node.url.indexOf(marker, 'https://'.length) >= 0
))
: undefined
const split = splitHttpUrlCandidate(node.url, { closingMarker: wrapper?.marker })
if (!split.url || (split.url === node.url && !wrapper)) continue

const fixedLink: Link = {
...node,
url: split.url,
children: [{ type: 'text', value: split.url }],
}

if (wrapper && previous?.type === 'text') {
const before = previous.value.slice(0, -wrapper.marker.length)
const trail = split.trail.slice(wrapper.marker.length)
const formatted = {
type: wrapper.type,
children: [fixedLink],
} as RootContent
const replacement: RootContent[] = []
if (before) replacement.push({ type: 'text', value: before })
replacement.push(formatted)
if (trail) replacement.push({ type: 'text', value: trail })
parent.children.splice(index - 1, 2, ...replacement)
index = index - 2 + replacement.length
continue
}

parent.children[index] = fixedLink
if (split.trail) {
parent.children.splice(index + 1, 0, { type: 'text', value: split.trail })
index++
}
}
}

export function remarkCumora() {
return (tree: Root): void => {
repairLiteralAutolinks(tree)

// Do not tokenize text that is already a link label. In particular, a
// GFM literal autolink label is the URL itself; converting that text again
// used to produce invalid nested `<a>` elements for every bare URL.
const linkedText = new WeakSet<object>()
visit(tree, ['link', 'linkReference'], (node) => {
visit(node, 'text', (text) => { linkedText.add(text) })
return SKIP
})

visit(tree, 'text', (node: Text, index, parent) => {
if (!parent || typeof index !== 'number') return
if (linkedText.has(node)) return
const tokens = parseBody(node.value)
// Nothing Cumora-specific in this text node — leave it untouched.
if (tokens.length === 1 && tokens[0].kind === 'text') return
Expand Down
29 changes: 4 additions & 25 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { type ClassValue, clsx } from 'clsx'
import { useEffect, useState } from 'react'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
Expand Down Expand Up @@ -76,6 +76,7 @@ export type RichToken =

import emojiRegex from 'emoji-regex'
import { findSkypeByShortcode, SKYPE_SHORTCODE_RE } from '@/lib/skypeEmojis'
import { splitHttpUrlCandidate } from './markdownUrls'

const DOC_REF_TOKEN_RE = /^doc_[A-Za-z0-9]+$/
const BOARD_REF_TOKEN_RE = /^board-[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/
Expand Down Expand Up @@ -119,28 +120,6 @@ function splitUnicodeEmoji(seg: string, out: RichToken[]): void {
if (last < seg.length) out.push({ kind: 'text', value: seg.slice(last) })
}

/** Strip trailing sentence punctuation from a captured URL match. Keeps
* balanced ()[]{}<> inside the URL — only trims a closing bracket when
* there's no matching opener inside the URL (typical case: the URL was
* inside parens in prose, e.g. "(see https://x.com)"). */
function trimUrlTrailing(raw: string): { url: string; trail: string } {
const closers: Record<string, string> = { ')': '(', ']': '[', '}': '{', '>': '<' }
let i = raw.length
while (i > 'https://'.length) {
const c = raw[i - 1]
if (c === '.' || c === ',' || c === ';' || c === ':' || c === '!' || c === '?' || c === '"' || c === "'") {
i--
} else if (closers[c]) {
const inside = raw.slice(0, i - 1)
const opens = (inside.match(new RegExp(`\\${closers[c]}`, 'g')) ?? []).length
const closes = (inside.match(new RegExp(`\\${c}`, 'g')) ?? []).length
if (closes >= opens) i--
else break
} else break
}
return { url: raw.slice(0, i), trail: raw.slice(i) }
}

export function parseBody(body: string): RichToken[] {
const tokens: RichToken[] = []
// Order matters: inline `code` first so backtick-wrapped @mentions / bold
Expand Down Expand Up @@ -181,7 +160,7 @@ export function parseBody(body: string): RichToken[] {
// Strip trailing punctuation, keeping balanced () [] {} <> inside the
// URL (Wikipedia titles, Mediawiki anchors, etc.) and re-emitting the
// trimmed tail as plain text so the message keeps its grammar.
const { url, trail } = trimUrlTrailing(t)
const { url, trail } = splitHttpUrlCandidate(t)
tokens.push({ kind: 'link', url, text: url })
if (trail) splitEmoji(trail, tokens)
} else if (t.startsWith('doc_')) {
Expand Down
81 changes: 81 additions & 0 deletions tests/message-markdown-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import type { Root } from 'mdast'
import remarkGfm from 'remark-gfm'
import remarkParse from 'remark-parse'
import { unified } from 'unified'
import { firstHttpUrlInMarkdown } from '../src/lib/markdownUrls'
import { remarkCumora } from '../src/lib/remarkCumora'

async function parse(markdown: string): Promise<Root> {
const processor = unified().use(remarkParse).use(remarkGfm).use(remarkCumora)
return await processor.run(processor.parse(markdown)) as Root
}

describe('chat Markdown links', () => {
it('repairs a bold URL followed immediately by CJK prose', async () => {
const tree = await parse('浏览器打开 **http://127.0.0.1:4182**(本机 preview)')
const paragraph = tree.children[0]
assert.equal(paragraph.type, 'paragraph')
if (paragraph.type !== 'paragraph') return

assert.deepEqual(paragraph.children.map((node) => node.type), ['text', 'strong', 'text', 'text'])
const strong = paragraph.children[1]
assert.equal(strong.type, 'strong')
if (strong.type !== 'strong') return
const link = strong.children[0]
assert.equal(link.type, 'link')
if (link.type !== 'link') return
assert.equal(link.url, 'http://127.0.0.1:4182')
assert.equal(link.children[0].type === 'text' ? link.children[0].value : null, link.url)
const visibleText = paragraph.children
.filter((node) => node.type === 'text')
.map((node) => node.value)
.join('')
assert.equal(visibleText, '浏览器打开 (本机 preview)')
assert.ok(!visibleText.includes('**'))
})

it('keeps a literal URL as one non-nested link', async () => {
const tree = await parse('打开 https://example.com/path')
const paragraph = tree.children[0]
assert.equal(paragraph.type, 'paragraph')
if (paragraph.type !== 'paragraph') return
const link = paragraph.children.find((node) => node.type === 'link')
assert.ok(link && link.type === 'link')
assert.deepEqual(link.children.map((node) => node.type), ['text'])
})

it('stops a literal URL at CJK punctuation', async () => {
const tree = await parse('详见 https://example.com/path(说明)')
const paragraph = tree.children[0]
assert.equal(paragraph.type, 'paragraph')
if (paragraph.type !== 'paragraph') return
const link = paragraph.children.find((node) => node.type === 'link')
assert.ok(link && link.type === 'link')
assert.equal(link.url, 'https://example.com/path')
})

it('does not alter an explicit Markdown link', async () => {
const tree = await parse('[本机预览](http://127.0.0.1:4182)')
const paragraph = tree.children[0]
assert.equal(paragraph.type, 'paragraph')
if (paragraph.type !== 'paragraph') return
const link = paragraph.children[0]
assert.equal(link.type, 'link')
if (link.type !== 'link') return
assert.equal(link.url, 'http://127.0.0.1:4182')
assert.equal(link.children[0].type === 'text' ? link.children[0].value : null, '本机预览')
})

it('uses the same URL boundary for link previews', () => {
assert.equal(
firstHttpUrlInMarkdown('浏览器打开 **http://127.0.0.1:4182**(本机 preview)'),
'http://127.0.0.1:4182',
)
assert.equal(
firstHttpUrlInMarkdown('详见 https://example.com/path(说明)'),
'https://example.com/path',
)
})
})
Loading