diff --git a/Dokka-plugin-kdoc2json/README.md b/Dokka-plugin-kdoc2json/README.md index 2915145e..f45a8ce0 100644 --- a/Dokka-plugin-kdoc2json/README.md +++ b/Dokka-plugin-kdoc2json/README.md @@ -1 +1,198 @@ -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 +cd kdoc-to-json +./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("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. + +```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, "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 + }""" +} + +dokka { + pluginsConfiguration { + registerBinding(JsonOutputPluginParameters::class, JsonOutputPluginParameters::class) + register("org.appdevforall.dokka.kdoc2json.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. | +| `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 + +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 %} +``` + +## 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/TODO.md b/Dokka-plugin-kdoc2json/TODO.md new file mode 100644 index 00000000..1f3edac1 --- /dev/null +++ b/Dokka-plugin-kdoc2json/TODO.md @@ -0,0 +1,10 @@ +TODO @Alex + +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) 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..477b03a2 --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/example-data-processor/build.gradle.kts @@ -0,0 +1,41 @@ +import org.jetbrains.dokka.InternalDokkaApi +import org.jetbrains.dokka.gradle.engine.plugins.DokkaPluginParametersBaseSpec +import javax.inject.Inject + +plugins { + kotlin("jvm") version "1.9.23" + // 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 kdoc-to-json plugin + mavenLocal() + mavenCentral() +} + +dependencies { + // Inject your custom Dokka plugin into the documentation pipeline + 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 00000000..1b33c55b Binary files /dev/null and b/Dokka-plugin-kdoc2json/examples/example-data-processor/gradle/wrapper/gradle-wrapper.jar differ 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..1afa5665 --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/example-data-processor/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "testlib" diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/testlib/Core.kt b/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/testlib/Core.kt new file mode 100644 index 00000000..7987b029 --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/testlib/Core.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/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 diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/build.gradle.kts b/Dokka-plugin-kdoc2json/kdoc-to-json/build.gradle.kts new file mode 100644 index 00000000..3343a62e --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + kotlin("jvm") version "1.9.24" + kotlin("plugin.serialization") version "1.9.24" + `maven-publish` +} + +group = "org.appdevforall.dokka" +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/kdoc-to-json/gradle/wrapper/gradle-wrapper.jar b/Dokka-plugin-kdoc2json/kdoc-to-json/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..1b33c55b Binary files /dev/null and b/Dokka-plugin-kdoc2json/kdoc-to-json/gradle/wrapper/gradle-wrapper.jar differ diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/gradle/wrapper/gradle-wrapper.properties b/Dokka-plugin-kdoc2json/kdoc-to-json/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..877e3643 --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/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/kdoc-to-json/gradlew b/Dokka-plugin-kdoc2json/kdoc-to-json/gradlew new file mode 100755 index 00000000..17a91706 --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/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/kdoc-to-json/gradlew.bat b/Dokka-plugin-kdoc2json/kdoc-to-json/gradlew.bat new file mode 100644 index 00000000..e95643d6 --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/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/kdoc-to-json/settings.gradle.kts b/Dokka-plugin-kdoc2json/kdoc-to-json/settings.gradle.kts new file mode 100644 index 00000000..937bcc6f --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "kdoc-to-json" \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonOutputPlugin.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonOutputPlugin.kt new file mode 100644 index 00000000..49cea407 --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonOutputPlugin.kt @@ -0,0 +1,19 @@ +package org.appdevforall.dokka.kdoc2json + +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/kdoc-to-json/src/main/kotlin/JsonPluginConfig.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonPluginConfig.kt new file mode 100644 index 00000000..74320666 --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonPluginConfig.kt @@ -0,0 +1,17 @@ +package org.appdevforall.dokka.kdoc2json + +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? = null, + val replaceHtmlExtension: Boolean = false, + @SerialName("omit-nulls") + 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 new file mode 100644 index 00000000..42c6e966 --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonRenderer.kt @@ -0,0 +1,240 @@ +package org.appdevforall.dokka.kdoc2json + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.* +import org.appdevforall.dokka.kdoc2json.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 { + + override fun render(root: RootPageNode) { + val fqcn = "org.appdevforall.dokka.kdoc2json.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 { + // 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}") + } + } 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) + + val json = Json { + prettyPrint = finalConfig.prettyPrint + classDiscriminator = finalConfig.classDiscriminator + } + + 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/kdoc-to-json/src/main/kotlin/LinkPostProcessor.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/LinkPostProcessor.kt new file mode 100644 index 00000000..a142b4e1 --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/LinkPostProcessor.kt @@ -0,0 +1,72 @@ +package org.appdevforall.dokka.kdoc2json + +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/kdoc-to-json/src/main/kotlin/ModelMapper.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/ModelMapper.kt new file mode 100644 index 00000000..996d1bae --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/ModelMapper.kt @@ -0,0 +1,434 @@ +package org.appdevforall.dokka.kdoc2json + +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.* +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/kdoc-to-json/src/main/kotlin/PluginLogger.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/PluginLogger.kt new file mode 100644 index 00000000..fa00999d --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/PluginLogger.kt @@ -0,0 +1,65 @@ +package org.appdevforall.dokka.kdoc2json + +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/kdoc-to-json/src/main/kotlin/dtos/SemanticModelDtos.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/dtos/SemanticModelDtos.kt new file mode 100644 index 00000000..e77e4d79 --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/dtos/SemanticModelDtos.kt @@ -0,0 +1,422 @@ +package org.appdevforall.dokka.kdoc2json.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/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 new file mode 100644 index 00000000..1fe64b64 --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/resources/META-INF/services/org.jetbrains.dokka.plugability.DokkaPlugin @@ -0,0 +1 @@ +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/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/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) 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()