@@ -4,7 +4,9 @@ package org.jetbrains.jewel.markdown.rendering
44import androidx.compose.runtime.ProvidableCompositionLocal
55import androidx.compose.runtime.staticCompositionLocalOf
66import java.net.URI
7+ import java.nio.file.Path
78import org.jetbrains.annotations.ApiStatus
9+ import org.jetbrains.annotations.VisibleForTesting
810import org.jetbrains.jewel.foundation.ExperimentalJewelApi
911import org.jetbrains.jewel.foundation.util.myLogger
1012
@@ -27,35 +29,127 @@ public interface ImageSourceResolver {
2729 *
2830 * @param rawDestination The raw destination string from the Markdown, e.g., "my-image.png" or
2931 * "https://example.com/image.png".
30- * @return A fully-qualified, loadable path to the image, which can be consumed by an image loader.
32+ * @return A fully-qualified, loadable path to the image, which can be consumed by an image loader, or `null` if the
33+ * image could not be resolved.
3134 */
32- public fun resolve (rawDestination : String ): String
35+ public fun resolve (rawDestination : String ): String?
36+
37+ public companion object {
38+ @VisibleForTesting
39+ internal val defaultCapabilities =
40+ setOf (ResolveCapability .PlainUri , ResolveCapability .RelativePathInResources , ResolveCapability .AbsolutePath )
41+
42+ /* *
43+ * Creates [ImageSourceResolver] that can resolve image links in Markdown files if they are either:
44+ * - plain URIs, e.g., `https://example.com/image.png` or `file:///image.png`
45+ * - absolute paths, e.g., `/image.png`
46+ * - relative paths in the current classloader's resources, e.g., `/images/my-image.png`
47+ * - relative paths relative to a given root directory [rootDir], e.g., `../images/my-image.png`
48+ *
49+ * If [logResolveFailure] is true, logs any failures to resolve image sources.
50+ */
51+ public fun create (rootDir : Path , logResolveFailure : Boolean ): ImageSourceResolver =
52+ create(
53+ buildSet {
54+ addAll(defaultCapabilities)
55+ add(ResolveCapability .RelativePath (rootDir))
56+ },
57+ logResolveFailure,
58+ )
59+
60+ /* *
61+ * Creates [ImageSourceResolver] that can resolve image links in Markdown files according to provided
62+ * [resolveCapabilities].
63+ *
64+ * If [logResolveFailure] is true, logs any failures to resolve image sources.
65+ */
66+ public fun create (
67+ resolveCapabilities : Set <ResolveCapability > = defaultCapabilities,
68+ logResolveFailure : Boolean = true,
69+ ): ImageSourceResolver = DefaultImageSourceResolver (resolveCapabilities)
70+ }
71+
72+ /* * Provides a list of capabilities that the default [ImageSourceResolver] implementation supports. */
73+ @ApiStatus.Experimental
74+ @ExperimentalJewelApi
75+ public sealed interface ResolveCapability {
76+ /* * Resolves a raw image destination string from a Markdown file into a fully-qualified, loadable path. */
77+ public fun resolve (rawDestination : String ): String?
78+
79+ /* * Represents the ability to resolve plain URIs as-is. */
80+ @ApiStatus.Experimental
81+ @ExperimentalJewelApi
82+ public object PlainUri : ResolveCapability {
83+ override fun toString (): String = " PlainUri"
84+
85+ override fun resolve (rawDestination : String ): String? {
86+ val uri = runCatching { URI .create(rawDestination) }.getOrNull() ? : return null
87+ return if (uri.isAbsolute) rawDestination else null
88+ }
89+ }
90+
91+ /* * Represents the ability to resolve relative paths in the current classloader's resources. */
92+ @ApiStatus.Experimental
93+ @ExperimentalJewelApi
94+ public object RelativePathInResources : ResolveCapability {
95+ override fun toString (): String = " RelativePathInResources"
96+
97+ override fun resolve (rawDestination : String ): String? =
98+ javaClass.classLoader.getResource(rawDestination.removePrefix(" /" ))?.toExternalForm()
99+ }
100+
101+ /* * Represents the ability to resolve absolute paths as-is. */
102+ public object AbsolutePath : ResolveCapability {
103+ override fun resolve (rawDestination : String ): String? {
104+ val rawPath = Path .of(rawDestination)
105+ if (rawPath.isAbsolute) return rawDestination
106+ return null
107+ }
108+ }
109+
110+ /* * Represents the ability to resolve relative paths relative to a given root directory [rootDir]. */
111+ @ApiStatus.Experimental
112+ @ExperimentalJewelApi
113+ public class RelativePath (private val rootDir : Path ) : ResolveCapability {
114+ override fun resolve (rawDestination : String ): String? {
115+ val rawPath = Path .of(rawDestination)
116+ // don't resolve absolute paths, it's not this resolver's capability
117+ if (rawPath.isAbsolute) return null
118+
119+ val normalizedRoot = runCatching { rootDir.toAbsolutePath().normalize() }.getOrNull() ? : return null
120+
121+ val resolved = runCatching { normalizedRoot.resolve(rawPath).normalize() }.getOrNull() ? : return null
122+
123+ return resolved.toString()
124+ }
125+
126+ override fun toString (): String = " RelativePath(rootDir=$rootDir )"
127+ }
128+ }
33129}
34130
35131/* *
36- * The default implementation of [ImageSourceResolver].
37- *
38- * Resolves full URIs as-is and attempts to find relative paths in the current classloader's resources.
132+ * The default implementation of [ImageSourceResolver] that can resolve image links in Markdown files according to
133+ * provided [resolveCapabilities].
39134 *
135+ * @param resolveCapabilities A list of [ImageSourceResolver.ResolveCapability]s that this resolver can support.
136+ * @param logResolveFailure Whether to log any failures to resolve image sources.
40137 * @see ImageSourceResolver
41138 */
42- internal object DefaultImageSourceResolver : ImageSourceResolver {
43- override fun resolve ( rawDestination : String ): String {
44- val uri = URI .create(rawDestination)
45- if (uri.scheme != null ) return rawDestination
46-
47- val resourceUrl = javaClass.classLoader.getResource (rawDestination.removePrefix( " / " ))
48-
49- if (resourceUrl == null ) {
139+ internal class DefaultImageSourceResolver (
140+ private val resolveCapabilities : Set < ImageSourceResolver . ResolveCapability > =
141+ ImageSourceResolver .defaultCapabilities,
142+ private val logResolveFailure : Boolean = true ,
143+ ) : ImageSourceResolver {
144+ override fun resolve (rawDestination : String ): String? {
145+ val result = resolveCapabilities.firstNotNullOfOrNull { it.resolve(rawDestination) }
146+ if (result == null && logResolveFailure ) {
50147 myLogger()
51148 .warn(
52- " Markdown image '$rawDestination ' expected at classpath '$rawDestination ' but not found. " +
53- " Please ensure it's in your 'src/main/resources/' folder."
149+ " Failed to resolve image source: $rawDestination . Supported capabilities: ${resolveCapabilities.joinToString()} "
54150 )
55- return rawDestination // This will cause Coil to fail and not render anything.
56151 }
57-
58- return resourceUrl.toExternalForm()
152+ return result
59153 }
60154}
61155
@@ -84,5 +178,5 @@ internal object DefaultImageSourceResolver : ImageSourceResolver {
84178@ExperimentalJewelApi
85179public val LocalMarkdownImageSourceResolver : ProvidableCompositionLocal <ImageSourceResolver > =
86180 staticCompositionLocalOf {
87- DefaultImageSourceResolver
181+ ImageSourceResolver .create()
88182 }
0 commit comments