DocsSearch.kt
src/jvmMain/kotlin/afield/tools/docs/DocsSearch.kt · 299 lines
package afield.tools.docs
import kotlinx.serialization.Serializable
import java.io.File
import java.text.Normalizer
import java.util.concurrent.atomic.AtomicReference
import kotlin.math.ln
Lexical search over the documentation sources (the Markdown pages under public/docs/, outside
assets/), for Open Anything. The semantic index finds pages by meaning; this finds them by the text a reader
remembers — a page title, a heading, or an identifier from an example such as once.renderer,
which an embedding search ranks poorly.
Each page is split into units — its title, each heading, each fenced code block, and each prose
paragraph — and a query matches a unit when every whitespace-separated term occurs in it, case
insensitively. Hits link into the built site (/field-docs/<page>/#<heading anchor>), so the
index works from the Markdown alone and does not need public/field4/ to have been built.
The index is rebuilt when any page's size or modification time changes.
object DocsSearch
The docs lexical index and its query entry points; see the file overview.
object DocsSearch {
data class Hit
@Serializable
data class Hit(
/** `page`, `section`, `code` or `text`. */
val kind: String,
/** Page path relative to the docs root, e.g. `running_code.md`. */
val page: String,
val pageTitle: String,
/** The heading the hit sits under (the heading itself for `section`). */
val heading: String = "",
val url: String,
/** 1-based line in the Markdown source. */
val line: Int,
val snippet: String,
/** Fence language for `code` hits. */
val language: String = "",
/** The whole matched unit, capped, for the palette's preview. */
val excerpt: String = "",
val score: Double,
)
data class Response(val query: String, val hits: List<Hit>, val totalMatches: Int, val truncated: Boolean)
@Serializable
data class Response(val query: String, val hits: List<Hit>, val totalMatches: Int, val truncated: Boolean)
internal enum class Kind(val id: String, val base: Double)
internal enum class Kind(val id: String, val base: Double) {
PAGE("page", 100.0), SECTION("section", 70.0), CODE("code", 50.0), TEXT("text", 30.0)
}
internal data class Chunk
internal data class Chunk(
val kind: Kind,
val text: String,
val lower: String,
val line: Int,
val heading: String,
val anchor: String,
val language: String = "",
)
internal data class Page(val path: String, val title: String, val url: String, val units: List<Chunk>)
internal data class Page(val path: String, val title: String, val url: String, val units: List<Chunk>)
private data class Index(val signature: Long, val pages: List<Page>)
private val cached = AtomicReference<Index?>(null)
fun docsRoot(): File?
-Dafield.docs.dir, else public/docs found by walking up from the working directory.
fun docsRoot(): File? {
System.getProperty("afield.docs.dir")?.takeIf { it.isNotBlank() }?.let {
return File(it).takeIf { d -> d.isDirectory }
}
var dir: File? = File(System.getProperty("user.dir")).absoluteFile
while (dir != null) {
val candidate = File(dir, "public/docs")
if (candidate.isDirectory) return candidate
dir = dir.parentFile
}
return null
}
fun search(rawQuery: String, limit: Int = 40, maxPerPage: Int = 3, root: File? = docsRoot()): Response
fun search(rawQuery: String, limit: Int = 40, maxPerPage: Int = 3, root: File? = docsRoot()): Response {
val query = rawQuery.trim()
if (query.isEmpty() || root == null) return Response(query, emptyList(), 0, false)
return searchPages(pages(root), query, limit.coerceIn(1, 200), maxPerPage.coerceIn(1, 20))
}
internal fun searchPages(pages: List<Page>, query: String, limit: Int, maxPerPage: Int): Response
body — 96 lines, from line 89
internal fun searchPages(pages: List<Page>, query: String, limit: Int, maxPerPage: Int): Response {
val phrase = query.lowercase()
val terms = phrase.split(Regex("\\s+")).filter { it.isNotEmpty() }
val hits = mutableListOf<Hit>()
for (page in pages) {
val pageHits = page.units.mapNotNull { unit -> scoreUnit(unit, phrase, terms)?.let { unit to it } }
if (pageHits.isEmpty()) continue
// Several hits per page are useful (a heading and its example), a page's every
// paragraph is not: keep the best few prose/code hits but every title/heading hit.
val structural = pageHits.filter { it.first.kind == Kind.PAGE || it.first.kind == Kind.SECTION }
val body = pageHits.filter { it.first.kind == Kind.CODE || it.first.kind == Kind.TEXT }
.sortedByDescending { it.second }.take(maxPerPage)
// Many matching units make the page itself more relevant.
val density = ln(pageHits.size.toDouble() + 1.0)
for ((unit, score) in structural + body) hits += toHit(page, unit, score + density, terms, phrase)
}
val sorted = hits.sortedWith(compareByDescending<Hit> { it.score }.thenBy { it.page }.thenBy { it.line })
return Response(query, sorted.take(limit), hits.size, hits.size > limit)
}
private fun scoreUnit(unit: Chunk, phrase: String, terms: List<String>): Double? {
if (terms.isEmpty() || !terms.all { unit.lower.contains(it) }) return null
var score = unit.kind.base
val trimmed = unit.lower.trim()
if (trimmed == phrase) score += 60.0
else if (terms.size > 1 && unit.lower.contains(phrase)) score += 30.0
if (trimmed.startsWith(phrase)) score += 10.0
for (term in terms) {
val at = unit.lower.indexOf(term)
if (at == 0 || !unit.lower[at - 1].isLetterOrDigit()) score += 4.0
val end = at + term.length
if (end >= unit.lower.length || !unit.lower[end].isLetterOrDigit()) score += 2.0
}
// A short unit that matches is about the query; a long one merely mentions it.
if (unit.kind == Kind.TEXT || unit.kind == Kind.CODE) score -= (unit.text.length / 400.0).coerceAtMost(10.0)
return score
}
private fun toHit(page: Page, unit: Chunk, score: Double, terms: List<String>, phrase: String): Hit {
val lines = unit.text.lines()
val needle = if (unit.lower.contains(phrase)) phrase else terms.first()
val offset = lines.indexOfFirst { it.lowercase().contains(needle) }.coerceAtLeast(0)
val line = when (unit.kind) {
// A code unit's text starts on the line after its opening fence.
Kind.CODE -> unit.line + 1 + offset
Kind.TEXT -> unit.line + offset
else -> unit.line
}
val snippet = when (unit.kind) {
Kind.PAGE, Kind.SECTION -> unit.text
else -> clip(lines.getOrElse(offset) { "" }.trim(), needle)
}
val excerpt = when (unit.kind) {
Kind.CODE -> lines.drop((offset - 6).coerceAtLeast(0)).take(16).joinToString("\n")
Kind.TEXT -> unit.text.take(1200)
else -> ""
}
return Hit(
kind = unit.kind.id,
page = page.path,
pageTitle = page.title,
heading = unit.heading,
url = if (unit.anchor.isEmpty()) page.url else "${page.url}#${unit.anchor}",
line = line,
snippet = snippet,
language = unit.language,
excerpt = excerpt,
score = score,
)
}
private fun clip(line: String, needle: String, width: Int = 140): String {
if (line.length <= width) return line
val at = line.lowercase().indexOf(needle).coerceAtLeast(0)
val start = (at - width / 3).coerceAtLeast(0)
val end = (start + width).coerceAtMost(line.length)
return (if (start > 0) "…" else "") + line.substring(start, end) + (if (end < line.length) "…" else "")
}
private fun markdownFiles(root: File): List<File> = root.walkTopDown()
.onEnter { it == root || (it.name != "assets" && !it.name.startsWith(".")) }
.filter { it.isFile && it.extension == "md" }
.sortedBy { it.path }
.toList()
private fun pages(root: File): List<Page> {
val files = markdownFiles(root)
var signature = root.absolutePath.hashCode().toLong()
for (file in files) signature = signature * 31 + file.path.hashCode() * 17 + file.lastModified() * 7 + file.length()
cached.get()?.takeIf { it.signature == signature }?.let { return it.pages }
val pages = files.mapNotNull { file ->
runCatching { parsePage(file.relativeTo(root).invariantSeparatorsPath, file.readText()) }.getOrNull()
}
cached.set(Index(signature, pages))
return pages
}
internal fun pageUrl(path: String): String
internal fun pageUrl(path: String): String {
val stem = path.removeSuffix(".md")
val dir = when {
stem == "index" -> ""
stem.endsWith("/index") -> stem.removeSuffix("index")
else -> "$stem/"
}
return "/field-docs/$dir"
}
private val fenceOpen = Regex("^(\\s*)(`{3,}|~{3,})(.*)$")
private val headingLine = Regex("^(#{1,6})\\s+(.*?)\\s*#*\\s*$")
private val explicitId = Regex("\\{[^}]*#([\\w-]+)[^}]*\\}\\s*$")
internal fun parsePage(path: String, source: String): Page
body — 81 lines, from line 200
internal fun parsePage(path: String, source: String): Page {
val lines = source.lines()
val units = mutableListOf<Chunk>()
val usedAnchors = mutableMapOf<String, Int>()
var title = ""
var i = 0
if (lines.firstOrNull()?.trim() == "---") {
val end = (1 until lines.size).firstOrNull { lines[it].trim() == "---" }
if (end != null) {
lines.subList(1, end).firstOrNull { it.startsWith("title:") }
?.let { title = it.removePrefix("title:").trim().trim('"', '\'') }
i = end + 1
}
}
var heading = ""
var anchor = ""
val paragraph = StringBuilder()
var paragraphLine = 0
fun flushParagraph() {
val text = paragraph.toString().trim()
if (text.isNotEmpty()) units += Chunk(Kind.TEXT, text, text.lowercase(), paragraphLine, heading, anchor)
paragraph.setLength(0)
}
while (i < lines.size) {
val raw = lines[i]
val fence = fenceOpen.matchEntire(raw)
if (fence != null) {
flushParagraph()
val marker = fence.groupValues[2]
val language = fence.groupValues[3].trim().trimStart('{', '.').split(Regex("[\\s{}]")).firstOrNull().orEmpty()
val start = i
val body = mutableListOf<String>()
i++
while (i < lines.size) {
val close = lines[i].trim()
if (close.startsWith(marker[0].toString().repeat(marker.length)) && close.all { it == marker[0] }) break
body += lines[i]
i++
}
val indent = fence.groupValues[1].length
val text = body.joinToString("\n") { if (it.length >= indent && it.take(indent).isBlank()) it.drop(indent) else it }
if (text.isNotBlank()) units += Chunk(Kind.CODE, text, text.lowercase(), start + 1, heading, anchor, language)
i++
continue
}
val h = headingLine.matchEntire(raw)
if (h != null) {
flushParagraph()
val level = h.groupValues[1].length
var text = h.groupValues[2]
val id = explicitId.find(text)?.groupValues?.get(1)
text = text.replace(Regex("\\{[^}]*\\}\\s*$"), "").trim()
val plain = plainHeading(text)
if (level == 1 && title.isEmpty()) {
title = plain
units += Chunk(Kind.PAGE, plain, plain.lowercase(), i + 1, "", "")
} else {
val base = id ?: slugify(plain)
val seen = usedAnchors[base]
anchor = if (seen == null) base else "${base}_$seen"
usedAnchors[base] = (seen ?: 0) + 1
heading = plain
units += Chunk(Kind.SECTION, plain, plain.lowercase(), i + 1, plain, anchor)
}
i++
continue
}
if (raw.isBlank()) flushParagraph()
else if (!raw.trimStart().startsWith("<!--")) {
if (paragraph.isEmpty()) paragraphLine = i + 1
paragraph.append(raw.trim()).append('\n')
}
i++
}
flushParagraph()
if (title.isEmpty()) title = path.substringAfterLast('/').removeSuffix(".md").replace('_', ' ')
if (units.none { it.kind == Kind.PAGE }) units.add(0, Chunk(Kind.PAGE, title, title.lowercase(), 1, "", ""))
return Page(path, title, pageUrl(path), units)
}
internal fun plainHeading(text: String): String
Heading text as rendered: code spans, emphasis and link syntax removed.
internal fun plainHeading(text: String): String = text
.replace(Regex("\\[([^]]*)]\\([^)]*\\)"), "$1")
.replace("`", "")
.replace(Regex("\\*\\*|__|\\*"), "")
.trim()
internal fun slugify(text: String): String
Python-Markdown's default toc slug, which Zensical uses for heading anchors.
internal fun slugify(text: String): String {
val ascii = Normalizer.normalize(text, Normalizer.Form.NFKD).replace(Regex("[^\\p{ASCII}]"), "")
return ascii.replace(Regex("<[^>]*>"), "")
.replace(Regex("[^\\w\\s-]"), "")
.trim()
.lowercase()
.replace(Regex("[-\\s]+"), "-")
}
}