From 46a885c2d50c5d693865c3577d5f6a753e56ae05 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 28 Aug 2026 12:33:42 -0400 Subject: [PATCH] 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. --- .../kilo-jetbrains/frontend/build.gradle.kts | 3 + .../ai/kilocode/client/ui/md/MdCommon.kt | 68 +++---------------- .../client/ui/md/hybrid/MdProjector.kt | 50 ++++++++++++++ .../ai/kilocode/client/ui/md/MdViewTest.kt | 7 ++ .../kilo-jetbrains/gradle/libs.versions.toml | 2 + 5 files changed, 70 insertions(+), 60 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/build.gradle.kts b/packages/kilo-jetbrains/frontend/build.gradle.kts index 65d80848ae7..7d034c5d043 100644 --- a/packages/kilo-jetbrains/frontend/build.gradle.kts +++ b/packages/kilo-jetbrains/frontend/build.gradle.kts @@ -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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt index e2eecef6137..5123cd7f569 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt @@ -19,14 +19,12 @@ internal object MdCommon { private val code = Regex("]*)?>(.*?)", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)) private val tag = Regex("<[^>]+>") private val ref = Regex("(?\"'`]+", 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("<", ">", """) - 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(" 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("$link") - } - 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(':') diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdProjector.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdProjector.kt index 1d3e077951b..53e142b2dc3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdProjector.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdProjector.kt @@ -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> = 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() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt index e310bcf99db..5e7010efcf7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt @@ -236,6 +236,13 @@ class MdViewTest : BasePlatformTestCase() { assertTrue(html.contains("")) } + fun `test inline code urls stop at characters that cannot appear in a url`() { + view.set("See `https://example.com/a`") + val html = view.html() + + assertTrue(html.contains("href=\"https://example.com/a\">https://example.com/a<b>")) + } + // ---- append ---- fun `test append accumulates source`() { diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml index f394f4a86dd..8f5c528c65d 100644 --- a/packages/kilo-jetbrains/gradle/libs.versions.toml +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -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" }