Skip to content
Open
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
10 changes: 10 additions & 0 deletions packages/core/src/lib/styled-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface StyleAttrs {
dim?: boolean
reverse?: boolean
blink?: boolean
href?: string
}

export function isStyledText(obj: any): obj is StyledText {
Expand Down Expand Up @@ -49,6 +50,7 @@ function applyStyle(input: StylableInput, style: StyleAttrs): TextChunk {

const fg = style.fg ? parseColor(style.fg) : existingChunk.fg
const bg = style.bg ? parseColor(style.bg) : existingChunk.bg
const href = style.href ?? existingChunk.href

const newAttrs = createTextAttributes(style)
const mergedAttrs = existingChunk.attributes ? existingChunk.attributes | newAttrs : newAttrs
Expand All @@ -59,6 +61,7 @@ function applyStyle(input: StylableInput, style: StyleAttrs): TextChunk {
fg,
bg,
attributes: mergedAttrs,
href,
}
} else {
const plainTextStr = String(input)
Expand All @@ -72,6 +75,7 @@ function applyStyle(input: StylableInput, style: StyleAttrs): TextChunk {
fg,
bg,
attributes,
href: style.href,
}
}
}
Expand Down Expand Up @@ -125,6 +129,12 @@ export const bg =
(input: StylableInput): TextChunk =>
applyStyle(input, { bg: color })

// Hyperlink function - applies underline style and href
export const link =
(url: string) =>
(input: StylableInput): TextChunk =>
applyStyle(input, { href: url, underline: true })

/**
* Template literal handler for styled text (non-cached version).
* Returns a StyledText object containing chunks of text with optional styles.
Expand Down
111 changes: 111 additions & 0 deletions packages/core/src/lib/tree-sitter-styled-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,117 @@ Normal paragraph with [link](https://example.com).`
})
})

describe("URL/Link href extraction", () => {
test("should extract href from autolink URLs", async () => {
// Autolinks in markdown use angle brackets
const markdownCode = "Check out <https://example.com> for more info"

const styledText = await treeSitterToStyledText(markdownCode, "markdown", syntaxStyle, client, {
conceal: { enabled: false },
})
const chunks = styledText.chunks

// Find the URL chunk (includes angle brackets in text)
const urlChunk = chunks.find((c) => c.text.includes("https://example.com"))
expect(urlChunk).toBeDefined()

// The href should be set to the URL text (with angle brackets since that's how tree-sitter parses it)
expect(urlChunk!.href).toBe("<https://example.com>")
})

test("should extract href from inline markdown links", async () => {
const markdownCode = "[Click here](https://test.org)"

const styledText = await treeSitterToStyledText(markdownCode, "markdown", syntaxStyle, client, {
conceal: { enabled: false },
})
const chunks = styledText.chunks

// Find the URL chunk (in the parentheses, without the parens)
const urlChunk = chunks.find((c) => c.text === "https://test.org")
expect(urlChunk).toBeDefined()
expect(urlChunk!.href).toBe("https://test.org")
})

test("should add underline attribute to URL chunks with href", async () => {
const markdownCode = "[Link](<https://example.com>)"

const styledText = await treeSitterToStyledText(markdownCode, "markdown", syntaxStyle, client, {
conceal: { enabled: false },
})
const chunks = styledText.chunks

// Find the chunk with href set
const urlChunk = chunks.find((c) => c.href !== undefined)
expect(urlChunk).toBeDefined()

// Should have underline attribute
const underlineAttr = createTextAttributes({ underline: true })
expect(urlChunk!.attributes! & underlineAttr).toBe(underlineAttr)
})

test("should not set href for non-URL text chunks", async () => {
const markdownCode = "Regular text without URLs"

const styledText = await treeSitterToStyledText(markdownCode, "markdown", syntaxStyle, client, {
conceal: { enabled: false },
})
const chunks = styledText.chunks

// None of the chunks should have href set
for (const chunk of chunks) {
expect(chunk.href).toBeUndefined()
}
})

test("should extract href from plain URLs without markdown syntax", async () => {
// Plain URLs should be automatically detected and made clickable
const markdownCode = "Visit https://example.com today"

const styledText = await treeSitterToStyledText(markdownCode, "markdown", syntaxStyle, client, {
conceal: { enabled: false },
})
const chunks = styledText.chunks

// Should find a chunk with the URL as href
const urlChunk = chunks.find((c) => c.href === "https://example.com")
expect(urlChunk).toBeDefined()
expect(urlChunk!.text).toBe("https://example.com")

// Should have underline attribute
const underlineAttr = createTextAttributes({ underline: true })
expect(urlChunk!.attributes! & underlineAttr).toBe(underlineAttr)
})

test("should handle multiple plain URLs in text", async () => {
const markdownCode = "Check https://first.com and https://second.com for info"

const styledText = await treeSitterToStyledText(markdownCode, "markdown", syntaxStyle, client, {
conceal: { enabled: false },
})
const chunks = styledText.chunks

const urlChunks = chunks.filter((c) => c.href !== undefined)
expect(urlChunks.length).toBe(2)
expect(urlChunks[0].href).toBe("https://first.com")
expect(urlChunks[1].href).toBe("https://second.com")
})

test("should handle URLs at start and end of text", async () => {
const markdownCode = "https://start.com is good and so is https://end.com"

const styledText = await treeSitterToStyledText(markdownCode, "markdown", syntaxStyle, client, {
conceal: { enabled: false },
})
const chunks = styledText.chunks

const urlChunks = chunks.filter((c) => c.href !== undefined)
expect(urlChunks.length).toBe(2)
expect(urlChunks[0].href).toBe("https://start.com")
expect(urlChunks[1].href).toBe("https://end.com")
})
})

describe("Style Inheritance", () => {
test("should merge styles from nested highlights with child overriding parent", () => {
const mockHighlights: SimpleHighlight[] = [
Expand Down
97 changes: 95 additions & 2 deletions packages/core/src/lib/tree-sitter-styled-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,92 @@ function getSpecificity(group: string): number {
return group.split(".").length
}

/**
* Check if a highlight group represents a URL/link that should be clickable
*/
function isLinkUrlGroup(group: string): boolean {
return group === "markup.link.url" || group === "string.special.url"
}

/**
* Regular expression to match URLs in text
* Matches http://, https://, and common URL patterns
*/
const URL_REGEX = /https?:\/\/[^\s<>\[\]()'"`,;]+[^\s<>\[\]()'"`,;.!?:]/g

/**
* Extract plain URLs from a text chunk and split it into multiple chunks
* with proper href attributes for the URL portions
*/
function splitChunkByUrls(chunk: TextChunk): TextChunk[] {
// If chunk already has href, don't process it
if (chunk.href) {
return [chunk]
}

const text = chunk.text
const results: TextChunk[] = []
let lastIndex = 0

// Reset regex state
URL_REGEX.lastIndex = 0

let match: RegExpExecArray | null
while ((match = URL_REGEX.exec(text)) !== null) {
const url = match[0]
const startIndex = match.index

// Add text before the URL (if any)
if (startIndex > lastIndex) {
results.push({
...chunk,
text: text.slice(lastIndex, startIndex),
href: undefined,
})
}

// Add the URL chunk with href and underline
const urlAttributes = chunk.attributes ?? 0
const underlineAttr = createTextAttributes({ underline: true })

results.push({
...chunk,
text: url,
href: url,
attributes: urlAttributes | underlineAttr,
})

lastIndex = startIndex + url.length
}

// Add remaining text after the last URL (if any)
if (lastIndex < text.length) {
results.push({
...chunk,
text: text.slice(lastIndex),
href: undefined,
})
}

// If no URLs were found, return the original chunk
if (results.length === 0) {
return [chunk]
}

return results
}

/**
* Process all chunks to extract plain URLs and make them clickable
*/
function processChunksForUrls(chunks: TextChunk[]): TextChunk[] {
const result: TextChunk[] = []
for (const chunk of chunks) {
result.push(...splitChunkByUrls(chunk))
}
return result
}

function shouldSuppressInInjection(group: string, meta: any): boolean {
if (meta?.isInjection) {
return false
Expand Down Expand Up @@ -182,6 +268,11 @@ export function treeSitterToTextChunks(
// Use merged style, falling back to default if nothing was merged
const finalStyle = Object.keys(mergedStyle).length > 0 ? mergedStyle : defaultStyle

// Check if this segment is a URL that should be clickable
// For markup.link.url groups, the segment text itself is the URL
const linkUrlGroup = sortedGroups.find((h) => isLinkUrlGroup(h.group))
const href = linkUrlGroup ? segmentText : undefined

chunks.push({
__isChunk: true,
text: segmentText,
Expand All @@ -191,10 +282,11 @@ export function treeSitterToTextChunks(
? createTextAttributes({
bold: finalStyle.bold,
italic: finalStyle.italic,
underline: finalStyle.underline,
underline: finalStyle.underline || !!href, // Underline links
dim: finalStyle.dim,
})
: 0,
href,
})
}
} else if (currentOffset < boundary.offset) {
Expand Down Expand Up @@ -272,7 +364,8 @@ export function treeSitterToTextChunks(
})
}

return chunks
// Post-process chunks to extract plain URLs and make them clickable
return processChunksForUrls(chunks)
}

export interface TreeSitterToStyledTextOptions {
Expand Down
Loading
Loading