Added support for an alternative current dir mode in pkldoc (#824)

Some systems have trouble with handling symlinks, which breaks the current directory links created by Pkldoc. In this PR, we add an alternative mode which creates a full copy of the latest published version contents in the current directory instead.

Co-authored-by: Dan Chao <dan.chao@apple.com>
This commit is contained in:
Vladimir Matveev
2025-02-19 08:52:32 -08:00
committed by GitHub
parent 2ffd201172
commit baa34a6dd1
11 changed files with 143 additions and 19 deletions

View File

@@ -232,10 +232,18 @@ Relative URIs are resolved against the working directory.
[%collapsible]
====
Default: (none) +
Example: `pkldoc`
Example: `pkldoc` +
The directory where generated documentation is placed.
====
.--no-symlinks
[%collapsible]
====
Create copies of files and directories instead of symbolic links.
In particular, this affects how the "current" directories containing documentation content for the last generated version should be created.
By default, a symbolic link is created pointing to the last generated version. If symlinks are disabled, a full copy of the last generated version is created.
====
Common CLI options:
include::../../pkl-cli/partials/cli-common-options.adoc[]

View File

@@ -534,6 +534,17 @@ Example: `outputDir = layout.projectDirectory.dir("pkl-docs")` +
The directory where generated documentation is placed.
====
.noSymlinks: Property<Boolean>
[%collapsible]
====
Default: `false` +
Example: `noSymlinks = true` +
Create copies of files and directories instead of symbolic links.
In particular, this affects how the "current" directories containing documentation content for the last generated version should be created.
By default, a symbolic link is created pointing to the last generated version.
If symlinks are disabled, a full copy of the last generated version is created.
====
Common properties:
include::../partials/gradle-modules-properties.adoc[]

View File

@@ -20,6 +20,9 @@ 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.createParentDirectories
import kotlin.io.path.exists
// not stored to avoid build-time initialization by native-image
val currentWorkingDir: Path
@@ -51,6 +54,19 @@ fun Path.writeString(
@Throws(IOException::class)
fun Path.readString(charset: Charset = Charsets.UTF_8): String = Files.readString(this, charset)
@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 */

View File

@@ -270,6 +270,7 @@ class CliDocGenerator(private val options: CliDocGeneratorOptions) : CliCommand(
versionComparator,
options.normalizedOutputDir,
options.isTestMode,
options.noSymlinks,
)
.run()
} catch (e: DocGeneratorException) {

View File

@@ -36,6 +36,17 @@ constructor(
* files (e.g., when stdlib line numbers change).
*/
val isTestMode: Boolean = false,
/**
* Disables creation of symlinks, replacing them with regular files and directories.
*
* In particular, this affects creation of the "current" directory which contains documentation
* for the latest version of the package.
*
* `false` will make the current directory into a symlink to the actual version directory. `true`,
* however, will create a full copy instead.
*/
val noSymlinks: Boolean = false,
) {
/** [outputDir] after undergoing normalization. */
val normalizedOutputDir: Path = base.normalizedWorkingDir.resolveSafely(outputDir)

View File

@@ -19,6 +19,7 @@ import java.io.IOException
import java.net.URI
import java.nio.file.Path
import kotlin.io.path.*
import org.pkl.commons.copyRecursively
import org.pkl.core.ModuleSchema
import org.pkl.core.PClassInfo
import org.pkl.core.Version
@@ -50,6 +51,7 @@ class DocGenerator(
/** A comparator for package versions. */
versionComparator: Comparator<String>,
/** The directory where generated documentation is placed. */
private val outputDir: Path,
@@ -60,8 +62,21 @@ class DocGenerator(
* files (e.g., when stdlib line numbers change).
*/
private val isTestMode: Boolean = false,
/**
* Disables creation of symbolic links, using copies of files and directories instead of them.
*
* In particular, determines how to create the "current" directory which contains documentation
* for the latest version of the package.
*
* `false` will make the current directory into a symlink to the actual version directory. `true`,
* however, will create a full copy instead.
*/
private val noSymlinks: Boolean = false,
) {
companion object {
const val CURRENT_DIRECTORY_NAME = "current"
internal fun List<PackageData>.current(
versionComparator: Comparator<String>
): List<PackageData> {
@@ -102,7 +117,7 @@ class DocGenerator(
val packagesData = packageDataGenerator.readAll()
val currentPackagesData = packagesData.current(descendingVersionComparator)
createSymlinks(currentPackagesData)
createCurrentDirectories(currentPackagesData)
htmlGenerator.generateSite(currentPackagesData)
searchIndexGenerator.generateSiteIndex(currentPackagesData)
@@ -117,14 +132,21 @@ class DocGenerator(
outputDir.resolve(IoUtils.encodePath("$name/$version")).deleteRecursively()
}
private fun createSymlinks(currentPackagesData: List<PackageData>) {
private fun createCurrentDirectories(currentPackagesData: List<PackageData>) {
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)
if (noSymlinks) {
dst.deleteRecursively()
src.copyRecursively(dst)
} else {
if (!dst.exists() || !dst.isSameFileAs(src)) {
dst.deleteRecursively()
dst.createSymbolicLinkPointingTo(IoUtils.relativize(src, basePath))
}
}
}
}
}

View File

@@ -21,6 +21,7 @@ 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.flag
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.options.required
import com.github.ajalt.clikt.parameters.types.path
@@ -30,6 +31,7 @@ import org.pkl.commons.cli.cliMain
import org.pkl.commons.cli.commands.BaseCommand
import org.pkl.commons.cli.commands.BaseOptions.Companion.parseModuleName
import org.pkl.commons.cli.commands.ProjectOptions
import org.pkl.commons.cli.commands.single
import org.pkl.core.Release
/** Main method for the Pkldoc CLI. */
@@ -57,11 +59,24 @@ class DocCommand :
.path()
.required()
private val noSymlinks: Boolean by
option(
names = arrayOf("--no-symlinks"),
help = "Create copies of directories and files instead of symbolic links.",
)
.single()
.flag(default = false)
private val projectOptions by ProjectOptions()
override fun run() {
val options =
CliDocGeneratorOptions(baseOptions.baseOptions(modules, projectOptions), outputDir, true)
CliDocGeneratorOptions(
baseOptions.baseOptions(modules, projectOptions),
outputDir,
true,
noSymlinks,
)
CliDocGenerator(options).run()
}
}

View File

@@ -18,6 +18,7 @@ package org.pkl.doc
import com.google.common.jimfs.Configuration
import com.google.common.jimfs.Jimfs
import java.net.URI
import java.nio.file.FileSystem
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.*
@@ -35,13 +36,14 @@ 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
class CliDocGeneratorTest {
companion object {
private val tempFileSystem by lazy { Jimfs.newFileSystem(Configuration.unix()) }
private val tempFileSystem: FileSystem by lazy { Jimfs.newFileSystem(Configuration.unix()) }
private val tmpOutputDir by lazy {
tempFileSystem.getPath("/work/output").apply { createDirectories() }
@@ -107,7 +109,7 @@ class CliDocGeneratorTest {
private val binaryFileExtensions = setOf("woff2", "png", "svg")
private fun runDocGenerator(outputDir: Path, cacheDir: Path?) {
private fun runDocGenerator(outputDir: Path, cacheDir: Path?, noSymlinks: Boolean = false) {
CliDocGenerator(
CliDocGeneratorOptions(
CliBaseOptions(
@@ -125,6 +127,7 @@ class CliDocGeneratorTest {
),
outputDir = outputDir,
isTestMode = true,
noSymlinks = noSymlinks,
)
)
.run()
@@ -254,15 +257,35 @@ 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 symlinks are disabled`(
@TempDir tempDir: Path
) {
PackageServer.populateCacheDir(tempDir)
runDocGenerator(actualOutputDir, tempDir, noSymlinks = true)
val currentDirectory = actualOutputDir.resolve("com.package1/current")
val sourceDirectory = actualOutputDir.resolve("com.package1/1.2.3")
assertThat(currentDirectory).isDirectory()
assertThat(currentDirectory.isSymbolicLink()).isFalse()
val expectedFiles = sourceDirectory.walk().map(sourceDirectory::relativize).toList()
val actualFiles = currentDirectory.walk().map(currentDirectory::relativize).toList()
assertThat(actualFiles).hasSameElementsAs(expectedFiles)
}
@Test

View File

@@ -261,8 +261,14 @@ public class PklPlugin implements Plugin<Project> {
.getBuildDirectory()
.map(it -> it.dir("pkldoc").dir(spec.getName())));
spec.getNoSymlinks().convention(false);
createModulesTask(PkldocTask.class, spec)
.configure(task -> task.getOutputDir().set(spec.getOutputDir()));
.configure(
task -> {
task.getOutputDir().set(spec.getOutputDir());
task.getNoSymlinks().set(spec.getNoSymlinks());
});
});
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,8 +16,11 @@
package org.pkl.gradle.spec;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.provider.Property;
/** Configuration options for Pkldoc generators. Documented in user manual. */
public interface PkldocSpec extends ModulesSpec {
DirectoryProperty getOutputDir();
Property<Boolean> getNoSymlinks();
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,6 +16,8 @@
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;
@@ -24,11 +26,17 @@ public abstract class PkldocTask extends ModulesTask {
@OutputDirectory
public abstract DirectoryProperty getOutputDir();
@Input
public abstract Property<Boolean> getNoSymlinks();
@Override
protected void doRunTask() {
new CliDocGenerator(
new CliDocGeneratorOptions(
getCliBaseOptions(), getOutputDir().get().getAsFile().toPath()))
getCliBaseOptions(),
getOutputDir().get().getAsFile().toPath(),
false,
getNoSymlinks().get()))
.run();
}
}