From 0e3b1c2dfdec57ad0d521a232d49d0a0fb7a1867 Mon Sep 17 00:00:00 2001 From: Nabil Abdel-Hafeez <7283535+987Nabil@users.noreply.github.com> Date: Wed, 20 Sep 2023 06:15:08 +0200 Subject: [PATCH] Add `md` interpolator to create Markdown based docs Also distinguish between inline code and code block docs --- .../scala-2/zio/http/MdInterpolator.scala | 26 +++++ .../scala-3/zio/http/MdInterpolator.scala | 27 ++++++ .../src/main/scala/zio/http/codec/Doc.scala | 94 ++++++++++++++----- .../src/main/scala/zio/http/package.scala | 2 +- .../http/{endpoint => codec}/DocSpec.scala | 19 +++- .../zio/http/codec/RichTextCodecSpec.scala | 4 +- 6 files changed, 140 insertions(+), 32 deletions(-) create mode 100644 zio-http/src/main/scala-2/zio/http/MdInterpolator.scala create mode 100644 zio-http/src/main/scala-3/zio/http/MdInterpolator.scala rename zio-http/src/test/scala/zio/http/{endpoint => codec}/DocSpec.scala (94%) diff --git a/zio-http/src/main/scala-2/zio/http/MdInterpolator.scala b/zio-http/src/main/scala-2/zio/http/MdInterpolator.scala new file mode 100644 index 0000000000..b0ccb37a69 --- /dev/null +++ b/zio-http/src/main/scala-2/zio/http/MdInterpolator.scala @@ -0,0 +1,26 @@ +/* + * Copyright 2021 - 2023 Sporta Technologies PVT LTD & the ZIO HTTP 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.http + +import zio.http.codec.Doc + +trait MdInterpolator { + + implicit class MdInterpolatorHelper(val sc: StringContext) { + def md(args: Any*): Doc = Doc.fromCommonMark(sc.s(args: _*).stripMargin) + } +} diff --git a/zio-http/src/main/scala-3/zio/http/MdInterpolator.scala b/zio-http/src/main/scala-3/zio/http/MdInterpolator.scala new file mode 100644 index 0000000000..c5e8e5e9e3 --- /dev/null +++ b/zio-http/src/main/scala-3/zio/http/MdInterpolator.scala @@ -0,0 +1,27 @@ +/* + * Copyright 2021 - 2023 Sporta Technologies PVT LTD & the ZIO HTTP 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.http + +import zio.http.codec.Doc + +trait MdInterpolator { + + extension(inline sc: StringContext) { + inline def md(inline args: Any*): Doc = Doc.fromCommonMark(sc.s(args*).stripMargin) + } + +} \ No newline at end of file diff --git a/zio-http/src/main/scala/zio/http/codec/Doc.scala b/zio-http/src/main/scala/zio/http/codec/Doc.scala index 0908df0e12..4b3cf5a01c 100644 --- a/zio-http/src/main/scala/zio/http/codec/Doc.scala +++ b/zio-http/src/main/scala/zio/http/codec/Doc.scala @@ -16,8 +16,7 @@ package zio.http.codec -import zio.stacktracer.TracingImplicits.disableAutoTrace - +import zio.http.codec.Doc.Span.CodeStyle import zio.http.template /** @@ -39,6 +38,7 @@ sealed trait Doc { self => case Doc.DescriptionList(xs) => xs.forall(_._2.isEmpty) case Doc.Sequence(left, right) => left.isEmpty && right.isEmpty case Doc.Listing(xs, _) => xs.forall(_.isEmpty) + case Doc.Raw(value, _) => value.isEmpty case _ => false } @@ -49,18 +49,20 @@ sealed trait Doc { self => def render(s: String): String = " " * indent + s span match { - case Span.Text(value) => render(value) - case Span.Code(value) => render(s"```${value.trim}```") - case Span.Link(value, text) => render(s"[${text.getOrElse(value)}]($value)") - case Span.Bold(value) => + case Span.Text(value) => render(value) + case Span.Code(value, CodeStyle.Block) => render(s"```${value.trim}\n```") + case Span.Code(value, CodeStyle.Inline) => render(s"`$value`") + case Span.Link(value, text) => render(s"[${text.getOrElse(value)}]($value)") + case Span.Bold(value) => s"${render("**")}${renderSpan(value, indent).trim}${render("**")}" - case Span.Italic(value) => + case Span.Italic(value) => s"${render("*")}${renderSpan(value, indent).trim}${render("*")}" - case Span.Error(value) => + case Span.Error(value) => s"${render(s"""""")}${render(value)}${render("")}" - case Span.Sequence(left, right) => - renderSpan(left, indent) - renderSpan(right, indent) + case Span.Sequence(left, right) => + val l = renderSpan(left, indent) + val r = renderSpan(right, indent) + s"$l$r" } } @@ -75,6 +77,7 @@ sealed trait Doc { self => case Doc.Empty => () case Doc.Header(value, level) => + if (writer.nonEmpty && writer.last != '\n') append("\n") append(s"${"#" * level} $value\n\n") case Doc.Paragraph(value) => @@ -94,8 +97,8 @@ sealed trait Doc { self => if (listingType == ListingType.Ordered) append(s"${i + 1}. ") else append("- ") doc match { case Listing(_, _) => - writer.append("\n") render(doc, indent + 1) + writer.append("\n") case Sequence(left, right) => render(left, indent) writer.deleteCharAt(writer.length - 1) @@ -110,6 +113,12 @@ sealed trait Doc { self => render(left, indent) render(right, indent) + case Doc.Raw(value, RawDocType.CommonMark) => + writer.append(value) + + case Doc.Raw(_, docType) => + throw new IllegalArgumentException(s"Unsupported raw doc type: $docType") + } } @@ -155,6 +164,11 @@ sealed trait Doc { self => case ListingType.Unordered => ul(elementsHtml) case ListingType.Ordered => ol(elementsHtml) } + + case Raw(value, RawDocType.Html) => + Html.fromString(value) + case Raw(_, docType) => + throw new IllegalArgumentException(s"Unsupported raw doc type: $docType") } } @@ -243,6 +257,12 @@ sealed trait Doc { self => case Doc.Sequence(left, right) => renderHelpDoc(left) renderHelpDoc(right) + + case Doc.Raw(value, RawDocType.Plain) => + writer.append(value) + + case Doc.Raw(_, docType) => + throw new IllegalArgumentException(s"Unsupported raw doc type: $docType") } def renderSpan(span: Span): Unit = { @@ -256,7 +276,7 @@ sealed trait Doc { self => writer.append(if (uppercase) value.toUpperCase() else value) - case Span.Code(value) => + case Span.Code(value, _) => setStyle(Console.WHITE) writer.append(value) resetStyle() @@ -294,7 +314,19 @@ sealed trait Doc { self => } object Doc { + + def fromCommonMark(commonMark: String): Doc = + Doc.Raw(commonMark, RawDocType.CommonMark) + + private sealed trait RawDocType + private object RawDocType { + case object Plain extends RawDocType + case object CommonMark extends RawDocType + case object Html extends RawDocType + } + case object Empty extends Doc + private final case class Raw(value: String, docType: RawDocType) extends Doc final case class Header(value: String, level: Int) extends Doc final case class Paragraph(value: Span) extends Doc final case class DescriptionList(definitions: List[(Span, Doc)]) extends Doc @@ -327,19 +359,23 @@ object Doc { def h3(t: String): Doc = Header(t, 3) def h4(t: String): Doc = Header(t, 4) def h5(t: String): Doc = Header(t, 5) + def h6(t: String): Doc = Header(t, 6) def p(t: String): Doc = Doc.Paragraph(Span.text(t)) def p(span: Span): Doc = Doc.Paragraph(span) sealed trait Span { self => - final def +(that: Span): Span = Span.Sequence(self, that) + final def +(that: Span): Span = + if (self.isEmpty) that + else if (that.isEmpty) self + else Span.Sequence(self, that) final def isEmpty: Boolean = self.size == 0 final def size: Int = self match { case Span.Text(value) => value.length - case Span.Code(value) => value.length + case Span.Code(value, _) => value.length case Span.Error(value) => value.length case Span.Bold(value) => value.size case Span.Italic(value) => value.size @@ -351,27 +387,35 @@ object Doc { import template._ self match { - case Span.Text(value) => value - case Span.Code(value) => code(value) - case Span.Error(value) => span(styleAttr := ("color", "red") :: Nil, value) - case Span.Bold(value) => b(value.toHtml) - case Span.Italic(value) => i(value.toHtml) - case Span.Link(value, text) => + case Span.Text(value) => value + case Span.Code(value, CodeStyle.Block) => pre(code(value)) + case Span.Code(value, CodeStyle.Inline) => code(value) + case Span.Error(value) => span(styleAttr := ("color", "red") :: Nil, value) + case Span.Bold(value) => b(value.toHtml) + case Span.Italic(value) => i(value.toHtml) + case Span.Link(value, text) => a(href := value.toASCIIString, Html.fromString(text.getOrElse(value.toASCIIString))) - case Span.Sequence(left, right) => left.toHtml ++ right.toHtml + case Span.Sequence(left, right) => left.toHtml ++ right.toHtml } } } object Span { final case class Text(value: String) extends Span - final case class Code(value: String) extends Span + final case class Code(value: String, codeStyle: CodeStyle) extends Span final case class Error(value: String) extends Span final case class Bold(value: Span) extends Span final case class Italic(value: Span) extends Span final case class Link(value: java.net.URI, text: Option[String]) extends Span final case class Sequence(left: Span, right: Span) extends Span - def code(t: String): Span = Span.Code(t) + sealed trait CodeStyle + object CodeStyle { + case object Inline extends CodeStyle + case object Block extends CodeStyle + } + + def code(t: String): Span = Span.Code(t, CodeStyle.Inline) + def codeBlock(t: String): Span = Span.Code(t, CodeStyle.Block) def empty: Span = Span.text("") def error(t: String): Span = Span.Error(t) def bold(span: Span): Span = Span.Bold(span) @@ -380,7 +424,7 @@ object Doc { def italic(t: String): Span = Span.Italic(text(t)) def text(t: String): Span = Span.Text(t) def link(uri: java.net.URI): Span = Span.Link(uri, None) - def link(uri: java.net.URI, text: String): Span = Span.Link(uri, Some(text)) + def link(uri: java.net.URI, text: String): Span = Span.Link(uri, Some(text).filter(_.nonEmpty)) def spans(span: Span, spans0: Span*): Span = spans(span :: spans0.toList) def spans(spans: Iterable[Span]): Span = spans.toList.foldLeft(empty)(_ + _) diff --git a/zio-http/src/main/scala/zio/http/package.scala b/zio-http/src/main/scala/zio/http/package.scala index 5ac8a96abe..8933dede59 100644 --- a/zio-http/src/main/scala/zio/http/package.scala +++ b/zio-http/src/main/scala/zio/http/package.scala @@ -20,7 +20,7 @@ import java.util.UUID import zio.http.codec.PathCodec -package object http extends UrlInterpolator { +package object http extends UrlInterpolator with MdInterpolator { /** * A smart constructor that attempts to construct a handler from the specified diff --git a/zio-http/src/test/scala/zio/http/endpoint/DocSpec.scala b/zio-http/src/test/scala/zio/http/codec/DocSpec.scala similarity index 94% rename from zio-http/src/test/scala/zio/http/endpoint/DocSpec.scala rename to zio-http/src/test/scala/zio/http/codec/DocSpec.scala index 184c702361..25dadafc97 100644 --- a/zio-http/src/test/scala/zio/http/endpoint/DocSpec.scala +++ b/zio-http/src/test/scala/zio/http/codec/DocSpec.scala @@ -14,12 +14,11 @@ * limitations under the License. */ -package zio.http.endpoint +package zio.http.codec import zio.test._ -import zio.http.ZIOHttpSpec -import zio.http.codec.Doc +import zio.http._ import zio.http.codec.Doc._ object DocSpec extends ZIOHttpSpec { @@ -65,7 +64,7 @@ object DocSpec extends ZIOHttpSpec { | |This is an error | - |```ZIO.succeed(1)``` + |`ZIO.succeed(1)` | |**This is strong** | @@ -208,5 +207,17 @@ object DocSpec extends ZIOHttpSpec { |""".stripMargin assertTrue(complexDoc == expected) }, + test("md interpolator") { + + val name = "John" + val age = 42 + val doc = md"""# Hello $name! + | + |You are $age years old.""" + val expected = """# Hello John! + | + |You are 42 years old.""".stripMargin + assertTrue(doc.toCommonMark == expected) + }, ) } diff --git a/zio-http/src/test/scala/zio/http/codec/RichTextCodecSpec.scala b/zio-http/src/test/scala/zio/http/codec/RichTextCodecSpec.scala index 2221c774c9..3097e5dee4 100644 --- a/zio-http/src/test/scala/zio/http/codec/RichTextCodecSpec.scala +++ b/zio-http/src/test/scala/zio/http/codec/RichTextCodecSpec.scala @@ -27,8 +27,8 @@ object RichTextCodecSpec extends ZIOHttpSpec { def textOf(doc: Doc): Option[String] = doc match { - case Doc.Paragraph(Doc.Span.Code(text)) => Some(text) - case _ => None + case Doc.Paragraph(Doc.Span.Code(text, _)) => Some(text) + case _ => None } override def spec = suite("Rich Text Codec Spec")(