Skip to content

Commit

Permalink
Introduce 'decorative headings' via #!
Browse files Browse the repository at this point in the history
  • Loading branch information
iamgio committed Dec 2, 2024
1 parent 9c542ed commit c653ee1
Show file tree
Hide file tree
Showing 9 changed files with 94 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@ import eu.iamgio.quarkdown.document.numbering.DocumentNumbering
import eu.iamgio.quarkdown.document.numbering.NumberingFormat

/**
* A node that requests its location to be tracked within the document.
* A node that requests its location to be tracked within the document's hierarchy.
* By location, it is meant the section indices ([SectionLocation]) the node is located in.
* @see SectionLocation
*/
interface LocationTrackableNode : Node
interface LocationTrackableNode : Node {
/**
* Whether this node should be tracked in the document's hierarchy.
*/
val canTrackLocation: Boolean
get() = true
}

/**
* The location of a node within the document, in terms of section indices.
Expand Down
10 changes: 10 additions & 0 deletions core/src/main/kotlin/eu/iamgio/quarkdown/ast/base/block/Heading.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,27 @@ import eu.iamgio.quarkdown.visitor.node.NodeVisitor
* A heading is identifiable, as it can be looked up in the document and can be referenced.
* It is also location trackable, meaning its position in the document hierarchy is determined, and possibly displayed.
* @param depth importance (`depth=1` for H1, `depth=6` for H6)
* @param isDecorative whether this heading is decorative.
* A decorative heading does not trigger automatic page breaks and is not counted in the document's hierarchy
* and is not numbered.
* @param customId optional custom ID. If `null`, the ID is automatically generated
*/
class Heading(
val depth: Int,
override val text: InlineContent,
val isDecorative: Boolean = false,
val customId: String? = null,
) : TextNode, Identifiable, LocationTrackableNode {
override fun <T> accept(visitor: NodeVisitor<T>) = visitor.visit(this)

override fun <T> accept(visitor: IdentifierProvider<T>) = visitor.visit(this)

/**
* Decorative headings are not assigned a location and are not counted.
*/
override val canTrackLocation: Boolean
get() = !isDecorative

/**
* @return whether this heading is a marker
* @see marker
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ interface ContextOptions : MediaStorageOptions {
* that do not have an explicit one.
* For example, a heading element (`# Hello world`) automatically generates
* an identifier (`hello-world`) that can be referenced by other elements.
* @see eu.iamgio.quarkdown.ast.attributes.IdentifierProvider
* @see eu.iamgio.quarkdown.ast.attributes.id.IdentifierProvider
*/
val enableAutomaticIdentifiers: Boolean

Expand All @@ -34,7 +34,10 @@ interface ContextOptions : MediaStorageOptions {
* @return whether the [heading] node should force a page break
* @see ContextOptions.autoPageBreakHeadingMaxDepth
*/
fun Context.shouldAutoPageBreak(heading: Heading) = !heading.isMarker && heading.depth <= this.options.autoPageBreakHeadingMaxDepth
fun Context.shouldAutoPageBreak(heading: Heading) =
!heading.isMarker &&
!heading.isDecorative &&
heading.depth <= this.options.autoPageBreakHeadingMaxDepth

/**
* Mutable [ContextOptions] implementation.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ class LocationAwarenessHook(private val context: MutableContext) : AstIteratorHo
// # C => current location: [3]
// ### C.0.A => current location: [3, 0, 1]
iterator.on<Heading> { heading ->
if (!heading.canTrackLocation) return@on // 'Decorative' headings are not assigned a location and are not counted.

location[heading.depth] = (location[heading.depth] ?: 0) + 1
location.entries.removeIf { it.key > heading.depth }

Expand All @@ -47,6 +49,8 @@ class LocationAwarenessHook(private val context: MutableContext) : AstIteratorHo

// The current location, loaded by the previous hook, is associated with each node that requests its location to be tracked.
iterator.on<LocationTrackableNode> { trackable ->
if (!trackable.canTrackLocation) return@on

val locationData =
location.asSequence()
.sortedBy { it.key }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,21 @@ package eu.iamgio.quarkdown.lexer.patterns

import eu.iamgio.quarkdown.lexer.regex.RegexBuilder
import eu.iamgio.quarkdown.lexer.regex.pattern.TokenRegexPattern
import eu.iamgio.quarkdown.lexer.tokens.*
import eu.iamgio.quarkdown.lexer.tokens.BlockCodeToken
import eu.iamgio.quarkdown.lexer.tokens.BlockQuoteToken
import eu.iamgio.quarkdown.lexer.tokens.BlockTextToken
import eu.iamgio.quarkdown.lexer.tokens.FencesCodeToken
import eu.iamgio.quarkdown.lexer.tokens.HeadingToken
import eu.iamgio.quarkdown.lexer.tokens.HorizontalRuleToken
import eu.iamgio.quarkdown.lexer.tokens.HtmlToken
import eu.iamgio.quarkdown.lexer.tokens.LinkDefinitionToken
import eu.iamgio.quarkdown.lexer.tokens.ListItemToken
import eu.iamgio.quarkdown.lexer.tokens.NewlineToken
import eu.iamgio.quarkdown.lexer.tokens.OrderedListToken
import eu.iamgio.quarkdown.lexer.tokens.ParagraphToken
import eu.iamgio.quarkdown.lexer.tokens.SetextHeadingToken
import eu.iamgio.quarkdown.lexer.tokens.TableToken
import eu.iamgio.quarkdown.lexer.tokens.UnorderedListToken

/**
* Regex patterns for [eu.iamgio.quarkdown.flavor.base.BaseMarkdownFlavor] blocks.
Expand Down Expand Up @@ -98,7 +112,7 @@ open class BaseMarkdownBlockTokenRegexPatterns {
name = "Heading",
wrap = ::HeadingToken,
regex =
"^ {0,3}(#{1,6})(?=\\s|$)(.*)(?:\\n+|$)"
"^ {0,3}(#{1,6})(!?)(?=\\s|$)(.*)(?:\\n+|$)"
.toRegex(),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,13 +139,17 @@ class BlockTokenParser(private val context: MutableContext) : BlockTokenVisitor<

val depth = groups.next().length // Amount of # characters.

// e.g. ###! Heading => the heading is decorative, meaning it's not part of the document structure.
val isDecorative = groups.next() == "!"

val rawText = groups.next().trim().takeUntilLastOccurrence(" #") // Remove trailing # characters.
// Heading {#custom-id} -> Heading, custom-id
val (text, customId) = splitHeadingTextAndId(rawText)

return Heading(
depth,
text.toInline(),
isDecorative,
customId,
)
}
Expand Down
13 changes: 13 additions & 0 deletions core/src/test/kotlin/eu/iamgio/quarkdown/BlockParserTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ class BlockParserTest {
assertEquals("Title", rawText)
assertNull(customId)
assertEquals(1, depth)
assertFalse(isDecorative)
}
with(nodes.next()) {
assertEquals("Title", rawText)
Expand All @@ -113,6 +114,18 @@ class BlockParserTest {
assertNull(customId)
assertEquals(3, depth)
}
with(nodes.next()) {
assertEquals("Decorative title", rawText)
assertNull(customId)
assertEquals(1, depth)
assertTrue(isDecorative)
}
with(nodes.next()) {
assertEquals("Decorative title", rawText)
assertNull(customId)
assertEquals(6, depth)
assertTrue(isDecorative)
}
with(nodes.next()) {
assertTrue(text.isEmpty())
assertNull(customId)
Expand Down
2 changes: 2 additions & 0 deletions core/src/test/resources/parsing/heading.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Title
## Title
### Title
#! Decorative title
######! Decorative title

####### Not a title
a ## Not a title
Expand Down
32 changes: 32 additions & 0 deletions test/src/test/kotlin/eu/iamgio/quarkdown/test/FullPipelineTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@ class FullPipelineTest {
assertEquals("<h2>Ti<em>tl</em>e</h2>", it)
}

execute("#! Title") {
assertEquals("<h1>Title</h1>", it)
}

execute("#### .sum {3} {2}") {
assertEquals("<h4>5</h4>", it)
}
Expand Down Expand Up @@ -1163,6 +1167,34 @@ class FullPipelineTest {
)
}

// Decorative headings are not numbered.
execute(
"""
.noautopagebreak
.numbering
- headings: 1.1
# A
## A/1
#! Nope!
# B
# C
## C/1
## C/2
""".trimIndent(),
DEFAULT_OPTIONS.copy(enableLocationAwareness = true),
) {
assertEquals(
"<h1 data-location=\"1\">A</h1>" +
"<h2 data-location=\"1.1\">A/1</h2>" +
"<h1>Nope!</h1>" +
"<h1 data-location=\"2\">B</h1>" +
"<h1 data-location=\"3\">C</h1>" +
"<h2 data-location=\"3.1\">C/1</h2>" +
"<h2 data-location=\"3.2\">C/2</h2>",
it,
)
}

// Roman numerals.
execute(
"""
Expand Down

0 comments on commit c653ee1

Please sign in to comment.