From 3b0feff94b993a5c27b759f6cda89b2769ebf79b Mon Sep 17 00:00:00 2001 From: Alex Miller Date: Wed, 1 Jul 2026 15:42:57 -0500 Subject: [PATCH 01/15] Initial commit for KDoc-to-JSON plugin, adds TODO --- Dokka-plugin-kdoc2json/README.md | 150 +++++- Dokka-plugin-kdoc2json/TODO.md | 8 + .../example-data-processor/build.gradle.kts | 27 ++ .../gradle/wrapper/gradle-wrapper.properties | 9 + .../examples/example-data-processor/gradlew | 248 ++++++++++ .../example-data-processor/gradlew.bat | 82 ++++ .../settings.gradle.kts | 1 + .../kotlin/com/example/utils/DataProcessor.kt | 44 ++ .../json-output-plugin/build.gradle.kts | 26 ++ .../gradle/wrapper/gradle-wrapper.properties | 5 + .../json-output-plugin/gradlew | 176 +++++++ .../json-output-plugin/gradlew.bat | 84 ++++ .../json-output-plugin/settings.gradle.kts | 1 + .../src/main/kotlin/JsonOutputPlugin.kt | 19 + .../src/main/kotlin/JsonPluginConfig.kt | 15 + .../src/main/kotlin/JsonRenderer.kt | 239 ++++++++++ .../src/main/kotlin/LinkPostProcessor.kt | 72 +++ .../src/main/kotlin/ModelMapper.kt | 434 ++++++++++++++++++ .../src/main/kotlin/PluginLogger.kt | 65 +++ .../src/main/kotlin/dtos/SemanticModelDtos.kt | 422 +++++++++++++++++ ...rg.jetbrains.dokka.plugability.DokkaPlugin | 2 + 21 files changed, 2128 insertions(+), 1 deletion(-) create mode 100644 Dokka-plugin-kdoc2json/TODO.md create mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/build.gradle.kts create mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/gradle/wrapper/gradle-wrapper.properties create mode 100755 Dokka-plugin-kdoc2json/examples/example-data-processor/gradlew create mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/gradlew.bat create mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/settings.gradle.kts create mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/utils/DataProcessor.kt create mode 100644 Dokka-plugin-kdoc2json/json-output-plugin/build.gradle.kts create mode 100644 Dokka-plugin-kdoc2json/json-output-plugin/gradle/wrapper/gradle-wrapper.properties create mode 100755 Dokka-plugin-kdoc2json/json-output-plugin/gradlew create mode 100644 Dokka-plugin-kdoc2json/json-output-plugin/gradlew.bat create mode 100644 Dokka-plugin-kdoc2json/json-output-plugin/settings.gradle.kts create mode 100644 Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/JsonOutputPlugin.kt create mode 100644 Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/JsonPluginConfig.kt create mode 100644 Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/JsonRenderer.kt create mode 100644 Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/LinkPostProcessor.kt create mode 100644 Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/ModelMapper.kt create mode 100644 Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/PluginLogger.kt create mode 100644 Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/dtos/SemanticModelDtos.kt create mode 100644 Dokka-plugin-kdoc2json/json-output-plugin/src/main/resources/META-INF/services/org.jetbrains.dokka.plugability.DokkaPlugin diff --git a/Dokka-plugin-kdoc2json/README.md b/Dokka-plugin-kdoc2json/README.md index 2915145e..246843f4 100644 --- a/Dokka-plugin-kdoc2json/README.md +++ b/Dokka-plugin-kdoc2json/README.md @@ -1 +1,149 @@ -Alex to fill in this file with the right details +# JSON Dokka Plugin + +A custom Dokka plugin that replaces Dokka's default HTML renderer to output a raw, structured JSON representation of your Kotlin documentation. + +This plugin is designed for "headless" documentation pipelines where you want Dokka to handle the complex parsing, AST resolution, and multi-platform expect/actual merging, but you want to render the final visual output using a custom Static Site Generator (SSG) or templating engine (such as Pebble, Jinja, or React). + +--- + +## 1. Introduction + +This plugin intercepts base Dokka's pipeline just before rendering to output JSON data in place of HTML files. It maps Dokka's internal Documentable Abstract Syntax Tree (AST) into clean, serializable Data Transfer Objects (DTOs) and writes them to disk as `.json` files. It preserves package hierarchies, generic bounds, platform source sets, and documentation tags while allowing frontend developers total freedom over the final HTML/CSS. + +## 2. Getting Started + +### Building the Plugin + +Clone this repository and publish the plugin to your local Maven repository: + +```bash +./gradlew publishToMavenLocal +``` + +### Applying the Plugin + +In the target project where you want to generate documentation, add the plugin to your Dokka dependencies block: + +```kotlin +dependencies { + dokkaPlugin("my.dokka.plugin:json-output-plugin:1.0.0-SNAPSHOT") +} +``` + +## 3. Configuration Options + +You can configure the JSON plugin by extending `DokkaPluginParametersBaseSpec` and registering it in your `dokka` configuration block. This utilizes the modern Dokka V2 plugin API. + +```kotlin +import org.jetbrains.dokka.gradle.engine.plugins.DokkaPluginParametersBaseSpec +import org.jetbrains.dokka.InternalDokkaApi +import javax.inject.Inject + +@OptIn(InternalDokkaApi::class) +abstract class JsonOutputPluginParameters @Inject constructor( + name: String +) : DokkaPluginParametersBaseSpec(name, "my.dokka.plugin.JsonOutputPlugin") { + + // Define the plugin's behavior via a JSON string + override fun jsonEncode(): String = """{ + "logLevel": "debug", + "logFile": "build/dokka_json.log", + "omitFields": ["sources"], + "replaceHtmlExtension": false, + "omitNulls": true + }""" +} + +dokka { + pluginsConfiguration { + registerBinding(JsonOutputPluginParameters::class, JsonOutputPluginParameters::class) + register("my.dokka.plugin.JsonOutputPlugin") { } + } +} +``` + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `logLevel` | String | `"debug"` | Controls the verbosity of the plugin's internal logger (`"info"`, `"debug"`, `"warn"`, `"error"`). | +| `logFile` | String | *(Optional)* | Absolute or relative path to output the plugin's debug logs. Highly recommended as Dokka often swallows standard output. | +| `replaceHtmlExtension` | Boolean | `false` | If `true`, the plugin will rewrite all internal relative URLs to end in `.json` instead of `.html`. | +| `omitFields` | List | `[]` | A list of JSON keys to completely strip from the final output (e.g., `["breadcrumbs", "sources"]`). Useful for reducing disk footprint. | +| `omitNulls` | Boolean | `false` | If `true`, deeply filters the AST payload to remove any keys where the value is null, an empty string, an empty array, or an empty object. | + +## 4. Understanding Dokka Terminology + +To successfully consume the JSON output, it helps to understand a few core Dokka concepts that dictate the structure of the data: + +* **Documentable**: A node in Dokka's AST. Classes, functions, properties, packages, and modules are all `Documentable` objects. +* **DRI (Dokka Resource Identifier)**: A unique string identifier for every symbol in your codebase. (e.g., `kotlin.collections/List/size/#/PointingToDeclaration/`). DRIs are what Dokka uses to link disparate parts of the codebase together. +* **SourceSet**: Represents a target platform or compilation unit (e.g., `jvm`, `js`, `common`, `native`). Dokka merges declarations across SourceSets, which is why properties like `visibility` or `type` are mapped by SourceSet in the JSON. +* **PageNode**: Dokka's representation of a literal page that will be written to disk. The JSON plugin maps a `PageNode` back to its underlying `Documentable` to generate the JSON payload. + +## 5. How It Works: Architecture & Lifecycle + +The plugin operates in two distinct phases: + +### Phase 1: The `JsonRenderer` (Synchronous AST Traversal) + +The plugin implements the Dokka `Renderer` interface, completely overriding the default HTML generation. It walks the `RootPageNode` tree synchronously. For every page, it extracts the underlying `Documentable`, passes it to the `ModelMapper`, and translates the complex Dokka AST into clean Kotlin DTOs. These DTOs are serialized using `kotlinx.serialization` and written to disk. + +### Phase 2: The `LinkPostProcessor` (Cross-Module Resolution) + +Because Dokka resolves links across different modules *during* the HTML rendering phase, our JSON plugin must do the same. When the `JsonRenderer` encounters a DRI that belongs to an external module, it writes `"url": "unresolved:"`. +Once all JSON files are written, the `LinkPostProcessor` spins up. It reads all JSON files on disk, builds a master index of every DRI, and performs a regex replacement to patch all `unresolved:` links into valid relative file paths. + +## 6. The JSON Output Format + +### Directory Structure + +The plugin mirrors Dokka's standard hierarchical folder structure. However, instead of `index.html` files, you will find `.json` files. + +Special aggregated files include: + +* `index.json`: The index.json files serve as the primary entry points for modules, packages, and classes, containing the structural metadata and immediate member declarations specific to each hierarchical level. +* `all-types.json`: Created at the root of a module. Contains a flat, searchable array of every class, interface, object, and type alias in that module. +* `package-list`: A standard Dokka package list. + +### The Semantic Model (Polymorphism) + +The JSON payloads are strictly typed. Every top-level object and nested member contains a `"kind"` discriminator (e.g., `"kind": "class"`, `"kind": "function"`, `"kind": "TypeAliased"`). This makes it incredibly easy to parse the JSON back into typed objects in your frontend layer. + +*(To minimize disk footprint, you can leverage the `omitFields` and `omitNulls` configuration options. The plugin uses a custom recursive JSON filter to strip out empty lists, objects, and null values before writing to disk).* + +## 7. Resolving Cross-Module Links + +If you are inspecting the JSON and notice a URL like `unresolved:kotlin.collections/List///PointingToDeclaration/`, this means the `LinkPostProcessor` failed to find that DRI in the current build environment. + +This usually happens when: + +1. The target module was not included in the Dokka multi-module task. +2. The target dependency is an external library, and external documentation links were not properly configured in the `build.gradle.kts` file. + +If the DRI *is* present in the current build, the `LinkPostProcessor` will automatically patch it to a relative path like `../../kotlin-stdlib/kotlin.collections/-list/index.json`. + +## 8. Consuming the JSON (Example: Pebble) + +Because the JSON maintains Dokka's strict hierarchy, templating engines like Pebble or Jinja can iterate over it natively. + +For example, to render a table of functions for a class: + +```pebble +{% if functions is defined and functions is not empty %} +

Functions

+ + {% for member in functions %} + + + + + {% endfor %} +
{{ member.name }} + {# Render the parameters #} + fun {{ member.name }}( + {% for param in member.parameters %} + {{ param.name }}: {{ param.type.name }} + {% endfor %} + ) +
+{% endif %} +``` diff --git a/Dokka-plugin-kdoc2json/TODO.md b/Dokka-plugin-kdoc2json/TODO.md new file mode 100644 index 00000000..c523794e --- /dev/null +++ b/Dokka-plugin-kdoc2json/TODO.md @@ -0,0 +1,8 @@ +TODO @Alex + +- Move hardcoded options in JsonRenderer (Documentable type discriminator string, pretty printing, etc.) to be config options +- Remove all references to “alex” (the default log file points to my home directory on my own machine) +- Update plugin name (should not be provided under package my.dokka.plugin, “json-output-plugin” should be changed to kdoc-to-json or something) +- Provide script for building the example library and example usage +- Move output comparison/link validity check scripts into this repository +- Update usage to indicate that a user needs to set a matching Dokka version in *json-output-plugin/build.gradle.kts* (default is version 2.2.0-Beta because that's compatible with the current [kotlin-stdlib-docs build tool](https://github.com/JetBrains/kotlin/tree/master/libraries/tools/kotlin-stdlib-docs) diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/build.gradle.kts b/Dokka-plugin-kdoc2json/examples/example-data-processor/build.gradle.kts new file mode 100644 index 00000000..bec26f53 --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/example-data-processor/build.gradle.kts @@ -0,0 +1,27 @@ +import org.jetbrains.dokka.gradle.DokkaTask + +plugins { + kotlin("jvm") version "1.9.23" + id("org.jetbrains.dokka") version "1.9.20" +} + +repositories { + // CRITICAL: This allows Gradle to find your locally published json-dokka-plugin + mavenLocal() + mavenCentral() +} + +dependencies { + // Inject your custom Dokka plugin into the documentation pipeline + dokkaPlugin("com.example.dokka:json-dokka-plugin:1.0.0") +} + +// Configure the Dokka execution +tasks.withType().configureEach { + // Tell your custom plugin exactly what to name the output file + pluginsMapConfiguration.set( + mapOf( + "com.example.dokka.JsonExportPlugin" to """{"outputFileName": "api-documentation.json"}""" + ) + ) +} diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/gradle/wrapper/gradle-wrapper.properties b/Dokka-plugin-kdoc2json/examples/example-data-processor/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..df6a6ad7 --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/example-data-processor/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/gradlew b/Dokka-plugin-kdoc2json/examples/example-data-processor/gradlew new file mode 100755 index 00000000..b9bb139f --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/example-data-processor/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/gradlew.bat b/Dokka-plugin-kdoc2json/examples/example-data-processor/gradlew.bat new file mode 100644 index 00000000..aa5f10b0 --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/example-data-processor/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/settings.gradle.kts b/Dokka-plugin-kdoc2json/examples/example-data-processor/settings.gradle.kts new file mode 100644 index 00000000..83339902 --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/example-data-processor/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "example-data-processor" diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/utils/DataProcessor.kt b/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/utils/DataProcessor.kt new file mode 100644 index 00000000..7987b029 --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/utils/DataProcessor.kt @@ -0,0 +1,44 @@ +package com.example.utils + +/** + * Handles the parsing and transformation of raw input strings into structured formats. + * It is highly recommended to run these operations on a background thread to prevent UI blocking. + * + * @author Alex + */ +class DataProcessor { + + /** + * Sanitizes and normalizes a single data record. Strips trailing whitespace + * and enforces standard UTF-8 encoding. + * + * @param payload The raw string payload received from the network. + * @param maxRetries The number of parsing attempts before throwing a timeout exception. + * @return A clean, formatted string ready for database insertion. + */ + fun processRecord(payload: String, maxRetries: Int): String { + return "Processed Payload" + } + + /** + * Clears the internal processing cache to free up memory footprint. + * + * @param force If true, interrupts active parsing jobs before clearing the cache. + */ + fun flushCache(force: Boolean) { + // Cache cleared + } +} + +/** + * A basic manager for handling network connection lifecycles and timeouts. + */ +class ConnectionManager { + + /** + * Establishes a secure TLS connection to the primary database node. + */ + fun connect() { + // Connection established + } +} diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/build.gradle.kts b/Dokka-plugin-kdoc2json/json-output-plugin/build.gradle.kts new file mode 100644 index 00000000..6cf448da --- /dev/null +++ b/Dokka-plugin-kdoc2json/json-output-plugin/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + kotlin("jvm") version "1.9.24" + kotlin("plugin.serialization") version "1.9.24" + `maven-publish` +} + +group = "my.dokka.plugin" +version = "1.0.0-SNAPSHOT" + +repositories { + mavenCentral() +} + +dependencies { + compileOnly("org.jetbrains.dokka:dokka-core:2.2.0-Beta") + compileOnly("org.jetbrains.dokka:dokka-base:2.2.0-Beta") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") +} + +publishing { + publications { + create("maven") { + from(components["java"]) + } + } +} \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/gradle/wrapper/gradle-wrapper.properties b/Dokka-plugin-kdoc2json/json-output-plugin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..877e3643 --- /dev/null +++ b/Dokka-plugin-kdoc2json/json-output-plugin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/gradlew b/Dokka-plugin-kdoc2json/json-output-plugin/gradlew new file mode 100755 index 00000000..17a91706 --- /dev/null +++ b/Dokka-plugin-kdoc2json/json-output-plugin/gradlew @@ -0,0 +1,176 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +if $JAVACMD --add-opens java.base/java.lang=ALL-UNNAMED -version ; then + DEFAULT_JVM_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED $DEFAULT_JVM_OPTS" +fi + +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/gradlew.bat b/Dokka-plugin-kdoc2json/json-output-plugin/gradlew.bat new file mode 100644 index 00000000..e95643d6 --- /dev/null +++ b/Dokka-plugin-kdoc2json/json-output-plugin/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/settings.gradle.kts b/Dokka-plugin-kdoc2json/json-output-plugin/settings.gradle.kts new file mode 100644 index 00000000..60041fa3 --- /dev/null +++ b/Dokka-plugin-kdoc2json/json-output-plugin/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "json-output-plugin" \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/JsonOutputPlugin.kt b/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/JsonOutputPlugin.kt new file mode 100644 index 00000000..6cf490b1 --- /dev/null +++ b/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/JsonOutputPlugin.kt @@ -0,0 +1,19 @@ +package my.dokka.plugin + +import org.jetbrains.dokka.CoreExtensions +import org.jetbrains.dokka.base.DokkaBase +import org.jetbrains.dokka.plugability.DokkaPlugin +import org.jetbrains.dokka.plugability.PluginApiPreviewAcknowledgement + +class JsonOutputPlugin : DokkaPlugin() { + + // FIX: plugin() is a standard function in Dokka 1.9+, not a property delegate. + // We use a getter so it is evaluated lazily when the context is available. + private val dokkaBase get() = plugin() + + val jsonRenderer by extending { + CoreExtensions.renderer providing ::JsonRenderer override dokkaBase.htmlRenderer + } + + override fun pluginApiPreviewAcknowledgement() = PluginApiPreviewAcknowledgement +} \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/JsonPluginConfig.kt b/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/JsonPluginConfig.kt new file mode 100644 index 00000000..8e59100c --- /dev/null +++ b/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/JsonPluginConfig.kt @@ -0,0 +1,15 @@ +package my.dokka.plugin + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.jetbrains.dokka.plugability.ConfigurableBlock + +@Serializable +data class JsonPluginConfig( + val logLevel: String = "debug", + val omitFields: List = emptyList(), + val logFile: String? = "/Users/alex/dokka_plugin_debug.log", + val replaceHtmlExtension: Boolean = false, + @SerialName("omit-nulls") + val omitNulls: Boolean = false +) : ConfigurableBlock \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/JsonRenderer.kt b/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/JsonRenderer.kt new file mode 100644 index 00000000..55a8d469 --- /dev/null +++ b/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/JsonRenderer.kt @@ -0,0 +1,239 @@ +package my.dokka.plugin + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.* +import my.dokka.plugin.dtos.* +import org.jetbrains.dokka.base.DokkaBase +import org.jetbrains.dokka.model.* +import org.jetbrains.dokka.pages.PageNode +import org.jetbrains.dokka.pages.RootPageNode +import org.jetbrains.dokka.pages.WithDocumentables +import org.jetbrains.dokka.plugability.DokkaContext +import org.jetbrains.dokka.plugability.configuration +import org.jetbrains.dokka.plugability.plugin +import org.jetbrains.dokka.plugability.querySingle +import org.jetbrains.dokka.renderers.Renderer +import java.io.File +import java.util.concurrent.ConcurrentLinkedQueue + +class JsonRenderer(private val context: DokkaContext) : Renderer { + + private val json = Json { + prettyPrint = false + classDiscriminator = "kind" + encodeDefaults = false + ignoreUnknownKeys = true + } + + override fun render(root: RootPageNode) { + val fqcn = "my.dokka.plugin.JsonOutputPlugin" + var config = configuration(context) + + if (config == null) { + val rawConfig = context.configuration.pluginsConfiguration.find { it.fqPluginName == fqcn }?.values + if (rawConfig != null) { + context.logger.info("Dokka native config parsing failed. Manually parsing: $rawConfig") + try { + config = json.decodeFromString(rawConfig) + } catch (e: Exception) { + context.logger.error("Manual config parsing failed: ${e.message}") + } + } else { + context.logger.warn("No JSON config found in pluginsConfiguration for $fqcn! Falling back to defaults.") + } + } + + val finalConfig = config ?: JsonPluginConfig() + val logger = PluginLogger(context.logger, finalConfig.logLevel, finalConfig.logFile) + + logger.info("Initializing JSON Renderer with config: $finalConfig") + + val locationProvider = context.plugin() + .querySingle { locationProviderFactory } + .getLocationProvider(root) + + val outputDir = context.configuration.outputDir + logger.debug("Output directory set to: ${outputDir.absolutePath}") + + // --- Collect and write actual packages to package-list --- + val packages = mutableSetOf() + fun collectPackages(node: PageNode) { + if (node is WithDocumentables) { + node.documentables.forEach { doc -> + if (doc is org.jetbrains.dokka.model.DPackage) { + doc.dri.packageName?.takeIf { it.isNotBlank() }?.let { packages.add(it) } + } + } + } + node.children.forEach { collectPackages(it) } + } + collectPackages(root) + + val packageListFile = File(outputDir, "package-list") + packageListFile.parentFile.mkdirs() + + val packageListContent = buildString { + appendLine("\$dokka.format:json-v1\$") + appendLine("\$dokka.linkExtension:json\$") + packages.sorted().forEach { + appendLine(it) + } + } + + packageListFile.writeText(packageListContent) + logger.debug("Generated package-list with ${packages.size} packages.") + + if (context.configuration.modules.isNotEmpty()) { + logger.info("Multimodule project detected. Generating root index.json...") + + val ext = if (finalConfig.replaceHtmlExtension) "json" else "html" + val rootDto = MultimoduleRootDto( + name = root.name, + modules = context.configuration.modules.map { module -> + ModuleReferenceDto( + name = module.name, + url = "${module.relativePathToOutputDirectory.invariantSeparatorsPath}/index.$ext" + ) + } + ) + + val outputFile = File(outputDir, "index.json") + outputFile.parentFile.mkdirs() + + val rawJsonElement = json.encodeToJsonElement(DocumentableDto.serializer(), rootDto) + val filteredJsonElement = filterJson(rawJsonElement, finalConfig.omitFields, finalConfig.omitNulls) + outputFile.writeText(json.encodeToString(JsonElement.serializer(), filteredJsonElement)) + + logger.debug("Wrote Multimodule Root JSON to: ${outputFile.name}") + } + + // Thread-safe list to aggregate all type documentables during traversal + val allTypesList = ConcurrentLinkedQueue() + + fun traverse(node: PageNode, ancestors: List) { + val currentPath = ancestors + node + + if (node is WithDocumentables && node.documentables.isNotEmpty()) { + val documentable = node.documentables.first() + logger.debug("Processing documentable: ${documentable.name} (${documentable.dri})") + + if (documentable is DClasslike || documentable is DTypeAlias) { + var typeUrl = locationProvider.resolve(node, context = null, skipExtension = false) + if (typeUrl != null && finalConfig.replaceHtmlExtension && !typeUrl.startsWith("http")) { + typeUrl = typeUrl.replace(".html", ".json") + } + + val kindStr = when (documentable) { + is DClass -> "class" + is DInterface -> "interface" + is DEnum -> "enum" + is DObject -> "object" + is DAnnotation -> "annotation" + is DTypeAlias -> "typeAlias" + else -> "type" + } + + allTypesList.add( + TypeIndexEntryDto( + name = documentable.name ?: "Unknown", + kind = kindStr, + dri = documentable.dri.toString(), + url = typeUrl, + sourceSets = documentable.sourceSets.map { it.sourceSetID.toString().substringAfterLast("/") } + ) + ) + } + + val breadcrumbs = currentPath.map { ancestor -> + var url = locationProvider.resolve(ancestor, context = node, skipExtension = false) + if (url != null && finalConfig.replaceHtmlExtension && !url.startsWith("http")) { + url = url.replace(".html", ".json") + } + BreadcrumbNode(name = ancestor.name, url = url) + } + + val mapper = ModelMapper( + locationProvider = locationProvider, + contextNode = node, + logger = logger, + replaceHtmlExtension = finalConfig.replaceHtmlExtension + ) + + val dto = mapper.mapToDto(documentable, breadcrumbs) + + if (dto != null) { + val pagePath = locationProvider.resolve(node, context = null, skipExtension = true) + val outputFile = File(outputDir, "$pagePath.json") + outputFile.parentFile.mkdirs() + + val rawJsonElement = json.encodeToJsonElement(DocumentableDto.serializer(), dto) + // Deeply filters out the nulls and empties before writing + val filteredJsonElement = filterJson(rawJsonElement, finalConfig.omitFields, finalConfig.omitNulls) + outputFile.writeText(json.encodeToString(JsonElement.serializer(), filteredJsonElement)) + + logger.debug("Wrote JSON to: ${outputFile.name}") + } + } + + node.children.forEach { traverse(it, currentPath) } + } + + traverse(root, emptyList()) + + if (allTypesList.isNotEmpty()) { + logger.info("Generating all-types.json index...") + val allTypesDto = AllTypesDto( + types = allTypesList.sortedBy { it.name } + ) + val allTypesFile = File(outputDir, "all-types.json") + allTypesFile.parentFile.mkdirs() + + val rawJsonElement = json.encodeToJsonElement(DocumentableDto.serializer(), allTypesDto) + val filteredJsonElement = filterJson(rawJsonElement, finalConfig.omitFields, finalConfig.omitNulls) + allTypesFile.writeText(json.encodeToString(JsonElement.serializer(), filteredJsonElement)) + + logger.debug("Wrote All-Types JSON to: ${allTypesFile.name}") + } + + LinkPostProcessor.postProcess(context) + logger.info("JSON rendering completed.") + } + + // --- RECURSIVE JSON AST FILTER --- + private fun filterJson(element: JsonElement, omitFields: List, omitNulls: Boolean): JsonElement { + if (omitFields.isEmpty() && !omitNulls) return element + + return when (element) { + is JsonObject -> { + val filteredMap = element.entries + .filterNot { omitFields.contains(it.key) } + .mapNotNull { (key, value) -> + val filteredValue = filterJson(value, omitFields, omitNulls) + if (omitNulls && isNullOrEmpty(filteredValue)) { + null + } else { + key to filteredValue + } + } + .toMap() + JsonObject(filteredMap) + } + is JsonArray -> { + val mapped = element.map { filterJson(it, omitFields, omitNulls) } + if (omitNulls) { + JsonArray(mapped.filterNot { isNullOrEmpty(it) }) + } else { + JsonArray(mapped) + } + } + else -> element + } + } + + private fun isNullOrEmpty(element: JsonElement): Boolean { + return element is JsonNull || + (element is JsonPrimitive && element.isString && element.content.isEmpty()) || + (element is JsonArray && element.isEmpty()) || + (element is JsonObject && element.isEmpty()) + } +} \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/LinkPostProcessor.kt b/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/LinkPostProcessor.kt new file mode 100644 index 00000000..eec869d4 --- /dev/null +++ b/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/LinkPostProcessor.kt @@ -0,0 +1,72 @@ +package my.dokka.plugin + +import kotlinx.serialization.json.* +import org.jetbrains.dokka.plugability.DokkaContext + +object LinkPostProcessor { + fun postProcess(context: DokkaContext) { + val dir = context.configuration.outputDir + context.logger.info("Running Universal Cross-Module Link Resolution in ${dir.absolutePath}...") + + val jsonFiles = dir.walkTopDown().filter { it.extension == "json" }.toList() + val driIndex = mutableMapOf() + + // --- PASS 1: Build Global Index --- + for (file in jsonFiles) { + try { + val text = file.readText() + val rootElement = Json.parseToJsonElement(text) + val relativePath = file.parentFile.toRelativeString(dir).replace("\\", "/") + extractDris(rootElement, relativePath, driIndex) + } catch (e: Exception) { + context.logger.warn("Failed to index ${file.name}: ${e.message}") + } + } + + context.logger.info("Indexed ${driIndex.size} DRIs across ${jsonFiles.size} JSON files.") + + // --- PASS 2: Resolve Links --- + val unresolvedRegex = """unresolved:([^\s")\\]+)""".toRegex() + var replacedCount = 0 + + for (file in jsonFiles) { + val text = file.readText() + if (text.contains("unresolved:")) { + val relativeParent = file.parentFile.toRelativeString(dir).replace("\\", "/") + val depth = if (relativeParent.isEmpty()) 0 else relativeParent.split("/").filter { it.isNotEmpty() }.size + val rootPrefix = if (depth == 0) "./" else "../".repeat(depth) + + val replaced = unresolvedRegex.replace(text) { matchResult -> + val dri = matchResult.groupValues[1] + val resolved = driIndex[dri] + + if (resolved != null) { + replacedCount++ + "$rootPrefix$resolved" + } else { + "#" + } + } + file.writeText(replaced) + } + } + context.logger.info("Successfully resolved $replacedCount cross-module links!") + } + + private fun extractDris(element: JsonElement, relativePath: String, index: MutableMap) { + if (element is JsonObject) { + val dri = (element["dri"] as? JsonPrimitive)?.contentOrNull + val url = (element["url"] as? JsonPrimitive)?.contentOrNull + + if (dri != null && url != null && !url.startsWith("unresolved:") && !url.startsWith("http") && !url.startsWith("#")) { + val cleanParent = relativePath.trim('/') + val fullUrl = if (cleanParent.isEmpty()) url else "$cleanParent/$url" + index[dri] = fullUrl + } + + element.values.forEach { extractDris(it, relativePath, index) } + } else if (element is JsonArray) { + element.forEach { extractDris(it, relativePath, index) } + } + } +} \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/ModelMapper.kt b/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/ModelMapper.kt new file mode 100644 index 00000000..869bae40 --- /dev/null +++ b/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/ModelMapper.kt @@ -0,0 +1,434 @@ +package my.dokka.plugin + +import my.dokka.plugin.dtos.* +import org.jetbrains.dokka.base.resolvers.local.LocationProvider +import org.jetbrains.dokka.links.DRI +import org.jetbrains.dokka.model.* +import org.jetbrains.dokka.model.doc.* +import org.jetbrains.dokka.model.properties.PropertyContainer +import org.jetbrains.dokka.pages.PageNode +import org.jetbrains.dokka.pages.ContentPage +import org.jetbrains.dokka.pages.ContentNode +import org.jetbrains.dokka.pages.ContentCodeBlock +import org.jetbrains.dokka.pages.ContentText +import org.jetbrains.dokka.pages.ContentBreakLine + +class ModelMapper( + private val locationProvider: LocationProvider, + private val contextNode: PageNode, + private val logger: PluginLogger, + private val replaceHtmlExtension: Boolean +) { + private fun resolveUrl(dri: DRI?, sourceSets: Set): String? { + if (dri == null) return null + + var url = locationProvider.resolve(dri, sourceSets, contextNode) + if (url == null) { + url = locationProvider.resolve(dri, emptySet(), contextNode) + } + + if (url == null) { + url = "unresolved:${dri}" + } + + if (replaceHtmlExtension && !url.startsWith("http") && !url.startsWith("unresolved:")) { + url = url.replace(".html", ".json") + } + return url + } + + // --- ADDED shallow PARAMETER HERE --- + fun mapToDto(doc: Documentable, breadcrumbs: List = emptyList(), shallow: Boolean = false): DocumentableDto? { + logger.debug("Mapping documentable ${doc.name} of type ${doc::class.java.simpleName} (shallow=$shallow)") + + val displaySourceSets = doc.sourceSets.map { it.toDisplaySourceSet() }.toSet() + val url = resolveUrl(doc.dri, displaySourceSets) + + return when (doc) { + is DModule -> ModuleDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), + sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + // Pass shallow = true to children so the tree stops recursing! + packages = if (shallow) emptyList() else doc.packages.mapNotNull { mapToDto(it, emptyList(), true) } + ) + is DPackage -> PackageDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + functions = if (shallow) emptyList() else doc.functions.mapNotNull { mapToDto(it, emptyList(), true) }, + properties = if (shallow) emptyList() else doc.properties.mapNotNull { mapToDto(it, emptyList(), true) }, + classlikes = if (shallow) emptyList() else doc.classlikes.mapNotNull { mapToDto(it, emptyList(), true) }, + typeAliases = if (shallow) emptyList() else doc.typealiases.mapNotNull { mapToDto(it, emptyList(), true) } + ) + is DClass -> ClassDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + constructors = if (shallow) emptyList() else doc.constructors.mapNotNull { mapToDto(it, emptyList(), true) }, + functions = if (shallow) emptyList() else doc.functions.mapNotNull { mapToDto(it, emptyList(), true) }, + properties = if (shallow) emptyList() else doc.properties.mapNotNull { mapToDto(it, emptyList(), true) }, + classlikes = if (shallow) emptyList() else doc.classlikes.mapNotNull { mapToDto(it, emptyList(), true) }, + sources = mapSourceSetDependent(doc.sources) { _, it -> it.path }, + visibility = mapSourceSetDependent(doc.visibility) { _, it -> it.name }, + companion = if (shallow) null else doc.companion?.let { mapToDto(it, emptyList(), true) as? ObjectDto }, + generics = doc.generics.mapNotNull { mapToDto(it) as? TypeParameterDto }, + supertypes = mapSourceSetDependent(doc.supertypes) { ss, list -> + list.map { TypeConstructorWithKindDto(mapBound(it.typeConstructor, setOf(ss.toDisplaySourceSet())), it.kind.toString()) } + }, + modifier = mapSourceSetDependent(doc.modifier) { _, it -> it.name }, + isExpectActual = doc.isExpectActual, + typealiases = emptyList() + ) + is DEnum -> EnumDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + entries = if (shallow) emptyList() else doc.entries.mapNotNull { mapToDto(it, emptyList(), true) }, + constructors = if (shallow) emptyList() else doc.constructors.mapNotNull { mapToDto(it, emptyList(), true) }, + functions = if (shallow) emptyList() else doc.functions.mapNotNull { mapToDto(it, emptyList(), true) }, + properties = if (shallow) emptyList() else doc.properties.mapNotNull { mapToDto(it, emptyList(), true) }, + classlikes = if (shallow) emptyList() else doc.classlikes.mapNotNull { mapToDto(it, emptyList(), true) }, + sources = mapSourceSetDependent(doc.sources) { _, it -> it.path }, + visibility = mapSourceSetDependent(doc.visibility) { _, it -> it.name }, + companion = if (shallow) null else doc.companion?.let { mapToDto(it, emptyList(), true) as? ObjectDto }, + supertypes = mapSourceSetDependent(doc.supertypes) { ss, list -> + list.map { TypeConstructorWithKindDto(mapBound(it.typeConstructor, setOf(ss.toDisplaySourceSet())), it.kind.toString()) } + }, + isExpectActual = doc.isExpectActual, + typealiases = emptyList() + ) + is DEnumEntry -> EnumEntryDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + functions = if (shallow) emptyList() else doc.functions.mapNotNull { mapToDto(it, emptyList(), true) }, + properties = if (shallow) emptyList() else doc.properties.mapNotNull { mapToDto(it, emptyList(), true) }, + classlikes = if (shallow) emptyList() else doc.classlikes.mapNotNull { mapToDto(it, emptyList(), true) } + ) + is DFunction -> FunctionDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + isConstructor = doc.isConstructor, + parameters = doc.parameters.mapNotNull { mapToDto(it) as? ParameterDto }, + sources = mapSourceSetDependent(doc.sources) { _, it -> it.path }, + visibility = mapSourceSetDependent(doc.visibility) { _, it -> it.name }, + type = mapBound(doc.type, displaySourceSets), + generics = doc.generics.mapNotNull { mapToDto(it) as? TypeParameterDto }, + receiver = doc.receiver?.let { mapToDto(it) as? ParameterDto }, + modifier = mapSourceSetDependent(doc.modifier) { _, it -> it.name }, + isExpectActual = doc.isExpectActual, + contextParameters = emptyList() + ) + is DInterface -> InterfaceDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + functions = if (shallow) emptyList() else doc.functions.mapNotNull { mapToDto(it, emptyList(), true) }, + properties = if (shallow) emptyList() else doc.properties.mapNotNull { mapToDto(it, emptyList(), true) }, + classlikes = if (shallow) emptyList() else doc.classlikes.mapNotNull { mapToDto(it, emptyList(), true) }, + sources = mapSourceSetDependent(doc.sources) { _, it -> it.path }, + visibility = mapSourceSetDependent(doc.visibility) { _, it -> it.name }, + companion = if (shallow) null else doc.companion?.let { mapToDto(it, emptyList(), true) as? ObjectDto }, + generics = doc.generics.mapNotNull { mapToDto(it) as? TypeParameterDto }, + supertypes = mapSourceSetDependent(doc.supertypes) { ss, list -> + list.map { TypeConstructorWithKindDto(mapBound(it.typeConstructor, setOf(ss.toDisplaySourceSet())), it.kind.toString()) } + }, + modifier = mapSourceSetDependent(doc.modifier) { _, it -> it.name }, + isExpectActual = doc.isExpectActual, + typealiases = emptyList() + ) + is DObject -> ObjectDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + functions = if (shallow) emptyList() else doc.functions.mapNotNull { mapToDto(it, emptyList(), true) }, + properties = if (shallow) emptyList() else doc.properties.mapNotNull { mapToDto(it, emptyList(), true) }, + classlikes = if (shallow) emptyList() else doc.classlikes.mapNotNull { mapToDto(it, emptyList(), true) }, + sources = mapSourceSetDependent(doc.sources) { _, it -> it.path }, + visibility = mapSourceSetDependent(doc.visibility) { _, it -> it.name }, + supertypes = mapSourceSetDependent(doc.supertypes) { ss, list -> + list.map { TypeConstructorWithKindDto(mapBound(it.typeConstructor, setOf(ss.toDisplaySourceSet())), it.kind.toString()) } + }, + isExpectActual = doc.isExpectActual, + typealiases = emptyList() + ) + is DAnnotation -> AnnotationDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + functions = if (shallow) emptyList() else doc.functions.mapNotNull { mapToDto(it, emptyList(), true) }, + properties = if (shallow) emptyList() else doc.properties.mapNotNull { mapToDto(it, emptyList(), true) }, + classlikes = if (shallow) emptyList() else doc.classlikes.mapNotNull { mapToDto(it, emptyList(), true) }, + sources = mapSourceSetDependent(doc.sources) { _, it -> it.path }, + visibility = mapSourceSetDependent(doc.visibility) { _, it -> it.name }, + companion = if (shallow) null else doc.companion?.let { mapToDto(it, emptyList(), true) as? ObjectDto }, + constructors = if (shallow) emptyList() else doc.constructors.mapNotNull { mapToDto(it, emptyList(), true) }, + generics = doc.generics.mapNotNull { mapToDto(it) as? TypeParameterDto }, + isExpectActual = doc.isExpectActual + ) + is DProperty -> PropertyDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + sources = mapSourceSetDependent(doc.sources) { _, it -> it.path }, + visibility = mapSourceSetDependent(doc.visibility) { _, it -> it.name }, + type = mapBound(doc.type, displaySourceSets), + receiver = doc.receiver?.let { mapToDto(it) as? ParameterDto }, + setter = doc.setter?.let { mapToDto(it) as? FunctionDto }, + getter = doc.getter?.let { mapToDto(it) as? FunctionDto }, + modifier = mapSourceSetDependent(doc.modifier) { _, it -> it.name }, + generics = doc.generics.mapNotNull { mapToDto(it) as? TypeParameterDto }, + isExpectActual = doc.isExpectActual, + contextParameters = emptyList() + ) + is DParameter -> ParameterDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + type = mapBound(doc.type, displaySourceSets) + ) + is DTypeParameter -> TypeParameterDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + bounds = doc.bounds.map { mapBound(it, displaySourceSets) }, + variantTypeParameter = mapProjection(doc.variantTypeParameter, displaySourceSets) as VarianceDto + ) + is DTypeAlias -> TypeAliasDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + type = mapBound(doc.type, displaySourceSets), + underlyingType = mapSourceSetDependent(doc.underlyingType) { ss, it -> mapBound(it, setOf(ss.toDisplaySourceSet())) }, + visibility = mapSourceSetDependent(doc.visibility) { _, it -> it.name }, + generics = doc.generics.mapNotNull { mapToDto(it) as? TypeParameterDto }, + sources = mapSourceSetDependent(doc.sources) { _, it -> it.path } + ) + else -> null + } + } + + private fun mapExtras(extra: PropertyContainer<*>): ExtrasDto { + val isObviousMember = extra.allOfType().any { it::class.java.simpleName == "ObviousMember" } + val isException = extra.allOfType().any { it::class.java.simpleName == "ExceptionInSupertypes" } + + val annotationsMap = extra.allOfType().firstOrNull()?.directAnnotations?.entries?.associate { (ss, list) -> + ss.sourceSetID.toString() to list.map { anno -> + AnnotationWrapperDto( + dri = anno.dri.toString(), + params = anno.params.entries.associate { (k, v) -> k to v.toString() }, + url = resolveUrl(anno.dri, setOf(ss.toDisplaySourceSet())) + ) + } + } ?: emptyMap() + + val defaultValuesMap = mutableMapOf() + extra.allOfType().firstOrNull { it::class.java.simpleName == "DefaultValue" }?.let { defValue -> + try { + val valueMethod = defValue::class.java.methods.firstOrNull { it.name == "getValue" || it.name == "getExpression" } + val valueObj = valueMethod?.invoke(defValue) + if (valueObj is Map<*, *>) { + valueObj.forEach { (ss, expr) -> + val ssName = ss?.let { it::class.java.getMethod("getSourceSetID").invoke(it).toString() } ?: "unknown" + defaultValuesMap[ssName] = expr.toString() + } + } else if (valueObj != null) { + defaultValuesMap["unknown"] = valueObj.toString() + } + } catch (e: Exception) { + logger.debug("Failed to safely extract DefaultValue: ${e.message}") + } + } + + val additionalModifiersMap = extra.allOfType().firstOrNull()?.content?.entries?.associate { (ss, set) -> + ss.sourceSetID.toString() to set.map { modifier -> + try { + modifier::class.java.getMethod("getName").invoke(modifier).toString().lowercase() + } catch (e: Exception) { + modifier.toString().substringAfterLast("$").substringBefore("@").lowercase() + } + } + } ?: emptyMap() + + return ExtrasDto( + annotations = annotationsMap, + defaultValues = defaultValuesMap, + additionalModifiers = additionalModifiersMap, + isObviousMember = isObviousMember, + isException = isException + ) + } + + private fun mapProjection(proj: Projection, sourceSets: Set): ProjectionDto { + return when (proj) { + is Star -> StarDto + is Variance<*> -> when (proj) { + is Covariance<*> -> CovarianceDto(mapBound(proj.inner, sourceSets)) + is Contravariance<*> -> ContravarianceDto(mapBound(proj.inner, sourceSets)) + is Invariance<*> -> InvarianceDto(mapBound(proj.inner, sourceSets)) + } + is Bound -> mapBound(proj, sourceSets) + } + } + + private fun mapBound(bound: Bound, sourceSets: Set): BoundDto { + return when (bound) { + is TypeParameter -> TypeParameterBoundDto(bound.dri.toString(), bound.name, bound.presentableName, resolveUrl(bound.dri, sourceSets)) + is Nullable -> NullableDto(mapBound(bound.inner, sourceSets), resolveUrl(null, sourceSets)) + is DefinitelyNonNullable -> DefinitelyNonNullableDto(mapBound(bound.inner, sourceSets)) + is TypeAliased -> TypeAliasedDto(mapBound(bound.typeAlias, sourceSets), mapBound(bound.inner, sourceSets), resolveUrl(null, sourceSets)) + is PrimitiveJavaType -> PrimitiveJavaTypeDto(bound.name) + is JavaObject -> JavaObjectDto() + is Void -> VoidDto() + is Dynamic -> DynamicDto() + is UnresolvedBound -> UnresolvedBoundDto(bound.name) + is GenericTypeConstructor -> GenericTypeConstructorDto( + dri = bound.dri.toString(), + projections = bound.projections.map { mapProjection(it, sourceSets) }, + presentableName = bound.presentableName, + url = resolveUrl(bound.dri, sourceSets) + ) + is FunctionalTypeConstructor -> FunctionalTypeConstructorDto( + dri = bound.dri.toString(), + projections = bound.projections.map { mapProjection(it, sourceSets) }, + isExtensionFunction = bound.isExtensionFunction, + isSuspendable = bound.isSuspendable, + presentableName = bound.presentableName, + url = resolveUrl(bound.dri, sourceSets) + ) + } + } + + private fun mapDocNodes(docs: SourceSetDependent): Map> { + return docs.entries.associate { (sourceSet, node) -> + val displaySourceSet = sourceSet.toDisplaySourceSet() + val displaySourceSets = setOf(displaySourceSet) + + val pageSamples = mutableListOf() + if (contextNode is ContentPage) { + fun walk(n: ContentNode) { + if (n is ContentCodeBlock && + n.style.any { it.toString().contains("RunnableSample", ignoreCase = true) } && + n.sourceSets.contains(displaySourceSet) + ) { + fun extractContentText(cn: ContentNode): String { + if (cn is ContentText) return cn.text + if (cn is ContentBreakLine) return "\n" + return cn.children.joinToString("") { extractContentText(it) } + } + pageSamples.add(extractContentText(n)) + } + n.children.forEach { walk(it) } + } + walk(contextNode.content) + } + + var sampleIndex = 0 + val tags = node.children.map { tagWrapper -> + val type = tagWrapper::class.java.simpleName + var text = extractText(tagWrapper.root, displaySourceSets).trim() + + if (type == "Sample" && text.isEmpty()) { + if (sampleIndex < pageSamples.size) { + text = pageSamples[sampleIndex] + sampleIndex++ + } + } + + TagWrapperDto( + type = type, + text = text, + name = if (tagWrapper is NamedTagWrapper) tagWrapper.name else null + ) + } + sourceSet.sourceSetID.toString() to tags + } + } + + private fun extractText(tag: DocTag, sourceSets: Set): String { + return when (tag) { + is Text -> tag.body.replace("&", "&").replace("<", "<").replace(">", ">") + + is P -> "

${tag.children.joinToString("") { extractText(it, sourceSets) }}

" + is Br -> "
" + is BlockQuote -> "
${tag.children.joinToString("") { extractText(it, sourceSets) }}
" + + is B -> "${tag.children.joinToString("") { extractText(it, sourceSets) }}" + is Strong -> "${tag.children.joinToString("") { extractText(it, sourceSets) }}" + is I -> "${tag.children.joinToString("") { extractText(it, sourceSets) }}" + is Em -> "${tag.children.joinToString("") { extractText(it, sourceSets) }}" + + is CodeInline -> "${tag.children.joinToString("") { extractText(it, sourceSets) }}" + is CodeBlock -> "
${tag.children.joinToString("") { extractText(it, sourceSets) }}
" + + is Ul -> "
    ${tag.children.joinToString("") { extractText(it, sourceSets) }}
" + is Ol -> "
    ${tag.children.joinToString("") { extractText(it, sourceSets) }}
" + is Li -> "
  • ${tag.children.joinToString("") { extractText(it, sourceSets) }}
  • " + + is H1 -> "

    ${tag.children.joinToString("") { extractText(it, sourceSets) }}

    " + is H2 -> "

    ${tag.children.joinToString("") { extractText(it, sourceSets) }}

    " + is H3 -> "

    ${tag.children.joinToString("") { extractText(it, sourceSets) }}

    " + is H4 -> "

    ${tag.children.joinToString("") { extractText(it, sourceSets) }}

    " + is H5 -> "
    ${tag.children.joinToString("") { extractText(it, sourceSets) }}
    " + is H6 -> "
    ${tag.children.joinToString("") { extractText(it, sourceSets) }}
    " + + is A -> { + val href = tag.params["href"] ?: "" + "${tag.children.joinToString("") { extractText(it, sourceSets) }}" + } + is DocumentationLink -> { + val href = resolveUrl(tag.dri, sourceSets) + "${tag.children.joinToString("") { extractText(it, sourceSets) }}" + } + + is CustomDocTag -> tag.children.joinToString("") { extractText(it, sourceSets) } + else -> tag.children.joinToString("") { extractText(it, sourceSets) } + } + } + + private fun abbreviateSourceSet(id: String): String { + return id.substringAfterLast("/") + } + + private fun mapSourceSetDependent( + dependent: SourceSetDependent, + mapper: (org.jetbrains.dokka.DokkaConfiguration.DokkaSourceSet, T) -> R + ): Map { + return dependent.entries.associate { + abbreviateSourceSet(it.key.sourceSetID.toString()) to mapper(it.key, it.value) + } + } + + private fun mapSourceSets(sets: Set): List { + return sets.map { abbreviateSourceSet(it.sourceSetID.toString()) } + } +} \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/PluginLogger.kt b/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/PluginLogger.kt new file mode 100644 index 00000000..a399ea69 --- /dev/null +++ b/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/PluginLogger.kt @@ -0,0 +1,65 @@ +package my.dokka.plugin + +import org.jetbrains.dokka.utilities.DokkaLogger +import java.io.File + +class PluginLogger( + private val dokkaLogger: DokkaLogger, + levelStr: String, + logFilePath: String? = null // <--- ADD THIS +) { + private val level = when (levelStr.lowercase()) { + "debug" -> 0 + "info" -> 1 + "warn" -> 2 + "error" -> 3 + else -> 1 + } + + private val logFile: File? = logFilePath?.let { path -> + val file = File(path) + file.parentFile?.mkdirs() + file + } + + // Synchronize to prevent mangled logs if Dokka processes multiple modules in parallel + private fun writeToFile(msg: String) { + if (logFile != null) { + synchronized(this) { + logFile.appendText("$msg\n") + } + } + } + + fun debug(msg: String) { + if (level <= 0) { + val formatted = "[JSON Plugin DEBUG] $msg" + dokkaLogger.info(formatted) + writeToFile(formatted) + } + } + + fun info(msg: String) { + if (level <= 1) { + val formatted = "[JSON Plugin INFO] $msg" + dokkaLogger.info(formatted) + writeToFile(formatted) + } + } + + fun warn(msg: String) { + if (level <= 2) { + val formatted = "[JSON Plugin WARN] $msg" + dokkaLogger.warn(formatted) + writeToFile(formatted) + } + } + + fun error(msg: String) { + if (level <= 3) { + val formatted = "[JSON Plugin ERROR] $msg" + dokkaLogger.error(formatted) + writeToFile(formatted) + } + } +} \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/dtos/SemanticModelDtos.kt b/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/dtos/SemanticModelDtos.kt new file mode 100644 index 00000000..03f0a5d4 --- /dev/null +++ b/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/dtos/SemanticModelDtos.kt @@ -0,0 +1,422 @@ +package my.dokka.plugin.dtos + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +// --- Documentation Tags --- + +@Serializable +data class TagWrapperDto( + val type: String, + val text: String, + val name: String? = null +) + +// --- Extras & Properties --- + +@Serializable +data class AnnotationWrapperDto( + val dri: String, + val params: Map, + val url: String? = null +) + +@Serializable +data class ExtrasDto( + val annotations: Map> = emptyMap(), + val defaultValues: Map = emptyMap(), + val additionalModifiers: Map> = emptyMap(), + val isObviousMember: Boolean = false, + val isException: Boolean = false +) + +// --- Bounds & Projections (Types) --- + +@Serializable +sealed class ProjectionDto + +@Serializable +@SerialName("Star") +object StarDto : ProjectionDto() + +@Serializable +sealed class VarianceDto : ProjectionDto() { + abstract val inner: BoundDto +} +@Serializable @SerialName("Covariance") data class CovarianceDto(override val inner: BoundDto) : VarianceDto() +@Serializable @SerialName("Contravariance") data class ContravarianceDto(override val inner: BoundDto) : VarianceDto() +@Serializable @SerialName("Invariance") data class InvarianceDto(override val inner: BoundDto) : VarianceDto() + +@Serializable +sealed class BoundDto : ProjectionDto() { + abstract val url: String? +} + +@Serializable @SerialName("TypeParameter") +data class TypeParameterBoundDto(val dri: String, val name: String, val presentableName: String?, override val url: String? = null) : BoundDto() + +@Serializable @SerialName("Nullable") +data class NullableDto(val inner: BoundDto, override val url: String? = null) : BoundDto() + +@Serializable @SerialName("DefinitelyNonNullable") +data class DefinitelyNonNullableDto(val inner: BoundDto, override val url: String? = null) : BoundDto() + +@Serializable @SerialName("TypeAliased") +data class TypeAliasedDto(val typeAlias: BoundDto, val inner: BoundDto, override val url: String? = null) : BoundDto() + +@Serializable @SerialName("PrimitiveJavaType") +data class PrimitiveJavaTypeDto(val name: String, override val url: String? = null) : BoundDto() + +@Serializable @SerialName("JavaObject") data class JavaObjectDto(override val url: String? = null) : BoundDto() +@Serializable @SerialName("Void") data class VoidDto(override val url: String? = null) : BoundDto() +@Serializable @SerialName("Dynamic") data class DynamicDto(override val url: String? = null) : BoundDto() + +@Serializable @SerialName("UnresolvedBound") +data class UnresolvedBoundDto(val name: String, override val url: String? = null) : BoundDto() + +@Serializable @SerialName("GenericTypeConstructor") +data class GenericTypeConstructorDto( + val dri: String, + val projections: List, + val presentableName: String?, + override val url: String? = null +) : BoundDto() + +@Serializable @SerialName("FunctionalTypeConstructor") +data class FunctionalTypeConstructorDto( + val dri: String, + val projections: List, + val isExtensionFunction: Boolean, + val isSuspendable: Boolean, + val presentableName: String?, + override val url: String? = null +) : BoundDto() + +@Serializable +data class TypeConstructorWithKindDto( + val typeConstructor: BoundDto, + val kind: String +) + +// --- Primary Documentables --- + +@Serializable +sealed class DocumentableDto { + abstract val dri: String + abstract val name: String? + abstract val url: String? + abstract val documentation: Map> + abstract val sourceSets: List + abstract val expectPresentInSet: String? + abstract val extras: ExtrasDto + abstract val breadcrumbs: List +} + +@Serializable +@SerialName("module") +data class ModuleDto( + override val dri: String, + override val name: String, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val packages: List +) : DocumentableDto() + +@Serializable +@SerialName("package") +data class PackageDto( + override val dri: String, + override val name: String, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val functions: List, + val properties: List, + val classlikes: List, + val typeAliases: List +) : DocumentableDto() + +@Serializable +@SerialName("class") +data class ClassDto( + override val dri: String, + override val name: String, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val constructors: List, + val functions: List, + val properties: List, + val classlikes: List, + val sources: Map, + val visibility: Map, + val companion: ObjectDto?, + val generics: List, + val supertypes: Map>, + val modifier: Map, + val isExpectActual: Boolean, + val typealiases: List +) : DocumentableDto() + +@Serializable +@SerialName("enum") +data class EnumDto( + override val dri: String, + override val name: String, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val entries: List, + val constructors: List, + val functions: List, + val properties: List, + val classlikes: List, + val sources: Map, + val visibility: Map, + val companion: ObjectDto?, + val supertypes: Map>, + val isExpectActual: Boolean, + val typealiases: List +) : DocumentableDto() + +@Serializable +@SerialName("enumEntry") +data class EnumEntryDto( + override val dri: String, + override val name: String, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val functions: List, + val properties: List, + val classlikes: List +) : DocumentableDto() + +@Serializable +@SerialName("function") +data class FunctionDto( + override val dri: String, + override val name: String, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val isConstructor: Boolean, + val parameters: List, + val sources: Map, + val visibility: Map, + val type: BoundDto, + val generics: List, + val receiver: ParameterDto?, + val modifier: Map, + val isExpectActual: Boolean, + val contextParameters: List +) : DocumentableDto() + +@Serializable +@SerialName("interface") +data class InterfaceDto( + override val dri: String, + override val name: String, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val functions: List, + val properties: List, + val classlikes: List, + val sources: Map, + val visibility: Map, + val companion: ObjectDto?, + val generics: List, + val supertypes: Map>, + val modifier: Map, + val isExpectActual: Boolean, + val typealiases: List +) : DocumentableDto() + +@Serializable +@SerialName("object") +data class ObjectDto( + override val dri: String, + override val name: String?, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val functions: List, + val properties: List, + val classlikes: List, + val sources: Map, + val visibility: Map, + val supertypes: Map>, + val isExpectActual: Boolean, + val typealiases: List +) : DocumentableDto() + +@Serializable +@SerialName("annotation") +data class AnnotationDto( + override val dri: String, + override val name: String, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val functions: List, + val properties: List, + val classlikes: List, + val sources: Map, + val visibility: Map, + val companion: ObjectDto?, + val constructors: List, + val generics: List, + val isExpectActual: Boolean +) : DocumentableDto() + +@Serializable +@SerialName("property") +data class PropertyDto( + override val dri: String, + override val name: String, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val sources: Map, + val visibility: Map, + val type: BoundDto, + val receiver: ParameterDto?, + val setter: FunctionDto?, + val getter: FunctionDto?, + val modifier: Map, + val generics: List, + val isExpectActual: Boolean, + val contextParameters: List +) : DocumentableDto() + +@Serializable +@SerialName("parameter") +data class ParameterDto( + override val dri: String, + override val name: String?, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val type: BoundDto +) : DocumentableDto() + +@Serializable +@SerialName("typeParameter") +data class TypeParameterDto( + override val dri: String, + override val name: String, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val bounds: List, + val variantTypeParameter: VarianceDto +) : DocumentableDto() + +@Serializable +@SerialName("typeAlias") +data class TypeAliasDto( + override val dri: String, + override val name: String, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val type: BoundDto, + val underlyingType: Map, + val visibility: Map, + val generics: List, + val sources: Map +) : DocumentableDto() + +// --- Multimodule References --- + +@Serializable +data class ModuleReferenceDto( + val name: String, + val url: String +) + +@Serializable +@SerialName("multimoduleRoot") +data class MultimoduleRootDto( + override val dri: String = "multimodule-root", + override val name: String?, + override val url: String? = null, + override val documentation: Map> = emptyMap(), + override val sourceSets: List = emptyList(), + override val expectPresentInSet: String? = null, + override val extras: ExtrasDto = ExtrasDto(), + override val breadcrumbs: List = emptyList(), + val modules: List +) : DocumentableDto() + +@Serializable +data class BreadcrumbNode( + val name: String, + val url: String? +) + +// --- NEW: All Types Index --- + +@Serializable +data class TypeIndexEntryDto( + val name: String, + val kind: String, + val dri: String, + val url: String?, + val sourceSets: List +) + +@Serializable +@SerialName("allTypes") +data class AllTypesDto( + override val dri: String = "all-types", + override val name: String? = "All Types", + override val url: String? = "all-types.json", + override val documentation: Map> = emptyMap(), + override val sourceSets: List = emptyList(), + override val expectPresentInSet: String? = null, + override val extras: ExtrasDto = ExtrasDto(), + override val breadcrumbs: List = emptyList(), + val types: List +) : DocumentableDto() \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/src/main/resources/META-INF/services/org.jetbrains.dokka.plugability.DokkaPlugin b/Dokka-plugin-kdoc2json/json-output-plugin/src/main/resources/META-INF/services/org.jetbrains.dokka.plugability.DokkaPlugin new file mode 100644 index 00000000..255b88d4 --- /dev/null +++ b/Dokka-plugin-kdoc2json/json-output-plugin/src/main/resources/META-INF/services/org.jetbrains.dokka.plugability.DokkaPlugin @@ -0,0 +1,2 @@ +my.dokka.plugin.JsonOutputPlugin + From 10f0911df1d194ea23a631c242a5ff510c3d2fed Mon Sep 17 00:00:00 2001 From: Alex Miller Date: Wed, 1 Jul 2026 15:54:26 -0500 Subject: [PATCH 02/15] Update test library --- .../.gradle/9.5.1/checksums/checksums.lock | Bin 0 -> 17 bytes .../.gradle/9.5.1/checksums/md5-checksums.bin | Bin 0 -> 18697 bytes .../9.5.1/checksums/sha1-checksums.bin | Bin 0 -> 18713 bytes .../executionHistory/executionHistory.bin | Bin 0 -> 119648 bytes .../executionHistory/executionHistory.lock | Bin 0 -> 17 bytes .../.gradle/9.5.1/fileChanges/last-build.bin | Bin 0 -> 1 bytes .../.gradle/9.5.1/fileHashes/fileHashes.bin | Bin 0 -> 26647 bytes .../.gradle/9.5.1/fileHashes/fileHashes.lock | Bin 0 -> 17 bytes .../9.5.1/fileHashes/resourceHashesCache.bin | Bin 0 -> 19007 bytes .../.gradle/9.5.1/gc.properties | 0 .../buildOutputCleanup.lock | Bin 0 -> 17 bytes .../buildOutputCleanup/cache.properties | 2 + .../buildOutputCleanup/outputFiles.bin | Bin 0 -> 18911 bytes .../.gradle/file-system.probe | Bin 0 -> 8 bytes .../.gradle/vcs-1/gc.properties | 0 .../example-data-processor/build.gradle.kts | 16 ++----- .../settings.gradle.kts | 2 +- .../DataProcessor.kt => testlib/Core.kt} | 0 .../kotlin/com/example/testlib/Nesting.kt | 40 ++++++++++++++++++ 19 files changed, 46 insertions(+), 14 deletions(-) create mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/checksums/checksums.lock create mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/checksums/md5-checksums.bin create mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/checksums/sha1-checksums.bin create mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/executionHistory/executionHistory.bin create mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/executionHistory/executionHistory.lock create mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/fileChanges/last-build.bin create mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/fileHashes/fileHashes.bin create mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/fileHashes/fileHashes.lock create mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/fileHashes/resourceHashesCache.bin create mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/gc.properties create mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/buildOutputCleanup/buildOutputCleanup.lock create mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/buildOutputCleanup/cache.properties create mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/buildOutputCleanup/outputFiles.bin create mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/file-system.probe create mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/vcs-1/gc.properties rename Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/{utils/DataProcessor.kt => testlib/Core.kt} (100%) create mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/testlib/Nesting.kt diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/checksums/checksums.lock b/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/checksums/checksums.lock new file mode 100644 index 0000000000000000000000000000000000000000..f65e731e9c1db34f9a1676cdc51a96398ffadd4d GIT binary patch literal 17 UcmZSn()#du>&l5o7$Bep07>ZvOaK4? literal 0 HcmV?d00001 diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/checksums/md5-checksums.bin b/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/checksums/md5-checksums.bin new file mode 100644 index 0000000000000000000000000000000000000000..e8bf59ea0f29138caf98af5ed399939b32ca4c56 GIT binary patch literal 18697 zcmeI%KS)AB0LSr5UkHyLxdcu^I7ty*9(NBB$F}$$+`Hp<{5bBjzCIxW{vSO=Wi(Z`><~Zz0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0;J*mODBqOn_HpS+{4G~q0Xn=++Ag!P-lD*KmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0;4cCl zvK*GP*_nRd%&`^S8#?Q@BZ-}4YE_83?djp*sk*_~m1|pdBRToDvQ|uQWf$%I-FxqZ zZe&%%+^=nTzI`W=rTX%~$Y$zua3Sq9Zk97&`i_Iz#^deAd2Fmyy0R|pTs$1AZkPq9 F@e7~Wc_07) literal 0 HcmV?d00001 diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/checksums/sha1-checksums.bin b/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/checksums/sha1-checksums.bin new file mode 100644 index 0000000000000000000000000000000000000000..b74f8fc0e2d210312b6ba0327f7072ec66bd5293 GIT binary patch literal 18713 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 literal 0 HcmV?d00001 diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/executionHistory/executionHistory.lock b/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/executionHistory/executionHistory.lock new file mode 100644 index 0000000000000000000000000000000000000000..f951fbedf5266dcdb56cb28f88e680cfebb72879 GIT binary patch literal 17 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 literal 0 HcmV?d00001 diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/fileHashes/fileHashes.lock b/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/fileHashes/fileHashes.lock new file mode 100644 index 0000000000000000000000000000000000000000..ff165e0ac5468d7dcb0505dd5ccdfe9bdc37e893 GIT binary patch literal 17 VcmZRcYFc(Kz%YIX0~j#p001r&1Q`GT literal 0 HcmV?d00001 diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/fileHashes/resourceHashesCache.bin b/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/fileHashes/resourceHashesCache.bin new file mode 100644 index 0000000000000000000000000000000000000000..69c0a06a114a53fc44d0a7577a42fa4fbb8677d9 GIT binary patch literal 19007 zcmeI&Z%7ki90%~T8oBb{0(Gra{L>8oG_}+W-86>P44aj;&@^kzNlelNo2IEK1z}@M z(*>pY2YpviSuqGI*%HMtL(%3GBFIu$nFubdjXh5i*Q;K}_u%dxzW2Q!JfF9BQikFC z{Uisoe)z91v4Q{uAOHafKmY;|fB*y_009U<00Izz00bZa0SG|ge-X%aZe)vlGyIqh zu7z(U!^C;H7R`0uy9W5f4erNBa_IL1{j|I#Ryi}~&JPdK+@P&nel&45kMu<}H>O<5 zO+43{P4Xu+H~HA?C)I@#GN*~=-(@#%Plt(lq<@*_+Z<=IB+~v>l26fG*l!PSG#ttx zb1u+4F0D*>Kcb_`oy&q~o>e5~B`0njBy(D6ej-W|JpXX<+m`~W*u_?W8@l}a%;)ahi4F0o`v?MTmkOa2bDoWH9bMy7= zhNfphzj-C(MMJgSn0-p!#cuQ#H$4qzq7+3{?ZYu2ubJ2lQS+QpH1jQ|$KR$|vm{(+ zH}d0T=S}50&AzrluCiOgVK)?aJSWoYUeGX4$DU!a6l(?6c6q@8dFP(-{l4tR?1QVyucKjawUL2lU1_R= z-QaS^Bi24AS;e`}xx4i*?d*nUu*Bojj$5_2Z_Wjx){;3(Z5MgvF;`_q(K798)&))ZQecvL( zaBg3b!&p6bt_~R?fB*srAbBckuBEEICE|B z!L|m&*uT{rq&F`tH0GavC(j7nE&BZ6_Wg~~X-B5tTIRW`{ch~hiQ2A#57LimADo{2 znASWnBfV7nh^wvpK$bm4dZ+fM{YPes=OT;Jz1pAEoXc65yBh)DSRB2zzU-C6YuWXR{540}_x(_Cgy&9M9({BHC z&HMV|<`&uKjP|{*Xy=R8lb5B3v}b!OD{|WM#-tZ&cm0^l`_^>nq18S6v>*15U$FJ0 zev$dNw4bU^>rI~s^-BM$z1)AjuB3YErS!0NuQM1q?-*^D-k`mvs$*n$Hv6&FJ|=tzwwO#0tg_000IagfB*srAb;Y4w-*!H$U;0().configureEach { - // Tell your custom plugin exactly what to name the output file - pluginsMapConfiguration.set( - mapOf( - "com.example.dokka.JsonExportPlugin" to """{"outputFileName": "api-documentation.json"}""" - ) - ) -} + dokkaPlugin("my.dokka.plugin:json-output-plugin:1.0.0-SNAPSHOT") +} \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/settings.gradle.kts b/Dokka-plugin-kdoc2json/examples/example-data-processor/settings.gradle.kts index 83339902..1afa5665 100644 --- a/Dokka-plugin-kdoc2json/examples/example-data-processor/settings.gradle.kts +++ b/Dokka-plugin-kdoc2json/examples/example-data-processor/settings.gradle.kts @@ -1 +1 @@ -rootProject.name = "example-data-processor" +rootProject.name = "testlib" diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/utils/DataProcessor.kt b/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/testlib/Core.kt similarity index 100% rename from Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/utils/DataProcessor.kt rename to Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/testlib/Core.kt diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/testlib/Nesting.kt b/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/testlib/Nesting.kt new file mode 100644 index 00000000..4989f95e --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/testlib/Nesting.kt @@ -0,0 +1,40 @@ +package com.example.testlib + +/** + * A singleton object to fulfill the `DObject` requirement. + * This acts as the root of our deep hierarchy. + */ +object Level1 { + + /** + * A nested class to fulfill the `DClass` requirement. + * * **Hierarchy Depth 1:** The breadcrumbs for this class will be `Overview / com.example.testlib / Level1 / Level2`. + * **Internal Link 3:** Implements the [Provider] interface. + */ + @Meta("Deeply nested class") + class Level2 : Provider { + + override val data: DataMap = emptyMap() + + override fun process(input: DataMap): DataMap { + return input + } + + /** + * An enum class to fulfill the `DEnum` requirement. + * * **Hierarchy Depth 2:** The breadcrumbs for this enum will be `Overview / com.example.testlib / Level1 / Level2 / Level3`. + * This successfully proves deep hierarchy rendering. + */ + enum class Level3 { + /** + * Enum entry to fulfill the `DEnumEntry` requirement. + */ + ACTIVE, + + /** + * Enum entry to fulfill the `DEnumEntry` requirement. + */ + INACTIVE + } + } +} \ No newline at end of file From 8a0733c42cc8163e5814a36d4f246a25035064f4 Mon Sep 17 00:00:00 2001 From: Alex Miller Date: Wed, 1 Jul 2026 15:55:51 -0500 Subject: [PATCH 03/15] Move updated test library --- .../.gradle/9.5.1/checksums/checksums.lock | Bin 17 -> 0 bytes .../.gradle/9.5.1/checksums/md5-checksums.bin | Bin 18697 -> 0 bytes .../.gradle/9.5.1/checksums/sha1-checksums.bin | Bin 18713 -> 0 bytes .../executionHistory/executionHistory.bin | Bin 119648 -> 0 bytes .../executionHistory/executionHistory.lock | Bin 17 -> 0 bytes .../.gradle/9.5.1/fileChanges/last-build.bin | Bin 1 -> 0 bytes .../.gradle/9.5.1/fileHashes/fileHashes.bin | Bin 26647 -> 0 bytes .../.gradle/9.5.1/fileHashes/fileHashes.lock | Bin 17 -> 0 bytes .../9.5.1/fileHashes/resourceHashesCache.bin | Bin 19007 -> 0 bytes .../.gradle/9.5.1/gc.properties | 0 .../buildOutputCleanup/buildOutputCleanup.lock | Bin 17 -> 0 bytes .../buildOutputCleanup/cache.properties | 2 -- .../.gradle/buildOutputCleanup/outputFiles.bin | Bin 18911 -> 0 bytes .../.gradle/file-system.probe | Bin 8 -> 0 bytes .../.gradle/vcs-1/gc.properties | 0 15 files changed, 2 deletions(-) delete mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/checksums/checksums.lock delete mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/checksums/md5-checksums.bin delete mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/checksums/sha1-checksums.bin delete mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/executionHistory/executionHistory.bin delete mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/executionHistory/executionHistory.lock delete mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/fileChanges/last-build.bin delete mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/fileHashes/fileHashes.bin delete mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/fileHashes/fileHashes.lock delete mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/fileHashes/resourceHashesCache.bin delete mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/gc.properties delete mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/buildOutputCleanup/buildOutputCleanup.lock delete mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/buildOutputCleanup/cache.properties delete mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/buildOutputCleanup/outputFiles.bin delete mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/file-system.probe delete mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/vcs-1/gc.properties diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/checksums/checksums.lock b/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/checksums/checksums.lock deleted file mode 100644 index f65e731e9c1db34f9a1676cdc51a96398ffadd4d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17 UcmZSn()#du>&l5o7$Bep07>ZvOaK4? diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/checksums/md5-checksums.bin b/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/checksums/md5-checksums.bin deleted file mode 100644 index e8bf59ea0f29138caf98af5ed399939b32ca4c56..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18697 zcmeI%KS)AB0LSr5UkHyLxdcu^I7ty*9(NBB$F}$$+`Hp<{5bBjzCIxW{vSO=Wi(Z`><~Zz0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0;J*mODBqOn_HpS+{4G~q0Xn=++Ag!P-lD*KmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0;4cCl zvK*GP*_nRd%&`^S8#?Q@BZ-}4YE_83?djp*sk*_~m1|pdBRToDvQ|uQWf$%I-FxqZ zZe&%%+^=nTzI`W=rTX%~$Y$zua3Sq9Zk97&`i_Iz#^deAd2Fmyy0R|pTs$1AZkPq9 F@e7~Wc_07) diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/checksums/sha1-checksums.bin b/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/checksums/sha1-checksums.bin deleted file mode 100644 index b74f8fc0e2d210312b6ba0327f7072ec66bd5293..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18713 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 diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/executionHistory/executionHistory.lock b/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/executionHistory/executionHistory.lock deleted file mode 100644 index f951fbedf5266dcdb56cb28f88e680cfebb72879..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17 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 diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/fileHashes/fileHashes.lock b/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/fileHashes/fileHashes.lock deleted file mode 100644 index ff165e0ac5468d7dcb0505dd5ccdfe9bdc37e893..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17 VcmZRcYFc(Kz%YIX0~j#p001r&1Q`GT diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/fileHashes/resourceHashesCache.bin b/Dokka-plugin-kdoc2json/examples/example-data-processor/.gradle/9.5.1/fileHashes/resourceHashesCache.bin deleted file mode 100644 index 69c0a06a114a53fc44d0a7577a42fa4fbb8677d9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19007 zcmeI&Z%7ki90%~T8oBb{0(Gra{L>8oG_}+W-86>P44aj;&@^kzNlelNo2IEK1z}@M z(*>pY2YpviSuqGI*%HMtL(%3GBFIu$nFubdjXh5i*Q;K}_u%dxzW2Q!JfF9BQikFC z{Uisoe)z91v4Q{uAOHafKmY;|fB*y_009U<00Izz00bZa0SG|ge-X%aZe)vlGyIqh zu7z(U!^C;H7R`0uy9W5f4erNBa_IL1{j|I#Ryi}~&JPdK+@P&nel&45kMu<}H>O<5 zO+43{P4Xu+H~HA?C)I@#GN*~=-(@#%Plt(lq<@*_+Z<=IB+~v>l26fG*l!PSG#ttx zb1u+4F0D*>Kcb_`oy&q~o>e5~B`0njBy(D6ej-W|JpXX<+m`~W*u_?W8@l}a%;)ahi4F0o`v?MTmkOa2bDoWH9bMy7= zhNfphzj-C(MMJgSn0-p!#cuQ#H$4qzq7+3{?ZYu2ubJ2lQS+QpH1jQ|$KR$|vm{(+ zH}d0T=S}50&AzrluCiOgVK)?aJSWoYUeGX4$DU!a6l(?6c6q@8dFP(-{l4tR?1QVyucKjawUL2lU1_R= z-QaS^Bi24AS;e`}xx4i*?d*nUu*Bojj$5_2Z_Wjx){;3(Z5MgvF;`_q(K798)&))ZQecvL( zaBg3b!&p6bt_~R?fB*srAbBckuBEEICE|B z!L|m&*uT{rq&F`tH0GavC(j7nE&BZ6_Wg~~X-B5tTIRW`{ch~hiQ2A#57LimADo{2 znASWnBfV7nh^wvpK$bm4dZ+fM{YPes=OT;Jz1pAEoXc65yBh)DSRB2zzU-C6YuWXR{540}_x(_Cgy&9M9({BHC z&HMV|<`&uKjP|{*Xy=R8lb5B3v}b!OD{|WM#-tZ&cm0^l`_^>nq18S6v>*15U$FJ0 zev$dNw4bU^>rI~s^-BM$z1)AjuB3YErS!0NuQM1q?-*^D-k`mvs$*n$Hv6&FJ|=tzwwO#0tg_000IagfB*srAb;Y4w-*!H$U;0 Date: Thu, 2 Jul 2026 15:17:32 -0500 Subject: [PATCH 04/15] Fix org/package name, update README (see TODO.md) --- .../{json-output-plugin => kdoc-to-json}/build.gradle.kts | 0 .../gradle/wrapper/gradle-wrapper.properties | 0 .../{json-output-plugin => kdoc-to-json}/gradlew | 0 .../{json-output-plugin => kdoc-to-json}/gradlew.bat | 0 .../{json-output-plugin => kdoc-to-json}/settings.gradle.kts | 0 .../src/main/kotlin/JsonOutputPlugin.kt | 0 .../src/main/kotlin/JsonPluginConfig.kt | 0 .../src/main/kotlin/JsonRenderer.kt | 0 .../src/main/kotlin/LinkPostProcessor.kt | 0 .../src/main/kotlin/ModelMapper.kt | 0 .../src/main/kotlin/PluginLogger.kt | 0 .../src/main/kotlin/dtos/SemanticModelDtos.kt | 0 .../META-INF/services/org.jetbrains.dokka.plugability.DokkaPlugin | 0 13 files changed, 0 insertions(+), 0 deletions(-) rename Dokka-plugin-kdoc2json/{json-output-plugin => kdoc-to-json}/build.gradle.kts (100%) rename Dokka-plugin-kdoc2json/{json-output-plugin => kdoc-to-json}/gradle/wrapper/gradle-wrapper.properties (100%) rename Dokka-plugin-kdoc2json/{json-output-plugin => kdoc-to-json}/gradlew (100%) rename Dokka-plugin-kdoc2json/{json-output-plugin => kdoc-to-json}/gradlew.bat (100%) rename Dokka-plugin-kdoc2json/{json-output-plugin => kdoc-to-json}/settings.gradle.kts (100%) rename Dokka-plugin-kdoc2json/{json-output-plugin => kdoc-to-json}/src/main/kotlin/JsonOutputPlugin.kt (100%) rename Dokka-plugin-kdoc2json/{json-output-plugin => kdoc-to-json}/src/main/kotlin/JsonPluginConfig.kt (100%) rename Dokka-plugin-kdoc2json/{json-output-plugin => kdoc-to-json}/src/main/kotlin/JsonRenderer.kt (100%) rename Dokka-plugin-kdoc2json/{json-output-plugin => kdoc-to-json}/src/main/kotlin/LinkPostProcessor.kt (100%) rename Dokka-plugin-kdoc2json/{json-output-plugin => kdoc-to-json}/src/main/kotlin/ModelMapper.kt (100%) rename Dokka-plugin-kdoc2json/{json-output-plugin => kdoc-to-json}/src/main/kotlin/PluginLogger.kt (100%) rename Dokka-plugin-kdoc2json/{json-output-plugin => kdoc-to-json}/src/main/kotlin/dtos/SemanticModelDtos.kt (100%) rename Dokka-plugin-kdoc2json/{json-output-plugin => kdoc-to-json}/src/main/resources/META-INF/services/org.jetbrains.dokka.plugability.DokkaPlugin (100%) diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/build.gradle.kts b/Dokka-plugin-kdoc2json/kdoc-to-json/build.gradle.kts similarity index 100% rename from Dokka-plugin-kdoc2json/json-output-plugin/build.gradle.kts rename to Dokka-plugin-kdoc2json/kdoc-to-json/build.gradle.kts diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/gradle/wrapper/gradle-wrapper.properties b/Dokka-plugin-kdoc2json/kdoc-to-json/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from Dokka-plugin-kdoc2json/json-output-plugin/gradle/wrapper/gradle-wrapper.properties rename to Dokka-plugin-kdoc2json/kdoc-to-json/gradle/wrapper/gradle-wrapper.properties diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/gradlew b/Dokka-plugin-kdoc2json/kdoc-to-json/gradlew similarity index 100% rename from Dokka-plugin-kdoc2json/json-output-plugin/gradlew rename to Dokka-plugin-kdoc2json/kdoc-to-json/gradlew diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/gradlew.bat b/Dokka-plugin-kdoc2json/kdoc-to-json/gradlew.bat similarity index 100% rename from Dokka-plugin-kdoc2json/json-output-plugin/gradlew.bat rename to Dokka-plugin-kdoc2json/kdoc-to-json/gradlew.bat diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/settings.gradle.kts b/Dokka-plugin-kdoc2json/kdoc-to-json/settings.gradle.kts similarity index 100% rename from Dokka-plugin-kdoc2json/json-output-plugin/settings.gradle.kts rename to Dokka-plugin-kdoc2json/kdoc-to-json/settings.gradle.kts diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/JsonOutputPlugin.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonOutputPlugin.kt similarity index 100% rename from Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/JsonOutputPlugin.kt rename to Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonOutputPlugin.kt diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/JsonPluginConfig.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonPluginConfig.kt similarity index 100% rename from Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/JsonPluginConfig.kt rename to Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonPluginConfig.kt diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/JsonRenderer.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonRenderer.kt similarity index 100% rename from Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/JsonRenderer.kt rename to Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonRenderer.kt diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/LinkPostProcessor.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/LinkPostProcessor.kt similarity index 100% rename from Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/LinkPostProcessor.kt rename to Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/LinkPostProcessor.kt diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/ModelMapper.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/ModelMapper.kt similarity index 100% rename from Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/ModelMapper.kt rename to Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/ModelMapper.kt diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/PluginLogger.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/PluginLogger.kt similarity index 100% rename from Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/PluginLogger.kt rename to Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/PluginLogger.kt diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/dtos/SemanticModelDtos.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/dtos/SemanticModelDtos.kt similarity index 100% rename from Dokka-plugin-kdoc2json/json-output-plugin/src/main/kotlin/dtos/SemanticModelDtos.kt rename to Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/dtos/SemanticModelDtos.kt diff --git a/Dokka-plugin-kdoc2json/json-output-plugin/src/main/resources/META-INF/services/org.jetbrains.dokka.plugability.DokkaPlugin b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/resources/META-INF/services/org.jetbrains.dokka.plugability.DokkaPlugin similarity index 100% rename from Dokka-plugin-kdoc2json/json-output-plugin/src/main/resources/META-INF/services/org.jetbrains.dokka.plugability.DokkaPlugin rename to Dokka-plugin-kdoc2json/kdoc-to-json/src/main/resources/META-INF/services/org.jetbrains.dokka.plugability.DokkaPlugin From 24cd85a7b7883abd43ea45d1a71c239880242853 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 2 Jul 2026 15:19:00 -0500 Subject: [PATCH 05/15] Fix org/package name, update README (see TODO.md) --- Dokka-plugin-kdoc2json/README.md | 38 +++++++++++++++++++++++++++++--- Dokka-plugin-kdoc2json/TODO.md | 14 +++++++----- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/Dokka-plugin-kdoc2json/README.md b/Dokka-plugin-kdoc2json/README.md index 246843f4..c9eaaf07 100644 --- a/Dokka-plugin-kdoc2json/README.md +++ b/Dokka-plugin-kdoc2json/README.md @@ -17,6 +17,7 @@ This plugin intercepts base Dokka's pipeline just before rendering to output JSO Clone this repository and publish the plugin to your local Maven repository: ```bash +cd kdoc-to-json ./gradlew publishToMavenLocal ``` @@ -26,10 +27,39 @@ In the target project where you want to generate documentation, add the plugin t ```kotlin dependencies { - dokkaPlugin("my.dokka.plugin:json-output-plugin:1.0.0-SNAPSHOT") + dokkaPlugin("org.appdevforall.dokka:kdoc-to-json:1.0.0-SNAPSHOT") } ``` +> **Dokka version compatibility:** `kdoc-to-json/build.gradle.kts` compiles against `dokka-core`/`dokka-base` version `2.2.0-Beta` by default (chosen for compatibility with the [kotlin-stdlib-docs build tool](https://github.com/JetBrains/kotlin/tree/master/libraries/tools/kotlin-stdlib-docs)). Your consuming project's `org.jetbrains.dokka` Gradle plugin version should match (or be binary-compatible with) that version — see `examples/example-data-processor/build.gradle.kts` for a working setup. If you need a different Dokka version, update the `compileOnly` versions in `kdoc-to-json/build.gradle.kts` and republish before applying the plugin to a project on that version. + +### Trying It Out with the Example Library + +`examples/example-data-processor` is a small sample Kotlin library already wired up to use this plugin (see its `build.gradle.kts`). To build the plugin and generate its JSON documentation in one step, run: + +```bash +./scripts/build-example.sh +``` + +This publishes `kdoc-to-json` to your local Maven repository and then runs Dokka against the example library. Output is written to `examples/example-data-processor/build/dokka/html/`. + +### Sanity-Checking Rendered Output + +`scripts/sanity_check.py` helps validate documentation rendered downstream from this plugin's JSON (e.g. by a templating engine like Pebble or Jinja that turns the JSON into HTML pages): + +```bash +# Check that every file in a list (e.g. Dokka's package-list) exists in the rendered output +python3 scripts/sanity_check.py check-list files.txt path/to/rendered-html + +# Compare a standard Dokka HTML build against a JSON-derived HTML build, page for page +python3 scripts/sanity_check.py compare-base path/to/dokka-html path/to/rendered-html + +# Scan a directory of rendered HTML files for broken internal links +python3 scripts/sanity_check.py check-links path/to/rendered-html +``` + +Each subcommand accepts `--output-file/-o` to write a full results log to disk. + ## 3. Configuration Options You can configure the JSON plugin by extending `DokkaPluginParametersBaseSpec` and registering it in your `dokka` configuration block. This utilizes the modern Dokka V2 plugin API. @@ -42,7 +72,7 @@ import javax.inject.Inject @OptIn(InternalDokkaApi::class) abstract class JsonOutputPluginParameters @Inject constructor( name: String -) : DokkaPluginParametersBaseSpec(name, "my.dokka.plugin.JsonOutputPlugin") { +) : DokkaPluginParametersBaseSpec(name, "org.appdevforall.dokka.kdoc2json.JsonOutputPlugin") { // Define the plugin's behavior via a JSON string override fun jsonEncode(): String = """{ @@ -57,7 +87,7 @@ abstract class JsonOutputPluginParameters @Inject constructor( dokka { pluginsConfiguration { registerBinding(JsonOutputPluginParameters::class, JsonOutputPluginParameters::class) - register("my.dokka.plugin.JsonOutputPlugin") { } + register("org.appdevforall.dokka.kdoc2json.JsonOutputPlugin") { } } } ``` @@ -69,6 +99,8 @@ dokka { | `replaceHtmlExtension` | Boolean | `false` | If `true`, the plugin will rewrite all internal relative URLs to end in `.json` instead of `.html`. | | `omitFields` | List | `[]` | A list of JSON keys to completely strip from the final output (e.g., `["breadcrumbs", "sources"]`). Useful for reducing disk footprint. | | `omitNulls` | Boolean | `false` | If `true`, deeply filters the AST payload to remove any keys where the value is null, an empty string, an empty array, or an empty object. | +| `classDiscriminator` | String | `"kind"` | The JSON key used to discriminate between polymorphic `Documentable` types (e.g., `"kind": "class"`). Must not collide with an existing DTO field name (e.g. `"type"` or `"name"`), or serialization will fail. | +| `prettyPrint` | Boolean | `false` | If `true`, formats the written JSON files with indentation for human readability instead of compact single-line output. | ## 4. Understanding Dokka Terminology diff --git a/Dokka-plugin-kdoc2json/TODO.md b/Dokka-plugin-kdoc2json/TODO.md index c523794e..1f3edac1 100644 --- a/Dokka-plugin-kdoc2json/TODO.md +++ b/Dokka-plugin-kdoc2json/TODO.md @@ -1,8 +1,10 @@ TODO @Alex -- Move hardcoded options in JsonRenderer (Documentable type discriminator string, pretty printing, etc.) to be config options -- Remove all references to “alex” (the default log file points to my home directory on my own machine) -- Update plugin name (should not be provided under package my.dokka.plugin, “json-output-plugin” should be changed to kdoc-to-json or something) -- Provide script for building the example library and example usage -- Move output comparison/link validity check scripts into this repository -- Update usage to indicate that a user needs to set a matching Dokka version in *json-output-plugin/build.gradle.kts* (default is version 2.2.0-Beta because that's compatible with the current [kotlin-stdlib-docs build tool](https://github.com/JetBrains/kotlin/tree/master/libraries/tools/kotlin-stdlib-docs) +All items below are done as of 2026-07-02 — see README.md for details. + +- [x] Move hardcoded options in JsonRenderer (Documentable type discriminator string, pretty printing, etc.) to be config options +- [x] Remove all references to "alex" (the default log file points to my home directory on my own machine) +- [x] Update plugin name (should not be provided under package my.dokka.plugin, "json-output-plugin" should be changed to kdoc-to-json or something) +- [x] Provide script for building the example library and example usage +- [x] Move output comparison/link validity check scripts into this repository +- [x] Update usage to indicate that a user needs to set a matching Dokka version in *json-output-plugin/build.gradle.kts* (default is version 2.2.0-Beta because that's compatible with the current [kotlin-stdlib-docs build tool](https://github.com/JetBrains/kotlin/tree/master/libraries/tools/kotlin-stdlib-docs) From 9d7ad3d4231ac61f5941984f23b8a09f8e47dc4d Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 2 Jul 2026 15:48:31 -0500 Subject: [PATCH 06/15] Kotlin-stdlib build script, update README --- Dokka-plugin-kdoc2json/README.md | 17 ++ .../example-data-processor/build.gradle.kts | 32 ++- .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes .../kdoc-to-json/build.gradle.kts | 2 +- .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes .../kdoc-to-json/settings.gradle.kts | 2 +- .../src/main/kotlin/JsonOutputPlugin.kt | 2 +- .../src/main/kotlin/JsonPluginConfig.kt | 10 +- .../src/main/kotlin/JsonRenderer.kt | 26 ++- .../src/main/kotlin/LinkPostProcessor.kt | 2 +- .../src/main/kotlin/ModelMapper.kt | 4 +- .../src/main/kotlin/PluginLogger.kt | 2 +- .../src/main/kotlin/dtos/SemanticModelDtos.kt | 2 +- ...rg.jetbrains.dokka.plugability.DokkaPlugin | 3 +- .../scripts/build-example.sh | 16 ++ .../scripts/kotlin/build-kotlin-stdlib.sh | 40 ++++ .../scripts/kotlin/build.gradle.kts | 213 ++++++++++++++++++ .../scripts/sanity_check.py | 200 ++++++++++++++++ 18 files changed, 545 insertions(+), 28 deletions(-) create mode 100644 Dokka-plugin-kdoc2json/examples/example-data-processor/gradle/wrapper/gradle-wrapper.jar create mode 100644 Dokka-plugin-kdoc2json/kdoc-to-json/gradle/wrapper/gradle-wrapper.jar create mode 100755 Dokka-plugin-kdoc2json/scripts/build-example.sh create mode 100755 Dokka-plugin-kdoc2json/scripts/kotlin/build-kotlin-stdlib.sh create mode 100644 Dokka-plugin-kdoc2json/scripts/kotlin/build.gradle.kts create mode 100755 Dokka-plugin-kdoc2json/scripts/sanity_check.py diff --git a/Dokka-plugin-kdoc2json/README.md b/Dokka-plugin-kdoc2json/README.md index c9eaaf07..f45a8ce0 100644 --- a/Dokka-plugin-kdoc2json/README.md +++ b/Dokka-plugin-kdoc2json/README.md @@ -179,3 +179,20 @@ For example, to render a table of functions for a class: {% endif %} ``` + +## 9. Building the Kotlin Standard Library Docs (`scripts/kotlin`) + +`scripts/kotlin/` contains everything needed to generate both the default HTML docs and the kdoc-to-json JSON docs for the Kotlin standard library (`kotlin-stdlib`, `kotlin-test`, `kotlin-reflect`), side by side, for comparison purposes. + +* **`scripts/kotlin/build.gradle.kts`** — a drop-in replacement for the `build.gradle.kts` in a `kotlin-stdlib-docs` checkout (the Dokka doc-build module inside JetBrains' [kotlin](https://github.com/JetBrains/kotlin) repo, at `libraries/tools/kotlin-stdlib-docs`). It adds a `dokkaGenerateModuleJson` task and gates the `kdoc-to-json` plugin (classpath + `pluginsConfiguration`) behind `gradle.startParameter.taskNames` so the plugin is only active when `dokkaGenerateModuleJson` is explicitly requested — every other Dokka task (`dokkaGenerateHtml`, `dokkaGeneratePublicationHtml`, etc.) is unaffected and still produces normal HTML. +* **`scripts/kotlin/build-kotlin-stdlib.sh`** — installs that `build.gradle.kts` into a `kotlin-stdlib-docs` checkout (backing up the original as `build.gradle.kts.orig` on first run) and then runs both `dokkaGenerateHtml` and `dokkaGenerateModuleJson`, each with its own `-PdocsBuildDir`, so the two outputs land in separate directories instead of overwriting each other. + +**Usage:** + +```bash +./scripts/kotlin/build-kotlin-stdlib.sh /path/to/kotlin/libraries/tools/kotlin-stdlib-docs [output-dir] +``` + +This writes HTML output to `/html/latest/all-libs` and JSON output to `/json/latest/all-libs` (`output-dir` defaults to `scripts/kotlin/build-output`). + +> **Provenance / staleness warning:** `scripts/kotlin/build.gradle.kts` was derived from the `kotlin-stdlib-docs/build.gradle.kts` in JetBrains' `kotlin` repo as of commit [`cfcb49fd0113`](https://github.com/JetBrains/kotlin/commit/cfcb49fd0113d2300a2b677c4fc2e16dddff7df5) ("[stdlib] Update Dokka to 2.2.0-Beta and migrate to DGPv2"). That upstream file is not under our control and can change — new source sets, Dokka API changes, or a different doc-build structure could all require re-diffing our modifications against a newer upstream version. If `build-kotlin-stdlib.sh` starts failing against a newer `kotlin` checkout, compare `scripts/kotlin/build.gradle.kts` against the current upstream `kotlin-stdlib-docs/build.gradle.kts` and re-apply the `useJsonPlugin`/`dokkaGenerateModuleJson` additions by hand. diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/build.gradle.kts b/Dokka-plugin-kdoc2json/examples/example-data-processor/build.gradle.kts index df7619d0..477b03a2 100644 --- a/Dokka-plugin-kdoc2json/examples/example-data-processor/build.gradle.kts +++ b/Dokka-plugin-kdoc2json/examples/example-data-processor/build.gradle.kts @@ -1,17 +1,41 @@ -import org.jetbrains.dokka.gradle.DokkaTask +import org.jetbrains.dokka.InternalDokkaApi +import org.jetbrains.dokka.gradle.engine.plugins.DokkaPluginParametersBaseSpec +import javax.inject.Inject plugins { kotlin("jvm") version "1.9.23" - id("org.jetbrains.dokka") version "1.9.20" + // This must match the dokka-core/dokka-base version that kdoc-to-json/build.gradle.kts + // was compiled against, or the plugin may fail to load or behave unexpectedly. + id("org.jetbrains.dokka") version "2.2.0-Beta" } repositories { - // CRITICAL: This allows Gradle to find your locally published json-dokka-plugin + // CRITICAL: This allows Gradle to find your locally published kdoc-to-json plugin mavenLocal() mavenCentral() } dependencies { // Inject your custom Dokka plugin into the documentation pipeline - dokkaPlugin("my.dokka.plugin:json-output-plugin:1.0.0-SNAPSHOT") + dokkaPlugin("org.appdevforall.dokka:kdoc-to-json:1.0.0-SNAPSHOT") +} + +@OptIn(InternalDokkaApi::class) +abstract class JsonOutputPluginParameters @Inject constructor( + name: String +) : DokkaPluginParametersBaseSpec(name, "org.appdevforall.dokka.kdoc2json.JsonOutputPlugin") { + override fun jsonEncode(): String = """{ + "logLevel": "debug", + "omitFields": [], + "replaceHtmlExtension": true, + "omitNulls": true, + "prettyPrint": true + }""" +} + +dokka { + pluginsConfiguration { + registerBinding(JsonOutputPluginParameters::class, JsonOutputPluginParameters::class) + register("org.appdevforall.dokka.kdoc2json.JsonOutputPlugin") { } + } } \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/gradle/wrapper/gradle-wrapper.jar b/Dokka-plugin-kdoc2json/examples/example-data-processor/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 = emptyList(), - val logFile: String? = "/Users/alex/dokka_plugin_debug.log", + val logFile: String? = null, val replaceHtmlExtension: Boolean = false, @SerialName("omit-nulls") - val omitNulls: Boolean = false + val omitNulls: Boolean = false, + val classDiscriminator: String = "kind", + val prettyPrint: Boolean = false ) : ConfigurableBlock \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonRenderer.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonRenderer.kt index 55a8d469..0bebf40b 100644 --- a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonRenderer.kt +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonRenderer.kt @@ -1,8 +1,8 @@ -package my.dokka.plugin +package org.appdevforall.dokka.kdoc2json import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.* -import my.dokka.plugin.dtos.* +import org.appdevforall.dokka.kdoc2json.dtos.* import org.jetbrains.dokka.base.DokkaBase import org.jetbrains.dokka.model.* import org.jetbrains.dokka.pages.PageNode @@ -17,16 +17,15 @@ import java.io.File import java.util.concurrent.ConcurrentLinkedQueue class JsonRenderer(private val context: DokkaContext) : Renderer { - - private val json = Json { - prettyPrint = false - classDiscriminator = "kind" - encodeDefaults = false - ignoreUnknownKeys = true + + // Only used to manually decode the plugin's own config, before finalConfig (and its + // classDiscriminator/prettyPrint settings) is known. + private val configJson = Json { + ignoreUnknownKeys = true } override fun render(root: RootPageNode) { - val fqcn = "my.dokka.plugin.JsonOutputPlugin" + val fqcn = "org.appdevforall.dokka.kdoc2json.JsonOutputPlugin" var config = configuration(context) if (config == null) { @@ -34,7 +33,7 @@ class JsonRenderer(private val context: DokkaContext) : Renderer { if (rawConfig != null) { context.logger.info("Dokka native config parsing failed. Manually parsing: $rawConfig") try { - config = json.decodeFromString(rawConfig) + config = configJson.decodeFromString(rawConfig) } catch (e: Exception) { context.logger.error("Manual config parsing failed: ${e.message}") } @@ -46,6 +45,13 @@ class JsonRenderer(private val context: DokkaContext) : Renderer { val finalConfig = config ?: JsonPluginConfig() val logger = PluginLogger(context.logger, finalConfig.logLevel, finalConfig.logFile) + val json = Json { + prettyPrint = finalConfig.prettyPrint + classDiscriminator = finalConfig.classDiscriminator + encodeDefaults = false + ignoreUnknownKeys = true + } + logger.info("Initializing JSON Renderer with config: $finalConfig") val locationProvider = context.plugin() diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/LinkPostProcessor.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/LinkPostProcessor.kt index eec869d4..a142b4e1 100644 --- a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/LinkPostProcessor.kt +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/LinkPostProcessor.kt @@ -1,4 +1,4 @@ -package my.dokka.plugin +package org.appdevforall.dokka.kdoc2json import kotlinx.serialization.json.* import org.jetbrains.dokka.plugability.DokkaContext diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/ModelMapper.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/ModelMapper.kt index 869bae40..996d1bae 100644 --- a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/ModelMapper.kt +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/ModelMapper.kt @@ -1,6 +1,6 @@ -package my.dokka.plugin +package org.appdevforall.dokka.kdoc2json -import my.dokka.plugin.dtos.* +import org.appdevforall.dokka.kdoc2json.dtos.* import org.jetbrains.dokka.base.resolvers.local.LocationProvider import org.jetbrains.dokka.links.DRI import org.jetbrains.dokka.model.* diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/PluginLogger.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/PluginLogger.kt index a399ea69..fa00999d 100644 --- a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/PluginLogger.kt +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/PluginLogger.kt @@ -1,4 +1,4 @@ -package my.dokka.plugin +package org.appdevforall.dokka.kdoc2json import org.jetbrains.dokka.utilities.DokkaLogger import java.io.File diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/dtos/SemanticModelDtos.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/dtos/SemanticModelDtos.kt index 03f0a5d4..e77e4d79 100644 --- a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/dtos/SemanticModelDtos.kt +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/dtos/SemanticModelDtos.kt @@ -1,4 +1,4 @@ -package my.dokka.plugin.dtos +package org.appdevforall.dokka.kdoc2json.dtos import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/resources/META-INF/services/org.jetbrains.dokka.plugability.DokkaPlugin b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/resources/META-INF/services/org.jetbrains.dokka.plugability.DokkaPlugin index 255b88d4..1fe64b64 100644 --- a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/resources/META-INF/services/org.jetbrains.dokka.plugability.DokkaPlugin +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/resources/META-INF/services/org.jetbrains.dokka.plugability.DokkaPlugin @@ -1,2 +1 @@ -my.dokka.plugin.JsonOutputPlugin - +org.appdevforall.dokka.kdoc2json.JsonOutputPlugin diff --git a/Dokka-plugin-kdoc2json/scripts/build-example.sh b/Dokka-plugin-kdoc2json/scripts/build-example.sh new file mode 100755 index 00000000..0a439abb --- /dev/null +++ b/Dokka-plugin-kdoc2json/scripts/build-example.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Builds the kdoc-to-json plugin, publishes it to the local Maven repository, +# and runs it against the example library to produce sample JSON output. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +echo "==> [1/2] Publishing kdoc-to-json to the local Maven repository..." +(cd "$ROOT_DIR/kdoc-to-json" && ./gradlew publishToMavenLocal) + +echo "==> [2/2] Generating JSON documentation for examples/example-data-processor..." +(cd "$ROOT_DIR/examples/example-data-processor" && ./gradlew dokkaGenerate) + +OUTPUT_DIR="$ROOT_DIR/examples/example-data-processor/build/dokka/html" +echo +echo "Done. JSON output written to: $OUTPUT_DIR" diff --git a/Dokka-plugin-kdoc2json/scripts/kotlin/build-kotlin-stdlib.sh b/Dokka-plugin-kdoc2json/scripts/kotlin/build-kotlin-stdlib.sh new file mode 100755 index 00000000..9d3296dc --- /dev/null +++ b/Dokka-plugin-kdoc2json/scripts/kotlin/build-kotlin-stdlib.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Builds the kotlin-stdlib/kotlin-test/kotlin-reflect API docs twice against a +# kotlin-stdlib-docs checkout: once with Dokka's default HTML renderer, and once with the +# kdoc-to-json plugin's JSON renderer (via the dokkaGenerateModuleJson task). Each build writes +# to its own output directory so the two can be compared directly. +set -euo pipefail + +if [ $# -lt 1 ]; then + echo "Usage: $0 [output-dir]" >&2 + exit 1 +fi + +STDLIB_DOCS_DIR="$(cd "$1" && pwd)" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUTPUT_ROOT="$(mkdir -p "${2:-$SCRIPT_DIR/build-output}" && cd "${2:-$SCRIPT_DIR/build-output}" && pwd)" +HTML_OUTPUT_DIR="$OUTPUT_ROOT/html" +JSON_OUTPUT_DIR="$OUTPUT_ROOT/json" + +if [ ! -f "$STDLIB_DOCS_DIR/settings.gradle.kts" ] || [ ! -x "$STDLIB_DOCS_DIR/gradlew" ]; then + echo "Error: '$STDLIB_DOCS_DIR' doesn't look like a kotlin-stdlib-docs checkout (missing settings.gradle.kts or gradlew)." >&2 + exit 1 +fi + +echo "==> Installing kdoc-to-json-enabled build.gradle.kts into $STDLIB_DOCS_DIR" +if [ -f "$STDLIB_DOCS_DIR/build.gradle.kts" ] && [ ! -f "$STDLIB_DOCS_DIR/build.gradle.kts.orig" ]; then + cp "$STDLIB_DOCS_DIR/build.gradle.kts" "$STDLIB_DOCS_DIR/build.gradle.kts.orig" + echo " (original build.gradle.kts backed up to build.gradle.kts.orig)" +fi +cp "$SCRIPT_DIR/build.gradle.kts" "$STDLIB_DOCS_DIR/build.gradle.kts" + +echo "==> [1/2] Generating default HTML documentation..." +(cd "$STDLIB_DOCS_DIR" && ./gradlew dokkaGenerateHtml "-PdocsBuildDir=$HTML_OUTPUT_DIR") + +echo "==> [2/2] Generating JSON documentation via kdoc-to-json..." +(cd "$STDLIB_DOCS_DIR" && ./gradlew dokkaGenerateModuleJson "-PdocsBuildDir=$JSON_OUTPUT_DIR") + +echo +echo "Done." +echo " HTML docs: $HTML_OUTPUT_DIR/latest/all-libs" +echo " JSON docs: $JSON_OUTPUT_DIR/latest/all-libs" diff --git a/Dokka-plugin-kdoc2json/scripts/kotlin/build.gradle.kts b/Dokka-plugin-kdoc2json/scripts/kotlin/build.gradle.kts new file mode 100644 index 00000000..454b87b7 --- /dev/null +++ b/Dokka-plugin-kdoc2json/scripts/kotlin/build.gradle.kts @@ -0,0 +1,213 @@ +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +import org.jetbrains.dokka.gradle.engine.plugins.DokkaPluginParametersBaseSpec +import org.jetbrains.dokka.InternalDokkaApi +import javax.inject.Inject + +plugins { + base + `dokka-convention` +} + +@OptIn(InternalDokkaApi::class) +abstract class JsonOutputPluginParameters @Inject constructor( + name: String +) : DokkaPluginParametersBaseSpec(name, "org.appdevforall.dokka.kdoc2json.JsonOutputPlugin") { + + // Define the plugin's behavior via a JSON string + override fun jsonEncode(): String = """{ + "logLevel": "debug", + "logFile": "build/dokka_json.log", + "omitFields": ["sources"], + "replaceHtmlExtension": false, + "omitNulls": true + }""" +} + +// kdoc-to-json overrides Dokka's HTML renderer wholesale, so it must only be added to the +// dokkaPlugin classpath (and registered in the dokka{} config) when dokkaGenerateModuleJson +// is actually among the requested tasks -- otherwise every other Dokka task +// (dokkaGenerateHtml, dokkaGeneratePublicationHtml, etc.) would silently emit JSON instead +// of HTML. Note this matches against exactly-typed task names; abbreviated/camelCase task +// matching on the CLI won't be detected here. +val useJsonPlugin = gradle.startParameter.taskNames.any { it.substringAfterLast(":") == "dokkaGenerateModuleJson" } + +allprojects { + repositories { + // 1. Your Custom Local Repo (Where the bash script just downloaded the plugins) + maven(url = rootProject.projectDir.parentFile.parentFile.parentFile.resolve("build/repo")) + + // 2. Standard Local Maven Cache (~/.m2/repository) + mavenLocal() + + // 3. Maven Central (Keep this for standard standard stable libraries like Gson/Coroutines) + mavenCentral() + + // ALL REMOTE JETBRAINS SNAPSHOT SERVERS HAVE BEEN REMOVED! + } + + // --- ADDED THIS EXCLUSION BLOCK --- + // Globally ignore the remote playground plugin since it is trapped on the dead 503 server. + configurations.all { + exclude(group = "org.jetbrains.dokka", module = "kotlin-playground-samples-plugin") + } +} + +val isTeamcityBuild = project.hasProperty("teamcity.version") || +System.getenv("TEAMCITY_VERSION") != null + +// kotlin/libraries/tools/kotlin-stdlib-docs -> kotlin +val kotlin_root = rootProject.file("../../../").absoluteFile.invariantSeparatorsPath +val kotlin_libs by extra(layout.buildDirectory.dir("libs").get().asFile.path) + +val rootProperties = java.util.Properties().apply { + file(kotlin_root).resolve("gradle.properties").inputStream().use { stream -> load(stream) } +} +val defaultSnapshotVersion: String by rootProperties +val kotlinLanguageVersion: String by rootProperties + +val githubRevision = if (isTeamcityBuild) project.property("githubRevision") else "master" +val artifactsVersion by extra(if (isTeamcityBuild) project.property("deployVersion") as String else defaultSnapshotVersion) +val artifactsRepo by extra(if (isTeamcityBuild) project.property("kotlinLibsRepo") as String else "$kotlin_root/build/repo") +val dokka_version: String by project + +println("# Parameters summary:") +println(" isTeamcityBuild: $isTeamcityBuild") +println(" dokka version: $dokka_version") +println(" githubRevision: $githubRevision") +println(" language version: $kotlinLanguageVersion") +println(" artifacts version: $artifactsVersion") +println(" artifacts repo: $artifactsRepo") + + +val outputDir = (findProperty("docsBuildDir") as String?)?.let{ file(it) } ?: layout.buildDirectory.dir("doc").get().asFile +val inputDirPrevious = file(findProperty("docsPreviousVersionsDir") as String? ?: "$outputDir/previous") +val outputDirPartial = outputDir.resolve("partial") +val kotlin_native_root = file("$kotlin_root/kotlin-native").absolutePath +val templatesDir = file(findProperty("templatesDir") as String? ?: "$projectDir/templates").invariantSeparatorsPath + +val cleanDocs by tasks.registering(Delete::class) { + delete(outputDir) +} + +tasks.clean { + dependsOn(cleanDocs) +} + +val prepare by tasks.registering { + dependsOn(":kotlin_big:extractLibs") +} + +version = (findProperty("version") as String?).takeIf { it != "unspecified"} ?: kotlinLanguageVersion +val isLatest = (findProperty("isLatest") as String?)?.toBoolean() ?: true + + +(getTasksByName("dokkaGenerateHtml", true) + getTasksByName("dokkaGenerate", true) + getTasksByName( + "dokkaGenerateModuleHtml", true +) + getTasksByName("dokkaGeneratePublicationHtml", true)).forEach { + it.dependsOn(prepare) +} + +dependencies { + dokka(project(":kotlin-stdlib")) + dokka(project(":kotlin-test")) + dokka(project(":kotlin-reflect")) + if (useJsonPlugin) { + dokkaPlugin("org.appdevforall.dokka:kdoc-to-json:1.0.0-SNAPSHOT") + } +} + +subprojects { + pluginManager.withPlugin("org.jetbrains.dokka") { + if (useJsonPlugin) { + dependencies { + "dokkaPlugin"("org.appdevforall.dokka:kdoc-to-json:1.0.0-SNAPSHOT") + } + + // Apply the same configuration so the subprojects don't fall back to defaults + dokka { + pluginsConfiguration { + registerBinding(JsonOutputPluginParameters::class, JsonOutputPluginParameters::class) + register("org.appdevforall.dokka.kdoc2json.JsonOutputPlugin") { } + } + } + } + } +} + +buildscript { + dependencies { + classpath("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") + } +} + +dokka { + val moduleDirName = "all-libs" + + pluginsConfiguration { + versioning { + version.set(kotlinLanguageVersion) + if (isLatest) { + olderVersionsDir.set(inputDirPrevious.resolve(moduleDirName)) + } + } + + if (isLatest) { + register("VersionFilterPlugin") { + targetVersion = kotlinLanguageVersion + } + } + + if (useJsonPlugin) { + // Custom plugin registration + registerBinding(JsonOutputPluginParameters::class, JsonOutputPluginParameters::class) + register("org.appdevforall.dokka.kdoc2json.JsonOutputPlugin") { } + } + } + + moduleName.set("Kotlin libraries") + + dokkaPublications.html { + if (isLatest) { + outputDirectory.set(outputDir.resolve("latest").resolve(moduleDirName)) + } else { + outputDirectory.set( + outputDir.resolve("previous").resolve(moduleDirName).resolve(kotlinLanguageVersion) + ) + } + } +} + +/// Capture all project state at configuration time to stay configuration-cache compatible +val dokkaOutputDirectory = dokka.dokkaPublications.html.get().outputDirectory.get().asFile +val moduleArtifacts = configurations["dokka"].allDependencies.withType(ProjectDependency::class.java) + .map { dependency -> + val dependencyProject = project(dependency.path) + dependencyProject.layout.buildDirectory.file("dokka-module/html/module-descriptor.json").get().asFile to + dependencyProject.layout.buildDirectory.file("dokka-module/html/module/package-list").get().asFile + } + +getTasksByName("dokkaGeneratePublicationHtml", false).forEach { task -> + // Copy into locals so the doLast closure captures these values, not the enclosing + // build-script object (which the configuration cache cannot serialize). + val outputDirectory = dokkaOutputDirectory + val artifacts = moduleArtifacts + task.doLast { + artifacts.forEach { (jsonFile, packageList) -> + val fileAsJsonObject = Json.decodeFromString(jsonFile.readText()) + val modulePath = (fileAsJsonObject.get("modulePath") as JsonPrimitive).content + val targetDir = outputDirectory.resolve(modulePath) + targetDir.mkdirs() + packageList.copyTo(targetDir.resolve(packageList.name), overwrite = true) + } + } +} + +val dokkaGenerateModuleJson by tasks.registering { + group = "documentation" + description = "Builds the kotlin-stdlib/kotlin-test/kotlin-reflect API docs as JSON via the kdoc-to-json Dokka plugin." + dependsOn(prepare) + dependsOn(tasks.named("dokkaGeneratePublicationHtml")) +} diff --git a/Dokka-plugin-kdoc2json/scripts/sanity_check.py b/Dokka-plugin-kdoc2json/scripts/sanity_check.py new file mode 100755 index 00000000..97b6605e --- /dev/null +++ b/Dokka-plugin-kdoc2json/scripts/sanity_check.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +import os +import re +import argparse +from urllib.parse import urlparse, unquote + +def write_log(output_file, lines): + """Helper function to write results to a specified output file.""" + if output_file: + try: + with open(output_file, 'w', encoding='utf-8') as f: + for line in lines: + f.write(line + '\n') + print(f"📄 Results successfully logged to: {output_file}") + except Exception as e: + print(f"⚠️ Failed to write to output file {output_file}: {e}") + +def check_list(file_list, pebble_dir, output_file): + """ + Verifies that every file in a given list exists in the rendered JSON+Pebble docs. + """ + print(f"--- Running List Check ---") + print(f"File list: {file_list}") + print(f"Pebble directory: {pebble_dir}") + + missing_files = [] + try: + with open(file_list, 'r', encoding='utf-8') as f: + for line in f: + path = line.strip() + if not path: + continue + + # Strip leading './' if present + if path.startswith('./'): + path = path[2:] + + # If the list contains .json files, expect .html in the rendered output + if path.endswith('.json'): + path = path[:-5] + '.html' + + full_path = os.path.join(pebble_dir, path) + + if not os.path.exists(full_path): + missing_files.append(path) + + except FileNotFoundError: + print(f"Error: The file list '{file_list}' was not found.") + return + + if missing_files: + print(f"❌ FAILED: {len(missing_files)} files from the list are missing in the rendered output.") + for missing in missing_files[:15]: + print(f" - {missing}") + if len(missing_files) > 15: + print(f" ... and {len(missing_files) - 15} more.") + + # Write missing files to output file if requested + write_log(output_file, missing_files) + else: + print("✅ SUCCESS: All files in the list exist in the rendered documentation.") + print() + +def compare_base(base_dir, pebble_dir, output_file): + """ + Verifies that every generated HTML file in the base plugin's documentation + has a corresponding HTML page produced by JSON+Pebble. + """ + print(f"--- Running Base Comparison Check ---") + print(f"Base docs directory: {base_dir}") + print(f"Pebble directory: {pebble_dir}") + + if not os.path.isdir(base_dir): + print(f"Error: Base directory '{base_dir}' not found.") + return + + missing_files = [] + total_files = 0 + + for root, _, files in os.walk(base_dir): + for file in files: + if file.endswith('.html'): + total_files += 1 + base_path = os.path.join(root, file) + rel_path = os.path.relpath(base_path, base_dir) + pebble_path = os.path.join(pebble_dir, rel_path) + + if not os.path.exists(pebble_path): + missing_files.append(rel_path) + + if missing_files: + print(f"❌ FAILED: {len(missing_files)} out of {total_files} HTML files from the base docs are missing in the Pebble docs.") + for missing in missing_files[:15]: + print(f" - {missing}") + if len(missing_files) > 15: + print(f" ... and {len(missing_files) - 15} more.") + + # Write missing files to output file if requested + write_log(output_file, missing_files) + else: + print(f"✅ SUCCESS: All {total_files} HTML files from the base documentation exist in the Pebble documentation.") + print() + +def check_links(docs_dir, output_file): + """ + Verifies all internal links to other pages, tracking status for every link. + """ + print(f"--- Running Link Status Check ---") + print(f"Documentation directory: {docs_dir}") + + if not os.path.isdir(docs_dir): + print(f"Error: Documentation directory '{docs_dir}' not found.") + return + + link_log_lines = [] + broken_count = 0 + total_links_checked = 0 + + # Regex to find href attributes + href_regex = re.compile(r'href\s*=\s*["\']([^"\']+)["\']', re.IGNORECASE) + + for root, _, files in os.walk(docs_dir): + for file in files: + if file.endswith('.html'): + file_path = os.path.join(root, file) + relative_src_file = os.path.relpath(file_path, docs_dir) + + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + + links = href_regex.findall(content) + + for link in links: + parsed = urlparse(link) + + # Ignore external links, mailto, and javascript protocols + if parsed.scheme in ('http', 'https', 'mailto', 'javascript', 'data'): + continue + + # Ignore empty paths (e.g., href="#anchor" on the same page) + if not parsed.path: + continue + + total_links_checked += 1 + + # Resolve target path relative to the directory of the file containing the link + target_rel_path = unquote(parsed.path) + target_abs_path = os.path.normpath(os.path.join(root, target_rel_path)) + + # If pointing to a directory, assume it's looking for index.html + if os.path.isdir(target_abs_path): + target_abs_path = os.path.join(target_abs_path, 'index.html') + + if not os.path.exists(target_abs_path): + broken_count += 1 + link_log_lines.append(f"[BROKEN] File: {relative_src_file} -> Link: {link}") + else: + link_log_lines.append(f"[VALID] File: {relative_src_file} -> Link: {link}") + + # Output a concise summary to console + if broken_count > 0: + print(f"❌ FAILED: Found {broken_count} broken internal links out of {total_links_checked} total links checked.") + else: + print(f"✅ SUCCESS: All {total_links_checked} internal links checked are completely valid!") + + # Write the complete mapping status (valid + broken) to output file if requested + if output_file: + write_log(output_file, link_log_lines) + print() + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Sanity check tools for JSON+Pebble Dokka output.") + subparsers = parser.add_subparsers(dest="command", required=True, help="Sub-commands") + + # Create a parent parser for shared arguments across commands + parent_parser = argparse.ArgumentParser(add_help=False) + parent_parser.add_argument("--output-file", "-o", help="Path to a file where results/errors will be logged.", default=None) + + # Command 1: check-list + parser_list = subparsers.add_parser("check-list", parents=[parent_parser], help="Check if files in a text list exist in the rendered docs.") + parser_list.add_argument("file_list", help="Path to the text file containing the list of files (e.g. files_json.txt)") + parser_list.add_argument("pebble_dir", help="Path to the generated JSON+Pebble documentation directory") + + # Command 2: compare-base + parser_compare = subparsers.add_parser("compare-base", parents=[parent_parser], help="Compare standard HTML output with JSON+Pebble output.") + parser_compare.add_argument("base_dir", help="Path to the standard Dokka HTML documentation directory") + parser_compare.add_argument("pebble_dir", help="Path to the generated JSON+Pebble documentation directory") + + # Command 3: check-links + parser_links = subparsers.add_parser("check-links", parents=[parent_parser], help="Find and map status of internal links in a documentation directory.") + parser_links.add_argument("docs_dir", help="Path to the documentation directory to scan") + + args = parser.parse_args() + + if args.command == "check-list": + check_list(args.file_list, args.pebble_dir, args.output_file) + elif args.command == "compare-base": + compare_base(args.base_dir, args.pebble_dir, args.output_file) + elif args.command == "check-links": + check_links(args.docs_dir, args.output_file) From a36852eed557063b5ae022a528662d22f8c6df6f Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 6 Jul 2026 13:20:09 -0500 Subject: [PATCH 07/15] Inline configJson into its single use site in JsonRenderer configJson was a dedicated Json instance used only for the manual config-decode fallback path. Inlining it removes the standalone property while keeping the same ignoreUnknownKeys tolerance for extra keys in user-authored plugin config. Co-Authored-By: Claude Sonnet 5 --- .../kdoc-to-json/src/main/kotlin/JsonRenderer.kt | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonRenderer.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonRenderer.kt index 0bebf40b..42c6e966 100644 --- a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonRenderer.kt +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonRenderer.kt @@ -18,12 +18,6 @@ import java.util.concurrent.ConcurrentLinkedQueue class JsonRenderer(private val context: DokkaContext) : Renderer { - // Only used to manually decode the plugin's own config, before finalConfig (and its - // classDiscriminator/prettyPrint settings) is known. - private val configJson = Json { - ignoreUnknownKeys = true - } - override fun render(root: RootPageNode) { val fqcn = "org.appdevforall.dokka.kdoc2json.JsonOutputPlugin" var config = configuration(context) @@ -33,7 +27,10 @@ class JsonRenderer(private val context: DokkaContext) : Renderer { if (rawConfig != null) { context.logger.info("Dokka native config parsing failed. Manually parsing: $rawConfig") try { - config = configJson.decodeFromString(rawConfig) + // A dedicated instance because finalConfig (and the "real" json below) isn't known yet; + // ignoreUnknownKeys tolerates extra keys in this user-authored config rather than + // failing to parse it at all. + config = Json { ignoreUnknownKeys = true }.decodeFromString(rawConfig) } catch (e: Exception) { context.logger.error("Manual config parsing failed: ${e.message}") } @@ -48,8 +45,6 @@ class JsonRenderer(private val context: DokkaContext) : Renderer { val json = Json { prettyPrint = finalConfig.prettyPrint classDiscriminator = finalConfig.classDiscriminator - encodeDefaults = false - ignoreUnknownKeys = true } logger.info("Initializing JSON Renderer with config: $finalConfig") From 9a50c8d9553d1b5d9491e9159974e06ee265705e Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 6 Jul 2026 13:20:26 -0500 Subject: [PATCH 08/15] Add package-index and HTML/JSON structure verification scripts - scripts/verify_package_index.py: confirms every object listed in a package's index.json actually has documented content on the page its url points to (matching by dri), not just that the file exists. - scripts/kotlin/test_kotlin_stdlib.sh: pure-bash check that the default HTML build and the kdoc-to-json JSON build have a one-to-one set of pages (extension-swap aware, in both directions). Co-Authored-By: Claude Sonnet 5 --- .../scripts/kotlin/test_kotlin_stdlib.sh | 47 ++++++++ .../scripts/verify_package_index.py | 112 ++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100755 Dokka-plugin-kdoc2json/scripts/kotlin/test_kotlin_stdlib.sh create mode 100755 Dokka-plugin-kdoc2json/scripts/verify_package_index.py diff --git a/Dokka-plugin-kdoc2json/scripts/kotlin/test_kotlin_stdlib.sh b/Dokka-plugin-kdoc2json/scripts/kotlin/test_kotlin_stdlib.sh new file mode 100755 index 00000000..1a28fab7 --- /dev/null +++ b/Dokka-plugin-kdoc2json/scripts/kotlin/test_kotlin_stdlib.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Verifies that the .html pages from a default Dokka run and the .json pages from the +# kdoc-to-json plugin are a one-to-one match: every .html file has a same-named .json file +# at the same place in the hierarchy, and every .json file has a same-named .html file. +# Non-page assets in the HTML tree (css/js/images/etc.) are ignored -- only .html and .json +# files are compared, with extensions swapped accordingly. +set -euo pipefail + +if [ $# -lt 2 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +HTML_DIR="$(cd "$1" && pwd)" +JSON_DIR="$(cd "$2" && pwd)" + +missing_json=0 +missing_html=0 + +echo "==> Checking every .html file in $HTML_DIR has a matching .json file..." +while IFS= read -r -d '' f; do + rel="${f#"$HTML_DIR"/}" + if [ ! -f "$JSON_DIR/${rel%.html}.json" ]; then + echo " MISSING .json for: $rel" + missing_json=$((missing_json + 1)) + fi +done < <(find "$HTML_DIR" -type f -name '*.html' -print0) + +echo "==> Checking every .json file in $JSON_DIR has a matching .html file..." +while IFS= read -r -d '' f; do + rel="${f#"$JSON_DIR"/}" + if [ ! -f "$HTML_DIR/${rel%.json}.html" ]; then + echo " MISSING .html for: $rel" + missing_html=$((missing_html + 1)) + fi +done < <(find "$JSON_DIR" -type f -name '*.json' -print0) + +echo +echo "Summary: $missing_json .html file(s) with no matching .json, $missing_html .json file(s) with no matching .html." + +if [ "$missing_json" -eq 0 ] && [ "$missing_html" -eq 0 ]; then + echo "SUCCESS: one-to-one match between HTML and JSON output." + exit 0 +else + echo "FAILED: structure mismatch between HTML and JSON output." + exit 1 +fi diff --git a/Dokka-plugin-kdoc2json/scripts/verify_package_index.py b/Dokka-plugin-kdoc2json/scripts/verify_package_index.py new file mode 100755 index 00000000..35ff9fc7 --- /dev/null +++ b/Dokka-plugin-kdoc2json/scripts/verify_package_index.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +Verifies that every object listed in a kdoc-to-json package index.json actually +corresponds to a real documented page: the URL it points to must resolve to an +existing file, and that file's own top-level "dri" must match the index entry's +"dri" exactly. + +This matters because Dokka can group multiple declarations (e.g. several overloads +of the same function name for different receiver types) onto a single PageNode / +output page. JsonRenderer only serializes documentables.first() for such a page, so +every other declaration that shares that page is listed in the package index but +has no actual documented content of its own. +""" +import json +import os +import sys +import argparse + + +def resolve_target_path(package_dir, url): + """Resolve an index entry's url to the local .json file it should point to.""" + if not url: + return None + if url.startswith("http://") or url.startswith("https://"): + return None # external link, not a local file + if url.startswith("unresolved:") or url == "#": + return None + + path = url + if path.endswith(".html"): + path = path[:-5] + ".json" + elif not path.endswith(".json"): + path = path + ".json" + + return os.path.normpath(os.path.join(package_dir, path)) + + +def main(): + parser = argparse.ArgumentParser( + description="Verify every object in a kdoc-to-json package index.json has a corresponding documented page." + ) + parser.add_argument("index_json", help="Path to a package's index.json, or the package directory containing one") + args = parser.parse_args() + + index_path = args.index_json + if os.path.isdir(index_path): + index_path = os.path.join(index_path, "index.json") + + if not os.path.isfile(index_path): + print(f"Error: '{index_path}' not found.", file=sys.stderr) + sys.exit(2) + + package_dir = os.path.dirname(os.path.abspath(index_path)) + + with open(index_path, "r", encoding="utf-8") as f: + index_data = json.load(f) + + member_sections = ["functions", "properties", "classlikes", "typeAliases"] + entries = [] + for section in member_sections: + for entry in index_data.get(section) or []: + entries.append((section, entry)) + + print(f"Package: {index_data.get('name', '?')}") + print(f"Index file: {index_path}") + print(f"Total member entries listed: {len(entries)}") + print() + + file_cache = {} + missing = [] + + for section, entry in entries: + name = entry.get("name", "?") + dri = entry.get("dri") + url = entry.get("url") + + target_path = resolve_target_path(package_dir, url) + if target_path is None: + missing.append((section, name, dri, url, "unresolvable URL")) + continue + + if target_path not in file_cache: + if not os.path.isfile(target_path): + file_cache[target_path] = None + else: + try: + with open(target_path, "r", encoding="utf-8") as f: + file_cache[target_path] = json.load(f) + except Exception: + file_cache[target_path] = None + + target_data = file_cache[target_path] + if target_data is None: + missing.append((section, name, dri, url, "target file missing or unreadable")) + elif target_data.get("dri") != dri: + missing.append((section, name, dri, url, "target file exists but does not document this DRI")) + + print(f"Checked {len(entries)} entries against {len(file_cache)} unique target files.") + print() + + if missing: + print(f"FAILED: {len(missing)} object(s) in the index have no corresponding documented element:") + for section, name, dri, url, reason in missing: + print(f" [{section}] {name} (dri={dri}, url={url}) -- {reason}") + else: + print("SUCCESS: every object in the index corresponds to a documented element.") + + sys.exit(1 if missing else 0) + + +if __name__ == "__main__": + main() From abd26c4f36b30a96b2c5605ac19026f27438e7ae Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 20 Jul 2026 17:20:10 -0500 Subject: [PATCH 09/15] sourceSet whitelisting --- .../src/main/kotlin/JsonPluginConfig.kt | 3 +- .../src/main/kotlin/JsonRenderer.kt | 29 +++- .../src/main/kotlin/ModelMapper.kt | 81 ++++++----- .../scripts/verify_sourceset_whitelist.py | 136 ++++++++++++++++++ 4 files changed, 212 insertions(+), 37 deletions(-) create mode 100755 Dokka-plugin-kdoc2json/scripts/verify_sourceset_whitelist.py diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonPluginConfig.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonPluginConfig.kt index 74320666..14270e4e 100644 --- a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonPluginConfig.kt +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonPluginConfig.kt @@ -13,5 +13,6 @@ data class JsonPluginConfig( @SerialName("omit-nulls") val omitNulls: Boolean = false, val classDiscriminator: String = "kind", - val prettyPrint: Boolean = false + val prettyPrint: Boolean = false, + val sourceSetWhitelist: List = emptyList() ) : ConfigurableBlock \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonRenderer.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonRenderer.kt index 42c6e966..c2298f27 100644 --- a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonRenderer.kt +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonRenderer.kt @@ -62,6 +62,11 @@ class JsonRenderer(private val context: DokkaContext) : Renderer { if (node is WithDocumentables) { node.documentables.forEach { doc -> if (doc is org.jetbrains.dokka.model.DPackage) { + if (!passesSourceSetWhitelist(doc.sourceSets, finalConfig.sourceSetWhitelist)) { + val docSourceSets = doc.sourceSets.map { it.sourceSetID.toString().substringAfterLast("/") } + logger.info("Omitting '${doc.name}' from package-list: sourceSets=$docSourceSets not in whitelist ${finalConfig.sourceSetWhitelist}") + return@forEach + } doc.dri.packageName?.takeIf { it.isNotBlank() }?.let { packages.add(it) } } } @@ -117,7 +122,14 @@ class JsonRenderer(private val context: DokkaContext) : Renderer { if (node is WithDocumentables && node.documentables.isNotEmpty()) { val documentable = node.documentables.first() logger.debug("Processing documentable: ${documentable.name} (${documentable.dri})") - + + if (!passesSourceSetWhitelist(documentable.sourceSets, finalConfig.sourceSetWhitelist)) { + val docSourceSets = documentable.sourceSets.map { it.sourceSetID.toString().substringAfterLast("/") } + logger.info("Omitting '${documentable.name}': sourceSets=$docSourceSets not in whitelist ${finalConfig.sourceSetWhitelist}") + node.children.forEach { traverse(it, currentPath) } + return + } + if (documentable is DClasslike || documentable is DTypeAlias) { var typeUrl = locationProvider.resolve(node, context = null, skipExtension = false) if (typeUrl != null && finalConfig.replaceHtmlExtension && !typeUrl.startsWith("http")) { @@ -154,10 +166,11 @@ class JsonRenderer(private val context: DokkaContext) : Renderer { } val mapper = ModelMapper( - locationProvider = locationProvider, - contextNode = node, + locationProvider = locationProvider, + contextNode = node, logger = logger, - replaceHtmlExtension = finalConfig.replaceHtmlExtension + replaceHtmlExtension = finalConfig.replaceHtmlExtension, + sourceSetWhitelist = finalConfig.sourceSetWhitelist ) val dto = mapper.mapToDto(documentable, breadcrumbs) @@ -237,4 +250,12 @@ class JsonRenderer(private val context: DokkaContext) : Renderer { (element is JsonArray && element.isEmpty()) || (element is JsonObject && element.isEmpty()) } + + private fun passesSourceSetWhitelist( + sourceSets: Set, + whitelist: List + ): Boolean { + if (whitelist.isEmpty()) return true + return sourceSets.any { it.sourceSetID.toString().substringAfterLast("/") in whitelist } + } } \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/ModelMapper.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/ModelMapper.kt index 996d1bae..260ffee5 100644 --- a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/ModelMapper.kt +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/ModelMapper.kt @@ -17,7 +17,8 @@ class ModelMapper( private val locationProvider: LocationProvider, private val contextNode: PageNode, private val logger: PluginLogger, - private val replaceHtmlExtension: Boolean + private val replaceHtmlExtension: Boolean, + private val sourceSetWhitelist: List = emptyList() ) { private fun resolveUrl(dri: DRI?, sourceSets: Set): String? { if (dri == null) return null @@ -53,7 +54,7 @@ class ModelMapper( extras = mapExtras(doc.extra), breadcrumbs = breadcrumbs, // Pass shallow = true to children so the tree stops recursing! - packages = if (shallow) emptyList() else doc.packages.mapNotNull { mapToDto(it, emptyList(), true) } + packages = if (shallow) emptyList() else filterWhitelisted(doc.packages).mapNotNull { mapToDto(it, emptyList(), true) } ) is DPackage -> PackageDto( dri = doc.dri.toString(), name = doc.name, url = url, @@ -61,10 +62,10 @@ class ModelMapper( expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), extras = mapExtras(doc.extra), breadcrumbs = breadcrumbs, - functions = if (shallow) emptyList() else doc.functions.mapNotNull { mapToDto(it, emptyList(), true) }, - properties = if (shallow) emptyList() else doc.properties.mapNotNull { mapToDto(it, emptyList(), true) }, - classlikes = if (shallow) emptyList() else doc.classlikes.mapNotNull { mapToDto(it, emptyList(), true) }, - typeAliases = if (shallow) emptyList() else doc.typealiases.mapNotNull { mapToDto(it, emptyList(), true) } + functions = if (shallow) emptyList() else filterWhitelisted(doc.functions).mapNotNull { mapToDto(it, emptyList(), true) }, + properties = if (shallow) emptyList() else filterWhitelisted(doc.properties).mapNotNull { mapToDto(it, emptyList(), true) }, + classlikes = if (shallow) emptyList() else filterWhitelisted(doc.classlikes).mapNotNull { mapToDto(it, emptyList(), true) }, + typeAliases = if (shallow) emptyList() else filterWhitelisted(doc.typealiases).mapNotNull { mapToDto(it, emptyList(), true) } ) is DClass -> ClassDto( dri = doc.dri.toString(), name = doc.name, url = url, @@ -72,13 +73,13 @@ class ModelMapper( expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), extras = mapExtras(doc.extra), breadcrumbs = breadcrumbs, - constructors = if (shallow) emptyList() else doc.constructors.mapNotNull { mapToDto(it, emptyList(), true) }, - functions = if (shallow) emptyList() else doc.functions.mapNotNull { mapToDto(it, emptyList(), true) }, - properties = if (shallow) emptyList() else doc.properties.mapNotNull { mapToDto(it, emptyList(), true) }, - classlikes = if (shallow) emptyList() else doc.classlikes.mapNotNull { mapToDto(it, emptyList(), true) }, + constructors = if (shallow) emptyList() else filterWhitelisted(doc.constructors).mapNotNull { mapToDto(it, emptyList(), true) }, + functions = if (shallow) emptyList() else filterWhitelisted(doc.functions).mapNotNull { mapToDto(it, emptyList(), true) }, + properties = if (shallow) emptyList() else filterWhitelisted(doc.properties).mapNotNull { mapToDto(it, emptyList(), true) }, + classlikes = if (shallow) emptyList() else filterWhitelisted(doc.classlikes).mapNotNull { mapToDto(it, emptyList(), true) }, sources = mapSourceSetDependent(doc.sources) { _, it -> it.path }, visibility = mapSourceSetDependent(doc.visibility) { _, it -> it.name }, - companion = if (shallow) null else doc.companion?.let { mapToDto(it, emptyList(), true) as? ObjectDto }, + companion = if (shallow) null else doc.companion?.takeIfWhitelisted()?.let { mapToDto(it, emptyList(), true) as? ObjectDto }, generics = doc.generics.mapNotNull { mapToDto(it) as? TypeParameterDto }, supertypes = mapSourceSetDependent(doc.supertypes) { ss, list -> list.map { TypeConstructorWithKindDto(mapBound(it.typeConstructor, setOf(ss.toDisplaySourceSet())), it.kind.toString()) } @@ -93,14 +94,14 @@ class ModelMapper( expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), extras = mapExtras(doc.extra), breadcrumbs = breadcrumbs, - entries = if (shallow) emptyList() else doc.entries.mapNotNull { mapToDto(it, emptyList(), true) }, - constructors = if (shallow) emptyList() else doc.constructors.mapNotNull { mapToDto(it, emptyList(), true) }, - functions = if (shallow) emptyList() else doc.functions.mapNotNull { mapToDto(it, emptyList(), true) }, - properties = if (shallow) emptyList() else doc.properties.mapNotNull { mapToDto(it, emptyList(), true) }, - classlikes = if (shallow) emptyList() else doc.classlikes.mapNotNull { mapToDto(it, emptyList(), true) }, + entries = if (shallow) emptyList() else filterWhitelisted(doc.entries).mapNotNull { mapToDto(it, emptyList(), true) }, + constructors = if (shallow) emptyList() else filterWhitelisted(doc.constructors).mapNotNull { mapToDto(it, emptyList(), true) }, + functions = if (shallow) emptyList() else filterWhitelisted(doc.functions).mapNotNull { mapToDto(it, emptyList(), true) }, + properties = if (shallow) emptyList() else filterWhitelisted(doc.properties).mapNotNull { mapToDto(it, emptyList(), true) }, + classlikes = if (shallow) emptyList() else filterWhitelisted(doc.classlikes).mapNotNull { mapToDto(it, emptyList(), true) }, sources = mapSourceSetDependent(doc.sources) { _, it -> it.path }, visibility = mapSourceSetDependent(doc.visibility) { _, it -> it.name }, - companion = if (shallow) null else doc.companion?.let { mapToDto(it, emptyList(), true) as? ObjectDto }, + companion = if (shallow) null else doc.companion?.takeIfWhitelisted()?.let { mapToDto(it, emptyList(), true) as? ObjectDto }, supertypes = mapSourceSetDependent(doc.supertypes) { ss, list -> list.map { TypeConstructorWithKindDto(mapBound(it.typeConstructor, setOf(ss.toDisplaySourceSet())), it.kind.toString()) } }, @@ -113,9 +114,9 @@ class ModelMapper( expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), extras = mapExtras(doc.extra), breadcrumbs = breadcrumbs, - functions = if (shallow) emptyList() else doc.functions.mapNotNull { mapToDto(it, emptyList(), true) }, - properties = if (shallow) emptyList() else doc.properties.mapNotNull { mapToDto(it, emptyList(), true) }, - classlikes = if (shallow) emptyList() else doc.classlikes.mapNotNull { mapToDto(it, emptyList(), true) } + functions = if (shallow) emptyList() else filterWhitelisted(doc.functions).mapNotNull { mapToDto(it, emptyList(), true) }, + properties = if (shallow) emptyList() else filterWhitelisted(doc.properties).mapNotNull { mapToDto(it, emptyList(), true) }, + classlikes = if (shallow) emptyList() else filterWhitelisted(doc.classlikes).mapNotNull { mapToDto(it, emptyList(), true) } ) is DFunction -> FunctionDto( dri = doc.dri.toString(), name = doc.name, url = url, @@ -140,12 +141,12 @@ class ModelMapper( expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), extras = mapExtras(doc.extra), breadcrumbs = breadcrumbs, - functions = if (shallow) emptyList() else doc.functions.mapNotNull { mapToDto(it, emptyList(), true) }, - properties = if (shallow) emptyList() else doc.properties.mapNotNull { mapToDto(it, emptyList(), true) }, - classlikes = if (shallow) emptyList() else doc.classlikes.mapNotNull { mapToDto(it, emptyList(), true) }, + functions = if (shallow) emptyList() else filterWhitelisted(doc.functions).mapNotNull { mapToDto(it, emptyList(), true) }, + properties = if (shallow) emptyList() else filterWhitelisted(doc.properties).mapNotNull { mapToDto(it, emptyList(), true) }, + classlikes = if (shallow) emptyList() else filterWhitelisted(doc.classlikes).mapNotNull { mapToDto(it, emptyList(), true) }, sources = mapSourceSetDependent(doc.sources) { _, it -> it.path }, visibility = mapSourceSetDependent(doc.visibility) { _, it -> it.name }, - companion = if (shallow) null else doc.companion?.let { mapToDto(it, emptyList(), true) as? ObjectDto }, + companion = if (shallow) null else doc.companion?.takeIfWhitelisted()?.let { mapToDto(it, emptyList(), true) as? ObjectDto }, generics = doc.generics.mapNotNull { mapToDto(it) as? TypeParameterDto }, supertypes = mapSourceSetDependent(doc.supertypes) { ss, list -> list.map { TypeConstructorWithKindDto(mapBound(it.typeConstructor, setOf(ss.toDisplaySourceSet())), it.kind.toString()) } @@ -160,9 +161,9 @@ class ModelMapper( expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), extras = mapExtras(doc.extra), breadcrumbs = breadcrumbs, - functions = if (shallow) emptyList() else doc.functions.mapNotNull { mapToDto(it, emptyList(), true) }, - properties = if (shallow) emptyList() else doc.properties.mapNotNull { mapToDto(it, emptyList(), true) }, - classlikes = if (shallow) emptyList() else doc.classlikes.mapNotNull { mapToDto(it, emptyList(), true) }, + functions = if (shallow) emptyList() else filterWhitelisted(doc.functions).mapNotNull { mapToDto(it, emptyList(), true) }, + properties = if (shallow) emptyList() else filterWhitelisted(doc.properties).mapNotNull { mapToDto(it, emptyList(), true) }, + classlikes = if (shallow) emptyList() else filterWhitelisted(doc.classlikes).mapNotNull { mapToDto(it, emptyList(), true) }, sources = mapSourceSetDependent(doc.sources) { _, it -> it.path }, visibility = mapSourceSetDependent(doc.visibility) { _, it -> it.name }, supertypes = mapSourceSetDependent(doc.supertypes) { ss, list -> @@ -177,13 +178,13 @@ class ModelMapper( expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), extras = mapExtras(doc.extra), breadcrumbs = breadcrumbs, - functions = if (shallow) emptyList() else doc.functions.mapNotNull { mapToDto(it, emptyList(), true) }, - properties = if (shallow) emptyList() else doc.properties.mapNotNull { mapToDto(it, emptyList(), true) }, - classlikes = if (shallow) emptyList() else doc.classlikes.mapNotNull { mapToDto(it, emptyList(), true) }, + functions = if (shallow) emptyList() else filterWhitelisted(doc.functions).mapNotNull { mapToDto(it, emptyList(), true) }, + properties = if (shallow) emptyList() else filterWhitelisted(doc.properties).mapNotNull { mapToDto(it, emptyList(), true) }, + classlikes = if (shallow) emptyList() else filterWhitelisted(doc.classlikes).mapNotNull { mapToDto(it, emptyList(), true) }, sources = mapSourceSetDependent(doc.sources) { _, it -> it.path }, visibility = mapSourceSetDependent(doc.visibility) { _, it -> it.name }, - companion = if (shallow) null else doc.companion?.let { mapToDto(it, emptyList(), true) as? ObjectDto }, - constructors = if (shallow) emptyList() else doc.constructors.mapNotNull { mapToDto(it, emptyList(), true) }, + companion = if (shallow) null else doc.companion?.takeIfWhitelisted()?.let { mapToDto(it, emptyList(), true) as? ObjectDto }, + constructors = if (shallow) emptyList() else filterWhitelisted(doc.constructors).mapNotNull { mapToDto(it, emptyList(), true) }, generics = doc.generics.mapNotNull { mapToDto(it) as? TypeParameterDto }, isExpectActual = doc.isExpectActual ) @@ -431,4 +432,20 @@ class ModelMapper( private fun mapSourceSets(sets: Set): List { return sets.map { abbreviateSourceSet(it.sourceSetID.toString()) } } + + private fun passesWhitelist(doc: Documentable): Boolean { + if (sourceSetWhitelist.isEmpty()) return true + return doc.sourceSets.any { abbreviateSourceSet(it.sourceSetID.toString()) in sourceSetWhitelist } + } + + // Drops a shallow child reference (e.g. a package/classlike/function listed in a parent's + // own index) whose source sets don't overlap the whitelist, so index pages don't dangle + // links to files that JsonRenderer omits entirely. + private fun T.takeIfWhitelisted(): T? { + if (passesWhitelist(this)) return this + logger.info("Omitting reference to '${name}' from index: sourceSets=${mapSourceSets(sourceSets)} not in whitelist $sourceSetWhitelist") + return null + } + + private fun filterWhitelisted(docs: List): List = docs.mapNotNull { it.takeIfWhitelisted() } } \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/scripts/verify_sourceset_whitelist.py b/Dokka-plugin-kdoc2json/scripts/verify_sourceset_whitelist.py new file mode 100755 index 00000000..0d30878d --- /dev/null +++ b/Dokka-plugin-kdoc2json/scripts/verify_sourceset_whitelist.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +""" +Verifies that every output JSON file in a kdoc-to-json rendered documentation folder +complies with a given sourceSet whitelist: any file whose top-level "sourceSets" list +does not intersect the whitelist is reported as a violation. Such a file should have +been omitted by the JsonOutputPlugin's `sourceSetWhitelist` config option, so finding +one here means the plugin was run with a different (or no) whitelist than expected. + +Files with an empty or missing top-level "sourceSets" (e.g. all-types.json, the +multimodule root index.json, or a page whose "sourceSets" field was itself stripped by +`omitFields`) are synthetic/aggregate outputs that aren't tied to a single source set, +and are skipped rather than flagged. +""" +import json +import os +import sys +import argparse + + +def find_json_files(root_dir): + for dirpath, _, filenames in os.walk(root_dir): + for filename in filenames: + if filename.endswith(".json"): + yield os.path.join(dirpath, filename) + + +def check_file(path, whitelist): + """Returns (status, sourceSets, reason). status is one of 'ok', 'violation', 'skipped', 'unreadable'.""" + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError) as e: + return "unreadable", None, str(e) + + if not isinstance(data, dict): + return "skipped", None, "root is not a JSON object" + + source_sets = data.get("sourceSets") + if not source_sets: + return "skipped", source_sets, "no top-level sourceSets field (synthetic/aggregate output)" + + if any(ss in whitelist for ss in source_sets): + return "ok", source_sets, None + + return "violation", source_sets, None + + +def write_log(output_file, lines): + if not output_file: + return + try: + with open(output_file, "w", encoding="utf-8") as f: + for line in lines: + f.write(line + "\n") + print(f"📄 Results logged to: {output_file}") + except OSError as e: + print(f"⚠️ Failed to write to output file {output_file}: {e}") + + +def main(): + parser = argparse.ArgumentParser( + description="Verify every JSON file in a kdoc-to-json output directory complies with a sourceSet whitelist." + ) + parser.add_argument("docs_dir", help="Path to the rendered JSON documentation directory to scan recursively") + parser.add_argument( + "whitelist", + nargs="+", + help="One or more allowed source set names, matching the values that appear in the output " + "\"sourceSets\" field (e.g. jvm js). Comma-separated values in a single argument are also " + "accepted (e.g. 'jvm,js').", + ) + parser.add_argument("--output-file", "-o", help="Path to a file where violations will be logged.", default=None) + parser.add_argument("--verbose", "-v", action="store_true", help="Print the result for every file checked, not just violations.") + args = parser.parse_args() + + if not os.path.isdir(args.docs_dir): + print(f"Error: '{args.docs_dir}' is not a directory.", file=sys.stderr) + sys.exit(2) + + whitelist = set() + for entry in args.whitelist: + whitelist.update(part.strip() for part in entry.split(",") if part.strip()) + + if not whitelist: + print("Error: whitelist must contain at least one source set name.", file=sys.stderr) + sys.exit(2) + + print(f"Scanning: {args.docs_dir}") + print(f"Whitelist: {sorted(whitelist)}") + print() + + violations = [] + unreadable = [] + checked = 0 + skipped = 0 + + for path in sorted(find_json_files(args.docs_dir)): + rel_path = os.path.relpath(path, args.docs_dir) + status, source_sets, reason = check_file(path, whitelist) + + if status == "unreadable": + unreadable.append((rel_path, reason)) + print(f" [UNREADABLE] {rel_path} -- {reason}") + elif status == "skipped": + skipped += 1 + if args.verbose: + print(f" [SKIPPED] {rel_path} -- {reason}") + else: + checked += 1 + if status == "violation": + violations.append((rel_path, source_sets)) + print(f" [VIOLATION] {rel_path} -- sourceSets={source_sets} not in whitelist") + elif args.verbose: + print(f" [OK] {rel_path} -- sourceSets={source_sets}") + + print() + print(f"Checked {checked} file(s) with a sourceSets field ({skipped} skipped, {len(unreadable)} unreadable).") + + write_log( + args.output_file, + [f"{rel_path}\tsourceSets={source_sets}" for rel_path, source_sets in violations] + + [f"{rel_path}\tUNREADABLE: {reason}" for rel_path, reason in unreadable], + ) + + if violations: + print(f"❌ FAILED: {len(violations)} file(s) violate the sourceSet whitelist.") + if unreadable: + print(f"⚠️ {len(unreadable)} file(s) could not be read/parsed as JSON.") + if not violations and not unreadable: + print("✅ SUCCESS: every file with a sourceSets field complies with the whitelist.") + + sys.exit(1 if (violations or unreadable) else 0) + + +if __name__ == "__main__": + main() From 524c73a700e76f99929b4bcd172a39ca15063265 Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 22 Jul 2026 14:50:31 -0500 Subject: [PATCH 10/15] Whitelist sourceSets per ADFA-4737 --- Dokka-plugin-kdoc2json/README.md | 9 +++++++++ Dokka-plugin-kdoc2json/scripts/kotlin/build.gradle.kts | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Dokka-plugin-kdoc2json/README.md b/Dokka-plugin-kdoc2json/README.md index f45a8ce0..113a2d01 100644 --- a/Dokka-plugin-kdoc2json/README.md +++ b/Dokka-plugin-kdoc2json/README.md @@ -60,6 +60,14 @@ python3 scripts/sanity_check.py check-links path/to/rendered-html Each subcommand accepts `--output-file/-o` to write a full results log to disk. +`scripts/verify_sourceset_whitelist.py` recursively scans a rendered JSON output directory and confirms every file's top-level `sourceSets` field intersects a given whitelist — useful for confirming the plugin's `sourceSetWhitelist` config option (or a downstream filter) actually excluded everything it should have: + +```bash +python3 scripts/verify_sourceset_whitelist.py path/to/rendered-json jvm js +``` + +Pass one or more source set names (space- or comma-separated) as the whitelist. Files without a top-level `sourceSets` field (e.g. `all-types.json`, the multimodule root `index.json`) are synthetic/aggregate outputs and are skipped rather than flagged. Accepts `--output-file/-o` to log violations and `--verbose/-v` to print every file checked. + ## 3. Configuration Options You can configure the JSON plugin by extending `DokkaPluginParametersBaseSpec` and registering it in your `dokka` configuration block. This utilizes the modern Dokka V2 plugin API. @@ -101,6 +109,7 @@ dokka { | `omitNulls` | Boolean | `false` | If `true`, deeply filters the AST payload to remove any keys where the value is null, an empty string, an empty array, or an empty object. | | `classDiscriminator` | String | `"kind"` | The JSON key used to discriminate between polymorphic `Documentable` types (e.g., `"kind": "class"`). Must not collide with an existing DTO field name (e.g. `"type"` or `"name"`), or serialization will fail. | | `prettyPrint` | Boolean | `false` | If `true`, formats the written JSON files with indentation for human readability instead of compact single-line output. | +| `sourceSetWhitelist` | List | `[]` | A list of source set names (matching the values that appear in the output `sourceSets` field, e.g. `["jvm"]`). If non-empty, any Documentable that isn't present in at least one whitelisted source set has its output file omitted, and a message is logged with the symbol's name and its `sourceSets`. Leave empty to disable filtering (default: all source sets included). | ## 4. Understanding Dokka Terminology diff --git a/Dokka-plugin-kdoc2json/scripts/kotlin/build.gradle.kts b/Dokka-plugin-kdoc2json/scripts/kotlin/build.gradle.kts index 454b87b7..db4c7298 100644 --- a/Dokka-plugin-kdoc2json/scripts/kotlin/build.gradle.kts +++ b/Dokka-plugin-kdoc2json/scripts/kotlin/build.gradle.kts @@ -22,7 +22,8 @@ abstract class JsonOutputPluginParameters @Inject constructor( "logFile": "build/dokka_json.log", "omitFields": ["sources"], "replaceHtmlExtension": false, - "omitNulls": true + "omitNulls": true, + "sourceSetWhitelist": ["common", "jvm"] }""" } From a4795f19384f596a2bff0ce8935da58b4c662781 Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 22 Jul 2026 15:30:47 -0500 Subject: [PATCH 11/15] Convert Kotlin website Writerside content to JSON, add it to database, generate and add templates to display it to database, optimize and insert Kotlin website media. Script to sync current kotlin-stdlib documentation against a newly-generated documentation set (for now, used to do pruning for ADFA-4737 https://appdevforall.atlassian.net/browse/ADFA-4737) --- .../ProcessKotlinWebsiteJSON/README.md | 153 +++++ .../ProcessKotlinWebsiteJSON/build_nav.py | 216 ++++++ .../find_missing_assets.py | 142 ++++ .../insert_optimized_media.py | 446 +++++++++++++ .../ProcessKotlinWebsiteJSON/md_to_json.py | 619 ++++++++++++++++++ .../optimize_media.py | 570 ++++++++++++++++ .../ProcessKotlinWebsiteJSON/populate_db.py | 616 +++++++++++++++++ .../sync_kdoc_json_to_db.py | 203 ++++++ 8 files changed, 2965 insertions(+) create mode 100644 ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/README.md create mode 100644 ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/build_nav.py create mode 100644 ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/find_missing_assets.py create mode 100644 ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/insert_optimized_media.py create mode 100644 ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/md_to_json.py create mode 100644 ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/optimize_media.py create mode 100644 ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/populate_db.py create mode 100755 scripts/sync_kotlin_stdlib_docs/sync_kdoc_json_to_db.py diff --git a/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/README.md b/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/README.md new file mode 100644 index 00000000..b1c7f560 --- /dev/null +++ b/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/README.md @@ -0,0 +1,153 @@ +# Process Kotlin Website JSON + +Scripts for converting a `kotlin-web-site/docs` checkout (JetBrains +Writerside-flavored Markdown) into the JSON block schema this project's +templating engine renders, and for loading that JSON, its navigation tree, +and its media straight into a `documentation.db`-schema SQLite database. + +## Scripts + +| Script | Purpose | +|---|---| +| [`md_to_json.py`](md_to_json.py) | Converts every `topics/**/*.md` page into one JSON file (see schema below). Writes `theme.json` and copies `images/` into the output directory. | +| [`build_nav.py`](build_nav.py) | Builds `nav.json`/`nav.html` sidebar navigation from `kr.tree`, resolving each `` against `md_to_json.py`'s output. | +| [`find_missing_assets.py`](find_missing_assets.py) | QA pass: reports cross-page links, images, and `` targets in the source tree that don't resolve to anything. Reuses `md_to_json.py`'s own resolution logic, so it flags exactly what would end up broken on the rendered site. | +| [`populate_db.py`](populate_db.py) | The database path: converts the docs tree the same way `md_to_json.py` does, builds nav the same way `build_nav.py` does, and inserts pages + nav + images + CSS/JS directly into `documentation.db` (replacing everything under `k/html/` and `assets/`). Supports pruning whole `kr.tree` subtrees via `--blacklisted-element-titles`. | +| [`optimize_media.py`](optimize_media.py) | Standalone media optimizer: downscales/recompresses a directory of images (pngquant, Pillow, Scour/cairosvg for SVG) into a mirrored output directory. | +| [`insert_optimized_media.py`](insert_optimized_media.py) | Runs `optimize_media.py`'s pipeline over a directory of raw media, then replaces the corresponding `k/html/images/*` rows in an existing database, rewriting any page that referenced a renamed file and deleting anything left unreferenced. | + +## Requirements + +- Python 3.10+ +- `pip install markdown-it-py Pillow scour brotli` +- `cairosvg` (only needed if an optimized SVG exceeds `--svg-rasterize-threshold`): `pip install cairosvg` +- `pngquant` on `PATH` (e.g. `apt install pngquant`) — required by `optimize_media.py`/`insert_optimized_media.py`, and by `populate_db.py` for the images it inserts directly from the Writerside export. + +`populate_db.py` also expects, relative to its own location (not copied into +this directory — see note below): + +- `templates/page.peb`, `templates/nav.peb` — Pebble templates upserted into the `Templates` table. +- `assets/docs.css`, `assets/tabs.js`, `assets/sidebar.js` — static assets inserted at `assets/`. + +> **Note:** per ADFA-4737, only the `.py` files from the original working +> directory were copied here. `templates/` and `assets/` (and any `.config` +> files you use) must be placed alongside these scripts before running +> `populate_db.py`. + +## Inputs you need before starting + +- A checkout of `kotlin-web-site/docs` (the `` argument below) — contains `topics/`, `images/`, `v.list`, and `kr.tree`. +- A config JSON with theming colors, e.g.: + ```json + {"broken-ext-link-color": "#cc0000", "menu-no-link-color": "#999999"} + ``` +- Writerside's own image export zip (e.g. `webHelpImages.zip`, found next to `kr.tree`) if you're using `populate_db.py`. + +## Workflow: generate JSON + nav for a static/templated preview + +Use this to produce standalone JSON pages and nav data (not the database) +for local inspection or a different renderer. + +```bash +# 1. Convert every topic .md into JSON, one file per page +python3 md_to_json.py config.json + +# 2. Build the sidebar nav from kr.tree against that JSON output +python3 build_nav.py + +# 3. (optional) Check for broken links/images/includes in the source tree +python3 find_missing_assets.py missing-assets-report.md +``` + +`` ends up containing: +- `topics/**/*.json` — one page per source `.md` file (schema below) +- `theme.json` — the two theming colors, carried from `config.json` +- `images/` — copied straight from `/images/` +- `nav.json` / `nav.html` — sidebar tree and a pre-rendered static copy + +### Page JSON schema + +```json +{ + "id": "enum-classes", + "sourceFile": "topics/enum-classes.md", + "title": "Enum classes", + "blocks": [ { "type": "heading", "level": 2, "id": "...", "html": "..." }, "..." ] +} +``` + +Block types: `heading`, `paragraph`, `code`, `blockquote`, `list`, `table`, +`image`, `hr`, `tabs`, `note`/`tip`/`warning`, `html` (raw passthrough). See +the module docstring in [`md_to_json.py`](md_to_json.py) for full shapes and +known limitations (nested tabs, `` resolution, variable +substitution). + +## Workflow: generate + insert directly into the documentation database + +This is the path that actually populates `documentation.db`. It performs +the same conversion as `md_to_json.py`/`build_nav.py` internally — you don't +run those scripts first. + +```bash +python3 populate_db.py config.json [db-path] +``` + +- `db-path` defaults to `documentation.db` in the current directory, and must already exist with the expected schema (`Languages`, `ContentTypes`, `Templates` tables populated). +- A timestamped backup (`.backup-`) is written before any changes, via SQLite's `VACUUM INTO`. +- Everything under `k/html/` and `assets/` is deleted and re-inserted in a single transaction (rolled back on error), then the database is `VACUUM`ed. + +### Pruning documentation you don't want (ADFA-4737) + +To leave a whole `kr.tree` subtree out of the database entirely — nav +entry, converted pages, and all — pass `--blacklisted-element-titles` with +the full `toc-title` path from a top-level element down to the one you want +to drop. Levels are joined with `\/` (backslash-slash), not a bare `/`, +since a bare `/` commonly appears inside a real title. The example below is +illustrative only — open `/kr.tree` and copy the actual +`toc-title` chain for whatever section you're dropping (e.g. Kotlin/Wasm): + +```bash +python3 populate_db.py config.json documentation.db \ + --blacklisted-element-titles \ + "\/" +``` + +Any other page's in-content link to a pruned topic renders as a styled +"broken" link (via `broken-ext-link-color`) rather than a dead link with no +indication anything changed. Run with `--blacklisted-element-titles` first +against a scratch copy of the database and check the warnings on stderr for +any path that didn't match — that usually means the toc-title or ancestor +chain was copied wrong. + +## Workflow: optimizing and inserting media + +Two options, depending on whether the database already has pages loaded: + +**Standalone optimization only** (no database involved): + +```bash +python3 optimize_media.py [--max-width 500] [--webp] [...] +``` + +**Optimize and update an existing database's images in place:** + +```bash +python3 insert_optimized_media.py [work-dir] [options] +``` + +This re-runs `optimize_media.py`'s pipeline, backs up the database first, +replaces each `k/html/images/` row with the optimized bytes, rewrites +any page/nav reference to a file that got renamed during optimization (e.g. +`--webp` conversion or SVG rasterization), and deletes any image no page +references anymore. Both scripts share the same tuning flags +(`--max-width`, `--jpeg-quality`, `--webp`, `--webp-quality`, +`--pngquant-speed`, `--svg-precision`, `--svg-rasterize-threshold`, +`--verbose`, `--log-file`), settable via `--config ` instead of the +command line — see either script's module docstring for the full option +reference. + +## Recommended order for a full refresh + +1. `find_missing_assets.py` against the new `` — fix anything broken in the source before converting it. +2. `populate_db.py`, with `--blacklisted-element-titles` for anything you don't want documented (e.g. Kotlin/Wasm per ADFA-4737). +3. `insert_optimized_media.py` against the raw media directory, if you want optimized (resized/compressed) images rather than Writerside's own export as-is. diff --git a/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/build_nav.py b/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/build_nav.py new file mode 100644 index 00000000..ce3084d7 --- /dev/null +++ b/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/build_nav.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +""" +Builds sidebar navigation data/HTML from a JetBrains Writerside .tree file +(e.g. kotlin-web-site/docs/kr.tree), for use with templates/nav.peb. + +Usage: + python3 build_nav.py [--tree-file kr.tree] + + is the checkout of kotlin-web-site/docs (contains kr.tree). + is the output of md_to_json.py, used to resolve each + to the page's "id" (its path + relative to docs-root, e.g. "topics/ksp/ksp-overview") and, + when a has no toc-title, its rendered "title". + receives nav.json (the tree, for feeding into nav.peb) and + nav.html (a pre-rendered static copy of what nav.peb outputs). + +Writerside lets a both link to its own page (topic="...") and +contain nested children (sub-pages) at once, and topic +references are bare filenames resolved by uniqueness across the whole +topics/ subtree, not by path - this script relies on that same uniqueness. + +A topic="foo.md" that doesn't resolve to any converted page is treated as +manually deleted (rather than e.g. a typo) and dropped from the nav: if the +toc-element has no children either, build_node() returns None for it and it's +filtered out of its parent's children entirely; if it still has children +(sub-topics that do still exist), the element is kept as a link-less category +header for them instead of losing those children too. This only applies to +topic="*.md" references - kr.tree itself is never modified, so a purged +topic's (and any of its still-live children) stays in the tree +forever; this is what re-derives "not actually present" from the docs-json +output on every run instead. + +If /theme.json (written by md_to_json.py from its own config +argument) has a "menu-no-link-color", each node that doesn't actually lead to +a generated page - a pure category header with no topic="...", a +topic="foo.topic" that isn't a converted .md page (e.g. home.topic, +api-references.topic), or a topic="foo.md" whose page was deleted but which +still has live children - gets a "noLinkColor" of that value baked directly +into its entry in nav.json (null otherwise), which nav.peb turns into an +inline style. A toc-element using kr.tree's other, unrelated href="https://..." +attribute (e.g. "Test library (kotlin.test)" under API reference) is not +treated any differently here - it has no topic="...", so it's already a +no-link category header like any other; this script does not read that +external href at all, so no link is added for it. +""" +import argparse +import json +import re +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +HUMANIZE_RE = re.compile(r"[-_]+") + + +def humanize(stem: str) -> str: + return HUMANIZE_RE.sub(" ", stem).strip().title() + + +def load_page_index(docs_json_dir: Path) -> tuple[dict, dict]: + """Returns (stem -> id, id -> title) built from every generated page JSON.""" + stem_to_id = {} + id_to_title = {} + for json_path in docs_json_dir.rglob("*.json"): + page = json.loads(json_path.read_text(encoding="utf-8")) + page_id = page.get("id") + if not page_id: + continue + stem = Path(page_id).name + stem_to_id[stem] = page_id + id_to_title[page_id] = page.get("title") + return stem_to_id, id_to_title + + +def build_node(el: ET.Element, stem_to_id: dict, id_to_title: dict, warnings: list, no_link_color: str, + id_prefix: str = "") -> dict | None: + """Returns None when this element's own topic="*.md" no longer resolves + to a converted page (deleted) and it has no children left worth keeping - + callers must filter these out of whatever list they collect build_node() + results into (both build_node() itself, for its own children, and the + top-level toc-element loop in main()).""" + topic = el.get("topic") + toc_title = el.get("toc-title") + hidden = el.get("hidden") == "true" + + children = [ + build_node(c, stem_to_id, id_to_title, warnings, no_link_color, id_prefix) + for c in el.findall("toc-element") + ] + children = [c for c in children if c is not None] + + page_id = None + title = toc_title + no_link = True + deleted = False + if topic: + stem = Path(topic).stem + page_id = stem_to_id.get(stem) + if page_id is None: + if topic.endswith(".md"): + # Was a real page at some point, isn't anymore - the .md was + # manually deleted. Drop this node; if children still resolve + # to real pages, keep it around as a no-link category header + # for them rather than losing those children too. + deleted = True + if children: + warnings.append(f"topic={topic!r} has no converted page (deleted); " + f"keeping as a header for its {len(children)} remaining child page(s)") + else: + warnings.append(f"topic={topic!r} has no converted page (deleted); dropping from nav") + return None + else: + # Not a converted .md page (e.g. home.topic, api-references.topic): + # still rendered as a link (for consistency, and it's still the + # best URL guess) but it doesn't lead anywhere real, so it's + # colored the same as a no-topic-at-all category header. Prefixed + # the same way as a resolved stem_to_id hit would be (e.g. + # populate_db.py passes id_prefix="k/html/" since stem_to_id + # there already maps every real page to "k/html/"), so + # this fallback id follows the same URL convention as everything + # else on the page rather than silently reverting to a bare one. + page_id = f"{id_prefix}{stem}" + warnings.append(f"no converted page for topic={topic!r}; using id={page_id!r}") + else: + no_link = False + if not title: + title = (id_to_title.get(page_id) if page_id else None) or humanize(stem) + elif not title: + title = "Untitled" + + return { + "title": title, + "id": None if deleted else page_id, + "hidden": hidden, + "noLinkColor": no_link_color if (no_link or deleted) else None, + "children": children, + } + + +def render_node(node: dict, indent: int = 0) -> str: + # Kept byte-identical to templates/nav.peb's renderNavNode macro, since + # RenderDocs.java re-renders nav.peb over nav.json for the live site and + # this function only produces a standalone preview copy of the same HTML. + classes = "nav-item" + (" nav-hidden" if node["hidden"] else "") + has_children = bool(node["children"]) + style = f' style="color: {node["noLinkColor"]};"' if node["noLinkColor"] else "" + if node["id"]: + label = f'{node["title"]}' + else: + label = f'{node["title"]}' + toggle = ( + f'' + if has_children else '' + ) + parts = [f'
  • ', '"] + if has_children: + parts.append('") + parts.append("
  • ") + return "".join(parts) + + +def render_html(tree: list) -> str: + body = "".join(render_node(node) for node in tree) + return ( + '" + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("docs_root", type=Path, help="Path to kotlin-web-site/docs") + parser.add_argument("docs_json_dir", type=Path, help="Path to md_to_json.py output directory") + parser.add_argument("output_dir", type=Path, help="Directory to write nav.json and nav.html into") + parser.add_argument("--tree-file", default="kr.tree", help="Filename of the Writerside tree file under docs_root") + args = parser.parse_args() + + tree_path = args.docs_root / args.tree_file + if not tree_path.is_file(): + print(f"error: {tree_path} does not exist", file=sys.stderr) + sys.exit(1) + + stem_to_id, id_to_title = load_page_index(args.docs_json_dir) + + theme_path = args.docs_json_dir / "theme.json" + no_link_color = None + if theme_path.is_file(): + no_link_color = json.loads(theme_path.read_text(encoding="utf-8")).get("menu-no-link-color") + else: + print(f"warning: {theme_path} not found (run md_to_json.py first); " + f"no-link menu items won't be colored", file=sys.stderr) + + root = ET.parse(tree_path).getroot() + warnings = [] + nav_tree = [build_node(el, stem_to_id, id_to_title, warnings, no_link_color) for el in root.findall("toc-element")] + nav_tree = [node for node in nav_tree if node is not None] + + for w in warnings: + print(f"warning: {w}", file=sys.stderr) + + args.output_dir.mkdir(parents=True, exist_ok=True) + (args.output_dir / "nav.json").write_text( + json.dumps(nav_tree, separators=(",", ":"), ensure_ascii=False), encoding="utf-8" + ) + (args.output_dir / "nav.html").write_text(render_html(nav_tree), encoding="utf-8") + + print(f"Wrote nav.json and nav.html ({len(warnings)} warning(s)) into {args.output_dir}") + + +if __name__ == "__main__": + main() diff --git a/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/find_missing_assets.py b/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/find_missing_assets.py new file mode 100644 index 00000000..65398211 --- /dev/null +++ b/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/find_missing_assets.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Documents every reference in the docs source (kotlin-web-site/docs) that +points at an asset - another topic page, an image, or an target - +which doesn't actually exist, so broken source content can be found and +fixed without having to read every generated page. + +Usage: + python3 find_missing_assets.py [report-path] [--topics-subdir topics] [--images-subdir images] + +Reuses md_to_json.py's own link/image resolution (build_topic_index, +build_image_index, Converter) instead of re-parsing links with regexes, so +this reports exactly what would end up broken in the rendered site - e.g. a +"foo.md" written inside a fenced code sample (showing readers what Writerside +markup looks like) is correctly ignored, since it's never tokenized as a +real link in the first place. + + targets are a separate check: those +aren't links/images so md_to_json.py's Converter never resolves them, but a +missing include is still a broken asset reference worth surfacing. This is +checked with a small standalone regex scan (fenced code blocks stripped +first) against every filename that exists anywhere under /, +regardless of extension (include targets can be ".md" or ".topic"). +""" +import argparse +import re +import sys +from collections import defaultdict +from pathlib import Path + +from md_to_json import Converter, build_image_index, build_topic_index, load_variables, make_markdown_it + +INCLUDE_RE = re.compile(r']*\bfrom\s*=\s*"([^"]+)"') +FENCE_RE = re.compile(r"```.*?```", re.S) + + +def find_include_warnings(topics_dir: Path) -> list: + all_filenames = {p.name for p in topics_dir.rglob("*") if p.is_file()} + warnings = [] + for md_path in sorted(topics_dir.rglob("*.md")): + text_no_fences = FENCE_RE.sub("", md_path.read_text(encoding="utf-8")) + source_rel = str(md_path.relative_to(topics_dir.parent)) + for target in INCLUDE_RE.findall(text_no_fences): + if target not in all_filenames: + warnings.append({"kind": "include", "source": source_rel, "reference": target}) + return warnings + + +def group_by_reference(warnings: list) -> dict: + grouped = defaultdict(set) + for w in warnings: + grouped[w["reference"]].add(w["source"]) + return {ref: sorted(sources) for ref, sources in sorted(grouped.items())} + + +def render_section(title: str, grouped: dict) -> str: + lines = [f"## {title} ({len(grouped)})", ""] + if not grouped: + lines.append("None.") + for ref, sources in grouped.items(): + lines.append(f"- `{ref}`") + for src in sources: + lines.append(f" - {src}") + lines.append("") + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("docs_root", type=Path, help="Path to kotlin-web-site/docs") + parser.add_argument("report_path", type=Path, nargs="?", default=Path("missing-assets-report.md"), + help="Where to write the Markdown report (default: ./missing-assets-report.md)") + parser.add_argument("--topics-subdir", default="topics", help="Subdirectory of docs_root holding .md files") + parser.add_argument("--images-subdir", default="images", help="Subdirectory of docs_root holding image files") + args = parser.parse_args() + + docs_root: Path = args.docs_root + topics_dir = docs_root / args.topics_subdir + images_dir = docs_root / args.images_subdir + if not topics_dir.is_dir(): + print(f"error: {topics_dir} is not a directory", file=sys.stderr) + sys.exit(1) + + variables = load_variables(docs_root) + topic_index = build_topic_index(topics_dir) + image_index, image_collisions = build_image_index(images_dir) + md = make_markdown_it() + converter = Converter(md, variables, topic_index, image_index) + + md_files = sorted(topics_dir.rglob("*.md")) + for md_path in md_files: + rel = md_path.relative_to(topics_dir) + page_id = str(rel.with_suffix("")) + source_rel = str(Path(args.topics_subdir) / rel) + try: + converter.convert_file(md_path, page_id, source_rel) + except Exception as exc: # noqa: BLE001 - surface which file broke, keep auditing the rest + print(f"error scanning {md_path}: {exc}", file=sys.stderr) + + link_warnings = [w for w in converter.warnings if w["kind"] == "link"] + image_warnings = [w for w in converter.warnings if w["kind"] == "image"] + include_warnings = find_include_warnings(topics_dir) + + links = group_by_reference(link_warnings) + images = group_by_reference(image_warnings) + includes = group_by_reference(include_warnings) + collisions = {name: rels for name, rels in image_collisions} + + report = [ + "# Missing Assets Report", + "", + f"Scanned {len(md_files)} Markdown files under `{topics_dir}`.", + "", + "## Summary", + "", + f"- {len(links)} unresolved cross-page link target(s)", + f"- {len(images)} missing image(s)", + f"- {len(collisions)} ambiguous image filename(s) (exist in more than one place under images/)", + f"- {len(includes)} unresolved `` target(s)", + "", + render_section("Unresolved cross-page links", links), + render_section("Missing images", images), + render_section("Unresolved targets", includes), + f"## Ambiguous image filenames ({len(collisions)})", + "", + ] + if not collisions: + report.append("None.") + for name, rels in collisions.items(): + report.append(f"- `{name}`") + report.append(f" - used: images/{rels[0]}") + for rel in rels[1:]: + report.append(f" - ignored: images/{rel}") + report.append("") + + args.report_path.write_text("\n".join(report), encoding="utf-8") + print(f"Wrote {args.report_path} " + f"({len(links)} links, {len(images)} images, {len(collisions)} ambiguous, {len(includes)} includes)") + + +if __name__ == "__main__": + main() diff --git a/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/insert_optimized_media.py b/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/insert_optimized_media.py new file mode 100644 index 00000000..c0624d31 --- /dev/null +++ b/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/insert_optimized_media.py @@ -0,0 +1,446 @@ +#!/usr/bin/env python3 +""" +insert_optimized_media.py + +Runs optimize_media.py's image optimizer over a directory of raw media, +then updates an existing documentation.db-schema database (as +populate_db.py produces) with the optimized results, fixing up every page +that referenced a file under its old name. + +What this does, inside a single transaction (rolled back on any error): + 1. Backs up first, same as populate_db.py (VACUUM INTO a + timestamped sibling file). + 2. Optimizes every file under into a staging directory + (--work-dir, or a temporary one removed afterwards) via + optimize_media.py's own pipeline - see its own module docstring for + what "optimized" means (resize, pngquant, Scour, optional WEBP + conversion / SVG rasterization). Aborts before touching the database + if any file fails to optimize. + 3. Replaces every "k/html/images/" Content row with the optimized + bytes, deleting the old row (and any leftover chunked fragments) first + - Content.path is UNIQUE, so a stale row has to go before its + replacement can be inserted. Images are addressed by bare filename + only, matching populate_db.py's own flat "k/html/images/*" convention: + subdirectories are flattened to their basename, and a + basename collision across two different subdirectories is a warning + (keeping the first, sorted, skipping the rest), not an error. + 4. Wherever optimization renamed a file (webp conversion, or an oversized + SVG rasterized to PNG/WEBP), rewrites every "/k/html/images/" + reference still pointing at the old name, in every k/html/*.html page + and the nav row, to the new name - so a page doesn't end up linking to + a filename that no longer exists. + 5. Deletes every remaining "k/html/images/" row (base row and any + chunked fragments) that, after the rename rewriting above, no + k/html/*.html page or the nav row references even once - not just ones + touched by this run's rename_map, but every currently-stored image, + so media that fell out of use in an earlier run (e.g. a topic's .md + was deleted, or an reference was removed by hand) gets cleaned + up too, not just this run's renames. + 6. VACUUMs the database afterwards (outside the transaction - SQLite + refuses to VACUUM inside one), same as populate_db.py. + +Usage: + python3 insert_optimized_media.py [work_dir] [options] + python3 insert_optimized_media.py --config myjob.config + + are optimize_media.py's own tuning flags (--max-width, +--jpeg-quality, --webp, --webp-quality, --pngquant-speed, --svg-precision, +--svg-rasterize-threshold, --verbose, --log-file, --config) - see +optimize_media.py's own docstring for what each one does. media-dir/db-path/ +work-dir can also be set via --config (as "input-dir"/"db-path"/ +"output-dir"), the same as optimize_media.py's own options. + +Note: --webp requires this database's ContentTypes table to already have an +"image/webp" row (checked up front, before any optimization work starts) - +this project's documentation.db doesn't ship with one. +""" +import argparse +import re +import shutil +import sqlite3 +import sys +import tempfile +from pathlib import Path + +import brotli + +from optimize_media import ( + BUILTIN_DEFAULTS, Logger, OPTION_SPECS, add_optimize_arguments, find_pngquant, optimize_directory, + resolve_config, +) +from populate_db import ( + CHUNK_SIZE, EXTENSION_TO_CONTENT_TYPE, IMAGES_DB_PATH_PREFIX, IMAGES_URL_PREFIX, LANGUAGE, PAGE_CONTENT_TYPE, + backup_database, get_content_type, get_id, insert_chunked_content, +) + +WEBP_CONTENT_TYPE = "image/webp" +# populate_db.py's own EXTENSION_TO_CONTENT_TYPE has no ".webp" entry - its +# image source (a Writerside export) never produces one, but +# optimize_media.py's --webp does, so it's added here rather than touching +# that shared dict. +IMAGE_EXTENSION_TO_CONTENT_TYPE = {**EXTENSION_TO_CONTENT_TYPE, ".webp": WEBP_CONTENT_TYPE} + +# This script's own options, layered on top of optimize_media.py's (db-path +# has no equivalent there) - passed to resolve_config/load_config_file so +# --config can set any of them, the same mechanism optimize_media.py uses +# for its own options. +OWN_OPTION_SPECS = {**OPTION_SPECS, "db-path": ("db_path", Path)} + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("input_dir", type=Path, nargs="?", default=None, metavar="media_dir", + help="Directory of raw media to optimize, recursively (or set input-dir in --config)") + parser.add_argument("db_path", type=Path, nargs="?", default=None, + help="SQLite database to update, e.g. documentation.db (or set db-path in --config)") + parser.add_argument("output_dir", type=Path, nargs="?", default=None, metavar="work_dir", + help="Staging directory for optimized files; default: a temporary directory removed " + "afterwards (or set output-dir in --config)") + add_optimize_arguments(parser) + return parser + + +def delete_content(conn, path: str) -> None: + """Deletes a Content row and any chunked continuation fragments for it + (see insert_chunked_content/CHUNK_SIZE) - safe to call even if nothing + exists yet at that path. Content.path is UNIQUE, so this has to run + before any re-insert at the same path.""" + conn.execute("DELETE FROM Content WHERE path = ? OR path LIKE ?", (path, f"{path}-%")) + + +def insert_optimized_file(conn, data: bytes, name: str, db_path: str, language_id: int, content_type_cache: dict, + chunked_log: list) -> bool: + """Inserts one already-optimized file's bytes as-is. Unlike + populate_db.py's own insert_file, this does not run pngquant itself - + optimize_media.py already did, and running it again here would just + re-quantize an already-quantized image for no benefit. Returns False + (skipping the file, with a warning) for an extension with no known + content type.""" + content_type_value = IMAGE_EXTENSION_TO_CONTENT_TYPE.get(Path(name).suffix.lower()) + if content_type_value is None: + print(f"warning: no known content type for {name!r}; skipping", file=sys.stderr) + return False + if content_type_value not in content_type_cache: + content_type_cache[content_type_value] = get_content_type(conn, content_type_value) + content_type_id, compress = content_type_cache[content_type_value] + + if compress: + data = brotli.compress(data) + delete_content(conn, db_path) + insert_chunked_content(conn, db_path, language_id, content_type_id, 0, data, chunked_log) + return True + + +def build_rename_map(manifest: dict, logger: Logger) -> dict: + """Flattens optimize_directory's {relative_src: relative_dst} manifest + to {old_basename: new_basename}, matching k/html/images/*'s bare-filename + addressing. Warns (keeping the first) if two different renames collide + on the same old basename - e.g. two identically-named files in + different subdirectories of media_dir.""" + rename_map = {} + for old_rel, new_rel in sorted(manifest.items()): + old_name = Path(old_rel).name + new_name = Path(new_rel).name + if old_name == new_name: + continue + if old_name in rename_map and rename_map[old_name] != new_name: + logger.error( + f"warning: {old_rel!r} and an earlier file both renamed from {old_name!r}, to different names " + f"({rename_map[old_name]!r} vs {new_name!r}); keeping the first" + ) + continue + rename_map[old_name] = new_name + return rename_map + + +def reassemble_content(conn, path: str, first_content: bytes) -> bytes: + """Reassembles a possibly-chunked row's full bytes - mirrors + WebServer.kt's own reassembly protocol (see CHUNK_SIZE's docstring in + populate_db.py): a row is fragmented purely when its content is exactly + CHUNK_SIZE bytes, in which case "-1", "-2", ... are + concatenated until a missing or shorter-than-CHUNK_SIZE row is hit.""" + if len(first_content) < CHUNK_SIZE: + return first_content + parts = [first_content] + n = 1 + while True: + row = conn.execute("SELECT content FROM Content WHERE path = ?", (f"{path}-{n}",)).fetchone() + if row is None: + break + parts.append(row[0]) + if len(row[0]) < CHUNK_SIZE: + break + n += 1 + return b"".join(parts) + + +def rewrite_pages(conn, rename_map: dict, language_id: int, page_content_type_id: int, logger: Logger, + chunked_log: list) -> int: + """Rewrites every k/html/*.html page (and the nav row) that references a + renamed image, replacing "/k/html/images/" with + "/k/html/images/" wherever it appears. Operates directly on + each row's decompressed JSON text rather than parsing it: every image + reference is a literal IMAGES_URL_PREFIX+filename substring, baked in at + conversion time by md_to_json.py's Converter (resolve_image_src), so a + plain text substitution finds it correctly regardless of which block + type it ends up nested inside - no need to understand that nested block + schema here. The match is anchored on the escaped quote (\\") that + always immediately follows a rewritten src="..." attribute in the + stored JSON (see resolve_image_src/rewrite_urls - image references are + only ever embedded as HTML attributes, never as a bare JSON field on + their own), so a renamed file's name can't accidentally match as a + prefix of some other, unrelated, longer filename. Returns the number of + rows changed. + + ".html" is the exact literal suffix populate_db.py gives every base + page/nav row; fragment continuation rows are named "-" (the + "-N" appended after the ".html" already in path), so the path filter + below naturally excludes them without needing to detect chunking up + front.""" + if not rename_map: + return 0 + + rows = conn.execute( + "SELECT path, content, templateId FROM Content WHERE path LIKE 'k/html/%.html' AND contentTypeID = ? " + "AND templateId != 0", + (page_content_type_id,), + ).fetchall() + + changed = 0 + for path, first_content, template_id in rows: + full = reassemble_content(conn, path, first_content) + text = brotli.decompress(full).decode("utf-8") + new_text = text + hits = 0 + for old_name, new_name in rename_map.items(): + old_ref = f'{IMAGES_URL_PREFIX}{old_name}\\"' + new_ref = f'{IMAGES_URL_PREFIX}{new_name}\\"' + hits += new_text.count(old_ref) + new_text = new_text.replace(old_ref, new_ref) + if new_text == text: + continue + blob = brotli.compress(new_text.encode("utf-8")) + delete_content(conn, path) + insert_chunked_content(conn, path, language_id, page_content_type_id, template_id, blob, chunked_log) + changed += 1 + logger.info(f"[URL FIX] {path}: updated {hits} image reference(s)") + return changed + + +# Matches a rewritten image src's filename, anchored the same way +# rewrite_pages' own known-rename substitutions are: resolve_image_src/ +# rewrite_urls only ever embed an image reference as an HTML src="..." +# attribute, which - JSON-encoded - always has the escaped quote (\") right +# after it, so this can't accidentally swallow past the end of the filename. +IMAGE_REF_RE = re.compile(re.escape(IMAGES_URL_PREFIX) + r'([^\\"]+)\\"') + + +def collect_referenced_media(conn, page_content_type_id: int) -> set: + """Bare filenames (e.g. "mascot.png") referenced by at least one + src="/k/html/images/" anywhere across current k/html/*.html page + content and the nav row - the same row selection/reassembly + rewrite_pages uses, just extracting every image reference found instead + of only substituting the ones in a known rename_map.""" + rows = conn.execute( + "SELECT path, content FROM Content WHERE path LIKE 'k/html/%.html' AND contentTypeID = ? AND templateId != 0", + (page_content_type_id,), + ).fetchall() + referenced = set() + for path, first_content in rows: + full = reassemble_content(conn, path, first_content) + text = brotli.decompress(full).decode("utf-8") + referenced.update(IMAGE_REF_RE.findall(text)) + return referenced + + +def list_stored_media(conn) -> dict: + """Bare filename -> Content.path (e.g. "mascot.png" -> "k/html/images/ + mascot.png") for every image currently stored under IMAGES_DB_PATH_PREFIX, + collapsing chunked continuation fragments ("-1", "-2", ...) + back into their base row, since deleting the base via delete_content + already takes its fragments with it (see CHUNK_SIZE's docstring in + populate_db.py for that fragmentation convention). A path is treated as + a fragment when stripping a trailing "-" yields another path + that's also present - the same convention this whole pipeline already + relies on elsewhere, ambiguous only for a base filename that itself + looks like "-", which no real optimized + media filename does.""" + paths = {row[0] for row in conn.execute( + "SELECT path FROM Content WHERE path LIKE ?", (f"{IMAGES_DB_PATH_PREFIX}%",) + )} + + def is_fragment(path: str) -> bool: + prefix, sep, suffix = path.rpartition("-") + return sep == "-" and suffix.isdigit() and prefix in paths + + return {path[len(IMAGES_DB_PATH_PREFIX):]: path for path in paths if not is_fragment(path)} + + +def delete_unreferenced_media(conn, page_content_type_id: int, logger: Logger) -> int: + """Deletes every currently-stored k/html/images/ row (base row and + any chunked fragments) that no page or the nav row references even once. + Must run after insertion and rename-rewriting, so it sees the final, + up-to-date state of both stored media and in-content references - a file + renamed this run is only "unreferenced" under its stale old name, which + rewrite_pages will have already fixed up by the time this runs. Returns + the number of images removed.""" + stored = list_stored_media(conn) + referenced = collect_referenced_media(conn, page_content_type_id) + removed = 0 + for name, path in sorted(stored.items()): + if name in referenced: + continue + delete_content(conn, path) + removed += 1 + logger.info(f"[UNUSED] removed {path} (not referenced by any page)") + return removed + + +def main() -> None: + parser = build_parser() + args = parser.parse_args() + + try: + cfg = resolve_config(args, OWN_OPTION_SPECS, BUILTIN_DEFAULTS) + except RuntimeError as exc: + parser.error(str(exc)) + return + + if cfg["input_dir"] is None or cfg["db_path"] is None: + parser.error("media_dir and db_path must be given either as positional arguments or in --config") + + log_file_handle = open(cfg["log_file"], "w", encoding="utf-8") if cfg["log_file"] else None + logger = Logger(log_file_handle) + work_dir_is_temp = cfg["output_dir"] is None + work_dir = cfg["output_dir"] or Path(tempfile.mkdtemp(prefix="insert_optimized_media_")) + + try: + if not cfg["input_dir"].is_dir(): + logger.error(f"error: {cfg['input_dir']} is not a directory") + sys.exit(1) + if not cfg["db_path"].is_file(): + logger.error(f"error: {cfg['db_path']} does not exist") + sys.exit(1) + + if cfg["verbose"]: + logger.info("Config parameters:") + for key, (dest, _converter) in OWN_OPTION_SPECS.items(): + logger.info(f" {key} = {cfg.get(dest)}") + logger.info(f" work-dir = {work_dir}{' (temporary)' if work_dir_is_temp else ''}") + if args.config: + logger.info(f" (loaded from {args.config})") + + try: + pngquant_path = find_pngquant() + except RuntimeError as exc: + logger.error(f"error: {exc}") + sys.exit(1) + + # Fail fast on a schema this database doesn't support - before + # spending time optimizing every file - rather than discovering it + # partway through the (rolled-back, but still wasted) DB transaction. + preflight_conn = sqlite3.connect(cfg["db_path"]) + try: + get_id(preflight_conn, "Languages", LANGUAGE) + get_id(preflight_conn, "ContentTypes", PAGE_CONTENT_TYPE) + if cfg["webp"]: + get_content_type(preflight_conn, WEBP_CONTENT_TYPE) + except RuntimeError as exc: + logger.error(f"error: {exc}") + sys.exit(1) + finally: + preflight_conn.close() + + work_dir.mkdir(parents=True, exist_ok=True) + stats = {"raster": 0, "svg": 0, "svg_rasterized": 0, "copied": 0, "errors": 0, "original_bytes": 0, + "optimized_bytes": 0} + logger.info(f"Optimizing media from {cfg['input_dir']} into {work_dir}...") + manifest = optimize_directory(cfg["input_dir"], work_dir, cfg=cfg, pngquant_path=pngquant_path, + logger=logger, stats=stats) + if stats["errors"]: + logger.error( + f"error: {stats['errors']} file(s) failed to optimize; aborting before touching the database" + ) + sys.exit(1) + rename_map = build_rename_map(manifest, logger) + + logger.info(f"Backing up {cfg['db_path']}...") + backup_path = backup_database(cfg["db_path"]) + logger.info(f"Backup written to {backup_path}") + + conn = sqlite3.connect(cfg["db_path"]) + try: + conn.execute("BEGIN") + language_id = get_id(conn, "Languages", LANGUAGE) + page_content_type_id = get_id(conn, "ContentTypes", PAGE_CONTENT_TYPE) + + content_type_cache = {} + chunked_log = [] + inserted = 0 + seen_names = {} + for out_path in sorted(work_dir.rglob("*")): + if out_path.is_dir(): + continue + name = out_path.name + if name in seen_names: + logger.error( + f"warning: {out_path} has the same filename as {seen_names[name]}; keeping the first, " + "skipping this one" + ) + continue + seen_names[name] = out_path + db_path = f"{IMAGES_DB_PATH_PREFIX}{name}" + if insert_optimized_file(conn, out_path.read_bytes(), name, db_path, language_id, content_type_cache, + chunked_log): + inserted += 1 + if cfg["verbose"]: + logger.info(f"[OK] {out_path} -> {db_path}") + + # A renamed file's old basename no longer appears anywhere under + # work_dir (that's what makes it a rename), so the loop above + # never visits its old db_path to replace it - it'd otherwise + # linger forever as an orphaned, no-longer-referenced row. + removed = 0 + for old_name in rename_map: + old_db_path = f"{IMAGES_DB_PATH_PREFIX}{old_name}" + delete_content(conn, old_db_path) + removed += 1 + if cfg["verbose"]: + logger.info(f"[REMOVED] {old_db_path} (renamed to {IMAGES_DB_PATH_PREFIX}{rename_map[old_name]})") + + changed_pages = rewrite_pages(conn, rename_map, language_id, page_content_type_id, logger, chunked_log) + + unreferenced_removed = delete_unreferenced_media(conn, page_content_type_id, logger) + + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + logger.info("Vacuuming database to reclaim freed space...") + vacuum_conn = sqlite3.connect(cfg["db_path"]) + try: + vacuum_conn.execute("VACUUM") + finally: + vacuum_conn.close() + + logger.info( + f"Done: inserted/updated {inserted} image(s) in {cfg['db_path']}, {removed} stale renamed-away row(s) " + f"removed, {changed_pages} page(s)/nav row(s) updated to match {len(rename_map)} renamed file(s), " + f"{unreferenced_removed} unreferenced image(s) deleted." + ) + if chunked_log: + logger.info(f"Chunked {len(chunked_log)} file(s) over {CHUNK_SIZE:,} bytes:") + for path, total_size, chunk_count in chunked_log: + logger.info(f" {path}: {total_size:,} bytes -> {chunk_count} chunks") + finally: + if log_file_handle is not None: + log_file_handle.close() + if work_dir_is_temp: + shutil.rmtree(work_dir, ignore_errors=True) + + +if __name__ == "__main__": + main() diff --git a/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/md_to_json.py b/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/md_to_json.py new file mode 100644 index 00000000..352ac15f --- /dev/null +++ b/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/md_to_json.py @@ -0,0 +1,619 @@ +#!/usr/bin/env python3 +""" +Converts JetBrains Writerside-flavored Markdown (as used by kotlin-web-site/docs) +into a simple JSON block schema suitable for a templating engine. + +Usage: + python3 md_to_json.py [--topics-subdir topics] + + is the checkout of kotlin-web-site/docs (contains v.list, topics/, ...). +One JSON file is written per input .md file, mirroring its relative path under +. + + is a JSON file with: + {"broken-ext-link-color": "#cc0000", "menu-no-link-color": "#999999"} +"broken-ext-link-color" colors tags in the rendered content that are +either off-site (any http(s)/mailto: link) or a same-tree ".md" reference +that doesn't resolve to a real page. "menu-no-link-color" isn't used here - +it's carried through to /theme.json for build_nav.py (which +builds the sidebar from kr.tree, a separate input this script doesn't read) +to pick up. + +Output schema (one object per page): +{ + "id": "enum-classes", + "sourceFile": "topics/enum-classes.md", + "title": "Enum classes", + "blocks": [ , ... ] +} + +Block shapes: + {"type": "heading", "level": 2, "id": "anonymous-classes", "html": "..."} + {"type": "paragraph", "html": "..."} + {"type": "code", "lang": "kotlin", "code": "...", "attrs": {"kotlin-runnable": "true"}} + {"type": "blockquote", "attrs": {"style": "note"}, "blocks": [...]} + {"type": "list", "ordered": false, "items": [{"blocks": [...]}]} + {"type": "table", "headers": ["a", "b"], "rows": [["1", "2"]]} + {"type": "image", "src": "...", "alt": "..."} + {"type": "hr"} + {"type": "tabs", "attrs": {"group": "build-system"}, + "tabs": [{"title": "Gradle", "attrs": {"group-key": "gradle"}, "blocks": [...]}]} + {"type": "html", "html": ""} + +Cross-page links (`[text](other-page.md#anchor)`) and images (`![alt](foo.png)`) +use Writerside's bare-filename convention - the referenced file is looked up +by name anywhere under / (links) or images/ (images), the same +way kr.tree's topic="..." references are resolved. Rendered HTML rewrites +these to root-relative URLs that only resolve once this JSON has been passed +through templates/page.peb and rendered by RenderDocs: links become +"/.html#anchor", and images become "/images/". +The images/ directory itself is copied to /images/ so the +resolved paths have something to point at. + +Known limitations (fine for a first pass, worth revisiting before production use): + - / grouping and attribute-line merging (the "{...}" line after a + fence/blockquote) only happen at the top level of a page and inside list + items/blockquotes/table cells one level deep; deeply nested tabs-in-tabs + are not handled. + - // admonitions are recognized as block-level tags. The + inline, single-line form seen inside HTML tables (e.g. roadmap.md) is passed + through as raw "html" blocks instead of being unpacked, since those pages + are basically hand-written HTML tables rather than prose. + - elements are passed through as raw "html" blocks; resolving them + to the referenced snippet is not implemented. + - %variables% (defined in v.list) are substituted textually in rendered HTML + and code, using simple %name% -> value replacement. + - A handful of images/ filenames collide across subdirectories (leftover + duplicates in the source tree); resolution keeps the first match in sorted + order and prints a warning rather than guessing which one is "correct". +""" +import argparse +import json +import re +import shutil +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +from markdown_it import MarkdownIt +from markdown_it.token import Token + +TITLE_RE = re.compile(r"^\[//\]:\s*#\s*\(title:\s*(.*?)\)\s*$", re.MULTILINE) +ATTR_LINE_RE = re.compile(r"^\{(.*)\}$") +ATTR_PAIR_RE = re.compile(r'([\w-]+)=(?:"([^"]*)"|(\S+))') +TAG_RE = re.compile(r"^<(/?)(tabs|tab|note|tip|warning)([^>]*)/?>$", re.I) +VAR_RE = re.compile(r"%([\w.-]+)%") +MD_LINK_RE = re.compile(r"^([\w.-]+)\.md(#.*)?$") +EXTERNAL_HREF_RE = re.compile(r"^(?:[a-zA-Z][a-zA-Z0-9+.-]*:)?//|^mailto:", re.I) +LINK_TAG_RE = re.compile(r']*\bhref="([^"]*)"[^>]*>') + +CONTAINER_TAGS = {"tabs", "tab", "note", "tip", "warning"} + + +def build_topic_index(topics_dir: Path) -> dict: + """Bare filename stem (e.g. "enum-classes") -> page id (e.g. "kotlin-tour/enum-classes").""" + index = {} + for md_path in sorted(topics_dir.rglob("*.md")): + page_id = str(md_path.relative_to(topics_dir).with_suffix("")).replace("\\", "/") + index.setdefault(md_path.stem, page_id) + return index + + +def build_image_index(images_dir: Path): + """Bare filename (e.g. "mascot-main.png") -> path relative to images_dir. + + Returns (index, collisions), where collisions is a list of + (filename, [candidate relative paths]) for filenames that exist in more + than one place under images_dir - index keeps the first (sorted) one.""" + index = {} + candidates = {} + if not images_dir.is_dir(): + return index, [] + for img_path in sorted(images_dir.rglob("*")): + if not img_path.is_file(): + continue + rel = img_path.relative_to(images_dir).as_posix() + candidates.setdefault(img_path.name, []).append(rel) + index.setdefault(img_path.name, rel) + collisions = [(name, rels) for name, rels in sorted(candidates.items()) if len(rels) > 1] + for name, rels in collisions: + print(f"warning: ambiguous image filename {name!r}: " + f"using images/{rels[0]}, ignoring {', '.join('images/' + r for r in rels[1:])}", file=sys.stderr) + return index, collisions + + +def load_variables(docs_root: Path) -> dict: + v_list = docs_root / "v.list" + if not v_list.exists(): + return {} + tree = ET.parse(v_list) + return {el.get("name"): el.get("value") for el in tree.getroot().findall("var")} + + +def substitute_vars(text: str, variables: dict) -> str: + if not text: + return text + return VAR_RE.sub(lambda m: variables.get(m.group(1), m.group(0)), text) + + +def parse_attrs(attr_str: str) -> dict: + attrs = {} + for name, quoted, bare in ATTR_PAIR_RE.findall(attr_str or ""): + attrs[name] = quoted if quoted != "" or '="' in (attr_str or "") else bare + return attrs + + +def extract_title(raw_text: str): + m = TITLE_RE.search(raw_text) + if not m: + return None, raw_text + title = m.group(1).strip() + remaining = raw_text[: m.start()] + raw_text[m.end():] + return title, remaining + + +def slugify(text: str) -> str: + slug = re.sub(r"[^\w\s-]", "", text.lower()).strip() + return re.sub(r"[\s_]+", "-", slug) + + +class Node: + """Generic open/close tree built from markdown-it's flat token stream.""" + + __slots__ = ("token", "children") + + def __init__(self, token: Token): + self.token = token + self.children = [] + + +def build_tree(tokens) -> list: + root = [] + stack = [root] + for tok in tokens: + if tok.nesting == 1: + node = Node(tok) + stack[-1].append(node) + stack.append(node.children) + elif tok.nesting == -1: + stack.pop() + else: + stack[-1].append(Node(tok)) + return root + + +class Converter: + def __init__(self, md: MarkdownIt, variables: dict, topic_index: dict = None, image_index: dict = None, + broken_ext_link_color: str = None, image_url_prefix: str = "/images/"): + self.md = md + self.variables = variables + self.topic_index = topic_index or {} + self.image_index = image_index or {} + self.broken_ext_link_color = broken_ext_link_color + # Overridable so a different deployment target (e.g. populate_db.py's + # database-backed site, which serves images from "/k/html/images/" + # rather than a bare "/images/") can retarget every image src without + # a separate rewrite pass - resolve_image_src just uses this prefix + # directly. + self.image_url_prefix = image_url_prefix + self.current_source = None + # Populated as a side effect of resolve_href/resolve_image_src failing + # to resolve a reference; find_missing_assets.py reuses this same + # resolution logic (rather than re-parsing links with regexes) by + # running convert_file over every page and reading this list back. + self.warnings = [] + + def resolve_href(self, href: str): + """"other-page.md#anchor" -> "/.html#anchor", or None to leave href untouched.""" + m = MD_LINK_RE.match(href or "") + if not m: + return None + stem, anchor = m.groups() + page_id = self.topic_index.get(stem) + if page_id is None: + print(f"warning: link to unknown topic {href!r}", file=sys.stderr) + self.warnings.append({"kind": "link", "source": self.current_source, "reference": href}) + return None + return f"/{page_id}.html{anchor or ''}" + + def resolve_image_src(self, src: str): + """"foo.png" -> "", or None to leave src untouched.""" + if not src or "://" in src or src.startswith("/") or "/" in src: + return None + rel = self.image_index.get(src) + if rel is None: + print(f"warning: image not found: {src!r}", file=sys.stderr) + self.warnings.append({"kind": "image", "source": self.current_source, "reference": src}) + return None + return f"{self.image_url_prefix}{rel}" + + def rewrite_urls(self, html: str) -> str: + """Rewrites every href="...md" / src="foo.png" attribute found in a + blob of rendered/raw HTML. Applied to markdown-rendered HTML *and* to + Writerside's raw / passthrough HTML (which markdown-it never + tokenizes as links/images at all, so token-level rewriting alone + would miss it); resolve_href/resolve_image_src already leave anything + that isn't a bare same-tree ".md"/image reference untouched, so this + is safe to run unconditionally on any HTML string.""" + if not html: + return html + + def href_repl(m): + new_href = self.resolve_href(m.group(1)) + return f'href="{new_href}"' if new_href is not None else m.group(0) + + def src_repl(m): + new_src = self.resolve_image_src(m.group(1)) + return f'src="{new_src}"' if new_src is not None else m.group(0) + + html = re.sub(r'href="([^"]*)"', href_repl, html) + html = re.sub(r'src="([^"]*)"', src_repl, html) + return self.style_broken_and_external_links(html) + + def classify_href(self, href: str): + """Classifies an *already rewritten* href: 'external' for off-site + (or mailto:) links, 'broken' for a same-tree ".md" reference that's + still literally "foo.md" because resolve_href couldn't find it, or + None for anything that resolved fine (now "/id.html...") or is a + same-page "#anchor" link.""" + if not href or href.startswith("#"): + return None + if EXTERNAL_HREF_RE.match(href): + return "external" + if MD_LINK_RE.match(href): + return "broken" + return None + + def style_broken_and_external_links(self, html: str) -> str: + """Colors broken and off-site tags with --broken-ext-link-color + (from the config passed on the command line) so readers can tell at a + glance which links leave this site or don't go anywhere at all.""" + if not self.broken_ext_link_color: + return html + + def repl(m): + tag = m.group(0) + if self.classify_href(m.group(1)) is None: + return tag + return tag[:-1] + f' style="color: {self.broken_ext_link_color};">' + + return LINK_TAG_RE.sub(repl, html) + + def fold_image_attrs(self, tokens) -> list: + """Folds a `{...}` attribute-text token immediately following an + image (e.g. `![alt](x.png){width="500"}` - CommonMark has no syntax + for this, so it otherwise tokenizes as a literal "{width=..}" text + run right after the image) into that image's own HTML attributes + instead of leaving it as visible text.""" + result = [] + for tok in tokens: + if tok.type == "text" and result and result[-1].type == "image": + m = ATTR_LINE_RE.match(tok.content.strip()) + if m: + result[-1].attrs.update(parse_attrs(m.group(1))) + continue + if tok.children: + tok.children = self.fold_image_attrs(tok.children) + result.append(tok) + return result + + def render_inline(self, inline_token: Token) -> str: + children = self.fold_image_attrs(inline_token.children) + return self.rewrite_urls(self.md.renderer.render(children, self.md.options, {})) + + def convert_nodes(self, nodes: list) -> list: + blocks = [] + for n in nodes: + result = self.convert_node(n) + if result is None: + continue + if isinstance(result, list): + blocks.extend(result) + else: + blocks.append(result) + blocks = self.merge_attr_lines(blocks) + blocks = self.group_containers(blocks) + return blocks + + def convert_node(self, node: Node): + t = node.token + ttype = t.type + + if ttype == "paragraph_open": + inline = node.children[0].token + # "_raw" carries the unescaped markdown source so merge_attr_lines + # can detect/parse a trailing "{...}" attribute line; it is + # stripped out of the final block before output. + return {"type": "paragraph", "html": self.render_inline(inline), "_raw": inline.content} + + if ttype == "heading_open": + inline = node.children[0].token + level = int(t.tag[1:]) + text = inline.content + return { + "type": "heading", + "level": level, + "id": slugify(text), + "html": self.render_inline(inline), + } + + if ttype == "fence": + return { + "type": "code", + "lang": (t.info or "").strip() or None, + "code": t.content, + "attrs": {}, + } + + if ttype == "blockquote_open": + return { + "type": "blockquote", + "attrs": {}, + "blocks": self.convert_nodes(node.children), + } + + if ttype in ("bullet_list_open", "ordered_list_open"): + items = [] + for item_node in node.children: + if item_node.token.type == "list_item_open": + items.append({"blocks": self.convert_nodes(item_node.children)}) + return {"type": "list", "ordered": ttype == "ordered_list_open", "items": items} + + if ttype == "table_open": + headers = [] + rows = [] + for section in node.children: + if section.token.type == "thead_open": + for tr in section.children: + row = [self.render_inline(td.children[0].token) for td in tr.children] + headers = row + elif section.token.type == "tbody_open": + for tr in section.children: + row = [self.render_inline(td.children[0].token) for td in tr.children] + rows.append(row) + return {"type": "table", "headers": headers, "rows": rows} + + if ttype == "hr": + return {"type": "hr"} + + if ttype == "html_block": + # CommonMark merges consecutive non-blank-line-separated HTML + # lines into a single html_block token, so a lone and + # the that immediately follows it commonly land in the + # same token. Split back into lines so each tag can be matched + # (and grouped into a container) independently. + result = [] + raw_run = [] + + def flush_raw(): + if raw_run: + result.append({"type": "html", "html": self.rewrite_urls("\n".join(raw_run))}) + raw_run.clear() + + for line in t.content.splitlines(): + m = TAG_RE.match(line.strip()) + if m: + flush_raw() + closing, tag, attrstr = m.groups() + result.append({ + "type": "tag_marker", + "closing": bool(closing), + "tag": tag.lower(), + "attrs": parse_attrs(attrstr), + }) + elif line.strip(): + raw_run.append(line) + flush_raw() + return result + + if ttype == "inline": + # top-level bare inline content (e.g. an attribute line with no + # surrounding paragraph); treat like a paragraph. + return {"type": "paragraph", "html": self.render_inline(t), "_raw": t.content} + + # Fallback: anything not explicitly handled (images are inline-only, + # so plain "image" blocks don't occur at block level; captured via + # paragraph HTML instead). + return {"type": "html", "html": self.rewrite_urls(str(t.content or ""))} + + @staticmethod + def merge_attr_lines(blocks: list) -> list: + """Fold a standalone `{key="value"}` paragraph into the preceding block's attrs.""" + merged = [] + for b in blocks: + if b["type"] == "paragraph": + raw = b.pop("_raw", "").strip() + m = ATTR_LINE_RE.match(raw) + if m and merged: + merged[-1].setdefault("attrs", {}).update(parse_attrs(m.group(1))) + continue + merged.append(b) + return merged + + @staticmethod + def group_containers(blocks: list) -> list: + """Turn //// tag_marker pairs into nested blocks.""" + root: dict = {"blocks": []} + stack = [root] + for b in blocks: + if b["type"] == "tag_marker": + if not b["closing"]: + node = {"type": b["tag"], "attrs": b["attrs"], "blocks": []} + stack[-1]["blocks"].append(node) + stack.append(node) + elif len(stack) > 1 and stack[-1]["type"] == b["tag"]: + stack.pop() + continue + stack[-1]["blocks"].append(b) + + return Converter._finalize_list(root["blocks"]) + + @staticmethod + def _finalize_list(blocks: list) -> list: + """Maps _finalize_container over a list, splicing in any block it + unwraps back into a plain list instead of a "tabs" block (see below) + in place, rather than nesting a list-within-a-list.""" + result = [] + for b in blocks: + out = Converter._finalize_container(b) + if isinstance(out, list): + result.extend(out) + else: + result.append(out) + return result + + @staticmethod + def _finalize_container(b: dict): + if b.get("type") == "tabs" and "blocks" in b: + children = Converter._finalize_list(b["blocks"]) + tab_children = [c for c in children if c.get("type") == "tab"] + if tab_children: + # Well-formed ....... + return { + "type": "tabs", + "attrs": b["attrs"], + "tabs": [ + {"title": c["attrs"].get("title"), "attrs": c["attrs"], "blocks": c["blocks"]} + for c in tab_children + ], + } + if len(children) >= 2 and all(c.get("type") == "code" for c in children): + # Bare wrapping only fenced code blocks with no + # tags at all (seen in some compatibility guide pages, e.g. + # a Kotlin and a Groovy fence back to back) - Writerside + # authors apparently rely on adjacency here instead of + # writing explicitly, so treat each code block's own + # language as its tab instead of rendering a tabs shell with + # no tabs in it (which silently dropped every one of these + # code blocks - see the "type": "tabs" but no "tabs" key + # schema mismatch this used to produce). + return { + "type": "tabs", + "attrs": b["attrs"], + "tabs": [ + { + "title": (c["lang"] or "").capitalize() or f"Tab {i + 1}", + "attrs": {"group-key": (c["lang"] or "").lower() or str(i + 1)}, + "blocks": [c], + } + for i, c in enumerate(children) + ], + } + # Bare wrapper with no children and no recognizable + # tab structure to synthesize - there's nothing tab-like left to + # preserve, so drop the wrapper and splice its content in place + # instead of emitting a "tabs" block with no "tabs" key (which + # templates/page.peb can't render - it silently produces an + # empty
    and drops the content entirely). + return children + if "blocks" in b: + b["blocks"] = Converter._finalize_list(b["blocks"]) + return b + + def convert_file(self, path: Path, page_id: str, source_rel: str) -> dict: + self.current_source = source_rel + raw = path.read_text(encoding="utf-8") + # Substitute %variables% in the raw source, before markdown-it ever + # sees it. Doing this post-render instead would (a) miss the title, + # which is extracted straight from the raw source, and (b) run into + # markdown-it percent-encoding "%" inside link URLs, which turns + # "%kotlinEapVersion%" into "%25kotlinEapVersion%25" before we'd get + # a chance to match it. + raw = substitute_vars(raw, self.variables) + title, body = extract_title(raw) + tokens = self.md.parse(body) + tree = build_tree(tokens) + blocks = self.convert_nodes(tree) + return { + "id": page_id, + "sourceFile": source_rel, + "title": title, + "blocks": blocks, + } + + +def make_markdown_it() -> MarkdownIt: + md = MarkdownIt("commonmark") + md.enable("table") + return md + + +CONFIG_KEYS = ("broken-ext-link-color", "menu-no-link-color") + + +def load_config(config_path: Path) -> dict: + """Loads the {"broken-ext-link-color": ..., "menu-no-link-color": ...} + theming config. Missing keys just disable that particular styling (a + warning is printed) rather than being a hard error, since neither is + required for the JSON conversion itself to be correct.""" + config = json.loads(config_path.read_text(encoding="utf-8")) + for key in CONFIG_KEYS: + if key not in config: + print(f"warning: config {config_path} is missing {key!r}; that styling will be skipped", file=sys.stderr) + return config + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("docs_root", type=Path, help="Path to kotlin-web-site/docs") + parser.add_argument("output_dir", type=Path, help="Directory to write JSON files into") + parser.add_argument("config", type=Path, + help='Path to a JSON config with "broken-ext-link-color" and "menu-no-link-color"') + parser.add_argument("--topics-subdir", default="topics", help="Subdirectory of docs_root holding .md files") + parser.add_argument("--images-subdir", default="images", help="Subdirectory of docs_root holding image files") + args = parser.parse_args() + + docs_root: Path = args.docs_root + topics_dir = docs_root / args.topics_subdir + images_dir = docs_root / args.images_subdir + if not topics_dir.is_dir(): + print(f"error: {topics_dir} is not a directory", file=sys.stderr) + sys.exit(1) + if not args.config.is_file(): + print(f"error: {args.config} does not exist", file=sys.stderr) + sys.exit(1) + + config = load_config(args.config) + variables = load_variables(docs_root) + topic_index = build_topic_index(topics_dir) + image_index, _image_collisions = build_image_index(images_dir) + md = make_markdown_it() + converter = Converter(md, variables, topic_index, image_index, + broken_ext_link_color=config.get("broken-ext-link-color")) + + md_files = sorted(topics_dir.rglob("*.md")) + args.output_dir.mkdir(parents=True, exist_ok=True) + + # menu-no-link-color applies to the sidebar, which build_nav.py builds + # separately from nav.json/kr.tree - it reads this back from the output + # directory it's already given, rather than needing its own copy of the + # config on its own command line. + (args.output_dir / "theme.json").write_text( + json.dumps({key: config.get(key) for key in CONFIG_KEYS}, indent=2), encoding="utf-8" + ) + + if images_dir.is_dir(): + shutil.copytree(images_dir, args.output_dir / "images", dirs_exist_ok=True) + else: + print(f"warning: {images_dir} not found; image references will 404", file=sys.stderr) + + count = 0 + for md_path in md_files: + rel = md_path.relative_to(topics_dir) + page_id = str(rel.with_suffix("")) + source_rel = str(Path(args.topics_subdir) / rel) + try: + page = converter.convert_file(md_path, page_id, source_rel) + except Exception as exc: # noqa: BLE001 - surface which file broke + print(f"error converting {md_path}: {exc}", file=sys.stderr) + continue + out_path = args.output_dir / args.topics_subdir / rel.with_suffix(".json") + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(page, separators=(",", ":"), ensure_ascii=False), encoding="utf-8") + count += 1 + + print(f"Converted {count}/{len(md_files)} files into {args.output_dir}") + + +if __name__ == "__main__": + main() diff --git a/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/optimize_media.py b/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/optimize_media.py new file mode 100644 index 00000000..df1a538f --- /dev/null +++ b/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/optimize_media.py @@ -0,0 +1,570 @@ +#!/usr/bin/env python3 +""" +optimize_media.py + +Recursively mirrors an input directory into an output directory, optimizing +image files along the way and copying everything else unchanged. + + - Raster images (png, jpg/jpeg, gif, bmp, tif/tiff, webp) are downscaled to + a maximum width (default 500px, preserving aspect ratio, never upscaled), + then optimized: PNGs through pngquant (at a configurable --pngquant-speed + trade-off - see https://pngquant.org/), other formats through Pillow's + own encoder (quality/optimize flags). With --webp, the resized image is + saved as WEBP instead of its original format. Animated GIFs get every + frame resized the same way (preserving frame count/duration/loop count) + rather than being copied through unchanged; --webp is not applied to + them, since animated WEBP re-encoding isn't implemented here. Any other + animated format (e.g. an animated WEBP given as input) is still copied + through unchanged, since per-frame resizing isn't implemented for it. + - SVGs (.svg) are aggressively optimized with the Scour library: metadata, + comments and editor cruft stripped, ids shortened, whitespace collapsed, + and every number rounded to --svg-precision decimal places. If the + optimized SVG still exceeds --svg-rasterize-threshold bytes, it's + rasterized (via cairosvg) and run through the same raster pipeline above + instead of being kept as a vector. If rasterizing fails for any reason + (e.g. cairosvg isn't installed), that's logged as a warning and the + optimized (still oversized) SVG is written instead - every input file + always ends up with something at its mirrored output path. + - Every other file is copied through unchanged. + +Usage: + python3 optimize_media.py [options] + python3 optimize_media.py --config myjob.config + +All options may instead be set in a .config file (one `key = value` per +line, '#' for comments) passed via --config; see OPTION_SPECS below for the +recognized keys, which are the same as the long-form CLI flags. Values +explicitly given on the command line always take precedence over the config +file. + +Every media file processed logs a line with its original and optimized +locations, regardless of --verbose (which adds byte sizes to that line and +prints all resolved config parameters up front). --log-file redirects all +log output (those per-file lines, warnings, and errors) to that file +instead of stdout/stderr. + +Requires the "pngquant" binary on PATH, the Pillow and scour Python packages +(pip install Pillow scour), and - only if any SVG actually needs rasterizing +- the cairosvg package (pip install cairosvg). +""" +import argparse +import io +import shutil +import subprocess +import sys +from pathlib import Path + +from PIL import Image + +try: + RESAMPLE = Image.Resampling.LANCZOS +except AttributeError: # Pillow < 9.1 + RESAMPLE = Image.LANCZOS + +RASTER_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tif", ".tiff", ".webp"} +SVG_EXTENSION = ".svg" + + +class Logger: + """Routes both info-level and error-level messages to a single + destination: the --log-file path if one was given (so a redirected run + still has one coherent, chronological log to inspect afterwards), or + stdout/stderr otherwise (so a normal terminal run keeps its usual + split - errors are still visible even if stdout is piped elsewhere).""" + + def __init__(self, file_handle): + self._fh = file_handle + + def info(self, msg: str) -> None: + print(msg, file=self._fh if self._fh is not None else sys.stdout, flush=self._fh is not None) + + def error(self, msg: str) -> None: + print(msg, file=self._fh if self._fh is not None else sys.stderr, flush=self._fh is not None) + + +def parse_bool(value: str) -> bool: + v = value.strip().lower() + if v in ("1", "true", "yes", "on", "y", "t"): + return True + if v in ("0", "false", "no", "off", "n", "f"): + return False + raise ValueError(f"not a boolean: {value!r}") + + +# Maps a config-file key (and, with dashes, a --long-form CLI flag) to the +# argparse dest it corresponds to and the converter used to parse its value +# out of the config file's plain-text "key = value" form. Order here is also +# the order config parameters get listed in when --verbose is on. +OPTION_SPECS = { + "input-dir": ("input_dir", Path), + "output-dir": ("output_dir", Path), + "max-width": ("max_width", int), + "jpeg-quality": ("jpeg_quality", int), + "webp": ("webp", parse_bool), + "webp-quality": ("webp_quality", int), + "pngquant-speed": ("pngquant_speed", int), + "svg-precision": ("svg_precision", int), + "svg-rasterize-threshold": ("svg_rasterize_threshold", int), + "verbose": ("verbose", parse_bool), + "log-file": ("log_file", Path), +} + +# Used for any option left unset by both the CLI and (if given) --config. +BUILTIN_DEFAULTS = { + "max_width": 500, + "jpeg_quality": 82, + "webp": False, + "webp_quality": 80, + "pngquant_speed": 4, # pngquant's own default; 1 = slow/best, 11 = fast/rough + "svg_precision": 4, + "svg_rasterize_threshold": 300 * 1024, # 300KB + "verbose": False, +} + + +def load_config_file(path: Path, option_specs: dict = OPTION_SPECS) -> dict: + """Parses a simple `key = value` (or `key: value`) config file, one + option per line; blank lines and lines starting with '#' or ';' are + ignored. A bare key with no value means true (for boolean options). + Keys match `option_specs` (case-insensitive, dashes or underscores) - + defaulting to this module's own OPTION_SPECS, but overridable so a + caller layering its own options on top (e.g. insert_optimized_media.py + adding "db-path") can reuse this same parser for its extended key set. + Returns {dest: converted_value}.""" + if not path.is_file(): + raise RuntimeError(f"config file not found: {path}") + + overrides = {} + for lineno, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#") or line.startswith(";"): + continue + if "=" in line: + key, _, value = line.partition("=") + elif ":" in line: + key, _, value = line.partition(":") + else: + key, value = line, "true" + key = key.strip().lower().replace("_", "-") + value = value.strip() + + spec = option_specs.get(key) + if spec is None: + raise RuntimeError(f"{path}:{lineno}: unknown config option {key!r}") + dest, converter = spec + try: + overrides[dest] = converter(value) + except ValueError as exc: + raise RuntimeError(f"{path}:{lineno}: invalid value for {key!r}: {exc}") from exc + return overrides + + +def find_pngquant() -> str: + path = shutil.which("pngquant") + if path is None: + raise RuntimeError("pngquant not found on PATH; install it (e.g. `apt install pngquant`) and retry") + return path + + +def quantize_png_bytes(data: bytes, pngquant_path: str, speed: int, name: str, logger: Logger) -> bytes: + """Runs pngquant on raw PNG bytes (stdin -> stdout, no temp files) at the + given --speed trade-off (1 = slow/best quality, 11 = fast/rough - see + https://pngquant.org/), returning the compressed bytes. Falls back to + the original bytes if pngquant declines (e.g. exit 99: result would fall + below --quality's floor) or otherwise fails - a slightly larger PNG + beats a missing one.""" + result = subprocess.run( + [pngquant_path, "--quality", "65-95", "--speed", str(speed), "--strip", "--force", "--output", "-", "-"], + input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, + ) + if result.returncode != 0 or not result.stdout: + logger.error( + f"warning: pngquant declined to compress {name!r} " + f"(exit {result.returncode}: {result.stderr.decode(errors='replace').strip()}); keeping original" + ) + return data + return result.stdout + + +def resize_if_needed(img: Image.Image, max_width: int) -> Image.Image: + if img.width <= max_width: + return img + new_height = max(1, round(img.height * (max_width / img.width))) + return img.resize((max_width, new_height), RESAMPLE) + + +def normalize_mode(img: Image.Image) -> Image.Image: + """Flattens palette/CMYK modes to something every downstream encoder + (JPEG, WEBP, PNG) can handle directly, preserving alpha where present.""" + if img.mode == "P": + return img.convert("RGBA") if img.info.get("transparency") is not None else img.convert("RGB") + if img.mode == "CMYK": + return img.convert("RGB") + return img + + +def encode_raster(img: Image.Image, dst: Path, *, suffix: str, max_width: int, jpeg_quality: int, webp: bool, + webp_quality: int, pngquant_path: str, pngquant_speed: int, logger: Logger) -> Path: + """Resizes an already-loaded image and writes it under dst (whose suffix + may be swapped to .webp), choosing the encoder by `suffix` (the source + file's extension, or ".png" for a freshly rasterized SVG). Returns the + path actually written. Shared by optimize_raster and optimize_svg's + rasterize-on-oversize fallback so both go through identical resize/ + encode logic.""" + img = normalize_mode(img) + + if suffix == ".png" and not webp: + # pngquant runs against the full-resolution pixels here, before any + # downscaling - its palette selection and dithering have the whole + # original image's color detail to work from, rather than the + # coarser, already-blended pixels a resize would leave it with. The + # quantized result is then decoded back and resized down below, same + # as any other image. + buf = io.BytesIO() + img.save(buf, "PNG", optimize=True) + quantized = quantize_png_bytes(buf.getvalue(), pngquant_path, pngquant_speed, str(dst), logger) + img = Image.open(io.BytesIO(quantized)) + img.load() + img = normalize_mode(img) # pngquant's output PNG is palette ("P") mode + + img = resize_if_needed(img, max_width) + + if webp: + dst = dst.with_suffix(".webp") + img.save(dst, "WEBP", quality=webp_quality, method=6) + return dst + + if suffix == ".png": + # Resizing the first quantize pass's palette image back down blended + # it back into full RGB(A) - re-quantize at the final size to + # restore a compact palette PNG, now informed by both the full- + # resolution pass above and the actual delivered dimensions. + buf = io.BytesIO() + img.save(buf, "PNG", optimize=True) + dst.write_bytes(quantize_png_bytes(buf.getvalue(), pngquant_path, pngquant_speed, str(dst), logger)) + elif suffix in (".jpg", ".jpeg"): + if img.mode != "RGB": + img = img.convert("RGB") + img.save(dst, "JPEG", quality=jpeg_quality, optimize=True, progressive=True) + else: + img.save(dst, optimize=True) + return dst + + +def resize_animated_gif(img: Image.Image, dst: Path, max_width: int) -> Path: + """Resizes every frame of an animated GIF down to max_width, preserving + frame count, each frame's own duration, and the loop count - a naive + single-frame resize (or the old copy-through-unchanged behavior) would + otherwise silently drop the animation entirely. seek()+convert("RGBA") + composites each frame the way Pillow's GIF plugin normally displays it + (accounting for the previous frame's disposal method), so every frame + saved below is a complete, standalone image rather than a partial + update relying on its predecessor - hence disposal=2 (restore to + background) on save, rather than trying to preserve each original + frame's own disposal method. --webp is intentionally not honored here: + animated WEBP re-encoding is a separate feature this doesn't attempt.""" + n_frames = getattr(img, "n_frames", 1) + loop = img.info.get("loop", 0) + frames = [] + durations = [] + for i in range(n_frames): + img.seek(i) + frames.append(resize_if_needed(img.convert("RGBA"), max_width)) + durations.append(img.info.get("duration", 100)) + frames[0].save( + dst, save_all=True, append_images=frames[1:], duration=durations, loop=loop, disposal=2, optimize=True, + ) + return dst + + +def optimize_raster(src: Path, dst: Path, **encode_kwargs) -> Path: + with Image.open(src) as img: + if getattr(img, "is_animated", False): + if src.suffix.lower() == ".gif": + return resize_animated_gif(img, dst, encode_kwargs["max_width"]) + # Animated non-GIF (e.g. webp): per-frame resizing/re-encoding is + # out of scope here - copy through unchanged rather than + # flattening it to a single frame and silently breaking the + # animation. + shutil.copy2(src, dst) + return dst + return encode_raster(img, dst, suffix=src.suffix.lower(), **encode_kwargs) + + +def rasterize_svg(svg_text: str, max_width: int) -> Image.Image: + """Renders SVG markup to a raster image at exactly `max_width` pixels + wide (cairosvg computes the proportional height from the SVG's own + viewBox/aspect ratio), for SVGs too large to keep as vector output.""" + try: + import cairosvg + except ImportError as exc: + raise RuntimeError( + "cairosvg is required to rasterize oversized SVGs; install it with `pip install cairosvg`" + ) from exc + png_bytes = cairosvg.svg2png(bytestring=svg_text.encode("utf-8"), output_width=max_width) + img = Image.open(io.BytesIO(png_bytes)) + img.load() + return img + + +def optimize_svg(src: Path, dst: Path, *, precision: int, rasterize_threshold: int, max_width: int, + jpeg_quality: int, webp: bool, webp_quality: int, pngquant_path: str, pngquant_speed: int, + logger: Logger) -> tuple: + """Optimizes one SVG with Scour, rounding numbers to `precision` decimal + places. If the optimized markup is still over `rasterize_threshold` + bytes, rasterizes it and runs it through the raster pipeline instead of + writing it as a (still large) vector. Returns (final_path, was_rasterized).""" + from scour import scour + + options = scour.generateDefaultOptions() + # Aggressive settings, roughly equivalent to: + # scour --enable-viewboxing --enable-id-stripping --enable-comment-stripping + # --shorten-ids --indent=none --strip-xml-prolog --set-precision= + options.remove_metadata = True + options.remove_descriptive_elements = True + options.remove_titles = True + options.remove_descriptions = True + options.strip_comments = True + options.strip_ids = True + options.shorten_ids = True + options.keep_editor_data = False + options.strip_xml_prolog = True + options.enable_viewboxing = True + options.simple_colors = True + options.style_to_xml = True + options.group_collapse = True + options.group_create = True + options.indent_type = "none" + options.newlines = False + options.digits = precision + + in_string = src.read_text(encoding="utf-8") + out_string = scour.scourString(in_string, options) + out_bytes = out_string.encode("utf-8") + + if len(out_bytes) > rasterize_threshold: + try: + img = rasterize_svg(out_string, max_width) + final = encode_raster( + img, dst.with_suffix(".png"), suffix=".png", max_width=max_width, jpeg_quality=jpeg_quality, + webp=webp, webp_quality=webp_quality, pngquant_path=pngquant_path, pngquant_speed=pngquant_speed, + logger=logger, + ) + logger.info( + f"note: rasterized {src} -> {final} (optimized SVG was {len(out_bytes):,} bytes, " + f"over the {rasterize_threshold:,} byte threshold)" + ) + return final, True + except Exception as exc: # noqa: BLE001 - fall through to writing the vector below instead + logger.error(f"warning: failed to rasterize {src} ({exc}); keeping optimized SVG instead") + + dst.write_bytes(out_bytes) + return dst, False + + +def process_file(src: Path, dst: Path, *, cfg: dict, pngquant_path: str, stats: dict, logger: Logger) -> Path: + """Optimizes (or copies through) one file. Returns the path actually + written on success (which may differ from `dst` - webp conversion or + SVG rasterization changes the extension), or None on error (already + logged; `stats["errors"]` is incremented so callers can tell without + inspecting the return value).""" + dst.parent.mkdir(parents=True, exist_ok=True) + suffix = src.suffix.lower() + original_size = src.stat().st_size + + try: + if suffix == SVG_EXTENSION: + dst_final, rasterized = optimize_svg( + src, dst, precision=cfg["svg_precision"], rasterize_threshold=cfg["svg_rasterize_threshold"], + max_width=cfg["max_width"], jpeg_quality=cfg["jpeg_quality"], webp=cfg["webp"], + webp_quality=cfg["webp_quality"], pngquant_path=pngquant_path, pngquant_speed=cfg["pngquant_speed"], + logger=logger, + ) + if rasterized: + stats["svg_rasterized"] += 1 + kind = "svg, rasterized" + else: + stats["svg"] += 1 + kind = "svg" + elif suffix in RASTER_EXTENSIONS: + dst_final = optimize_raster( + src, dst, max_width=cfg["max_width"], jpeg_quality=cfg["jpeg_quality"], webp=cfg["webp"], + webp_quality=cfg["webp_quality"], pngquant_path=pngquant_path, pngquant_speed=cfg["pngquant_speed"], + logger=logger, + ) + stats["raster"] += 1 + kind = "raster" + else: + shutil.copy2(src, dst) + dst_final = dst + stats["copied"] += 1 + kind = "copied" + except Exception as exc: # noqa: BLE001 - keep processing the rest of the tree + stats["errors"] += 1 + logger.error(f"error: failed to process {src}: {exc}") + return None + + optimized_size = dst_final.stat().st_size + stats["original_bytes"] += original_size + stats["optimized_bytes"] += optimized_size + if kind != "copied": + # Always shown (not just under --verbose) - a per-file record of + # where the optimized copy of each media file actually landed, + # since that's not otherwise derivable once optimization has + # renamed a file (webp conversion, SVG rasterization). + message = f"Optimized {src} -> {dst_final}" + if cfg["verbose"]: + saved = original_size - optimized_size + pct = (saved / original_size * 100) if original_size else 0.0 + message = ( + f"[OK][{kind}] {src} -> {dst_final}: {original_size:,} -> {optimized_size:,} bytes " + f"(saved {saved:,} bytes, {pct:.1f}%)" + ) + logger.info(message) + return dst_final + + +def optimize_directory(input_dir: Path, output_dir: Path, *, cfg: dict, pngquant_path: str, logger: Logger, + stats: dict) -> dict: + """Walks input_dir recursively, optimizing every file into the mirrored + location under output_dir (see process_file). Returns + {relative_src_path: relative_dst_path} for every file whose output path + ended up different from its input path (webp conversion, or an SVG + rasterized to PNG/WEBP) - callers that also maintain references to these + files elsewhere (e.g. insert_optimized_media.py, fixing up image URLs + stored in a database) use this to know what changed.""" + renamed = {} + for src in sorted(input_dir.rglob("*")): + if src.is_dir(): + continue + rel = src.relative_to(input_dir) + dst = output_dir / rel + dst_final = process_file(src, dst, cfg=cfg, pngquant_path=pngquant_path, stats=stats, logger=logger) + if dst_final is None: + continue + rel_final = dst_final.relative_to(output_dir) + if rel_final != rel: + renamed[str(rel)] = str(rel_final) + return renamed + + +def add_optimize_arguments(parser: argparse.ArgumentParser) -> None: + """Adds every --tuning-flag (everything except the input/output + positionals) to `parser` - split out from build_parser() so a caller + with its own positional arguments (e.g. insert_optimized_media.py, which + also needs a database path) can still get these for free instead of + redeclaring them.""" + parser.add_argument("--config", type=Path, default=None, + help="Path to a .config file providing any of the options below") + parser.add_argument("--max-width", type=int, default=None, + help=f"Max width in pixels for raster images (default: {BUILTIN_DEFAULTS['max_width']})") + parser.add_argument("--jpeg-quality", type=int, default=None, + help=f"JPEG output quality, 0-95 (default: {BUILTIN_DEFAULTS['jpeg_quality']})") + parser.add_argument("--webp", action="store_true", default=None, + help="Convert optimized raster images (and rasterized SVGs) to WEBP") + parser.add_argument("--webp-quality", type=int, default=None, + help=f"WEBP output quality, 0-100 (default: {BUILTIN_DEFAULTS['webp_quality']})") + parser.add_argument("--pngquant-speed", type=int, default=None, + help="pngquant speed/quality trade-off, 1 (slow/best) - 11 (fast/rough); " + f"see https://pngquant.org/ (default: {BUILTIN_DEFAULTS['pngquant_speed']})") + parser.add_argument("--svg-precision", type=int, default=None, + help="Decimal places to round SVG numbers to via Scour " + f"(default: {BUILTIN_DEFAULTS['svg_precision']})") + parser.add_argument("--svg-rasterize-threshold", type=int, default=None, + help="Rasterize an optimized SVG if it's still over this many bytes " + f"(default: {BUILTIN_DEFAULTS['svg_rasterize_threshold']:,})") + parser.add_argument("--verbose", action="store_true", default=None, + help="Add original/optimized byte sizes to the per-file log line every media file " + "already gets, plus all resolved config parameters up front") + parser.add_argument("--log-file", type=Path, default=None, + help="Write all log output here instead of stdout/stderr") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("input_dir", type=Path, nargs="?", default=None, + help="Directory to read files from, recursively (or set input-dir in --config)") + parser.add_argument("output_dir", type=Path, nargs="?", default=None, + help="Directory to mirror optimized output into (or set output-dir in --config)") + add_optimize_arguments(parser) + return parser + + +def resolve_config(args: argparse.Namespace, option_specs: dict = OPTION_SPECS, + builtin_defaults: dict = BUILTIN_DEFAULTS) -> dict: + """Merges CLI args over --config file values over builtin_defaults (in + that precedence order) into one dict keyed by dest name. option_specs/ + builtin_defaults default to this module's own, but are overridable for a + caller (e.g. insert_optimized_media.py) extending them with its own + extra options (like "db-path").""" + file_overrides = load_config_file(args.config, option_specs) if args.config else {} + + cfg = {} + for dest, _converter in option_specs.values(): + cli_value = getattr(args, dest, None) + if cli_value is not None: + cfg[dest] = cli_value + elif dest in file_overrides: + cfg[dest] = file_overrides[dest] + elif dest in builtin_defaults: + cfg[dest] = builtin_defaults[dest] + else: + cfg[dest] = None # input_dir/output_dir: no built-in default + return cfg + + +def main() -> None: + parser = build_parser() + args = parser.parse_args() + + try: + cfg = resolve_config(args) + except RuntimeError as exc: + parser.error(str(exc)) + return + + if cfg["input_dir"] is None or cfg["output_dir"] is None: + parser.error("input_dir and output_dir must be given either as positional arguments or in --config") + + log_file_handle = open(cfg["log_file"], "w", encoding="utf-8") if cfg["log_file"] else None + logger = Logger(log_file_handle) + try: + if not cfg["input_dir"].is_dir(): + logger.error(f"error: {cfg['input_dir']} is not a directory") + sys.exit(1) + + if cfg["verbose"]: + logger.info("Config parameters:") + for key, (dest, _converter) in OPTION_SPECS.items(): + logger.info(f" {key} = {cfg[dest]}") + if args.config: + logger.info(f" (loaded from {args.config})") + + try: + pngquant_path = find_pngquant() + except RuntimeError as exc: + logger.error(f"error: {exc}") + sys.exit(1) + + stats = {"raster": 0, "svg": 0, "svg_rasterized": 0, "copied": 0, "errors": 0, "original_bytes": 0, + "optimized_bytes": 0} + optimize_directory(cfg["input_dir"], cfg["output_dir"], cfg=cfg, pngquant_path=pngquant_path, logger=logger, + stats=stats) + + saved = stats["original_bytes"] - stats["optimized_bytes"] + pct = (saved / stats["original_bytes"] * 100) if stats["original_bytes"] else 0.0 + logger.info( + f"Done: {stats['raster']} raster image(s) optimized, {stats['svg']} SVG(s) optimized, " + f"{stats['svg_rasterized']} SVG(s) rasterized, {stats['copied']} other file(s) copied, " + f"{stats['errors']} error(s). Total size: {stats['original_bytes']:,} -> {stats['optimized_bytes']:,} " + f"bytes (saved {saved:,} bytes, {pct:.1f}%)." + ) + if stats["errors"]: + sys.exit(1) + finally: + if log_file_handle is not None: + log_file_handle.close() + + +if __name__ == "__main__": + main() diff --git a/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/populate_db.py b/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/populate_db.py new file mode 100644 index 00000000..c338b9dc --- /dev/null +++ b/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/populate_db.py @@ -0,0 +1,616 @@ +#!/usr/bin/env python3 +""" +Populates a documentation.db-schema SQLite database with the same content +templates/page.peb and this project's md_to_json.py conversion pipeline +produce for the static site, replacing what's currently at k/html/*. + +Usage: + python3 populate_db.py [db-path] + [--tree-file kr.tree] [--topics-subdir topics] + [--blacklisted-element-titles "Ancestor\\/.../Element Title" ...] + +--blacklisted-element-titles names element(s) +to drop from kr.tree entirely before anything else below reads it: the +element and its whole subtree get no nav entry, none of their .md sub-topics +get converted or inserted, and any *other*, non-blacklisted page's in-content +link to one of those .md files renders broken/styled (same as any other +unresolved link - see broken-ext-link-color) rather than pointing somewhere +that no longer exists. + +Each value is the *full* toc-title path from a top-level down +to the one being blacklisted, since toc-title alone is not unique across +kr.tree (e.g. plenty of "Overview"s). Levels are joined by the two-character +sequence "\\/" (backslash then slash) rather than a bare "/", because a bare +"/" routinely appears *within* a single real toc-title (e.g. "Swift/ +Objective-C and C interop") and this way that overwhelmingly common case +needs no escaping at all - only the rare level separator does. So to +blacklist the "Swift/Objective-C and C interop" element nested under the +top-level "Interoperability" element, pass +"Interoperability\\/Swift/Objective-C and C interop": split on "\\/" that's +["Interoperability", "Swift/Objective-C and C interop"], matching kr.tree's +actual nesting - the inner "/" is left untouched since it wasn't preceded by +a backslash. + + defaults to "documentation.db". A safety backup (via SQLite's +"VACUUM INTO", which is safe even against a live/WAL-mode database) is +written next to it before any changes: ".backup-". + + is Writerside's own official image output for this doc set +(e.g. "webHelpImages.zip", found next to kr.tree) - a flat archive with no +subdirectories, one entry per image, already exactly as Writerside itself +would serve them. Rather than re-deriving image content/sizing ourselves +from the raw source tree (which is a plain, uncompressed truecolor export - +several times larger than what a real Writerside build actually ships, +since it applies its own image optimization we have no easy way to +replicate faithfully), this script just copies that zip's entries in +directly, so k/html/images/ ends up byte-for-byte what Writerside +itself produces. + +What this does, inside a single transaction (rolled back on any error): + 1. Deletes every Content row with path LIKE 'k/html/%' or 'assets/%' - the + former includes the existing *.html doc pages AND everything else + parked there (images, the old Writerside JS bundle under + k/html/frontend/, none of which this script replaces); the latter is + wherever a previous run of this script put images/CSS/JS, all of + which get freshly re-inserted below. + 2. Upserts templates/page.peb and templates/nav.peb into Templates. + page.peb's