|
| 1 | +#!/usr/bin/env kotlinr |
| 2 | + |
| 3 | +/** Build the Pages site from completed Kover and Dokka tasks; no network or dependencies. |
| 4 | + * Usage: kotlinr .github/scripts/build-pages.main.kts <tasks-dir> <output-dir> <commit-sha> [ISO-instant] |
| 5 | + */ |
| 6 | + |
| 7 | +import java.io.File |
| 8 | +import java.time.Instant |
| 9 | +import java.util.Locale |
| 10 | +import javax.xml.XMLConstants |
| 11 | +import javax.xml.parsers.DocumentBuilderFactory |
| 12 | +import org.w3c.dom.Element |
| 13 | + |
| 14 | +data class Coverage(val covered: Long, val missed: Long) { |
| 15 | + val total = Math.addExact(covered, missed) |
| 16 | + val percent: Double? = if (total == 0L) null else covered.toDouble() * 100 / total |
| 17 | + val label: String = percent?.let { String.format(Locale.ROOT, "%.2f%%", it) } ?: "n/a" |
| 18 | +} |
| 19 | + |
| 20 | +fun escape(value: String): String = value.replace("&", "&").replace("<", "<") |
| 21 | + .replace(">", ">").replace("\"", """).replace("'", "'") |
| 22 | + |
| 23 | +fun counters(file: File): Map<String, Coverage> { |
| 24 | + val factory = DocumentBuilderFactory.newInstance().apply { |
| 25 | + setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) |
| 26 | + setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "") |
| 27 | + setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "") |
| 28 | + isXIncludeAware = false |
| 29 | + isExpandEntityReferences = false |
| 30 | + } |
| 31 | + val report = factory.newDocumentBuilder().parse(file).documentElement |
| 32 | + require(report.tagName == "report") { "Expected a Kover report: $file" } |
| 33 | + // Only report totals: package/class/method counters would count the same code repeatedly. |
| 34 | + val children = (0 until report.childNodes.length).mapNotNull { report.childNodes.item(it) as? Element } |
| 35 | + return listOf("LINE", "BRANCH").associateWith { type -> |
| 36 | + val node = children.singleOrNull { it.tagName == "counter" && it.getAttribute("type") == type } |
| 37 | + requireNotNull(node) { "Missing or duplicate $type total in $file" } |
| 38 | + val covered = node.getAttribute("covered").toLong() |
| 39 | + val missed = node.getAttribute("missed").toLong() |
| 40 | + require(covered >= 0 && missed >= 0) { "Negative $type counter in $file" } |
| 41 | + Coverage(covered, missed) |
| 42 | + } |
| 43 | +} |
| 44 | + |
| 45 | +fun badge(label: String, coverage: Coverage, provenance: String): String { |
| 46 | + val left = label.length * 7 + 16 |
| 47 | + val right = 68 |
| 48 | + val width = left + right |
| 49 | + val color = when { |
| 50 | + coverage.percent == null -> "#6b7280" |
| 51 | + coverage.percent >= 90 -> "#16803c" |
| 52 | + coverage.percent >= 75 -> "#996000" |
| 53 | + else -> "#b91c1c" |
| 54 | + } |
| 55 | + return """<svg xmlns="http://www.w3.org/2000/svg" width="$width" height="24" role="img" aria-label="${escape("$label: ${coverage.label}")}"> |
| 56 | + <title>${escape("$label: ${coverage.label}; $provenance")}</title> |
| 57 | + <rect width="$width" height="24" rx="4" fill="#374151"/> |
| 58 | + <path d="M$left 0H${width - 4}Q$width 0 $width 4V20Q$width 24 ${width - 4} 24H$left Z" fill="$color"/> |
| 59 | + <g fill="white" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11" text-anchor="middle"> |
| 60 | + <text x="${left / 2}" y="16">${escape(label)}</text> |
| 61 | + <text x="${left + right / 2}" y="16">${coverage.label}</text> |
| 62 | + </g> |
| 63 | + </svg> |
| 64 | + """.trimIndent() |
| 65 | +} |
| 66 | + |
| 67 | +fun page(title: String, provenance: String, body: String): String = """ |
| 68 | + <!doctype html> |
| 69 | + <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> |
| 70 | + <title>${escape(title)}</title><style> |
| 71 | + body{font:16px/1.6 system-ui,sans-serif;max-width:850px;margin:3rem auto;padding:0 1rem;color:#17212b} |
| 72 | + a{color:#0759a5}table{border-collapse:collapse}td,th{padding:.5rem 1rem;text-align:left;border-bottom:1px solid #ddd} |
| 73 | + </style></head><body><h1>${escape(title)}</h1>$provenance$body</body></html> |
| 74 | +""".trimIndent() + "\n" |
| 75 | + |
| 76 | +require(args.size in 3..4) { "Usage: build-pages.main.kts <tasks-dir> <output-dir> <commit-sha> [ISO-instant]" } |
| 77 | +val tasks = File(args[0]) |
| 78 | +val output = File(args[1]) |
| 79 | +val sha = args[2] |
| 80 | +require(Regex("[0-9a-f]{40}").matches(sha)) { "Expected full commit SHA" } |
| 81 | +val generated = args.getOrNull(3)?.let(Instant::parse) ?: Instant.now() |
| 82 | +val modules = listOf("kotaml", "kotaml-json") |
| 83 | +val reports = modules.associateWith { module -> counters(tasks.resolve("_${module}_koverReport@kover/coverage.xml")) } |
| 84 | +for (module in modules) { |
| 85 | + for (path in listOf("_${module}_koverReport@kover/html", "_${module}_dokkaHtml@dokka/html")) { |
| 86 | + require(tasks.resolve("$path/index.html").isFile) { "Missing report: $path/index.html" } |
| 87 | + require(tasks.resolve(path).walkTopDown().none { java.nio.file.Files.isSymbolicLink(it.toPath()) }) { |
| 88 | + "Pages artifacts must not contain symbolic links: $path" |
| 89 | + } |
| 90 | + } |
| 91 | +} |
| 92 | +require(!output.exists() || output.isDirectory && output.listFiles()!!.isEmpty()) { "Output must be empty: $output" } |
| 93 | +check(output.isDirectory || output.mkdirs()) |
| 94 | +val provenance = "<p>Commit <a href=\"https://github.com/Heapy/kotaml/commit/$sha\"><code>${sha.take(12)}</code></a> · Generated <time>$generated</time> (UTC)</p>" |
| 95 | +val badges = output.resolve("badges").apply { mkdirs() } |
| 96 | +for ((module, totals) in reports) { |
| 97 | + val destination = output.resolve("coverage/$module").apply { mkdirs() } |
| 98 | + check(tasks.resolve("_${module}_koverReport@kover/html").copyRecursively(destination.resolve("report"))) |
| 99 | + tasks.resolve("_${module}_koverReport@kover/coverage.xml").copyTo(destination.resolve("coverage.xml")) |
| 100 | + val rows = listOf("LINE" to "lines", "BRANCH" to "branches").joinToString("\n") { (type, name) -> |
| 101 | + val coverage = totals.getValue(type) |
| 102 | + badges.resolve("$module-$name.svg").writeText(badge("$module JVM $name", coverage, "$sha; $generated")) |
| 103 | + println("$module $name: ${coverage.label} (${coverage.covered}/${coverage.total})") |
| 104 | + "<tr><th>$name</th><td>${coverage.label}</td><td>${coverage.covered} / ${coverage.total}</td></tr>" |
| 105 | + } |
| 106 | + destination.resolve("index.html").writeText(page("$module JVM coverage", provenance, """ |
| 107 | + <p>Coverage from JVM tests. Branch coverage does not measure every combination of conditions.</p> |
| 108 | + <table><thead><tr><th>Metric</th><th>Coverage</th><th>Covered / total</th></tr></thead><tbody>$rows</tbody></table> |
| 109 | + <p><a href="report/index.html">Browse the Kover report</a> · <a href="coverage.xml">XML report</a> · <a href="../../index.html">All reports</a></p> |
| 110 | + """.trimIndent())) |
| 111 | + check(tasks.resolve("_${module}_dokkaHtml@dokka/html").copyRecursively(output.resolve("api/$module"))) |
| 112 | +} |
| 113 | +val links = modules.joinToString("\n") { module -> |
| 114 | + "<li><strong>$module</strong>: <a href=\"coverage/$module/\">JVM coverage</a> · <a href=\"api/$module/\">KMP API documentation</a></li>" |
| 115 | +} |
| 116 | +output.resolve("index.html").writeText(page("kotaml documentation and coverage", provenance, "<ul>$links</ul>")) |
| 117 | +output.resolve(".nojekyll").writeText("") |
| 118 | +println("Pages site: ${output.absolutePath}") |
0 commit comments