diff --git a/core/shared/src/main/scala/laika/api/Renderer.scala b/core/shared/src/main/scala/laika/api/Renderer.scala index 786bc891c..5b0ac038c 100644 --- a/core/shared/src/main/scala/laika/api/Renderer.scala +++ b/core/shared/src/main/scala/laika/api/Renderer.scala @@ -136,22 +136,27 @@ abstract class Renderer private[laika] (val config: OperationConfig, skipRewrite .leftMap(InvalidConfig(_)) } - (if (skipRewrite) Right(targetElement) else rewrite).map { elementToRender => - val renderContext = - new Formatter.Context[Formatter]( - renderFunction, - elementToRender, - Nil, - styles, - doc.path, - pathTranslator, - if (config.compactRendering) Indentation.none else Indentation.default, - config.messageFilters.render - ) - - val formatter = format.formatterFactory(renderContext) - - renderFunction(formatter, elementToRender) + (if (skipRewrite) Right(targetElement) else rewrite).flatMap { elementToRender => + doc.config + .getOpt[String]("laika.renderTarget.absolute.baseUrl") + .leftMap(InvalidConfig(_)) + .map { baseUrlOpt => + val renderContext = + new Formatter.Context[Formatter]( + renderFunction, + elementToRender, + Nil, + styles, + doc.path, + pathTranslator, + if (config.compactRendering) Indentation.none else Indentation.default, + config.messageFilters.render, + baseUrlOpt + ) + + val formatter = format.formatterFactory(renderContext) + renderFunction(formatter, elementToRender) + } } } diff --git a/core/shared/src/main/scala/laika/api/format/Formatter.scala b/core/shared/src/main/scala/laika/api/format/Formatter.scala index c87f7a293..2a399ec90 100644 --- a/core/shared/src/main/scala/laika/api/format/Formatter.scala +++ b/core/shared/src/main/scala/laika/api/format/Formatter.scala @@ -61,6 +61,12 @@ abstract class Formatter protected { */ def pathTranslator: PathTranslator = context.pathTranslator + /** The absolute base URL to use for rendering internal targets as absolute URLs. */ + def internalTargetsAbsoluteBaseUrl: Option[String] = context.internalTargetsAbsoluteBaseUrl + + /** Indicates whether internal targets should be rendered as absolute URLs. */ + def internalTargetsAbsolute: Boolean = internalTargetsAbsoluteBaseUrl.nonEmpty + /** The styles the new renderer should apply to the rendered elements. * * Only used for some special render formats like XSL-FO. @@ -172,7 +178,8 @@ object Formatter { val path: Path, val pathTranslator: PathTranslator, val indentation: Formatter.Indentation, - val messageFilter: MessageFilter + val messageFilter: MessageFilter, + val internalTargetsAbsoluteBaseUrl: Option[String] ) { def forChildElement(child: Element): Context[FMT] = @@ -184,7 +191,8 @@ object Formatter { path, pathTranslator, indentation, - messageFilter + messageFilter, + internalTargetsAbsoluteBaseUrl ) def withIndentation(newValue: Formatter.Indentation): Context[FMT] = @@ -196,7 +204,8 @@ object Formatter { path, pathTranslator, newValue, - messageFilter + messageFilter, + internalTargetsAbsoluteBaseUrl ) } diff --git a/core/shared/src/main/scala/laika/internal/render/HTMLRenderer.scala b/core/shared/src/main/scala/laika/internal/render/HTMLRenderer.scala index c634f0bec..e3e83535d 100644 --- a/core/shared/src/main/scala/laika/internal/render/HTMLRenderer.scala +++ b/core/shared/src/main/scala/laika/internal/render/HTMLRenderer.scala @@ -152,15 +152,27 @@ private[laika] class HTMLRenderer(format: String) } } - def renderTarget(target: Target): String = fmt.pathTranslator.translate(target) match { - case ext: ExternalTarget => ext.url - case int: InternalTarget => - val relPath = int.relativeTo(fmt.path).relativePath - if (relPath.withoutFragment.toString.endsWith("/index.html")) - relPath.withBasename("").withoutSuffix.toString - else - relPath.toString - } + def renderTarget(target: Target): String = + fmt.pathTranslator.translate(target) match { + case ext: ExternalTarget => ext.url + case int: InternalTarget => + fmt.internalTargetsAbsoluteBaseUrl match { + + case Some(base) => + val abs = int.render(internalTargetsAbsolute = true) + val normalizedBase = + if (base == "/") "" + else "/" + base.stripPrefix("/").stripSuffix("/") + if (normalizedBase.isEmpty) abs else normalizedBase + abs + + case None => + val relPath = int.relativeTo(fmt.path).relativePath + if (relPath.withoutFragment.toString.endsWith("/index.html")) + relPath.withBasename("").withoutSuffix.toString + else + relPath.toString + } + } def renderSpanContainer(con: SpanContainer): String = { diff --git a/io/src/test/scala/laika/io/TreeRendererSpec.scala b/io/src/test/scala/laika/io/TreeRendererSpec.scala index 84ae372de..b13285a26 100644 --- a/io/src/test/scala/laika/io/TreeRendererSpec.scala +++ b/io/src/test/scala/laika/io/TreeRendererSpec.scala @@ -24,6 +24,7 @@ import laika.api.Renderer import laika.api.bundle.{ BundleOrigin, ExtensionBundle, PathTranslator } import laika.api.config.Origin.TreeScope import laika.api.config.{ Config, ConfigBuilder, Origin } +import laika.api.config.Origin.DocumentScope import laika.api.errors.{ InvalidDocument, InvalidDocuments } import laika.api.format.{ Formatter, TagFormatter } import laika.ast @@ -1307,4 +1308,78 @@ class TreeRendererSpec extends CatsEffectSuite res.assertEquals(expected) } + test("render internal targets as absolute when per-document baseUrl is configured") { + + val cfgRoot = + ConfigBuilder + .withOrigin(Origin(DocumentScope, Root / "404")) + .withValue("laika.renderTarget.absolute.baseUrl", "/") + .build + + val doc404Root = + Document( + Root / "404", + RootElement( + p(SpanLink.internal("/doc.html")("to-doc")) + ) + ).withConfig(cfgRoot) + + val cfgProject = + ConfigBuilder + .withOrigin(Origin(DocumentScope, Root / "404-project")) + .withValue("laika.renderTarget.absolute.baseUrl", "/project") + .build + + val doc404Project = + Document( + Root / "404-project", + RootElement( + p( + SpanLink.internal("/doc.html")("to-doc"), + Text(" "), + SpanLink.internal("/index.html")("home"), + Text(" "), + SpanLink.internal("/guide/index.html#install")("install") + ) + ) + ).withConfig(cfgProject) + + val normalDoc = + Document( + Root / "doc", + RootElement( + p(SpanLink.internal("/other.html")("to-other")) + ) + ) + + val inputTree = + DocumentTree.builder + .addDocument(doc404Root) + .addDocument(doc404Project) + .addDocument(normalDoc) + .buildRoot + + val renderer = + Renderer.of(HTML).parallel[IO].build + + renderer.use( + _.from(inputTree) + .toMemory + .render + ).map { result => + val rendered404Root = + result.allDocuments.find(_.path == Root / "404.html").get + val rendered404Project = + result.allDocuments.find(_.path == Root / "404-project.html").get + val renderedDoc = + result.allDocuments.find(_.path == Root / "doc.html").get + + assert(rendered404Root.content.contains("""href="/doc.html"""")) + assert(rendered404Project.content.contains("""href="/project/doc.html"""")) + assert(rendered404Project.content.contains("""href="/project/index.html"""")) + assert(rendered404Project.content.contains("""href="/project/guide/index.html#install"""")) + assert(renderedDoc.content.contains("""href="other.html"""")) + } + } + }