From d21e4cecffd8256e57505fee05fdec25b40a91e7 Mon Sep 17 00:00:00 2001 From: abby-ql Date: Mon, 2 Mar 2026 16:21:50 +0000 Subject: [PATCH 01/10] Add internalTargetsAbsoluteBaseUrl to Formatter.Context --- Option[Config] | 0 Option[PathAttributes] | 0 Option[PathAttributes], | 0 .../main/scala/laika/api/format/Formatter.scala | 15 ++++++++++++--- 4 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 Option[Config] create mode 100644 Option[PathAttributes] create mode 100644 Option[PathAttributes], diff --git a/Option[Config] b/Option[Config] new file mode 100644 index 000000000..e69de29bb diff --git a/Option[PathAttributes] b/Option[PathAttributes] new file mode 100644 index 000000000..e69de29bb diff --git a/Option[PathAttributes], b/Option[PathAttributes], new file mode 100644 index 000000000..e69de29bb 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..c283900b9 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 ) } From 67e8d818f6f91805b26deb15337ba5947cedf4fc Mon Sep 17 00:00:00 2001 From: abby-ql Date: Mon, 2 Mar 2026 16:23:26 +0000 Subject: [PATCH 02/10] Pass per-document absolute baseUrl from config during rendering --- .../src/main/scala/laika/api/Renderer.scala | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/core/shared/src/main/scala/laika/api/Renderer.scala b/core/shared/src/main/scala/laika/api/Renderer.scala index 786bc891c..2c7cea60c 100644 --- a/core/shared/src/main/scala/laika/api/Renderer.scala +++ b/core/shared/src/main/scala/laika/api/Renderer.scala @@ -136,23 +136,28 @@ 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) + } + } } /** Creates a new instance that will skip the rewrite phase when rendering elements. From b093218ce46c4662940d1ca832791a459bc6c02c Mon Sep 17 00:00:00 2001 From: abby-ql Date: Mon, 2 Mar 2026 16:24:20 +0000 Subject: [PATCH 03/10] Render InternalTarget as absolute with configured baseUrl --- .../laika/internal/render/HTMLRenderer.scala | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) 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..ec2747b96 100644 --- a/core/shared/src/main/scala/laika/internal/render/HTMLRenderer.scala +++ b/core/shared/src/main/scala/laika/internal/render/HTMLRenderer.scala @@ -155,11 +155,33 @@ 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 + fmt.internalTargetsAbsoluteBaseUrl match { + + case Some(base) => + val abs = int.render(internalTargetsAbsolute = true) + val (pathPart, fragPart) = abs.split("#", 2) match { + case Array(p) => (p, "") + case Array(p, f) => (p, "#" + f) + } + val strippedPath = + if (pathPart.endsWith("/index.html")) pathPart.stripSuffix("index.html") + else pathPart + val normalizedBase = + if (base == "/") "" + else "/" + base.stripPrefix("/").stripSuffix("/") + val combined = + if (normalizedBase.isEmpty) strippedPath + fragPart + else normalizedBase + strippedPath + fragPart + + combined + + 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 = { From 74f7b679fa5e1ea093e3a5d9c16830fa8ffe6ba3 Mon Sep 17 00:00:00 2001 From: abby-ql Date: Mon, 2 Mar 2026 16:24:46 +0000 Subject: [PATCH 04/10] Add more tests --- .../scala/laika/io/TreeRendererSpec.scala | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/io/src/test/scala/laika/io/TreeRendererSpec.scala b/io/src/test/scala/laika/io/TreeRendererSpec.scala index 84ae372de..3f83cccda 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,79 @@ 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/"""")) + assert(rendered404Project.content.contains("""href="/project/guide/#install"""")) + assert(renderedDoc.content.contains("""href="other.html"""")) + } + } + } From 197d1d64d64a37e00b8953bb49709757cdbd16e7 Mon Sep 17 00:00:00 2001 From: abby-ql Date: Mon, 2 Mar 2026 16:26:17 +0000 Subject: [PATCH 05/10] formatting --- Option[Config] | 0 Option[PathAttributes] | 0 Option[PathAttributes], | 0 core/shared/src/main/scala/laika/api/Renderer.scala | 2 +- .../src/main/scala/laika/api/format/Formatter.scala | 6 +++--- .../scala/laika/internal/render/HTMLRenderer.scala | 10 +++++----- io/src/test/scala/laika/io/TreeRendererSpec.scala | 5 ++--- 7 files changed, 11 insertions(+), 12 deletions(-) delete mode 100644 Option[Config] delete mode 100644 Option[PathAttributes] delete mode 100644 Option[PathAttributes], diff --git a/Option[Config] b/Option[Config] deleted file mode 100644 index e69de29bb..000000000 diff --git a/Option[PathAttributes] b/Option[PathAttributes] deleted file mode 100644 index e69de29bb..000000000 diff --git a/Option[PathAttributes], b/Option[PathAttributes], deleted file mode 100644 index e69de29bb..000000000 diff --git a/core/shared/src/main/scala/laika/api/Renderer.scala b/core/shared/src/main/scala/laika/api/Renderer.scala index 2c7cea60c..5b0ac038c 100644 --- a/core/shared/src/main/scala/laika/api/Renderer.scala +++ b/core/shared/src/main/scala/laika/api/Renderer.scala @@ -157,7 +157,7 @@ abstract class Renderer private[laika] (val config: OperationConfig, skipRewrite val formatter = format.formatterFactory(renderContext) renderFunction(formatter, elementToRender) } - } + } } /** Creates a new instance that will skip the rewrite phase when rendering elements. 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 c283900b9..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,10 +61,10 @@ abstract class Formatter protected { */ def pathTranslator: PathTranslator = context.pathTranslator - /** The absolute base URL to use for rendering internal targets as absolute URLs.*/ + /** 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.*/ + + /** 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. 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 ec2747b96..7b78a27df 100644 --- a/core/shared/src/main/scala/laika/internal/render/HTMLRenderer.scala +++ b/core/shared/src/main/scala/laika/internal/render/HTMLRenderer.scala @@ -156,20 +156,20 @@ private[laika] class HTMLRenderer(format: String) case ext: ExternalTarget => ext.url case int: InternalTarget => fmt.internalTargetsAbsoluteBaseUrl match { - + case Some(base) => - val abs = int.render(internalTargetsAbsolute = true) + val abs = int.render(internalTargetsAbsolute = true) val (pathPart, fragPart) = abs.split("#", 2) match { case Array(p) => (p, "") case Array(p, f) => (p, "#" + f) } - val strippedPath = + val strippedPath = if (pathPart.endsWith("/index.html")) pathPart.stripSuffix("index.html") else pathPart - val normalizedBase = + val normalizedBase = if (base == "/") "" else "/" + base.stripPrefix("/").stripSuffix("/") - val combined = + val combined = if (normalizedBase.isEmpty) strippedPath + fragPart else normalizedBase + strippedPath + fragPart diff --git a/io/src/test/scala/laika/io/TreeRendererSpec.scala b/io/src/test/scala/laika/io/TreeRendererSpec.scala index 3f83cccda..b513810d5 100644 --- a/io/src/test/scala/laika/io/TreeRendererSpec.scala +++ b/io/src/test/scala/laika/io/TreeRendererSpec.scala @@ -1367,12 +1367,11 @@ class TreeRendererSpec extends CatsEffectSuite .toMemory .render ).map { result => - - val rendered404Root = + val rendered404Root = result.allDocuments.find(_.path == Root / "404.html").get val rendered404Project = result.allDocuments.find(_.path == Root / "404-project.html").get - val renderedDoc = + val renderedDoc = result.allDocuments.find(_.path == Root / "doc.html").get assert(rendered404Root.content.contains("""href="/doc.html"""")) From c6b03a950d7e2114cd783afb0fbccfbdf214395d Mon Sep 17 00:00:00 2001 From: abby-ql Date: Mon, 2 Mar 2026 16:59:02 +0000 Subject: [PATCH 06/10] Fix non exhaustive matching --- .../src/main/scala/laika/internal/render/HTMLRenderer.scala | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 7b78a27df..2ff7fb425 100644 --- a/core/shared/src/main/scala/laika/internal/render/HTMLRenderer.scala +++ b/core/shared/src/main/scala/laika/internal/render/HTMLRenderer.scala @@ -160,8 +160,10 @@ private[laika] class HTMLRenderer(format: String) case Some(base) => val abs = int.render(internalTargetsAbsolute = true) val (pathPart, fragPart) = abs.split("#", 2) match { - case Array(p) => (p, "") - case Array(p, f) => (p, "#" + f) + case Array(p) => (p, "") + case Array(p, f) => (p, "#" + f) + case Array() => ("", "") + } } val strippedPath = if (pathPart.endsWith("/index.html")) pathPart.stripSuffix("index.html") From f7217ccf3bad1dc35b714a5d5e9df83a5df9aea7 Mon Sep 17 00:00:00 2001 From: abby-ql Date: Mon, 2 Mar 2026 17:01:39 +0000 Subject: [PATCH 07/10] Fix bracket --- .../src/main/scala/laika/internal/render/HTMLRenderer.scala | 1 - 1 file changed, 1 deletion(-) 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 2ff7fb425..b7d69a6fa 100644 --- a/core/shared/src/main/scala/laika/internal/render/HTMLRenderer.scala +++ b/core/shared/src/main/scala/laika/internal/render/HTMLRenderer.scala @@ -164,7 +164,6 @@ private[laika] class HTMLRenderer(format: String) case Array(p, f) => (p, "#" + f) case Array() => ("", "") } - } val strippedPath = if (pathPart.endsWith("/index.html")) pathPart.stripSuffix("index.html") else pathPart From 3dca00458725761180d8705a10438aa9cb048e55 Mon Sep 17 00:00:00 2001 From: abby-ql Date: Mon, 2 Mar 2026 17:06:32 +0000 Subject: [PATCH 08/10] Formatting again --- .../src/main/scala/laika/internal/render/HTMLRenderer.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 b7d69a6fa..c34c2f96d 100644 --- a/core/shared/src/main/scala/laika/internal/render/HTMLRenderer.scala +++ b/core/shared/src/main/scala/laika/internal/render/HTMLRenderer.scala @@ -160,9 +160,9 @@ private[laika] class HTMLRenderer(format: String) case Some(base) => val abs = int.render(internalTargetsAbsolute = true) val (pathPart, fragPart) = abs.split("#", 2) match { - case Array(p) => (p, "") - case Array(p, f) => (p, "#" + f) - case Array() => ("", "") + case Array(p) => (p, "") + case Array(p, f) => (p, "#" + f) + case Array() => ("", "") } val strippedPath = if (pathPart.endsWith("/index.html")) pathPart.stripSuffix("index.html") From 3bdf462553e272d47f7ae0aefdf7397e6f165ab1 Mon Sep 17 00:00:00 2001 From: abby-ql Date: Mon, 2 Mar 2026 18:18:53 +0000 Subject: [PATCH 09/10] Removed logic dealing with fragments --- .../laika/internal/render/HTMLRenderer.scala | 21 +++++-------------- .../scala/laika/io/TreeRendererSpec.scala | 8 +++---- 2 files changed, 9 insertions(+), 20 deletions(-) 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 c34c2f96d..c1b5bdec0 100644 --- a/core/shared/src/main/scala/laika/internal/render/HTMLRenderer.scala +++ b/core/shared/src/main/scala/laika/internal/render/HTMLRenderer.scala @@ -152,29 +152,18 @@ private[laika] class HTMLRenderer(format: String) } } - def renderTarget(target: Target): String = fmt.pathTranslator.translate(target) match { + 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 (pathPart, fragPart) = abs.split("#", 2) match { - case Array(p) => (p, "") - case Array(p, f) => (p, "#" + f) - case Array() => ("", "") - } - val strippedPath = - if (pathPart.endsWith("/index.html")) pathPart.stripSuffix("index.html") - else pathPart - val normalizedBase = + val abs = int.render(internalTargetsAbsolute = true) + val normalizedBase = if (base == "/") "" else "/" + base.stripPrefix("/").stripSuffix("/") - val combined = - if (normalizedBase.isEmpty) strippedPath + fragPart - else normalizedBase + strippedPath + fragPart - - combined + if (normalizedBase.isEmpty) abs else normalizedBase + abs case None => val relPath = int.relativeTo(fmt.path).relativePath diff --git a/io/src/test/scala/laika/io/TreeRendererSpec.scala b/io/src/test/scala/laika/io/TreeRendererSpec.scala index b513810d5..79cbfcd61 100644 --- a/io/src/test/scala/laika/io/TreeRendererSpec.scala +++ b/io/src/test/scala/laika/io/TreeRendererSpec.scala @@ -1367,17 +1367,17 @@ class TreeRendererSpec extends CatsEffectSuite .toMemory .render ).map { result => - val rendered404Root = + val rendered404Root = result.allDocuments.find(_.path == Root / "404.html").get val rendered404Project = result.allDocuments.find(_.path == Root / "404-project.html").get - val renderedDoc = + 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/"""")) - assert(rendered404Project.content.contains("""href="/project/guide/#install"""")) + 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"""")) } } From 0766c23ac0f6f960f6b4d0974421e900ec1c7ff7 Mon Sep 17 00:00:00 2001 From: abby-ql Date: Mon, 2 Mar 2026 18:23:29 +0000 Subject: [PATCH 10/10] Formatting again --- .../laika/internal/render/HTMLRenderer.scala | 38 +++++++++---------- .../scala/laika/io/TreeRendererSpec.scala | 4 +- 2 files changed, 21 insertions(+), 21 deletions(-) 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 c1b5bdec0..e3e83535d 100644 --- a/core/shared/src/main/scala/laika/internal/render/HTMLRenderer.scala +++ b/core/shared/src/main/scala/laika/internal/render/HTMLRenderer.scala @@ -154,25 +154,25 @@ private[laika] class HTMLRenderer(format: String) 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 - } - } + 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 79cbfcd61..b13285a26 100644 --- a/io/src/test/scala/laika/io/TreeRendererSpec.scala +++ b/io/src/test/scala/laika/io/TreeRendererSpec.scala @@ -1367,11 +1367,11 @@ class TreeRendererSpec extends CatsEffectSuite .toMemory .render ).map { result => - val rendered404Root = + val rendered404Root = result.allDocuments.find(_.path == Root / "404.html").get val rendered404Project = result.allDocuments.find(_.path == Root / "404-project.html").get - val renderedDoc = + val renderedDoc = result.allDocuments.find(_.path == Root / "doc.html").get assert(rendered404Root.content.contains("""href="/doc.html""""))