Skip to content

Commit

Permalink
Introduce richtext-markdown as a generic Markdown renderer
Browse files Browse the repository at this point in the history
Markdown unfortunately does not have a universally agreed standard. Different flavors, libraries can parse Markdown content in their own ways. `richtext-commonmark` uses `Commonmark` spec and parser to convert any given markdown String into an intermediate Abstract Syntax Tree that has been defined internally since its release.

The idea has always been to extract this ASTNode structure out of this module to enable custom parsing logic to be supplied from outside, and make `richtext-commonmark` only responsible for rendering the given tree using `richtext-ui`.

This change introduces a new module named `richtext-markdown` which is actually just a rename of the existing `richtext-commonmark`. This new module also exposes its internal `AstNode` APIs to enable other modules to render their own AstNode structures.

`richtext-commonmark` becomes a helper module which can parse any given string into an AstNode via `CommonmarkAstNodeParser`. Also, it still provides a `RichTextScope.Markdown` function that simply does the conversion asynchronously, then calls the `Markdown(AstNode)` flavor from `richtext-markdown`.
  • Loading branch information
halilozercan committed Feb 8, 2024
1 parent 8920e78 commit 39b991d
Show file tree
Hide file tree
Showing 34 changed files with 385 additions and 169 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.markdown.MarkdownParseOptions
import com.halilibo.richtext.commonmark.Markdown
import com.halilibo.richtext.commonmark.MarkdownParseOptions
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material.RichText
import com.halilibo.richtext.ui.resolveDefaults
Expand Down
1 change: 0 additions & 1 deletion desktop-sample/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import org.jetbrains.compose.compose
import org.jetbrains.compose.desktop.application.dsl.TargetFormat

plugins {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.singleWindowApplication
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.commonmark.Markdown
import com.halilibo.richtext.ui.CodeBlockStyle
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material.RichText
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
Compose Richtext is a collection of Compose libraries for working with rich text formatting and
documents.

`richtext-ui`, `richtext-commonmark`, and `richtext-ui-material`|`richtext-ui-material3` are Kotlin Multiplatform(KMP) Compose Libraries.
`richtext-ui`, `richtext-markdown`, `richtext-commonmark`, and `richtext-ui-material`|`richtext-ui-material3` are Kotlin Multiplatform(KMP) Compose Libraries.
All these modules can be used in Android and Desktop Compose apps.

Each library is documented separately, see the navigation menu for the list. This site also includes
Expand Down
40 changes: 35 additions & 5 deletions docs/richtext-commonmark.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![Android Library](https://img.shields.io/badge/Platform-Android-green.svg?style=for-the-badge)](https://developer.android.com/studio/build/dependencies)
[![JVM Library](https://img.shields.io/badge/Platform-JVM-red.svg?style=for-the-badge)](https://kotlinlang.org/docs/mpp-intro.html)

Library for rendering Markdown in Compose using [CommonMark](https://github.com/commonmark/commonmark-java)
Library for parsing and rendering Markdown in Compose using [CommonMark](https://github.com/commonmark/commonmark-java)
library/spec to parse, and `richtext-ui` to render.

## Gradle
Expand All @@ -14,7 +14,37 @@ dependencies {
}
```

## Usage
## Parsing

`richtext-markdown` module renders a given Markdown Abstract Syntax Tree. It accepts a root
`AstNode`. This library gives you a parser called `CommonmarkAstNodeParser` to easily convert any
String to an `AstNode` that represents the Markdown tree.

```kotlin
val parser = CommonmarkAstNodeParser()
val astNode = parser.parse(
"""
# Demo
Emphasis, aka italics, with *asterisks* or _underscores_. Strong emphasis, aka bold, with **asterisks** or __underscores__. Combined emphasis with **asterisks and _underscores_**. [Links with two blocks, text in square-brackets, destination is in parentheses.](https://www.example.com). Inline `code` has `back-ticks around` it.
1. First ordered list item
2. Another item
* Unordered sub-list.
3. And another item.
You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown).
* Unordered list can use asterisks
- Or minuses
+ Or pluses
""".trimIndent()
)
// ...

RichTextScope.Markdown(astNode)
```

## Rendering

The simplest way to render markdown is just pass a string to the [`Markdown`](../api/richtext-commonmark/com.halilibo.richtext.markdown/-markdown.html)
composable under RichText scope:
Expand Down Expand Up @@ -61,12 +91,12 @@ Which produces something like this:

![markdown demo](img/markdown-demo.png)

## MarkdownParseOptions
## [`MarkdownParseOptions`](../api/richtext-commonmark/com.halilibo.richtext.commonmark/-markdown-parse-options.html)

Passing `MarkdownParseOptions` into `Markdown` provides the ability to control some aspects of the markdown parser:
Passing `MarkdownParseOptions` into either `Markdown` composable or `CommonmarkAstNodeParser.parse` method provides the ability to control some aspects of the markdown parser:

```kotlin
val markdownParseOptions = MarkdownParseOptions.Default.copy(
val markdownParseOptions = MarkdownParseOptions(
autolink = false
)

Expand Down
54 changes: 54 additions & 0 deletions docs/richtext-markdown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Markdown

[![Android Library](https://img.shields.io/badge/Platform-Android-green.svg?style=for-the-badge)](https://developer.android.com/studio/build/dependencies)
[![JVM Library](https://img.shields.io/badge/Platform-JVM-red.svg?style=for-the-badge)](https://kotlinlang.org/docs/mpp-intro.html)

Library for rendering Markdown tree defined as an `AstNode`.

## Gradle

```kotlin
dependencies {
implementation("com.halilibo.compose-richtext:richtext-markdown:${richtext_version}")
}
```

## Usage

`richtext-markdown` module renders a given Markdown Abstract Syntax Tree. It accepts a root
`AstNode`. However, this library does not include a parser to convert a regular Markdown String into
an `AstNode`. Please refer to `richtext-commonmark` for a sample implementation or a quick way to
render Markdown.

## Rendering

The simplest way to render markdown is just pass an `AstNode` to the [`Markdown`](../api/richtext-commonmark/com.halilibo.richtext.markdown/-markdown.html)
composable under RichText scope:

~~~kotlin
RichText(
modifier = Modifier.padding(16.dp)
) {
val parser = remember(options) { CommonmarkAstNodeParser(options) }
val astNode = remember(parser) {
parser.parse(
"""
# Demo
Emphasis, aka italics, with *asterisks* or _underscores_. Strong emphasis, aka bold, with **asterisks** or __underscores__. Combined emphasis with **asterisks and _underscores_**. [Links with two blocks, text in square-brackets, destination is in parentheses.](https://www.example.com). Inline `code` has `back-ticks around` it.
1. First ordered list item
2. Another item
* Unordered sub-list.
3. And another item.
You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown).
* Unordered list can use asterisks
- Or minuses
+ Or pluses
""".trimIndent()
)
}
Markdown(astNode)
}
~~~
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ nav:
- richtext-ui-material.md
- richtext-ui-material3.md
- richtext-ui.md
- richtext-markdown.md
- richtext-commonmark.md
- printing.md
- slideshow.md
Expand Down
9 changes: 2 additions & 7 deletions richtext-commonmark/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,23 @@ repositories {
}

android {
namespace = "com.halilibo.richtext.markdown"
namespace = "com.halilibo.richtext.commonmark"
}

kotlin {
sourceSets {
val commonMain by getting {
dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
api(project(":richtext-ui"))
api(project(":richtext-markdown"))
}
}
val commonTest by getting

val androidMain by getting {
kotlin.srcDir("src/commonJvmAndroid/kotlin")
dependencies {
implementation(Compose.coil)

implementation(Commonmark.core)
implementation(Commonmark.tables)
implementation(Commonmark.strikethrough)
Expand All @@ -38,9 +36,6 @@ kotlin {
val jvmMain by getting {
kotlin.srcDir("src/commonJvmAndroid/kotlin")
dependencies {
implementation(compose.desktop.currentOs)
implementation(Network.okHttp)

implementation(Commonmark.core)
implementation(Commonmark.tables)
implementation(Commonmark.strikethrough)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
package com.halilibo.richtext.markdown
package com.halilibo.richtext.commonmark

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import com.halilibo.richtext.markdown.node.AstBlockQuote
import com.halilibo.richtext.markdown.node.AstBulletList
import com.halilibo.richtext.markdown.node.AstCode
import com.halilibo.richtext.markdown.node.AstDocument
import com.halilibo.richtext.markdown.node.AstEmphasis
Expand Down Expand Up @@ -35,6 +30,7 @@ import com.halilibo.richtext.markdown.node.AstTableRoot
import com.halilibo.richtext.markdown.node.AstTableRow
import com.halilibo.richtext.markdown.node.AstText
import com.halilibo.richtext.markdown.node.AstThematicBreak
import com.halilibo.richtext.markdown.node.AstUnorderedList
import org.commonmark.ext.autolink.AutolinkExtension
import org.commonmark.ext.gfm.strikethrough.Strikethrough
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension
Expand Down Expand Up @@ -90,7 +86,7 @@ internal fun convert(

val newNodeType: AstNodeType? = when (node) {
is BlockQuote -> AstBlockQuote
is BulletList -> AstBulletList(bulletMarker = node.bulletMarker)
is BulletList -> AstUnorderedList(bulletMarker = node.bulletMarker)
is Code -> AstCode(literal = node.literal)
is Document -> AstDocument
is Emphasis -> AstEmphasis(delimiter = node.openingDelimiter)
Expand Down Expand Up @@ -186,26 +182,30 @@ internal fun convert(
return newNode
}

internal fun Node.convert() = convert(this)
public actual class CommonmarkAstNodeParser actual constructor(
options: MarkdownParseOptions
) {

@Composable
internal actual fun parsedMarkdownAst(text: String, options: MarkdownParseOptions): AstNode? {
val parser = remember(options) {
Parser.builder()
.extensions(
listOfNotNull(
TablesExtension.create(),
StrikethroughExtension.create(),
if (options.autolink) AutolinkExtension.create() else null
)
private val parser = Parser.builder()
.extensions(
listOfNotNull(
TablesExtension.create(),
StrikethroughExtension.create(),
if (options.autolink) AutolinkExtension.create() else null
)
.build()
}
)
.build()

val astRootNode by produceState<AstNode?>(null, text, parser) {
value = parser.parse(text).convert()
}
public actual fun parse(text: String): AstNode {
val commonmarkNode = parser.parse(text)
?: throw IllegalArgumentException(
"Could not parse the given text content into a meaningful Markdown representation!"
)

return astRootNode
return convert(commonmarkNode)
?: throw IllegalArgumentException(
"Could not convert the generated Commonmark Node into an ASTNode!"
)
}
}

Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.halilibo.richtext.markdown

import com.halilibo.richtext.commonmark.convert
import com.halilibo.richtext.markdown.node.AstImage
import com.halilibo.richtext.markdown.node.AstNode
import com.halilibo.richtext.markdown.node.AstNodeLinks
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.halilibo.richtext.commonmark

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.commonmark.MarkdownParseOptions.Companion
import com.halilibo.richtext.markdown.node.AstNode
import com.halilibo.richtext.ui.RichTextScope

/**
* A composable that renders Markdown content according to Commonmark specification using RichText.
*
* @param content Markdown text. No restriction on length.
* @param markdownParseOptions Options for the Markdown parser.
* @param onLinkClicked A function to invoke when a link is clicked from rendered content.
*/
@Composable
public fun RichTextScope.Markdown(
content: String,
markdownParseOptions: MarkdownParseOptions = Companion.Default,
onLinkClicked: ((String) -> Unit)? = null
) {
val commonmarkAstNodeParser = remember(markdownParseOptions) {
CommonmarkAstNodeParser(markdownParseOptions)
}

val astRootNode by produceState<AstNode?>(null, commonmarkAstNodeParser) {
value = commonmarkAstNodeParser.parse(content)
}

astRootNode?.let {
Markdown(astNode = it, onLinkClicked = onLinkClicked)
}
}

/**
* A helper class that can convert any text content into an ASTNode tree and return its root.
*/
public expect class CommonmarkAstNodeParser(
options: MarkdownParseOptions = MarkdownParseOptions.Default
) {

/**
* Parse markdown content and return Abstract Syntax Tree(AST).
*
* @param text Markdown text to be parsed.
* @param options Options for the Commonmark Markdown parser.
*/
public fun parse(text: String): AstNode
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.halilibo.richtext.markdown
package com.halilibo.richtext.commonmark

/**
* Allows configuration of the Markdown parser
Expand Down

This file was deleted.

This file was deleted.

Loading

0 comments on commit 39b991d

Please sign in to comment.