From d8b1f81708548ff0bfea0c739f1550950819a803 Mon Sep 17 00:00:00 2001 From: memo Date: Sat, 30 Nov 2024 16:40:34 +0100 Subject: [PATCH] render descriptions on channel page with Markdown syntax --- CHANGELOG.md | 2 + .../templates/package-template-basic.yaml | 14 ++++- web/channel/index-dev.html | 2 + web/channel/index.html | 2 + .../main/scala/sc4pac/web/ChannelPage.scala | 55 ++++++++++++------- 5 files changed, 52 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0cf9dc..f678f4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ - improved error message if channel-build fails randomly in case old files could not be removed (#6) - improved `sc4pac` bash script to allow symlinking into path on Linux/macOS - The progress spinner animation was switched to ASCII symbols for compatibility with non-Unicode fonts in some terminals. +- The metadata text fields like `description` etc. are now rendered as Markdown. + For correct text wrapping, multiline text blocks should start with `|` instead of `>`, from now on. ## [0.4.5] - 2024-10-17 diff --git a/channel-testing/yaml/templates/package-template-basic.yaml b/channel-testing/yaml/templates/package-template-basic.yaml index ce045c3..57ec79b 100644 --- a/channel-testing/yaml/templates/package-template-basic.yaml +++ b/channel-testing/yaml/templates/package-template-basic.yaml @@ -52,15 +52,23 @@ assets: # Optional list of assets from which to extract files (zero or more) # Additional descriptive information, entirely optional. info: summary: "One-line summary" - warning: > + warning: | Special instructions to display before installation, such as bulldozing requirements. conflicts: "Notes about incompatibilities" - description: > + description: | Longer text with some details. Yet more text… - Use syntax `pkg=group:name` to reference packages in description text. + Use the syntax `pkg=group:name` to reference packages in description text. + + ### Markdown + + Common **Markdown** syntaxes like *emphasis*, [URLs](https://memo33.github.io/sc4pac/) + or lists are supported: + - one + - two + - three author: "" # original author of the content, if useful images: [ "img1.png", "img2.png" ] website: "example.org" diff --git a/web/channel/index-dev.html b/web/channel/index-dev.html index 16c1251..7398112 100644 --- a/web/channel/index-dev.html +++ b/web/channel/index-dev.html @@ -17,6 +17,8 @@ + + diff --git a/web/channel/index.html b/web/channel/index.html index 5545479..55207d6 100644 --- a/web/channel/index.html +++ b/web/channel/index.html @@ -17,6 +17,8 @@ + + diff --git a/web/src/main/scala/sc4pac/web/ChannelPage.scala b/web/src/main/scala/sc4pac/web/ChannelPage.scala index 16b3869..7af361c 100644 --- a/web/src/main/scala/sc4pac/web/ChannelPage.scala +++ b/web/src/main/scala/sc4pac/web/ChannelPage.scala @@ -36,6 +36,26 @@ object JsonData extends SharedData { parseModule(_).left.map(e => throw new IllegalArgumentException(e)).merge) } +@js.native +@js.annotation.JSGlobal +object DOMPurify extends js.Object { + def sanitize(text: String): String = js.native +} + +@js.native +@js.annotation.JSGlobal("marked") +object Marked extends js.Object { + def parse(text: String): String = js.native + def use(extensions: js.Any*): Unit = js.native +} + +@js.native +trait TokensCodespan extends js.Object { // see https://github.com/markedjs/marked/blob/7e4e3435eaca2b0f48f5aedb53d67a36170086f3/src/Tokens.ts#L57 + val `type`: String // "codespan" + val raw: String // with quotes + val text: String // without quotes +} + object ChannelPage { // val channelUrl = "http://localhost:8090/channel/" @@ -88,20 +108,15 @@ object ChannelPage { else H.code(module.orgName) /** Highlights syntax `pkg=group:name` in metadata description text. */ - def applyMarkdownPkg(line: String): H.Frag = { - val builder = Seq.newBuilder[H.Frag] - var idx = 0 - for (m <- BareModule.pkgMarkdownRegex.findAllMatchIn(line)) { - if (idx < m.start) { - builder += line.substring(idx, m.start) - } - builder += pkgNameFrag(BareModule(Organization(m.group(1)), ModuleName(m.group(2))), link = true) - idx = m.end - } - if (idx < line.length) { - builder += line.substring(idx, line.length) - } - builder.result() + def renderCodespan(codespan: TokensCodespan): String = codespan.raw match + case BareModule.pkgMarkdownRegex(group, name) => + val module = BareModule(Organization(group), ModuleName(name)) + // a raw re-implementation of pkgNameFrag + s"""${module.group.value}:${module.name.value}""" + case _ => s"${codespan.text}" + + def markdownFrag(text: String): H.Frag = { + H.raw(DOMPurify.sanitize(Marked.parse(text))) } def pkgInfoFrag(pkg: JsonData.Package) = { @@ -109,17 +124,16 @@ object ChannelPage { val b = Seq.newBuilder[H.Frag] def add(label: String, child: H.Frag): Unit = b += H.tr(H.th(label), H.td(child)) - def mkPar(text: String) = text.trim.linesIterator.map(line => H.p(applyMarkdownPkg(line))).toSeq // add("Name", pkg.name) // add("Group", pkg.group) add("Version", pkg.version) - add("Summary", if (pkg.info.summary.nonEmpty) applyMarkdownPkg(pkg.info.summary) else "-") + add("Summary", if (pkg.info.summary.nonEmpty) markdownFrag(pkg.info.summary) else "-") if (pkg.info.description.nonEmpty) - add("Description", mkPar(pkg.info.description)) + add("Description", markdownFrag(pkg.info.description)) if (pkg.info.warning.nonEmpty) - add("Warning", mkPar(pkg.info.warning)) - add("Conflicts", if (pkg.info.conflicts.isEmpty) "None" else mkPar(pkg.info.conflicts)) + add("Warning", markdownFrag(pkg.info.warning)) + add("Conflicts", if (pkg.info.conflicts.isEmpty) "None" else markdownFrag(pkg.info.conflicts)) if (pkg.info.author.nonEmpty) add("Author", pkg.info.author) if (pkg.info.website.nonEmpty) { @@ -193,7 +207,7 @@ object ChannelPage { H.table(H.id := "channelcontents")(H.tbody(items.flatMap { item => if (displayCategory2.isEmpty || item.category == displayCategory2) { // either show all or filter by category item.toBareDep match - case mod: BareModule => Some(H.tr(H.td(pkgNameFrag(mod, link = true)), H.td(applyMarkdownPkg(item.summary)))) + case mod: BareModule => Some(H.tr(H.td(pkgNameFrag(mod, link = true)), H.td(markdownFrag(item.summary)))) case _: BareAsset => None } else { None @@ -203,6 +217,7 @@ object ChannelPage { } def setupUI(): Unit = { + Marked.use(js.Dictionary("renderer" -> js.Dictionary("codespan" -> (renderCodespan: js.Function)))) val urlParams = new dom.URLSearchParams(dom.window.location.search) val pkgName = urlParams.get("pkg") if (pkgName == null) {