Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
name: Docs

# Build the Antora site (with generated operator pages and the
# cross-backend coverage matrix) on every PR, and publish to GitHub
# Pages on pushes to develop. Dokka API bundling is wired in
# commit 6 of the docs-to-Antora migration (see issue #494).

on:
push:
branches: [ main, develop ]
paths:
- 'docs/**'
- '.github/workflows/docs.yml'
- 'build.gradle.kts'
- 'build-logic/**'
- 'skainet-lang/skainet-lang-core/**'
pull_request:
paths:
- 'docs/**'
- '.github/workflows/docs.yml'
- 'build.gradle.kts'
- 'build-logic/**'
- 'skainet-lang/skainet-lang-core/**'
workflow_dispatch:

concurrency:
group: docs-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read
pages: write
id-token: write

jobs:
build-docs:
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- name: Checkout
uses: actions/checkout@v4

# JDK 25 matches the version used by every other workflow in
# this repo. Runs on the RUNNER, not inside the Docker
# container, so the Gradle wrapper cache works and generateDocs
# / dokkaGenerate see the right JDK.
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '25'

- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
restore-keys: |
gradle-${{ runner.os }}-

# Emit the KSP-driven operator fragments and the coverage
# matrix into docs/modules/ROOT/pages/reference/operators/.
# Also generate the full Dokka API aggregate so commit 6 can
# bundle it; running both here means commit 6 is a pure
# workflow-step + Gradle-task-registration change with no
# Gradle re-run cost.
- name: Generate operator docs and Dokka
run: ./gradlew --no-daemon generateDocs dokkaGenerate

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

# The Chromium layer makes the image ~400 MB. First build is
# ~3–5 minutes; subsequent runs are sub-minute via the GHA
# cache. Transformers skipped caching here — this workflow
# improves on that.
- name: Build Antora image
uses: docker/build-push-action@v5
with:
context: docs/.docker
tags: skainet-antora:local
load: true
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Build Antora site
run: |
docker run --rm \
-v "${{ github.workspace }}:/antora" \
--workdir /antora/docs \
skainet-antora:local \
--stacktrace \
antora-playbook.yml

# Bundle Dokka HTML under a sibling `/api/` path of the
# Antora site. Must run AFTER Antora has populated
# docs/build/site/, never before — bundleDokkaIntoSite is a
# plain Copy task that would otherwise pre-create the target
# directory and the later Antora run would wipe it.
- name: Bundle Dokka API into site
run: ./gradlew --no-daemon bundleDokkaIntoSite

- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs/build/site

deploy-docs:
if: github.ref == 'refs/heads/develop' && github.event_name == 'push'
needs: build-docs
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}

steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
56 changes: 0 additions & 56 deletions .github/workflows/dokka-pages.yml

This file was deleted.

6 changes: 3 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# 🏗️ Architecture
SKaiNET uses a hybrid backend strategy that separates development iteration from production deployment.
# Architecture

![Architecture diagram of SKaiNET compiler](docs/SKaiNET-compiler.svg)
See the published site:
https://skainet-developers.github.io/SKaiNET/skainet/reference/architecture.html
160 changes: 156 additions & 4 deletions build-logic/convention/src/main/kotlin/GenerateDocumentationTask.kt
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,135 @@ abstract class GenerateDocumentationTask : DefaultTask() {

private fun generateAsciidoc(module: OperatorDocModule, outputDir: File) {
outputDir.mkdirs()

if (generateIndex.getOrElse(true)) {
generateMainIndex(module, outputDir)
}

module.operators.forEach { operator ->
generateOperatorPage(operator, module, outputDir)
}

// Sibling cross-backend coverage matrix. Lives one level above
// the per-operator pages so a single URL gives the whole
// picture. Skipped when includeBackendStatus is disabled.
if (includeBackendStatus.getOrElse(true)) {
emitOpsStatusMatrix(module, outputDir)
}
}

/**
* Emit a single-page `ops-status-matrix.adoc` with rows of
* operator.function pairs and columns of every backend that
* appears in any function's `statusByBackend` map. Cells carry
* the status emoji; a totals footer shows how many functions
* each backend supports out of the total.
*
* Written to [outputDir].parentFile.parentFile so that, under the
* Antora `reference/operators/generated/` layout, the matrix
* lands at `reference/ops-status-matrix.adoc` — one navigable
* click away from the operator index and with a stable URL.
* Falls back to writing next to [outputDir] when the path
* doesn't have the expected depth (flat layouts).
*/
private fun emitOpsStatusMatrix(module: OperatorDocModule, outputDir: File) {
val matrixDir = outputDir.parentFile?.parentFile ?: outputDir
matrixDir.mkdirs()
val matrixFile = File(matrixDir, "ops-status-matrix.adoc")

// Collect every backend that appears anywhere, sorted so the
// column order is stable across runs.
val allBackends: List<String> = module.operators
.flatMap { op -> op.functions.flatMap { it.statusByBackend.keys } }
.toSortedSet()
.toList()

// Row view: (operator, function) pair -> per-backend status.
data class Row(val operator: String, val function: String, val status: Map<String, String>)
val rows: List<Row> = module.operators.flatMap { op ->
op.functions.map { fn -> Row(op.name, fn.name, fn.statusByBackend) }
}

matrixFile.writeText(buildString {
appendLine("= Operator Coverage Matrix")
appendLine(":description: Cross-backend status for every operator function in SKaiNET.")
appendLine("")
appendLine("Generated from `operators.json` version `${module.version}` on ${formatTimestamp(module.timestamp)}.")
appendLine("")
appendLine("Rows are `Operator.function` pairs; columns are backends that appear in any function's `statusByBackend` map. A missing entry means the backend makes no claim about the function — treat it as \"unknown\", not \"not supported\".")
appendLine("")
if (rows.isEmpty() || allBackends.isEmpty()) {
appendLine("NOTE: No backend status information found in the source data.")
appendLine("")
return@buildString
}

// Table header: 1 col for the row label + 1 col per backend.
val colSpec = (listOf("2") + List(allBackends.size) { "1" }).joinToString(",")
appendLine("[cols=\"$colSpec\", options=\"header\"]")
appendLine("|===")
append("| Operator.function ")
allBackends.forEach { append("| $it ") }
appendLine("")
appendLine("")

rows.forEach { row ->
append("| `${row.operator}.${row.function}` ")
allBackends.forEach { backend ->
val raw = row.status[backend]
val cell = if (raw == null) "—" else shortStatus(raw)
append("| $cell ")
}
appendLine("")
}

// Totals footer: number of "done" rows per backend out
// of total row count. A status counts as done when it
// maps to the green check in shortStatus.
appendLine("")
append("| *Done* ")
allBackends.forEach { backend ->
val n = rows.count { isDone(it.status[backend]) }
append("| *$n / ${rows.size}* ")
}
appendLine("")
appendLine("|===")
appendLine("")
appendLine("Per-function detail including notes lives in xref:reference/operators/generated/index.adoc[Operator reference].")
})
}

/**
* Short emoji-only rendering of a backend status, for use in the
* compact matrix cells. The long-form wording stays on the
* per-function backend-status table produced by
* [generateBackendStatusTable].
*
* The vocabulary covers both the planning-style strings
* (`supported` / `partial` / `not_supported` / `planned`) and
* the implementation-style strings the KSP processor actually
* emits today (`implemented` / `in_progress` / `missing`).
* Unknown values fall back to the raw string so the matrix
* never silently hides a status the generator didn't anticipate.
*/
private fun shortStatus(status: String): String = when (status.lowercase()) {
"supported", "implemented", "done" -> "✅"
"partial" -> "⚠️"
"not_supported", "missing", "unsupported" -> "❌"
"planned" -> "⏳"
"in_progress", "wip" -> "🚧"
else -> status
}

/**
* Whether a status string counts toward the totals footer in
* the ops-status matrix. Mirrors the "green check" branch of
* [shortStatus] — any status rendered with ✅ is counted as
* done.
*/
private fun isDone(status: String?): Boolean = when (status?.lowercase()) {
"supported", "implemented", "done" -> true
else -> false
}

private fun generateMarkdown(module: OperatorDocModule, outputDir: File) {
Expand All @@ -87,25 +208,56 @@ abstract class GenerateDocumentationTask : DefaultTask() {

private fun generateMainIndex(module: OperatorDocModule, outputDir: File) {
val indexFile = File(outputDir, "index.adoc")
// When the output directory sits under an Antora module's
// `modules/<name>/pages/` tree, xrefs in the emitted index
// must be resolved relative to that `pages/` root, not the
// current file. Auto-derive the prefix from the output path
// so the generator works both with Antora and with flat doc
// layouts (empty prefix -> bare filenames, the original
// behavior).
val xrefPrefix = deriveAntoraXrefPrefix(outputDir)
indexFile.writeText(buildString {
appendLine("= AI-NET Operators Reference")
appendLine("")
appendLine("Generated from version `${module.version}` on ${formatTimestamp(module.timestamp)}")
appendLine("")
appendLine("== Operators by Modality")
appendLine("")

val operatorsByModality = module.operators.groupBy { it.modality }
operatorsByModality.forEach { (modality, operators) ->
appendLine("=== ${modality.capitalize()}")
appendLine("")
operators.forEach { operator ->
appendLine("* xref:${operator.name.lowercase()}.adoc[${operator.name}]")
appendLine("* xref:$xrefPrefix${operator.name.lowercase()}.adoc[${operator.name}]")
}
appendLine("")
}
})
}

/**
* If [outputDir] lives under an Antora `modules/<name>/pages/...`
* tree, return the path segment from `pages/` down to the output
* directory, suffixed with `/`. Otherwise return an empty string,
* so the generator emits bare-filename xrefs (the pre-Antora
* behavior).
*
* Example:
* ```
* /repo/docs/modules/ROOT/pages/reference/operators/generated
* → "reference/operators/generated/"
* /repo/docs/operators/generated → ""
* ```
*/
private fun deriveAntoraXrefPrefix(outputDir: File): String {
val path = outputDir.absolutePath.replace(File.separatorChar, '/')
val marker = "/pages/"
val idx = path.indexOf(marker)
if (idx < 0) return ""
val tail = path.substring(idx + marker.length)
return if (tail.isEmpty()) "" else "$tail/"
}

private fun generateOperatorPage(operator: OperatorDoc, module: OperatorDocModule, outputDir: File) {
val operatorFile = File(outputDir, "${operator.name.lowercase()}.adoc")
Expand Down
Loading
Loading