mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
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:
@@ -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)
|
||||
|
||||
|
||||
+8
-60
@@ -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("<", ">", """)
|
||||
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(':')
|
||||
|
||||
+50
@@ -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()
|
||||
|
||||
+7
@@ -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><b>"))
|
||||
}
|
||||
|
||||
// ---- 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" }
|
||||
|
||||
Reference in New Issue
Block a user