Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,6 @@ Thanks @gggeon96!

 

#### [LSP] Embeddable language server

The Quarkdown language server API now allows the LSP to be embedded in custom hosts and run across multiple instances inside a single JVM, thanks to customizable IO streams and pluggable hooks.

 

### Changed

 
Expand Down Expand Up @@ -62,9 +56,14 @@ HELLO, world

 

#### Live preview now uses SSE
#### Faster live preview

The live preview connection between the local server and the browser now runs over Server-Sent Events instead of WebSockets.
Live preview now references third-party libraries through symbolic links instead of copying them (although already checksum-optimized). Preview rebuilds are faster and use less disk space, especially in documents that include code blocks, equations, or Mermaid diagrams.

Additionally, the live preview connection between the local server and the browser now runs over Server-Sent Events (SSE) instead of WebSockets.
Comment thread
iamgio marked this conversation as resolved.

> [!NOTE]
> If you experience errors after launching live preview for the first time after updating, delete the output directory and run again (or run with `--clean`).

 

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
package com.quarkdown.cli.util

import com.quarkdown.core.util.IOUtils
import java.io.File
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.deleteRecursively

/**
* Cleans [this] directory by deleting all files and directories inside it.
* Does nothing if the directory is empty or if the file does not exist or is not a directory.
*/
@OptIn(ExperimentalPathApi::class)
fun File.cleanDirectory() {
listFiles()?.forEach { it.toPath().deleteRecursively() }
listFiles()?.forEach { IOUtils.deleteWithoutFollowingLinks(it.toPath()) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.quarkdown.cli.util

import com.quarkdown.cli.TempDirectory
import java.io.File
import java.nio.file.Files
import kotlin.io.path.createSymbolicLinkPointingTo
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

/**
* Tests for [cleanDirectory] and other CLI-level IO utilities.
*/
class IOUtilsTest : TempDirectory() {
@BeforeTest
fun setUp() {
reset()
}

@AfterTest
fun tearDown() {
directory.deleteRecursively()
}

@Test
fun `clean removes children but keeps directory`() {
File(directory, "a.txt").writeText("a")
File(directory, "b.txt").writeText("b")
File(directory, "nested").apply {
mkdir()
File(this, "c.txt").writeText("c")
}

directory.cleanDirectory()

assertTrue(directory.exists())
assertEquals(emptyList(), directory.listFiles()?.toList())
}

@Test
fun `clean on empty directory is a no-op`() {
directory.cleanDirectory()

assertTrue(directory.exists())
assertEquals(emptyList(), directory.listFiles()?.toList())
}

@Test
fun `clean on non-existent file is a no-op`() {
val missing = File(directory, "missing")
missing.cleanDirectory()

assertFalse(missing.exists())
}

@Test
fun `clean does not follow symlinks to external files`() {
val externalTarget =
Files.createTempDirectory("external-target").toFile().apply {
File(this, "keepme.txt").writeText("keep me")
}

try {
File(directory, "link")
.toPath()
.createSymbolicLinkPointingTo(externalTarget.toPath())

directory.cleanDirectory()

assertEquals(emptyList(), directory.listFiles()?.toList(), "Symlink entry should be removed")
assertTrue(externalTarget.exists(), "External target directory should not be touched")
assertTrue(
File(externalTarget, "keepme.txt").exists(),
"Files inside the symlink target should not be touched",
)
} finally {
externalTarget.deleteRecursively()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,11 @@ data class PipelineOptions(
val permissions: Set<Permission> = Permission.DEFAULT_SET,
val mediaStorageOptionsOverrides: MediaStorageOptions = ReadOnlyMediaStorageOptions(),
val errorHandler: PipelineErrorHandler = BasePipelineErrorHandler(),
)
) {
/**
* Whether to symlink or copy dependency files from the installation layout,
* such as third-party JavaScript libraries.
*/
val symlinkDependencies: Boolean
get() = this.isPreview
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@ import java.io.File
* @param name the output file name (with extension, since the original file name is used as-is)
* @param file the source file (or directory) to copy
* @param useChecksumInvalidation whether to also create a checksum file for this artifact, used for incremental builds
* to determine whether the artifact has changed since the last build and should be recreated.
* to determine whether the artifact has changed since the last build and should be recreated
* @param symlink whether to create a symbolic link to the source file instead of copying it.
* Takes precedence over [useChecksumInvalidation]
*/
data class FileReferenceOutputArtifact(
override val name: String,
val file: File,
val useChecksumInvalidation: Boolean = false,
val symlink: Boolean = false,
) : OutputResource {
override fun <T> accept(visitor: OutputResourceVisitor<T>): T = visitor.visit(this)
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import com.quarkdown.core.pipeline.output.visitor.FileResourceExporter.NameProvi
import com.quarkdown.core.util.IOUtils
import com.quarkdown.core.util.sanitizeFileName
import java.io.File
import java.nio.file.Files

/**
* A visitor that saves each type of [OutputResource] to a file and returns it.
Expand Down Expand Up @@ -84,13 +85,17 @@ class FileResourceExporter(
}

/**
* Copies a [FileReferenceOutputArtifact] to the output location.
* Exports a [FileReferenceOutputArtifact] to the output location.
* If the source is a directory, it is copied recursively.
*
* When [FileReferenceOutputArtifact.useChecksumInvalidation] is enabled, a sibling
* `.checksum` file is maintained next to the output. If the checksum of the source
* matches the stored value, the copy is skipped entirely. This avoids redundant I/O
* for large assets (fonts, third-party libraries) that rarely change between builds.
* Resolution order:
* 1. If [FileReferenceOutputArtifact.symlink] is set and the platform supports symbolic links,
* a link is created instead of copying.
* 2. Otherwise, if [FileReferenceOutputArtifact.useChecksumInvalidation] is enabled, a sibling
* `.checksum` file is maintained next to the output; the copy is skipped when the source's
* checksum matches the stored value. This avoids redundant I/O for large assets (fonts,
* third-party libraries) that rarely change between builds.
* 3. Otherwise, the source is copied unconditionally.
*
* @return the copied file or directory
*/
Expand All @@ -100,27 +105,37 @@ class FileResourceExporter(

target.parentFile?.mkdirs()

if (artifact.useChecksumInvalidation) {
val checksumFile = target.resolveSibling("${target.name}.checksum")
val currentChecksum = IOUtils.computeChecksum(artifact.file)
val storedChecksum = checksumFile.takeIf { it.isFile }?.readText()

if (currentChecksum == storedChecksum && target.exists()) {
Log.debug { "Skipping '${artifact.name}': checksum unchanged ($currentChecksum)" }
return@also
}
if (artifact.symlink && IOUtils.trySymlink(target.toPath(), artifact.file.toPath())) return@also

Log.debug {
"Copying '${artifact.name}': checksum changed " +
"(stored=${storedChecksum ?: "<none>"}, current=$currentChecksum)"
}
copyFileOrDirectory(artifact.file, target)
checksumFile.writeText(currentChecksum)
if (artifact.useChecksumInvalidation) {
copyWithChecksumInvalidation(artifact, target)
} else {
copyFileOrDirectory(artifact.file, target)
}
}

private fun copyWithChecksumInvalidation(
artifact: FileReferenceOutputArtifact,
target: File,
) {
val checksumFile = target.resolveSibling("${target.name}.checksum")
val currentChecksum = IOUtils.computeChecksum(artifact.file)
val storedChecksum = checksumFile.takeIf { it.isFile }?.readText()

if (currentChecksum == storedChecksum && target.exists() && !Files.isSymbolicLink(target.toPath())) {
Log.debug { "Skipping '${artifact.name}': checksum unchanged ($currentChecksum)" }
return
}

Log.debug {
"Copying '${artifact.name}': checksum changed " +
"(stored=${storedChecksum ?: "<none>"}, current=$currentChecksum)"
}

copyFileOrDirectory(artifact.file, target)
checksumFile.writeText(currentChecksum)
}

private fun copyFileOrDirectory(
source: File,
target: File,
Expand Down
58 changes: 58 additions & 0 deletions quarkdown-core/src/main/kotlin/com/quarkdown/core/util/IOUtils.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
package com.quarkdown.core.util

import com.quarkdown.core.log.Log
import java.io.File
import java.io.IOException
import java.nio.file.FileAlreadyExistsException
import java.nio.file.Files
import java.nio.file.Path
import java.security.MessageDigest
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.Path
import kotlin.io.path.createSymbolicLinkPointingTo
import kotlin.io.path.deleteRecursively

/**
* Utility methods for file-based operations.
Expand Down Expand Up @@ -69,6 +75,58 @@ object IOUtils {
return digest.digest().joinToString("") { "%02x".format(it) }
}

/**
* Recursively deletes the file or directory at [path] without following symbolic links:
* each link entry is removed, but the file or directory the link points to is left untouched.
* No-op if [path] does not exist.
*/
@OptIn(ExperimentalPathApi::class)
fun deleteWithoutFollowingLinks(path: Path) = path.deleteRecursively()

/**
* Attempts to (re)create a symbolic link at [target] pointing to [source]. Returns `true` on
* success, `false` if symbolic links are unavailable on this platform (caller should fall
* back to a copy-based path).
*/
fun trySymlink(
target: Path,
source: Path,
): Boolean =
try {
if (isAlreadySymlinkTo(target, source)) {
Log.debug { "Symlink '${target.fileName}' already points to '$source'; reusing" }
} else {
createOrReplaceSymlinkAt(target, source)
Log.debug { "Symlinked '${target.fileName}' to '$source'" }
}
true
} catch (e: IOException) {
Log.debug { "Symlink unavailable for '${target.fileName}' (${e.message}); falling back" }
false
}

/** Whether [target] is already a symbolic link whose stored target is exactly [source]. */
private fun isAlreadySymlinkTo(
target: Path,
source: Path,
): Boolean = Files.isSymbolicLink(target) && Files.readSymbolicLink(target) == source

/**
* Creates a symbolic link at [target] pointing to [source]. If a file, directory, or stale
* symlink already occupies the target, it is cleared and the creation is retried.
*/
private fun createOrReplaceSymlinkAt(
target: Path,
source: Path,
) {
try {
target.createSymbolicLinkPointingTo(source)
} catch (_: FileAlreadyExistsException) {
deleteWithoutFollowingLinks(target)
target.createSymbolicLinkPointingTo(source)
}
}

/**
* Resolves a path to its real (symlink-resolved) form if the file exists,
* falling back to absolute + normalized form for non-existent paths.
Expand Down
Loading
Loading