From d323dfad857211bb2e22df041c24e178065d2185 Mon Sep 17 00:00:00 2001 From: Leonid Dubinsky Date: Sun, 22 Mar 2026 21:48:11 -0400 Subject: [PATCH] Add support for Markdown wiki links (disable by default) --- .../scala/zio/blocks/docs/HtmlRenderer.scala | 11 ++++++ .../main/scala/zio/blocks/docs/Inline.scala | 24 ++++++++++++- .../main/scala/zio/blocks/docs/Parser.scala | 34 ++++++++++++++++--- .../scala/zio/blocks/docs/ParserConfig.scala | 31 +++++++++++++++++ .../main/scala/zio/blocks/docs/Renderer.scala | 7 ++++ .../zio/blocks/docs/TerminalRenderer.scala | 11 ++++++ .../scala/zio/blocks/docs/ParserSpec.scala | 19 +++++++++++ .../scala/zio/blocks/docs/RendererSpec.scala | 16 +++++++++ .../scala/zio/blocks/docs/RoundTripSpec.scala | 14 ++++++++ 9 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 markdown/shared/src/main/scala/zio/blocks/docs/ParserConfig.scala diff --git a/markdown/shared/src/main/scala/zio/blocks/docs/HtmlRenderer.scala b/markdown/shared/src/main/scala/zio/blocks/docs/HtmlRenderer.scala index 0fdbfbf453..bccfe4605d 100644 --- a/markdown/shared/src/main/scala/zio/blocks/docs/HtmlRenderer.scala +++ b/markdown/shared/src/main/scala/zio/blocks/docs/HtmlRenderer.scala @@ -218,6 +218,17 @@ object HtmlRenderer { case None => s"""${renderInlines(text)}""" } + case WikiLink(url, text) => + text match { + case Some(text) => s"""$text}""" + case None => s"""$url""" + } + case Inline.WikiLink(url, text) => + text match { + case Some(text) => s"""$text}""" + case None => s"""$url""" + } + case Image(alt, url, titleOpt) => titleOpt match { case Some(title) => s"""${escape(alt)}""" diff --git a/markdown/shared/src/main/scala/zio/blocks/docs/Inline.scala b/markdown/shared/src/main/scala/zio/blocks/docs/Inline.scala index 5570ff5879..ae6b659d7e 100644 --- a/markdown/shared/src/main/scala/zio/blocks/docs/Inline.scala +++ b/markdown/shared/src/main/scala/zio/blocks/docs/Inline.scala @@ -19,7 +19,7 @@ package zio.blocks.docs import zio.blocks.chunk.Chunk /** - * An inline markdown element. + * An inline Markdown element. * * Inline elements are the content within block elements, such as text, * emphasis, links, and code spans. @@ -80,6 +80,17 @@ object Inline { */ final case class Link(text: Chunk[Inline], url: String, title: Option[String]) extends Inline + /** + * A wiki link. + * + * @param url + * Wiki link URL + * + * @param text + * Wiki link text + */ + final case class WikiLink(url: String, text: Option[String]) extends Inline + /** * An image. * @@ -169,6 +180,17 @@ final case class Strikethrough(content: Chunk[Inline]) extends Inline */ final case class Link(text: Chunk[Inline], url: String, title: Option[String]) extends Inline +/** + * A wiki link. + * + * @param url + * Wiki link URL + * + * @param text + * Wiki link text + */ +final case class WikiLink(url: String, text: Option[String]) extends Inline + /** * An image. * diff --git a/markdown/shared/src/main/scala/zio/blocks/docs/Parser.scala b/markdown/shared/src/main/scala/zio/blocks/docs/Parser.scala index ec8a925dd7..ba1d20e02c 100644 --- a/markdown/shared/src/main/scala/zio/blocks/docs/Parser.scala +++ b/markdown/shared/src/main/scala/zio/blocks/docs/Parser.scala @@ -59,11 +59,16 @@ object Parser { * $${err.line}: $${err.message}") } }}} */ def parse(input: String): Either[ParseError, Doc] = { - val state = new ParserState(input) + val state = new ParserState(input, ParserConfig.default) state.parseDocument() } - private class ParserState(input: String) { + def parse(input: String, config: ParserConfig): Either[ParseError, Doc] = { + val state = new ParserState(input, config) + state.parseDocument() + } + + private class ParserState(input: String, config: ParserConfig) { private val lines: Array[String] = input.split("\n", -1) private var lineIndex: Int = 0 @@ -212,7 +217,7 @@ object Parser { } val innerInput = quoteLines.result().toList.mkString("\n") - val innerState = new ParserState(innerInput) + val innerState = new ParserState(innerInput, config) innerState.parseBlocks().map(blocks => BlockQuote(blocks)) } @@ -254,7 +259,7 @@ object Parser { } val itemText = continuationLines.result().toList.mkString("\n") - val innerState = new ParserState(itemText) + val innerState = new ParserState(itemText, config) val innerBlocks = innerState.parseBlocks().getOrElse(Chunk.empty) val finalBlocks = @@ -312,7 +317,7 @@ object Parser { } val itemText = continuationLines.result().toList.mkString("\n") - val innerState = new ParserState(itemText) + val innerState = new ParserState(itemText, config) val innerBlocks = innerState.parseBlocks().getOrElse(Chunk.empty) val finalBlocks = @@ -553,6 +558,12 @@ object Parser { result += Text("`") pos += 1 } + } else if (config.processWikiLinks && remaining.startsWith("[[") && remaining.substring(2).contains("]]")) { + val closeBrackets = remaining.indexOf("]]", 2) + if (closeBrackets > 2) { + result += parseWikiLink(remaining.substring(2, closeBrackets)) + pos += closeBrackets + 2 + } } else if (remaining.startsWith("![")) { val closeBracket = remaining.indexOf(']', 2) if (closeBracket > 2 && remaining.length > closeBracket + 1 && remaining(closeBracket + 1) == '(') { @@ -652,6 +663,19 @@ object Parser { } } + private def parseWikiLink(body: String): WikiLink = { + val trimmed = body.trim + val pipeIdx = trimmed.indexOf('|') + if (pipeIdx > 0) { + WikiLink( + trimmed.substring(0, pipeIdx).trim, + Some(trimmed.substring(pipeIdx + 1).trim) + ) + } else { + WikiLink(trimmed, None) + } + } + private def findNextSpecial(text: String): Int = { var i = 0 while (i < text.length) { diff --git a/markdown/shared/src/main/scala/zio/blocks/docs/ParserConfig.scala b/markdown/shared/src/main/scala/zio/blocks/docs/ParserConfig.scala new file mode 100644 index 0000000000..b262db4dff --- /dev/null +++ b/markdown/shared/src/main/scala/zio/blocks/docs/ParserConfig.scala @@ -0,0 +1,31 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.docs + +/** + * Configuration for [[Parser]]. + * + * @param processWikiLinks + * If true, process wiki links + */ +final case class ParserConfig( + processWikiLinks: Boolean = false +) + +object ParserConfig { + val default: ParserConfig = ParserConfig() +} diff --git a/markdown/shared/src/main/scala/zio/blocks/docs/Renderer.scala b/markdown/shared/src/main/scala/zio/blocks/docs/Renderer.scala index 761f754086..ce695c5c29 100644 --- a/markdown/shared/src/main/scala/zio/blocks/docs/Renderer.scala +++ b/markdown/shared/src/main/scala/zio/blocks/docs/Renderer.scala @@ -244,6 +244,13 @@ object Renderer { val titleStr = titleOpt.map(t => s""" "$t"""").getOrElse("") s"![${alt}]($url$titleStr)" + case WikiLink(url, text) => + val textStr = text.map(text => s" | $text").getOrElse("") + s"[[$url$textStr]]" + case Inline.WikiLink(url, text) => + val textStr = text.map(text => s" | $text").getOrElse("") + s"[[$url$textStr]]" + case HtmlInline(content) => content case Inline.HtmlInline(content) => diff --git a/markdown/shared/src/main/scala/zio/blocks/docs/TerminalRenderer.scala b/markdown/shared/src/main/scala/zio/blocks/docs/TerminalRenderer.scala index 5e0183b3f1..54c2012630 100644 --- a/markdown/shared/src/main/scala/zio/blocks/docs/TerminalRenderer.scala +++ b/markdown/shared/src/main/scala/zio/blocks/docs/TerminalRenderer.scala @@ -245,6 +245,17 @@ object TerminalRenderer { case Inline.Image(alt, url, _) => s"[Image: $alt] ($url)" + case WikiLink(url, text) => + text match { + case None => s"$Blue$Underline$url$Reset" + case Some(text) => s"$Blue$Underline$text$Reset ($url)" + } + case Inline.WikiLink(url, text) => + text match { + case None => s"$Blue$Underline$url$Reset" + case Some(text) => s"$Blue$Underline$text$Reset ($url)" + } + case HtmlInline(html) => html case Inline.HtmlInline(html) => diff --git a/markdown/shared/src/test/scala/zio/blocks/docs/ParserSpec.scala b/markdown/shared/src/test/scala/zio/blocks/docs/ParserSpec.scala index 905b993dfb..8dac3c3a36 100644 --- a/markdown/shared/src/test/scala/zio/blocks/docs/ParserSpec.scala +++ b/markdown/shared/src/test/scala/zio/blocks/docs/ParserSpec.scala @@ -506,6 +506,25 @@ object ParserSpec extends MarkdownBaseSpec { link.title == Some("title") ) }, + test("parses wiki link") { + val result = Parser.parse("[[url]]", ParserConfig.default.copy(processWikiLinks = true)) + val para = result.toOption.get.blocks.head.asInstanceOf[Paragraph] + println(result.toOption.get.blocks.head) + val link = para.content.head.asInstanceOf[WikiLink] + assertTrue( + link.url == "url", + link.text.isEmpty + ) + }, + test("parses wiki link with text") { + val result = Parser.parse("[[url | text]]", ParserConfig.default.copy(processWikiLinks = true)) + val para = result.toOption.get.blocks.head.asInstanceOf[Paragraph] + val link = para.content.head.asInstanceOf[WikiLink] + assertTrue( + link.url == "url", + link.text == Some("text") + ) + }, test("parses image") { val result = Parser.parse("![alt](url)") val para = result.toOption.get.blocks.head.asInstanceOf[Paragraph] diff --git a/markdown/shared/src/test/scala/zio/blocks/docs/RendererSpec.scala b/markdown/shared/src/test/scala/zio/blocks/docs/RendererSpec.scala index 1773508b2a..24fcb00349 100644 --- a/markdown/shared/src/test/scala/zio/blocks/docs/RendererSpec.scala +++ b/markdown/shared/src/test/scala/zio/blocks/docs/RendererSpec.scala @@ -245,6 +245,14 @@ object RendererSpec extends MarkdownBaseSpec { val doc = Doc(Chunk(Paragraph(Chunk(Link(Chunk(Text("text")), "url", Some("title")))))) assertTrue(Renderer.render(doc) == "[text](url \"title\")\n\n") }, + test("renders wiki link") { + val doc = Doc(Chunk(Paragraph(Chunk(WikiLink("a wiki link", None))))) + assertTrue(Renderer.render(doc) == "[[a wiki link]]\n\n") + }, + test("renders wiki link with text") { + val doc = Doc(Chunk(Paragraph(Chunk(WikiLink("a wiki link", Some("text")))))) + assertTrue(Renderer.render(doc) == "[[a wiki link | text]]\n\n") + }, test("renders image without title") { val doc = Doc(Chunk(Paragraph(Chunk(Image("alt", "url", None))))) assertTrue(Renderer.render(doc) == "![alt](url)\n\n") @@ -333,6 +341,14 @@ object RendererSpec extends MarkdownBaseSpec { ) assertTrue(Renderer.render(doc) == "[text](url \"title\")\n\n") }, + test("renders Inline.WikiLink") { + val doc = Doc(Chunk(Paragraph(Chunk(WikiLink("a wiki link", None))))) + assertTrue(Renderer.render(doc) == "[[a wiki link]]\n\n") + }, + test("renders Inline.WikiLink with text") { + val doc = Doc(Chunk(Paragraph(Chunk(WikiLink("a wiki link", Some("text")))))) + assertTrue(Renderer.render(doc) == "[[a wiki link | text]]\n\n") + }, test("renders Inline.Image without title") { val doc = Doc(Chunk(Paragraph(Chunk(Inline.Image("alt", "url", None))))) assertTrue(Renderer.render(doc) == "![alt](url)\n\n") diff --git a/markdown/shared/src/test/scala/zio/blocks/docs/RoundTripSpec.scala b/markdown/shared/src/test/scala/zio/blocks/docs/RoundTripSpec.scala index d0093ce7f6..1ab767ab81 100644 --- a/markdown/shared/src/test/scala/zio/blocks/docs/RoundTripSpec.scala +++ b/markdown/shared/src/test/scala/zio/blocks/docs/RoundTripSpec.scala @@ -122,6 +122,20 @@ object RoundTripSpec extends MarkdownBaseSpec { val reparsed = rendered.flatMap(Parser.parse) assertTrue(parsed == reparsed) }, + test("wiki link round-trips") { + val input = "[[url]]\n\n" + val parsed = Parser.parse(input) + val rendered = parsed.map(Renderer.render) + val reparsed = rendered.flatMap(Parser.parse) + assertTrue(parsed == reparsed) + }, + test("wiki link with text round-trips") { + val input = "[[url | text]]\n\n" + val parsed = Parser.parse(input) + val rendered = parsed.map(Renderer.render) + val reparsed = rendered.flatMap(Parser.parse) + assertTrue(parsed == reparsed) + }, test("image round-trips") { val input = "![alt](url)\n\n" val parsed = Parser.parse(input)