diff --git a/docs/modules/pkl-doc/pages/index.adoc b/docs/modules/pkl-doc/pages/index.adoc index 75b7ecae2..84737a83b 100644 --- a/docs/modules/pkl-doc/pages/index.adoc +++ b/docs/modules/pkl-doc/pages/index.adoc @@ -232,10 +232,22 @@ Relative URIs are resolved against the working directory. [%collapsible] ==== Default: (none) + -Example: `pkldoc` +Example: `pkldoc` + The directory where generated documentation is placed. ==== +.--current-directory-mode +[%collapsible] +==== +Default: `symlink` + +Example: `copy` + +How the "current" directories containing documentation content for the last generated version +should be created. By default, in the symlink mode, a symbolic link is created pointing to the +last generated version. In the copy mode, a full copy of the last generated version +is created. Symbolic links are not processed correctly by some systems (e.g. GitHub Pages), +so the copy mode is occasionally useful. +==== + Common CLI options: include::../../pkl-cli/partials/cli-common-options.adoc[] diff --git a/docs/modules/pkl-gradle/pages/index.adoc b/docs/modules/pkl-gradle/pages/index.adoc index 373e5ba1e..181c6d685 100644 --- a/docs/modules/pkl-gradle/pages/index.adoc +++ b/docs/modules/pkl-gradle/pages/index.adoc @@ -515,6 +515,18 @@ Example: `outputDir = layout.projectDirectory.dir("pkl-docs")` + The directory where generated documentation is placed. ==== +.currentDirectoryMode: Property +[%collapsible] +==== +Default: `DocsiteGenerator.CurrentDirectoryMode.SYMLINK` + +Example: `currentDirectoryMode = DocsiteGenerator.CurrentDirectoryMode.COPY` + +How the "current" directories containing documentation content for the last generated version +should be created. By default, in the symlink mode, a symbolic link is created pointing to the +last generated version. In the copy mode, a full copy of the last generated version +is created. Symbolic links are not processed correctly by some systems (e.g. GitHub Pages), +so the copy mode is occasionally useful. +==== + Common properties: include::../partials/gradle-modules-properties.adoc[] diff --git a/pkl-commons/src/main/kotlin/org/pkl/commons/Paths.kt b/pkl-commons/src/main/kotlin/org/pkl/commons/Paths.kt index dfb40c7c9..9b2dd43ea 100644 --- a/pkl-commons/src/main/kotlin/org/pkl/commons/Paths.kt +++ b/pkl-commons/src/main/kotlin/org/pkl/commons/Paths.kt @@ -20,6 +20,7 @@ import java.nio.charset.Charset import java.nio.file.* import java.nio.file.attribute.FileAttribute import java.util.stream.Stream +import kotlin.io.path.copyTo import kotlin.io.path.createDirectories import kotlin.io.path.deleteIfExists import kotlin.io.path.exists @@ -72,6 +73,19 @@ fun Path.deleteRecursively() { } } +@Throws(IOException::class) +fun Path.copyRecursively(target: Path) { + if (exists()) { + target.createParentDirectories() + walk().use { paths -> + paths.forEach { src -> + val dst = target.resolve(this@copyRecursively.relativize(src)) + src.copyTo(dst, overwrite = true) + } + } + } +} + private val isWindows by lazy { System.getProperty("os.name").contains("Windows") } /** Copy implementation from IoUtils.toNormalizedPathString */ diff --git a/pkl-doc/src/main/kotlin/org/pkl/doc/CliDocGenerator.kt b/pkl-doc/src/main/kotlin/org/pkl/doc/CliDocGenerator.kt index 41ce50ffa..ac30d53b4 100644 --- a/pkl-doc/src/main/kotlin/org/pkl/doc/CliDocGenerator.kt +++ b/pkl-doc/src/main/kotlin/org/pkl/doc/CliDocGenerator.kt @@ -269,7 +269,8 @@ class CliDocGenerator(private val options: CliDocGeneratorOptions) : CliCommand( importedModules::getValue, versionComparator, options.normalizedOutputDir, - options.isTestMode + options.isTestMode, + options.currentDirectoryMode ) .run() } catch (e: DocGeneratorException) { diff --git a/pkl-doc/src/main/kotlin/org/pkl/doc/CliDocGeneratorOptions.kt b/pkl-doc/src/main/kotlin/org/pkl/doc/CliDocGeneratorOptions.kt index a79af105d..9f823a197 100644 --- a/pkl-doc/src/main/kotlin/org/pkl/doc/CliDocGeneratorOptions.kt +++ b/pkl-doc/src/main/kotlin/org/pkl/doc/CliDocGeneratorOptions.kt @@ -35,7 +35,18 @@ constructor( * Generates source URLs with fixed line numbers `#L123-L456` to avoid churn in expected output * files (e.g., when stdlib line numbers change). */ - val isTestMode: Boolean = false + val isTestMode: Boolean = false, + + /** + * Determines how to create the "current" directory which contains documentation for the latest + * version of the package. + * + * [DocGenerator.CurrentDirectoryMode.SYMLINK] will make the current directory into a symlink to + * the actual version directory. [DocGenerator.CurrentDirectoryMode.COPY], however, will create a + * full copy instead. + */ + var currentDirectoryMode: DocGenerator.CurrentDirectoryMode = + DocGenerator.CurrentDirectoryMode.SYMLINK ) { /** [outputDir] after undergoing normalization. */ val normalizedOutputDir: Path = base.normalizedWorkingDir.resolveSafely(outputDir) diff --git a/pkl-doc/src/main/kotlin/org/pkl/doc/DocGenerator.kt b/pkl-doc/src/main/kotlin/org/pkl/doc/DocGenerator.kt index 6c80ee1ad..81e1709a3 100644 --- a/pkl-doc/src/main/kotlin/org/pkl/doc/DocGenerator.kt +++ b/pkl-doc/src/main/kotlin/org/pkl/doc/DocGenerator.kt @@ -19,9 +19,9 @@ import java.io.IOException import java.net.URI import java.nio.file.Path import kotlin.io.path.createSymbolicLinkPointingTo -import kotlin.io.path.deleteIfExists import kotlin.io.path.exists import kotlin.io.path.isSameFileAs +import org.pkl.commons.copyRecursively import org.pkl.commons.deleteRecursively import org.pkl.core.ModuleSchema import org.pkl.core.PClassInfo @@ -53,6 +53,7 @@ class DocGenerator( /** A comparator for package versions. */ versionComparator: Comparator, + /** The directory where generated documentation is placed. */ private val outputDir: Path, @@ -62,9 +63,20 @@ class DocGenerator( * Generates source URLs with fixed line numbers `#L123-L456` to avoid churn in expected output * files (e.g., when stdlib line numbers change). */ - private val isTestMode: Boolean = false + private val isTestMode: Boolean = false, + + /** + * Determines how to create the "current" directory which contains documentation for the latest + * version of the package. + * + * [CurrentDirectoryMode.SYMLINK] will make the current directory into a symlink to the actual + * version directory. [CurrentDirectoryMode.COPY], however, will create a full copy instead. + */ + private val currentDirectoryMode: CurrentDirectoryMode = CurrentDirectoryMode.SYMLINK ) { companion object { + const val CURRENT_DIRECTORY_NAME = "current" + internal fun List.current( versionComparator: Comparator ): List { @@ -105,7 +117,7 @@ class DocGenerator( val packagesData = packageDataGenerator.readAll() val currentPackagesData = packagesData.current(descendingVersionComparator) - createSymlinks(currentPackagesData) + createCurrentDirectories(currentPackagesData) htmlGenerator.generateSite(currentPackagesData) searchIndexGenerator.generateSiteIndex(currentPackagesData) @@ -120,16 +132,38 @@ class DocGenerator( outputDir.resolve("$name/$version").deleteRecursively() } - private fun createSymlinks(currentPackagesData: List) { + private fun createCurrentDirectories(currentPackagesData: List) { for (packageData in currentPackagesData) { val basePath = outputDir.resolve(packageData.ref.pkg.pathEncoded) val src = basePath.resolve(packageData.ref.version) - val dest = basePath.resolve("current") - if (dest.exists() && dest.isSameFileAs(src)) continue - dest.deleteIfExists() - dest.createSymbolicLinkPointingTo(IoUtils.relativize(src, basePath)) + val dst = basePath.resolve(CURRENT_DIRECTORY_NAME) + + when (currentDirectoryMode) { + CurrentDirectoryMode.SYMLINK -> { + if (!dst.exists() || !dst.isSameFileAs(src)) { + dst.deleteRecursively() + dst.createSymbolicLinkPointingTo(IoUtils.relativize(src, basePath)) + } + } + CurrentDirectoryMode.COPY -> { + dst.deleteRecursively() + src.copyRecursively(dst) + } + } } } + + /** + * Determines how to create the "current" directory with the contents of the latest generated + * version of the package docs. + */ + enum class CurrentDirectoryMode { + /** Create a symlink pointing to the directory with the latest version of documentation. */ + SYMLINK, + + /** Make a full copy of the directory with the latest version of documentation. */ + COPY + } } internal class DocPackage(val docPackageInfo: DocPackageInfo, val modules: List) { diff --git a/pkl-doc/src/main/kotlin/org/pkl/doc/Main.kt b/pkl-doc/src/main/kotlin/org/pkl/doc/Main.kt index b3dd0d760..ef1fdcd38 100644 --- a/pkl-doc/src/main/kotlin/org/pkl/doc/Main.kt +++ b/pkl-doc/src/main/kotlin/org/pkl/doc/Main.kt @@ -21,8 +21,10 @@ import com.github.ajalt.clikt.parameters.arguments.argument import com.github.ajalt.clikt.parameters.arguments.convert import com.github.ajalt.clikt.parameters.arguments.multiple import com.github.ajalt.clikt.parameters.groups.provideDelegate +import com.github.ajalt.clikt.parameters.options.default import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.options.required +import com.github.ajalt.clikt.parameters.types.enum import com.github.ajalt.clikt.parameters.types.path import java.net.URI import java.nio.file.Path @@ -57,6 +59,15 @@ class DocCommand : .path() .required() + private val currentDirectoryMode: DocGenerator.CurrentDirectoryMode by + option( + names = arrayOf("--current-directory-mode"), + metavar = "", + help = "How current directory should be created (as a symlink or as a full copy)" + ) + .enum() + .default(DocGenerator.CurrentDirectoryMode.SYMLINK) + private val projectOptions by ProjectOptions() override fun run() { @@ -67,7 +78,8 @@ class DocCommand : projectOptions, ), outputDir, - true + true, + currentDirectoryMode ) CliDocGenerator(options).run() } diff --git a/pkl-doc/src/test/kotlin/org/pkl/doc/CliDocGeneratorTest.kt b/pkl-doc/src/test/kotlin/org/pkl/doc/CliDocGeneratorTest.kt index 0ecec6add..c19d8db8a 100644 --- a/pkl-doc/src/test/kotlin/org/pkl/doc/CliDocGeneratorTest.kt +++ b/pkl-doc/src/test/kotlin/org/pkl/doc/CliDocGeneratorTest.kt @@ -36,6 +36,7 @@ import org.pkl.commons.test.FileTestUtils import org.pkl.commons.test.PackageServer import org.pkl.commons.test.listFilesRecursively import org.pkl.commons.toPath +import org.pkl.commons.walk import org.pkl.core.Version import org.pkl.core.util.IoUtils import org.pkl.doc.DocGenerator.Companion.current @@ -113,7 +114,12 @@ class CliDocGeneratorTest { "svg", ) - private fun runDocGenerator(outputDir: Path, cacheDir: Path?) { + private fun runDocGenerator( + outputDir: Path, + cacheDir: Path?, + currentDirectoryMode: DocGenerator.CurrentDirectoryMode = + DocGenerator.CurrentDirectoryMode.SYMLINK + ) { CliDocGenerator( CliDocGeneratorOptions( CliBaseOptions( @@ -130,7 +136,8 @@ class CliDocGeneratorTest { moduleCacheDir = cacheDir ), outputDir = outputDir, - isTestMode = true + isTestMode = true, + currentDirectoryMode = currentDirectoryMode ) ) .run() @@ -260,15 +267,36 @@ class CliDocGeneratorTest { } @Test - fun `creates a symlink called current`(@TempDir tempDir: Path) { + fun `creates a symlink called current by default`(@TempDir tempDir: Path) { PackageServer.populateCacheDir(tempDir) runDocGenerator(actualOutputDir, tempDir) + val expectedSymlink = actualOutputDir.resolve("com.package1/current") val expectedDestination = actualOutputDir.resolve("com.package1/1.2.3") - org.junit.jupiter.api.Assertions.assertTrue(Files.isSymbolicLink(expectedSymlink)) - org.junit.jupiter.api.Assertions.assertTrue( - Files.isSameFile(expectedSymlink, expectedDestination) + + assertThat(expectedSymlink).isSymbolicLink().matches { + Files.isSameFile(it, expectedDestination) + } + } + + @Test + fun `creates a copy of the latest output called current when configured`(@TempDir tempDir: Path) { + PackageServer.populateCacheDir(tempDir) + runDocGenerator( + actualOutputDir, + tempDir, + currentDirectoryMode = DocGenerator.CurrentDirectoryMode.COPY ) + + val currentDirectory = actualOutputDir.resolve("com.package1/current") + val sourceDirectory = actualOutputDir.resolve("com.package1/1.2.3") + + assertThat(currentDirectory).isDirectory() + + val expectedFiles = sourceDirectory.walk().map(sourceDirectory::relativize).toList() + val actualFiles = currentDirectory.walk().map(currentDirectory::relativize).toList() + + assertThat(actualFiles).hasSameElementsAs(expectedFiles) } @Test diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java b/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java index 86a0c2342..e59bc85ee 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java @@ -42,6 +42,7 @@ import org.pkl.core.util.IoUtils; import org.pkl.core.util.LateInit; import org.pkl.core.util.Nullable; +import org.pkl.doc.DocGenerator.CurrentDirectoryMode; import org.pkl.gradle.spec.AnalyzeImportsSpec; import org.pkl.gradle.spec.BasePklSpec; import org.pkl.gradle.spec.CodeGenSpec; @@ -249,8 +250,14 @@ private void configurePkldocTasks(NamedDomainObjectContainer specs) .getBuildDirectory() .map(it -> it.dir("pkldoc").dir(spec.getName()))); + spec.getCurrentDirectoryMode().convention(CurrentDirectoryMode.SYMLINK); + createModulesTask(PkldocTask.class, spec) - .configure(task -> task.getOutputDir().set(spec.getOutputDir())); + .configure( + task -> { + task.getOutputDir().set(spec.getOutputDir()); + task.getCurrentDirectoryMode().set(spec.getCurrentDirectoryMode()); + }); }); } diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/spec/PkldocSpec.java b/pkl-gradle/src/main/java/org/pkl/gradle/spec/PkldocSpec.java index 6b5c5ac24..fd376b857 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/spec/PkldocSpec.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/spec/PkldocSpec.java @@ -16,8 +16,12 @@ package org.pkl.gradle.spec; import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.provider.Property; +import org.pkl.doc.DocGenerator.CurrentDirectoryMode; /** Configuration options for Pkldoc generators. Documented in user manual. */ public interface PkldocSpec extends ModulesSpec { DirectoryProperty getOutputDir(); + + Property getCurrentDirectoryMode(); } diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/task/PkldocTask.java b/pkl-gradle/src/main/java/org/pkl/gradle/task/PkldocTask.java index 78968d13d..69df26a8d 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/task/PkldocTask.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/task/PkldocTask.java @@ -16,19 +16,28 @@ package org.pkl.gradle.task; import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; import org.gradle.api.tasks.OutputDirectory; import org.pkl.doc.CliDocGenerator; import org.pkl.doc.CliDocGeneratorOptions; +import org.pkl.doc.DocGenerator.CurrentDirectoryMode; public abstract class PkldocTask extends ModulesTask { @OutputDirectory public abstract DirectoryProperty getOutputDir(); + @Input + public abstract Property getCurrentDirectoryMode(); + @Override protected void doRunTask() { new CliDocGenerator( new CliDocGeneratorOptions( - getCliBaseOptions(), getOutputDir().get().getAsFile().toPath())) + getCliBaseOptions(), + getOutputDir().get().getAsFile().toPath(), + false, + getCurrentDirectoryMode().get())) .run(); } }