@@ -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,134 @@ 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 (
41+ ResolveCapability .PlainUri ,
42+ ResolveCapability .RelativePathInResources (),
43+ ResolveCapability .AbsolutePath ,
44+ )
45+
46+ /* *
47+ * Creates [ImageSourceResolver] that can resolve image links in Markdown files if they are either:
48+ * - plain URIs, e.g., `https://example.com/image.png` or `file:///image.png`
49+ * - absolute paths, e.g., `/image.png`
50+ * - relative paths in the current classloader's resources, e.g., `/images/my-image.png`
51+ * - relative paths relative to a given root directory [rootDir], e.g., `../images/my-image.png`
52+ *
53+ * If [logResolveFailure] is true, logs any failures to resolve image sources.
54+ */
55+ public fun create (rootDir : Path , logResolveFailure : Boolean ): ImageSourceResolver =
56+ create(
57+ buildSet {
58+ addAll(defaultCapabilities)
59+ add(ResolveCapability .RelativePath (rootDir))
60+ },
61+ logResolveFailure,
62+ )
63+
64+ /* *
65+ * Creates [ImageSourceResolver] that can resolve image links in Markdown files according to provided
66+ * [resolveCapabilities].
67+ *
68+ * If [logResolveFailure] is true, logs any failures to resolve image sources.
69+ */
70+ public fun create (
71+ resolveCapabilities : Set <ResolveCapability > = defaultCapabilities,
72+ logResolveFailure : Boolean = true,
73+ ): ImageSourceResolver = DefaultImageSourceResolver (resolveCapabilities, logResolveFailure)
74+ }
75+
76+ /* * Provides a list of capabilities that the default [ImageSourceResolver] implementation supports. */
77+ @ApiStatus.Experimental
78+ @ExperimentalJewelApi
79+ public sealed interface ResolveCapability {
80+ /* * Resolves a raw image destination string from a Markdown file into a fully-qualified, loadable path. */
81+ public fun resolve (rawDestination : String ): String?
82+
83+ /* * Represents the ability to resolve plain URIs as-is. */
84+ @ApiStatus.Experimental
85+ @ExperimentalJewelApi
86+ public object PlainUri : ResolveCapability {
87+ override fun toString (): String = " PlainUri"
88+
89+ override fun resolve (rawDestination : String ): String? {
90+ val uri = runCatching { URI .create(rawDestination) }.getOrNull() ? : return null
91+ return if (uri.isAbsolute) rawDestination else null
92+ }
93+ }
94+
95+ /* *
96+ * Represents the ability to resolve relative paths in the [resourceClass] classloader's resources, or in the
97+ * current classloader's resources if [resourceClass] is `null`.
98+ */
99+ @ApiStatus.Experimental
100+ @ExperimentalJewelApi
101+ public class RelativePathInResources (val resourceClass : Class <* >? = null ) : ResolveCapability {
102+ override fun toString (): String = " RelativePathInResources"
103+
104+ override fun resolve (rawDestination : String ): String? =
105+ (resourceClass ? : javaClass).classLoader.getResource(rawDestination.removePrefix(" /" ))?.toExternalForm()
106+ }
107+
108+ /* * Represents the ability to resolve absolute paths as-is. */
109+ public object AbsolutePath : ResolveCapability {
110+ override fun resolve (rawDestination : String ): String? {
111+ val rawPath = Path .of(rawDestination)
112+ if (rawPath.isAbsolute) return rawDestination
113+ return null
114+ }
115+ }
116+
117+ /* * Represents the ability to resolve relative paths relative to a given root directory [rootDir]. */
118+ @ApiStatus.Experimental
119+ @ExperimentalJewelApi
120+ public class RelativePath (private val rootDir : Path ) : ResolveCapability {
121+ override fun resolve (rawDestination : String ): String? {
122+ val rawPath = Path .of(rawDestination)
123+ // don't resolve absolute paths, it's not this resolver's capability
124+ if (rawPath.isAbsolute) return null
125+
126+ val normalizedRoot = runCatching { rootDir.toAbsolutePath().normalize() }.getOrNull() ? : return null
127+
128+ val resolved = runCatching { normalizedRoot.resolve(rawPath).normalize() }.getOrNull() ? : return null
129+
130+ return resolved.toString()
131+ }
132+
133+ override fun toString (): String = " RelativePath(rootDir=$rootDir )"
134+ }
135+ }
33136}
34137
35138/* *
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.
139+ * The default implementation of [ImageSourceResolver] that can resolve image links in Markdown files according to
140+ * provided [resolveCapabilities].
39141 *
142+ * @param resolveCapabilities A list of [ImageSourceResolver.ResolveCapability]s that this resolver can support.
143+ * @param logResolveFailure Whether to log any failures to resolve image sources.
40144 * @see ImageSourceResolver
41145 */
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 ) {
146+ internal class DefaultImageSourceResolver (
147+ private val resolveCapabilities : Set < ImageSourceResolver . ResolveCapability > =
148+ ImageSourceResolver .defaultCapabilities,
149+ private val logResolveFailure : Boolean = true ,
150+ ) : ImageSourceResolver {
151+ override fun resolve (rawDestination : String ): String? {
152+ val result = resolveCapabilities.firstNotNullOfOrNull { it.resolve(rawDestination) }
153+ if (result == null && logResolveFailure ) {
50154 myLogger()
51155 .warn(
52- " Markdown image '$rawDestination ' expected at classpath '$rawDestination ' but not found. " +
53- " Please ensure it's in your 'src/main/resources/' folder."
156+ " Failed to resolve image source: $rawDestination . Supported capabilities: ${resolveCapabilities.joinToString()} "
54157 )
55- return rawDestination // This will cause Coil to fail and not render anything.
56158 }
57-
58- return resourceUrl.toExternalForm()
159+ return result
59160 }
60161}
61162
@@ -84,5 +185,5 @@ internal object DefaultImageSourceResolver : ImageSourceResolver {
84185@ExperimentalJewelApi
85186public val LocalMarkdownImageSourceResolver : ProvidableCompositionLocal <ImageSourceResolver > =
86187 staticCompositionLocalOf {
87- DefaultImageSourceResolver
188+ ImageSourceResolver .create()
88189 }
0 commit comments