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] [%collapsible]
==== ====
Default: (none) + Default: (none) +
Example: `pkldoc` Example: `pkldoc` +
The directory where generated documentation is placed. 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: Common CLI options:
include::../../pkl-cli/partials/cli-common-options.adoc[] 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. 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: Common properties:
include::../partials/gradle-modules-properties.adoc[] 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.*
import java.nio.file.attribute.FileAttribute import java.nio.file.attribute.FileAttribute
import java.util.stream.Stream 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 // not stored to avoid build-time initialization by native-image
val currentWorkingDir: Path val currentWorkingDir: Path
@@ -51,6 +54,19 @@ fun Path.writeString(
@Throws(IOException::class) @Throws(IOException::class)
fun Path.readString(charset: Charset = Charsets.UTF_8): String = Files.readString(this, charset) 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") } private val isWindows by lazy { System.getProperty("os.name").contains("Windows") }
/** Copy implementation from IoUtils.toNormalizedPathString */ /** Copy implementation from IoUtils.toNormalizedPathString */

View File

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

View File

@@ -36,6 +36,17 @@ constructor(
* files (e.g., when stdlib line numbers change). * files (e.g., when stdlib line numbers change).
*/ */
val isTestMode: Boolean = false, 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. */ /** [outputDir] after undergoing normalization. */
val normalizedOutputDir: Path = base.normalizedWorkingDir.resolveSafely(outputDir) val normalizedOutputDir: Path = base.normalizedWorkingDir.resolveSafely(outputDir)

View File

@@ -19,6 +19,7 @@ import java.io.IOException
import java.net.URI import java.net.URI
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.* import kotlin.io.path.*
import org.pkl.commons.copyRecursively
import org.pkl.core.ModuleSchema import org.pkl.core.ModuleSchema
import org.pkl.core.PClassInfo import org.pkl.core.PClassInfo
import org.pkl.core.Version import org.pkl.core.Version
@@ -50,6 +51,7 @@ class DocGenerator(
/** A comparator for package versions. */ /** A comparator for package versions. */
versionComparator: Comparator<String>, versionComparator: Comparator<String>,
/** The directory where generated documentation is placed. */ /** The directory where generated documentation is placed. */
private val outputDir: Path, private val outputDir: Path,
@@ -60,8 +62,21 @@ class DocGenerator(
* files (e.g., when stdlib line numbers change). * files (e.g., when stdlib line numbers change).
*/ */
private val isTestMode: Boolean = false, 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 { companion object {
const val CURRENT_DIRECTORY_NAME = "current"
internal fun List<PackageData>.current( internal fun List<PackageData>.current(
versionComparator: Comparator<String> versionComparator: Comparator<String>
): List<PackageData> { ): List<PackageData> {
@@ -102,7 +117,7 @@ class DocGenerator(
val packagesData = packageDataGenerator.readAll() val packagesData = packageDataGenerator.readAll()
val currentPackagesData = packagesData.current(descendingVersionComparator) val currentPackagesData = packagesData.current(descendingVersionComparator)
createSymlinks(currentPackagesData) createCurrentDirectories(currentPackagesData)
htmlGenerator.generateSite(currentPackagesData) htmlGenerator.generateSite(currentPackagesData)
searchIndexGenerator.generateSiteIndex(currentPackagesData) searchIndexGenerator.generateSiteIndex(currentPackagesData)
@@ -117,14 +132,21 @@ class DocGenerator(
outputDir.resolve(IoUtils.encodePath("$name/$version")).deleteRecursively() outputDir.resolve(IoUtils.encodePath("$name/$version")).deleteRecursively()
} }
private fun createSymlinks(currentPackagesData: List<PackageData>) { private fun createCurrentDirectories(currentPackagesData: List<PackageData>) {
for (packageData in currentPackagesData) { for (packageData in currentPackagesData) {
val basePath = outputDir.resolve(packageData.ref.pkg.pathEncoded) val basePath = outputDir.resolve(packageData.ref.pkg.pathEncoded)
val src = basePath.resolve(packageData.ref.version) val src = basePath.resolve(packageData.ref.version)
val dest = basePath.resolve("current") val dst = basePath.resolve(CURRENT_DIRECTORY_NAME)
if (dest.exists() && dest.isSameFileAs(src)) continue
dest.deleteIfExists() if (noSymlinks) {
dest.createSymbolicLinkPointingTo(IoUtils.relativize(src, basePath)) 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.convert
import com.github.ajalt.clikt.parameters.arguments.multiple import com.github.ajalt.clikt.parameters.arguments.multiple
import com.github.ajalt.clikt.parameters.groups.provideDelegate 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.option
import com.github.ajalt.clikt.parameters.options.required import com.github.ajalt.clikt.parameters.options.required
import com.github.ajalt.clikt.parameters.types.path 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.BaseCommand
import org.pkl.commons.cli.commands.BaseOptions.Companion.parseModuleName import org.pkl.commons.cli.commands.BaseOptions.Companion.parseModuleName
import org.pkl.commons.cli.commands.ProjectOptions import org.pkl.commons.cli.commands.ProjectOptions
import org.pkl.commons.cli.commands.single
import org.pkl.core.Release import org.pkl.core.Release
/** Main method for the Pkldoc CLI. */ /** Main method for the Pkldoc CLI. */
@@ -57,11 +59,24 @@ class DocCommand :
.path() .path()
.required() .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() private val projectOptions by ProjectOptions()
override fun run() { override fun run() {
val options = val options =
CliDocGeneratorOptions(baseOptions.baseOptions(modules, projectOptions), outputDir, true) CliDocGeneratorOptions(
baseOptions.baseOptions(modules, projectOptions),
outputDir,
true,
noSymlinks,
)
CliDocGenerator(options).run() CliDocGenerator(options).run()
} }
} }

View File

@@ -18,6 +18,7 @@ package org.pkl.doc
import com.google.common.jimfs.Configuration import com.google.common.jimfs.Configuration
import com.google.common.jimfs.Jimfs import com.google.common.jimfs.Jimfs
import java.net.URI import java.net.URI
import java.nio.file.FileSystem
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.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.PackageServer
import org.pkl.commons.test.listFilesRecursively import org.pkl.commons.test.listFilesRecursively
import org.pkl.commons.toPath import org.pkl.commons.toPath
import org.pkl.commons.walk
import org.pkl.core.Version import org.pkl.core.Version
import org.pkl.core.util.IoUtils import org.pkl.core.util.IoUtils
import org.pkl.doc.DocGenerator.Companion.current import org.pkl.doc.DocGenerator.Companion.current
class CliDocGeneratorTest { class CliDocGeneratorTest {
companion object { 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 { private val tmpOutputDir by lazy {
tempFileSystem.getPath("/work/output").apply { createDirectories() } tempFileSystem.getPath("/work/output").apply { createDirectories() }
@@ -107,7 +109,7 @@ class CliDocGeneratorTest {
private val binaryFileExtensions = setOf("woff2", "png", "svg") 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( CliDocGenerator(
CliDocGeneratorOptions( CliDocGeneratorOptions(
CliBaseOptions( CliBaseOptions(
@@ -125,6 +127,7 @@ class CliDocGeneratorTest {
), ),
outputDir = outputDir, outputDir = outputDir,
isTestMode = true, isTestMode = true,
noSymlinks = noSymlinks,
) )
) )
.run() .run()
@@ -254,15 +257,35 @@ class CliDocGeneratorTest {
} }
@Test @Test
fun `creates a symlink called current`(@TempDir tempDir: Path) { fun `creates a symlink called current by default`(@TempDir tempDir: Path) {
PackageServer.populateCacheDir(tempDir) PackageServer.populateCacheDir(tempDir)
runDocGenerator(actualOutputDir, tempDir) runDocGenerator(actualOutputDir, tempDir)
val expectedSymlink = actualOutputDir.resolve("com.package1/current") val expectedSymlink = actualOutputDir.resolve("com.package1/current")
val expectedDestination = actualOutputDir.resolve("com.package1/1.2.3") val expectedDestination = actualOutputDir.resolve("com.package1/1.2.3")
org.junit.jupiter.api.Assertions.assertTrue(Files.isSymbolicLink(expectedSymlink))
org.junit.jupiter.api.Assertions.assertTrue( assertThat(expectedSymlink).isSymbolicLink().matches {
Files.isSameFile(expectedSymlink, expectedDestination) 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 @Test

View File

@@ -261,8 +261,14 @@ public class PklPlugin implements Plugin<Project> {
.getBuildDirectory() .getBuildDirectory()
.map(it -> it.dir("pkldoc").dir(spec.getName()))); .map(it -> it.dir("pkldoc").dir(spec.getName())));
spec.getNoSymlinks().convention(false);
createModulesTask(PkldocTask.class, spec) 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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -16,8 +16,11 @@
package org.pkl.gradle.spec; package org.pkl.gradle.spec;
import org.gradle.api.file.DirectoryProperty; import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.provider.Property;
/** Configuration options for Pkldoc generators. Documented in user manual. */ /** Configuration options for Pkldoc generators. Documented in user manual. */
public interface PkldocSpec extends ModulesSpec { public interface PkldocSpec extends ModulesSpec {
DirectoryProperty getOutputDir(); 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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -16,6 +16,8 @@
package org.pkl.gradle.task; package org.pkl.gradle.task;
import org.gradle.api.file.DirectoryProperty; 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.gradle.api.tasks.OutputDirectory;
import org.pkl.doc.CliDocGenerator; import org.pkl.doc.CliDocGenerator;
import org.pkl.doc.CliDocGeneratorOptions; import org.pkl.doc.CliDocGeneratorOptions;
@@ -24,11 +26,17 @@ public abstract class PkldocTask extends ModulesTask {
@OutputDirectory @OutputDirectory
public abstract DirectoryProperty getOutputDir(); public abstract DirectoryProperty getOutputDir();
@Input
public abstract Property<Boolean> getNoSymlinks();
@Override @Override
protected void doRunTask() { protected void doRunTask() {
new CliDocGenerator( new CliDocGenerator(
new CliDocGeneratorOptions( new CliDocGeneratorOptions(
getCliBaseOptions(), getOutputDir().get().getAsFile().toPath())) getCliBaseOptions(),
getOutputDir().get().getAsFile().toPath(),
false,
getNoSymlinks().get()))
.run(); .run();
} }
} }