Skip to content

Commit

Permalink
Add Markdown renderer code and sample (#294)
Browse files Browse the repository at this point in the history
Add markdown renderer code and sample

Co-authored-by: Fabrizio Scarponi <[email protected]>
  • Loading branch information
rock3r and fscarponi authored Feb 12, 2024
1 parent de51bc3 commit 1290a0b
Show file tree
Hide file tree
Showing 49 changed files with 20,524 additions and 2 deletions.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,10 @@ The project is split in modules:
* `int-ui-decorated-window` has a standalone version of the Int UI styling values for the custom window decoration
that can be used in any Compose for Desktop app
6. `ide-laf-bridge` contains the Swing LaF bridge to use in IntelliJ Platform plugins (see more below)
7. `samples` contains the example apps, which showcase the available components:
7. `markdown` contains a few modules:
* `core` the core logic for parsing and rendering Markdown documents with Jewel, using GitHub-like styling
* `extensions` contains several extensions to the base CommonMark specs that can be used to add more features
8. `samples` contains the example apps, which showcase the available components:
* `standalone` is a regular CfD app, using the standalone theme definitions and custom window decoration
* `ide-plugin` is an IntelliJ plugin that showcases the use of the Swing Bridge

Expand Down
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ dependencies {
sarif(projects.ideLafBridge)
sarif(projects.intUi.intUiDecoratedWindow)
sarif(projects.intUi.intUiStandalone)
sarif(projects.markdown.core)
sarif(projects.markdown.extensionGfmAlerts)
sarif(projects.samples.idePlugin)
sarif(projects.samples.standalone)
sarif(projects.ui)
Expand Down
27 changes: 27 additions & 0 deletions buildSrc/src/main/kotlin/ValidatePublicApiTask.kt
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import org.gradle.api.GradleException
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.SourceTask
import org.gradle.api.tasks.TaskAction
import java.io.File
import java.util.Stack
import java.util.regex.PatternSyntaxException

@CacheableTask
open class ValidatePublicApiTask : SourceTask() {

@Input
var excludedClassRegexes: Set<String> = emptySet()

init {
group = "verification"

Expand All @@ -26,11 +31,20 @@ open class ValidatePublicApiTask : SourceTask() {
logger.info("Validating ${source.files.size} API file(s)...")

val violations = mutableMapOf<File, Set<String>>()
val excludedRegexes = excludedClassRegexes.map {
try {
it.toRegex()
} catch (ignored: PatternSyntaxException) {
throw GradleException("Invalid data exclusion regex: '$it'")
}
}.toSet()

inputs.files.forEach { apiFile ->
logger.lifecycle("Validating public API from file ${apiFile.path}")

apiFile.useLines { lines ->
val actualDataClasses = findDataClasses(lines)
.filterExclusions(excludedRegexes)

if (actualDataClasses.isNotEmpty()) {
violations[apiFile] = actualDataClasses
Expand Down Expand Up @@ -92,6 +106,19 @@ open class ValidatePublicApiTask : SourceTask() {
.keys
return actualDataClasses
}

private fun Set<String>.filterExclusions(excludedRegexes: Set<Regex>): Set<String> {
if (excludedRegexes.isEmpty()) return this

return filterNot { dataClassFqn ->
val isExcluded = excludedRegexes.any { it.matchEntire(dataClassFqn) != null }

if (isExcluded) {
logger.info(" Ignoring excluded data class $dataClassFqn")
}
isExcluded
}.toSet()
}
}

@Suppress("DataClassShouldBeImmutable") // Only used in a loop, saves memory and is faster
Expand Down
9 changes: 9 additions & 0 deletions buildSrc/src/main/kotlin/jewel-check-public-api.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
@file:Suppress("UnstableApiUsage")

import org.jetbrains.jewel.buildlogic.apivalidation.ApiValidationExtension

plugins {
id("org.jetbrains.kotlinx.binary-compatibility-validator")
id("dev.drewhamilton.poko")
Expand All @@ -23,12 +25,19 @@ kotlin {
explicitApi()
}

val extension = project.extensions.create("publicApiValidation", ApiValidationExtension::class.java)

with(extension) {
excludedClassRegexes.convention(emptySet())
}

tasks {
val validatePublicApi =
register<ValidatePublicApiTask>("validatePublicApi") {
include { it.file.extension == "api" }
source(project.fileTree("api"))
dependsOn(named("apiCheck"))
excludedClassRegexes = project.the<ApiValidationExtension>().excludedClassRegexes.get()
}

named("check") { dependsOn(validatePublicApi) }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.jetbrains.jewel.buildlogic.apivalidation

import org.gradle.api.provider.SetProperty

interface ApiValidationExtension {

val excludedClassRegexes: SetProperty<String>
}
5 changes: 5 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
[versions]
commonmark = "0.21.0"
composeDesktop = "1.6.0-dev1397"
detekt = "1.23.4"
dokka = "1.8.20"
Expand All @@ -14,6 +15,10 @@ kotlinxBinaryCompat = "0.14.0"
poko = "0.13.1"

[libraries]
commonmark-core = { module = "org.commonmark:commonmark", version.ref = "commonmark" }

filePicker = { module = "com.darkrockstudios:mpfilepicker", version = "3.1.0" }

kotlinSarif = { module = "io.github.detekt.sarif4k:sarif4k", version.ref = "kotlinSarif" }
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
Expand Down
121 changes: 121 additions & 0 deletions markdown/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
## Jewel Markdown Renderer

> [!IMPORTANT]
> The Jewel Markdown renderer is currently considered **experimental**. Its API and implementations may change at any
> time, and no guarantees are made for binary and source compatibility. It might also have bugs and missing features.
Adds the ability to render Markdown as native Compose UI.

Currently supports the [CommonMark 0.30](https://spec.commonmark.org/0.30/) specs. When `commonmark-java` will be
updated to support the new 0.31.2 specs, we'll inherit that.

Additional supported Markdown, via extensions:

* Alerts ([GitHub Flavored Markdown][alerts-specs]) — see [`extension-gfm-alerts`](extension-gfm-alerts)

[alerts-specs]: https://github.com/orgs/community/discussions/16925

On the roadmap, but not currently supported — in no particular order:

* Tables ([GitHub Flavored Markdown](https://github.github.com/gfm/#tables-extension-))
* Strikethrough ([GitHub Flavored Markdown](https://github.github.com/gfm/#strikethrough-extension-))
* Image loading (via [Coil 3](https://coil-kt.github.io/coil/upgrading_to_coil3/))
* Auto-linking ([GitHub Flavored Markdown](https://github.github.com/gfm/#autolinks-extension-))
* Task list items ([GitHub Flavored Markdown](https://github.github.com/gfm/#task-list-items-extension-))
* Keyboard shortcuts highlighting (specialized HTML handling)
* Collapsing sections ([GitHub Flavored Markdown][details-specs])
* Theme-sensitive image loading ([GitHub Flavored Markdown][dark-mode-pics-specs])
* Emojis ([GitHub Flavored Markdown][emoji-specs])
* Footnotes ([GitHub Flavored Markdown][footnotes-specs])

[details-specs]: https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-collapsed-sections

[dark-mode-pics-specs]: https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#specifying-the-theme-an-image-is-shown-to

[emoji-specs]: https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#using-emojis

[footnotes-specs]: https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#footnotes

Not supported, and not on the roadmap:

* Inline HTML rendering
* Mermaid diagrams (GitHub Flavored Markdown)
* LaTeX rendering, both inline and not (GitHub Flavored Markdown)
* topoJSON/geoJSON rendering (GitHub Flavored Markdown)
* 3D STL models (GitHub Flavored Markdown)
* Rich rendering of embeds such as videos, YouTube, GitHub Gists/...

## How to use Jewel's Markdown renderer

The process that leads to rendering Markdown in a native UI is two-pass.

The first pass is an upfront rendering that pre-processes blocks into `MarkdownBlock`s but doesn't touch the inline
Markdown. It's recommended to run this outside of the composition, since it has no dependencies on it.

```kotlin
// Somewhere outside of composition...
val processor = MarkdownProcessor()
val rawMarkdown = "..."
val processedBlocks = processor.processMarkdownDocument(rawMarkdown)
```

The second pass is done in the composition, and essentially renders a series of `MarkdownBlock`s into native Jewel UI:

```kotlin
@Composable
fun Markdown(blocks: List<MarkdownBlock>) {
val isDark = JewelTheme.isDark
val blockRenderer = remember(markdownStyling, isDark) {
if (isDark) MarkdownBlockRenderer.dark() else MarkdownBlockRenderer.light()
}

val scrollState = rememberScrollState()
SelectionContainer(Modifier.fillMaxSize()) {
Column(
state = scrollState,
verticalArrangement = Arrangement.spacedBy(markdownStyling.blockVerticalSpacing),
) {
items(markdownBlocks) { blockRenderer.render(it) }
}
}
}
```

If you expect long Markdown documents, you can also use a `LazyColumn` to get better performances.

### Using extensions

By default, the processor will ignore any kind of Markdown it doesn't support. To support additional features, such as
ones found in GitHub Flavored Markdown, you can use extensions. If you don't specify any extension, the processor will
be restricted to the [CommonMark specs](https://specs.commonmark.org) as supported by
[`commonmark-java`](https://github.com/commonmark/commonmark-java).

Extensions are composed of two parts: a parsing and a rendering part. The two parts need to be passed to the
`MarkdownProcessor` and `MarkdownBlockRenderer`, respectively:

```kotlin
// Where the parsing happens...
val parsingExtensions = listOf(/*...*/)
val processor = MarkdownProcessor(extensions)

// Where the rendering happens...
val blockRenderer = remember(markdownStyling, isDark) {
if (isDark) {
MarkdownBlockRenderer.dark(
rendererExtensions = listOf(/*...*/),
inlineRenderer = InlineMarkdownRenderer.default(parsingExtensions),
)
} else {
MarkdownBlockRenderer.light(
rendererExtensions = listOf(/*...*/),
inlineRenderer = InlineMarkdownRenderer.default(parsingExtensions),
)
}
}
```

It is strongly recommended to use the corresponding set of rendering extensions as the ones used for parsing, otherwise
the custom blocks will be parsed but not rendered.

Note that you should create an `InlineMarkdownRenderer` with the same list of extensions that was used to build the
processor, as even though inline rendering extensions are not supported yet, they will be in the future.
Loading

0 comments on commit 1290a0b

Please sign in to comment.