Turning URLs into User Friendly Links with Stimulus in Rails

Sometimes the best product ideas come from small annoyances. I was writing in a blog app and realized that copying and pasting a link to another article felt clumsy. The URL sat there in the editor, raw and unhelpful. What I really wanted was the title of the article, linked automatically.

That small frustration made me wonder how it could be implemented. So I built it, and here’s how I did it.

The idea

Let’s say we have a blog app with articles. To create an article, users enter a title and body. When published, the page title (shown in the browser tab) is the article title.

For example, if we visit an article called Rails is awesome, we’ll see that text in the tab:

<!DOCTYPE html>
<html>
  <head>
    <title>Rails is awesome</title>
    ...

Now imagine a user wants to link that article from another post. Instead of pasting the URL and then editing it into a link, they just paste the URL and it turns into a link with the article’s title.

Building it with Trix and Stimulus

I used Trix as the rich text editor and a Stimulus controller to handle paste events. The logic is simple:

  1. Detect when a user pastes text.
  2. Check if it’s a URL from the same domain.
  3. Fetch the article title.
  4. Replace the pasted URL with a link.

First thing to do is associate the text editor with a stimulus controller and bind the trix-paste action:

<%= form.rich_text_area :body, 
      data: { 
        controller: "url-link", 
        action: "trix-paste->url-link#handlePaste" 
      } 
%>

Here’s the function that kicks things off:

handlePaste(event) {
  const plainText = this.getPlainTextFromPaste(event.paste)

  // Checks if it is a url from the same domain
  if (!plainText?.startsWith(window.location.origin)) return

  setTimeout(() => this.replacePastedUrl(plainText), 100)
}

I added a short timeout so the user sees what’s happening before the replacement.

Replacing the pasted URL

Once we confirm it’s a valid URL, we fetch the title, select the pasted text, and swap it out for a link:

async replacePastedUrl(url) {
  const title = await this.fetchTitle(url)
  const editor = this.element.editor
  const currentRange = editor.getSelectedRange()    
  const range = [currentRange[0] - url.length, currentRange[0]]

  editor.setSelectedRange(range)
  editor.activateAttribute("href", url)
  editor.insertString(title)
  editor.deactivateAttribute("href")
}

Fetching the title

To get the title, we make a request to the URL and grab the <title> tag:

async fetchTitle(url) {
  const response = await fetch(url)
  const html = await response.text()
  const doc = new DOMParser().parseFromString(html, "text/html")

  return doc.querySelector("title")?.textContent?.trim() || url
}

And that’s all! We should have our editor replace the url with a links 💪.

Handling non-string paste content

One detail that tripped me up: sometimes the paste event gives you HTML instead of plain text. To cover that case, I added a helper that normalizes both:

getPlainTextFromPaste(paste) {
  if (paste.string) return paste.string.trim();  if (paste.html) {
    const tempDiv = document.createElement('div')
    tempDiv.innerHTML = paste.html
    return (tempDiv.textContent || tempDiv.innerText || '').trim()
  }
  return ''
}

The complete controller

Here’s the full Stimulus controller, which we’ll name  url_link_controller.js:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  handlePaste(event) {
    const plainText = this.getPlainTextFromPaste(event.paste)

    if (!plainText?.startsWith(window.location.origin)) return
  
    setTimeout(() => this.replacePastedUrl(plainText), 100)
  }

  async replacePastedUrl(url) {
    const title = await this.fetchTitle(url)

    const editor = this.element.editor
    const currentRange = editor.getSelectedRange()
    const range = [currentRange[0] - url.length, currentRange[0]]

    editor.setSelectedRange(range)
    editor.activateAttribute("href", url)
    editor.insertString(title)
    editor.deactivateAttribute("href")
  }

  async fetchTitle(url) {
    const response = await fetch(url)
    const html = await response.text()
    const doc = new DOMParser().parseFromString(html, "text/html")
    return doc.querySelector("title")?.textContent?.trim() || url
  }

  getPlainTextFromPaste(paste) {
    if (paste.string) return paste.string.trim();

    if (paste.html) {
      const tempDiv = document.createElement('div')
      tempDiv.innerHTML = paste.html
      return (tempDiv.textContent || tempDiv.innerText || '').trim()
    }

    return ''
  }
}

Takeaway

The lesson for me was that small improvements like this can dramatically change how smooth an editor feels. If users are pasting links often, this tiny detail can make the difference between friction and flow.