How can I make plugin-created `<span>`s recognized as links by live preview mode?

I maintain a small plugin that turns text that matches a regex into a clickable link. Specifically, go/something is rendered as a link and when clicked on, sends the user to http://go/something. I don’t use live preview, so I’ve gotten by with unstyled text in source mode and a MarkdownRenderChild for reader view.

I’ve recently been trying to adapt the plugin for the various editing modes using a Codemirror ViewPlugin. I’ve got everything working the way I want for source mode, but I’ve been having trouble with live preview.

I’m correctly turning go/something ranges into the following span:

<span class="cm-url cm-golink" data-golink-href="http://go/something">
  go/something
</span>

This is powered by the following Decoration:

Decoration.mark({
    class: [
      "cm-url", // to make styling work like we want
      GOLINK_HTML_CLASS, // to identify these as being ours
    ].join(" "),
    attributes: {
      [DATA_PROPERTY]: href,
      title: href,
    },
    inclusiveStart: true,
    inclusiveEnd: true,
  }).range(from, to);

The created spans acts just like a link I’ve typed manually (e.g. https://example.com). My issue is in live preview mode.

For links that Obsidian recognizes, it adds <a> tags under the span when the cursor is elsewhere on the page:

<span class="cm-url" spellcheck="false">
  <a class="cm-underline" tabindex="-1" href="#">
    https://go/big
  </a>
</span>

However, my spans don’t get the same treatment. I can fake it with some additional CSS and a class that’s only applied to live preview, but it would be nice to tell Obsidian that these spans are links (just like the ones its finding) and let it take care of the rest. Is that possible? Am I missing something?

Thank you!

You can make <a> nested to the <span> by creating two decorations with the same range. For instance:

import { RangeSetBuilder } from "@codemirror/state";
import { Decoration } from "@codemirror/view";

let innerDeco = Decoration.mark({ class: "inner", tagName: "a" }), // <a>
    outerDeco = Decoration.mark({ class: "outer" }); // <span>

function setDecoAtRanges(ranges: { from: number, to: number }[]): DecorationSet {
    let builder = new RangeSetBuilder();

    for (let range of ranges) {
        builder.add(range.from, range.to, innerDeco); // innerDeco must come first
        builder.add(range.from, range.to, outerDeco);
    }

    return builder.finish();
}

Inner decoration must comes first before the outer one.

Thank you for the reply! That works for manually creating the <a> tag, but I was hoping to tap into whatever auto-formatting Obsidian does in live preview without managing it myself. For instance, with my plugin, the following document would hopefully show 2 links that both act identically (open browser when clicked, can be edited with cursor, etc):

go/neat

https://example.com

The manual approach you suggested builds the right HTML, but I’d have to recreate all of the logic to change the cursor and remove the <a> tag when the line is active, etc (which is what Obsidian does for manually typed links).

Is there not a way to tell Obsidian’s live preview that “hey, this line is a link! even if you don’t think it is”?

I’m afraid not, I haven’t found a way to do so. I think that’s better to implement the logic yourself.