refactor(jetbrains): reuse autolink url scanner

Replace the custom URL boundary and bracket trimming logic with the same
org.nibor.autolink scanner used by CommonMark's autolink extension. Run
it against raw inline-code AST literals so HtmlWriter owns escaping, and
declare the dependency explicitly so Gradle bundles it with the plugin.
This commit is contained in:
kirillk
2026-08-28 12:33:42 -04:00
parent 1d6744e63e
commit 46a885c2d5
5 changed files with 70 additions and 60 deletions
@@ -26,6 +26,9 @@ dependencies {
implementation(libs.commonmark.autolink)
implementation(libs.commonmark.tables)
implementation(libs.commonmark.strikethrough)
// Bundled explicitly rather than relied on as a transitive of commonmark-ext-autolink: the URL
// scanner is used directly to linkify code spans.
implementation(libs.autolink)
implementation(libs.kotlinx.serialization.json)
implementation(libs.zxing.core)
@@ -19,14 +19,12 @@ internal object MdCommon {
private val code = Regex("<code(\\s[^>]*)?>(.*?)</code>", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
private val tag = Regex("<[^>]+>")
private val ref = Regex("(?<![\\w@:/.-])((?:\\.?[A-Za-z0-9_-]{1,80}/){1,20}[A-Za-z0-9_.-]{1,120}\\.[A-Za-z0-9_-]{1,20}(?::\\d{1,7}(?:-\\d{1,7})?)?|[A-Za-z0-9_.-]{1,120}\\.(?:ts|tsx|js|jsx|mjs|cjs|kt|kts|java|md|mdx|txt|json|jsonc|yaml|yml|toml|xml|html|css|scss|rs|go|py|rb|php|swift|c|h|cpp|hpp|cs|sh|zsh|bash|sql|gradle)(?::\\d{1,7}(?:-\\d{1,7})?)?)")
private val url = Regex("https?://[^\\s<>\"'`]+", RegexOption.IGNORE_CASE)
private val single = setOf("readme.md", "package.json", "tsconfig.json", "jsconfig.json", "kilo.json", "kilo.jsonc")
// Entities the markdown renderer emits for characters that cannot appear in a URL; a match that
// runs into one of them is cut short so the link stops at the original delimiter.
private val entities = listOf("&lt;", "&gt;", "&quot;")
private const val TRAIL = ".,;:!?"
private const val REF_SEGMENT_LIMIT = 16_384
/** Anchor class [ai.kilocode.client.ui.md.hybrid.MdProjector]'s code-span linkifier tags its links with. */
const val URL_REF_CLASS = "kilo-url-ref"
val tags = listOf(
"body", "p", "div", "span", "ul", "ol", "li", "table", "thead", "tbody", "tr", "th", "td",
"blockquote", "h1", "h2", "h3", "h4", "h5", "h6", "a", "tt", "code", "samp", "pre",
@@ -41,7 +39,7 @@ internal object MdCommon {
.replace("\r", " ")
fun inlineCode(html: String, opts: MdStyle): String {
if (!html.contains('<') && !scan(html)) return html
if (!html.contains('<') && !html.contains('.')) return html
val color = hex(opts.inlineCodeFg)
val styled = if (html.contains("<code", ignoreCase = true)) {
val out = StringBuilder()
@@ -80,7 +78,7 @@ internal object MdCommon {
rules.append("em, i { color: ${hex(opts.emphasisFg)} } ")
rules.append("a { color: ${hex(opts.linkColor)} } ")
rules.append("a.kilo-file-ref, code a.kilo-file-ref { color: ${hex(SessionUiStyle.View.Markdown.string())}; font-family: '${css(opts.codeFont)}', monospace; text-decoration: underline } ")
rules.append("a.kilo-url-ref, code a.kilo-url-ref { color: ${hex(opts.linkColor)}; font-family: '${css(opts.codeFont)}', monospace; text-decoration: underline } ")
rules.append("a.$URL_REF_CLASS, code a.$URL_REF_CLASS { color: ${hex(opts.linkColor)}; font-family: '${css(opts.codeFont)}', monospace; text-decoration: underline } ")
rules.append("ul, ol { color: ${hex(opts.listMarkerFg)} } ")
rules.append("li { color: ${hex(opts.foreground)} } ")
rules.append("tt, code, samp, pre, pre code { font-family: '${css(opts.codeFont)}', monospace; border-width: 0 } ")
@@ -142,7 +140,7 @@ internal object MdCommon {
}
private fun refs(html: String): String {
if (!scan(html)) return html
if (!html.contains('.')) return html
val out = StringBuilder()
var at = 0
for (match in protect.findAll(html)) {
@@ -155,7 +153,7 @@ internal object MdCommon {
}
private fun tags(html: String): String {
if (!scan(html)) return html
if (!html.contains('.')) return html
val out = StringBuilder()
var at = 0
for (match in tag.findAll(html)) {
@@ -167,29 +165,8 @@ internal object MdCommon {
return out.toString()
}
/**
* Linkifies plain text between tags: `http(s)` URLs first, then file references in whatever text
* is left over. URLs are handled here rather than by the markdown parser because CommonMark's
* autolink extension skips code spans, so a URL in backticks would otherwise stay inert text.
*/
private fun paths(text: String): String {
if (text.length > REF_SEGMENT_LIMIT || !scan(text)) return text
if (!text.contains("://")) return files(text)
val out = StringBuilder()
var at = 0
for (match in url.findAll(text)) {
out.append(files(text.substring(at, match.range.first)))
val link = cut(match.value)
at = match.range.first + link.length
// The renderer already escaped the source, so the matched text is a valid attribute value.
out.append("<a class=\"kilo-url-ref\" href=\"$link\">$link</a>")
}
out.append(files(text.substring(at)))
return out.toString()
}
private fun files(text: String): String {
if (!text.contains('.')) return text
if (text.length > REF_SEGMENT_LIMIT || !text.contains('.')) return text
return ref.replace(text) { match ->
val path = match.value
if (!pathish(path)) return@replace path
@@ -200,35 +177,6 @@ internal object MdCommon {
}
}
/** Trims a raw URL match down to the part that belongs to the link. */
private fun cut(value: String): String {
var end = value.length
for (item in entities) {
val at = value.indexOf(item)
if (at in 0 until end) end = at
}
while (end > 0) {
val char = value[end - 1]
if (char in TRAIL) {
end--
continue
}
val open = when (char) {
')' -> '('
']' -> '['
'}' -> '{'
else -> break
}
val head = value.substring(0, end)
if (head.count { it == open } >= head.count { it == char }) break
end--
}
return value.substring(0, end)
}
/** True when [text] can contain a file reference or a URL worth scanning for. */
private fun scan(text: String): Boolean = text.contains('.') || text.contains("://")
private fun pathish(path: String): Boolean {
if (path.contains('/')) return true
val name = path.substringBefore(':')
@@ -1,5 +1,6 @@
package ai.kilocode.client.ui.md.hybrid
import ai.kilocode.client.ui.md.MdCommon
import com.intellij.openapi.fileTypes.PlainTextFileType
import org.commonmark.ext.autolink.AutolinkExtension
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension
@@ -7,13 +8,19 @@ import org.commonmark.ext.gfm.tables.TableBlock
import org.commonmark.ext.gfm.tables.TablesExtension
import org.commonmark.node.AbstractVisitor
import org.commonmark.node.Block
import org.commonmark.node.Code
import org.commonmark.node.Document
import org.commonmark.node.FencedCodeBlock
import org.commonmark.node.IndentedCodeBlock
import org.commonmark.node.Node
import org.commonmark.node.ThematicBreak
import org.commonmark.parser.Parser
import org.commonmark.renderer.NodeRenderer
import org.commonmark.renderer.html.HtmlNodeRendererContext
import org.commonmark.renderer.html.HtmlRenderer
import org.nibor.autolink.LinkExtractor
import org.nibor.autolink.LinkSpan
import org.nibor.autolink.LinkType
internal class MdProjector {
private val extensions = listOf(
@@ -28,6 +35,9 @@ internal class MdProjector {
.extensions(extensions)
.escapeHtml(true)
.sanitizeUrls(true)
// HtmlRenderer always appends the core node renderer last, so any factory added here wins
// for the node types it handles.
.nodeRendererFactory { context -> CodeLinks(context) }
.build()
fun project(text: String): Projection {
@@ -213,6 +223,46 @@ internal class MdProjector {
}
}
/**
* Renders `Code` (inline code span) nodes, linkifying any `http(s)` URL found in the literal text.
*
* CommonMark's [AutolinkExtension] only scans [org.commonmark.node.Text] nodes, so a URL written in
* backticks is otherwise never linkified. This reuses the same URL scanner the extension is built on
* ([LinkExtractor], from the `autolink` library CommonMark depends on) to detect links, then relies on
* [org.commonmark.renderer.html.HtmlWriter] to escape both the link text and the `href` attribute the
* same way the core renderer would.
*/
private class CodeLinks(private val context: HtmlNodeRendererContext) : NodeRenderer {
companion object {
private val EXTRACTOR: LinkExtractor = LinkExtractor.builder().linkTypes(setOf(LinkType.URL)).build()
}
override fun getNodeTypes(): Set<Class<out Node>> = setOf(Code::class.java)
override fun render(node: Node) {
val code = node as Code
val html = context.writer
html.tag("code", context.extendAttributes(code, "code", emptyMap()))
val literal = code.literal
for (span in EXTRACTOR.extractSpans(literal)) {
val text = literal.substring(span.beginIndex, span.endIndex)
if (span !is LinkSpan || !web(text)) {
html.text(text)
continue
}
html.tag("a", mapOf("class" to MdCommon.URL_REF_CLASS, "href" to text))
html.text(text)
html.tag("/a")
}
html.tag("/code")
}
// LinkExtractor.linkTypes(URL) matches any "scheme://…", not just http(s) (e.g. file://, ftp://);
// restrict to what SessionFileLinks.isFileHref routes to the browser opener.
private fun web(text: String): Boolean =
text.startsWith("http://", ignoreCase = true) || text.startsWith("https://", ignoreCase = true)
}
internal sealed class Desc {
data class Html(val body: String) : Desc()
data class Code(val text: String, val kind: Kind) : Desc()
@@ -236,6 +236,13 @@ class MdViewTest : BasePlatformTestCase() {
assertTrue(html.contains("<a class=\"kilo-file-ref\" href=\"packages/opencode/src/session/prompt.ts\">"))
}
fun `test inline code urls stop at characters that cannot appear in a url`() {
view.set("See `https://example.com/a<b>`")
val html = view.html()
assertTrue(html.contains("href=\"https://example.com/a\">https://example.com/a</a>&lt;b&gt;"))
}
// ---- append ----
fun `test append accumulates source`() {
@@ -12,6 +12,7 @@ okhttp = "4.12.0"
openapi-generator = "7.21.0"
detekt = "1.23.8"
commonmark = "0.28.0"
autolink = "0.12.0"
zxing = "3.5.3"
changelog = "2.5.0"
commons-compress = "1.28.0"
@@ -21,6 +22,7 @@ commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark"
commonmark-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" }
commonmark-tables = { module = "org.commonmark:commonmark-ext-gfm-tables", version.ref = "commonmark" }
commonmark-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" }
autolink = { module = "org.nibor.autolink:autolink", version.ref = "autolink" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse", version.ref = "okhttp" }
okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }