From 7e24b934bbd424669a1c935894dd8799d4db0348 Mon Sep 17 00:00:00 2001 From: Linnea Gräf Date: Thu, 2 May 2024 23:32:11 +0200 Subject: Add note blocks and an integration test --- .../moe/nea/blog/gen/HtmlFragmentGenerator.kt | 10 +- .../kotlin/moe/nea/blog/gen/MD2HtmlGenerator.kt | 10 + src/main/kotlin/moe/nea/blog/md/MarkdownParser.kt | 374 +++++++++++---------- src/main/kotlin/moe/nea/blog/md/ext/NoteBlocks.kt | 57 ++++ 4 files changed, 267 insertions(+), 184 deletions(-) create mode 100644 src/main/kotlin/moe/nea/blog/md/ext/NoteBlocks.kt (limited to 'src/main/kotlin/moe') diff --git a/src/main/kotlin/moe/nea/blog/gen/HtmlFragmentGenerator.kt b/src/main/kotlin/moe/nea/blog/gen/HtmlFragmentGenerator.kt index 3ba4f79..7702b75 100644 --- a/src/main/kotlin/moe/nea/blog/gen/HtmlFragmentGenerator.kt +++ b/src/main/kotlin/moe/nea/blog/gen/HtmlFragmentGenerator.kt @@ -4,4 +4,12 @@ import moe.nea.blog.md.MarkdownElement fun interface HtmlFragmentGenerator { fun generateHtml(htmlGenerator: MD2HtmlGenerator, node: T): HtmlFragment -} \ No newline at end of file +} + +abstract class DefHtmlFragmentGenerator : HtmlFragmentGenerator { + override fun generateHtml(htmlGenerator: MD2HtmlGenerator, node: T): HtmlFragment { + return HtmlDsl().apply { makeHtml(htmlGenerator, node) }.intoFragment() + } + + abstract fun HtmlDsl.makeHtml(generator: MD2HtmlGenerator, node: T) +} diff --git a/src/main/kotlin/moe/nea/blog/gen/MD2HtmlGenerator.kt b/src/main/kotlin/moe/nea/blog/gen/MD2HtmlGenerator.kt index 0077469..9c9299e 100644 --- a/src/main/kotlin/moe/nea/blog/gen/MD2HtmlGenerator.kt +++ b/src/main/kotlin/moe/nea/blog/gen/MD2HtmlGenerator.kt @@ -8,6 +8,7 @@ import moe.nea.blog.md.FormatSequence import moe.nea.blog.md.Header import moe.nea.blog.md.Italics import moe.nea.blog.md.Link +import moe.nea.blog.md.MDList import moe.nea.blog.md.MarkdownElement import moe.nea.blog.md.Paragraph import moe.nea.blog.md.Whitespace @@ -49,6 +50,15 @@ class MD2HtmlGenerator { registerFragmentGenerator { generator, node -> element("em", mapOf(), generator.generateHtml(node.inner)) } + registerFragmentGenerator { generator, node -> + element("ul", mapOf()) { + for (item in node.elements) { + element("li", mapOf()) { + +generator.generateHtml(item) + } + } + } + } registerFragmentGenerator { generator, node -> element("a", mapOf("href" to node.target), generator.generateHtml(node.label ?: Begin())) } diff --git a/src/main/kotlin/moe/nea/blog/md/MarkdownParser.kt b/src/main/kotlin/moe/nea/blog/md/MarkdownParser.kt index 11aff14..673a0ed 100644 --- a/src/main/kotlin/moe/nea/blog/md/MarkdownParser.kt +++ b/src/main/kotlin/moe/nea/blog/md/MarkdownParser.kt @@ -1,191 +1,199 @@ package moe.nea.blog.md import moe.nea.blog.util.indentSize -import java.util.* +import java.util.Stack class MarkdownParser(source: String) { - private val lines = source.lines() - private var lineIndex = 0 - private var blockIndents = 0 - private val indentStack = Stack() - - private val blockParsers = mutableListOf() - private val inlineParsers = mutableListOf() - private val preprocessors = Stack() - private var peekedLine: String? = null - - fun findParserFor(line: String): BlockParser? { - return blockParsers.filter { it.detect(line) } - .maxByOrNull { it.prio } - } - - fun readChildBlock(): MarkdownBlock? { - val peek = peekLine() ?: return null - val blockParser = findParserFor(peek) ?: ParagraphParser - return blockParser.parse(this) - } - - fun pushPreProcessor(preProcessor: LinePreProcessor) { - peekedLine = null - preprocessors.push(preProcessor) - } - - fun popPreProcessor() { - peekedLine = null - preprocessors.pop() - } - - fun preProcessLine(string: String): String? { - var acc = string - for (processor in preprocessors) { - acc = processor.preprocess(lineIndex, acc) ?: return null - } - return acc - } - - fun pushIndent(newIndent: Int) { - indentStack.push(blockIndents) - blockIndents += newIndent - peekedLine = null - } - - fun popIndent() { - blockIndents = indentStack.pop() - peekedLine = null - } - - fun unpeekLine() { - peekedLine = null - } - - fun consumeLine(): String? { - val line = peekLine() - if (line != null) { - peekedLine = null - lineIndex++ - } - return line - } - - fun peekLine(): String? { - if (lineIndex !in lines.indices) return null - val line = peekedLine ?: preProcessLine(lines[lineIndex]) ?: return null - peekedLine = line - val indent = line.indentSize() - if (indent != null && indent < blockIndents) { - peekedLine = null - return null - } - return line.drop(blockIndents) - } - - fun parseInlineTextUntil( - text: String, - initialLookback: MarkdownFormat, - breakout: (lookback: MarkdownFormat, remaining: String) -> Boolean - ): Pair, String> { - val seq = mutableListOf() - var remaining = text - var lastToken: MarkdownFormat = initialLookback - while (remaining.isNotEmpty()) { - if (breakout(lastToken, remaining)) - break - val (tok, next) = parseInlineTextOnce(lastToken, remaining) - seq.add(tok) - lastToken = tok - remaining = next - } - return seq to remaining - } - - fun parseInlineTextOnce(lookback: MarkdownFormat, text: String): Pair { - require(text.isNotEmpty()) // TODO handle empty string - val parser = inlineParsers.find { it.detect(lookback, text) } - if (parser != null) { - return parser.parse(this, text) - } - if (text[0] == ' ') { - return Pair(Whitespace(), text.substring(1)) - } - val nextSpecial = text.indexOfFirst { it in inlineParsers.flatMap { it.specialSyntax } || it == ' ' } - if (nextSpecial == 0) { - return Pair(Word(text.substring(0, 1)), text.substring(1)) - } - if (nextSpecial == -1) { - return Pair(Word(text), "") - } - return Pair(Word(text.substring(0, nextSpecial)), text.substring(nextSpecial)) - } - - fun parseInlineText(text: String): MarkdownFormat { - val (tokens, rest) = parseInlineTextUntil(text, Begin()) { _, _ -> false } - require(rest.isEmpty()) - return collapseInlineFormat(tokens, true) - } - - private fun expandMarkdownFormats(sequence: List): List { - val elongated = mutableListOf() - for (markdownFormat in sequence) { - if (markdownFormat is FormatSequence) { - elongated.addAll(expandMarkdownFormats(markdownFormat.list)) - } else { - elongated.add(markdownFormat) - } - } - return elongated - } - - private fun collapseMarkdownFormats( - sequence: List, - trimWhitespace: Boolean - ): MutableList { - val shortened = mutableListOf() - var last: MarkdownFormat = if (trimWhitespace) Whitespace() else Begin() - for (format in sequence) { - if (format is Whitespace && last is Whitespace) { - continue - } - last = format - shortened.add(format) - } - return shortened - } - - fun collapseInlineFormat(sequence: List, trimWhitespace: Boolean): MarkdownFormat { - val formats = collapseMarkdownFormats(expandMarkdownFormats(sequence), trimWhitespace) - return formats.singleOrNull() ?: FormatSequence(formats) - } - - fun readDocument(): Document { - val list = mutableListOf() - while (true) { - val block = readChildBlock() ?: break - list.add(block) - } - return Document(list) - } - - fun addDefaultParsers() { - blockParsers.add(CodeBlockParser) - blockParsers.add(HeaderParser) - blockParsers.add(ListParser) - blockParsers.add(BlockQuoteParser) - inlineParsers.add(ItalicsParser) - inlineParsers.add(LinkParser) - inlineParsers.add(ImageParser) - } - - fun getLineIndex(): Int { - return lineIndex - } - - fun mergeBlocks(elements: List): MarkdownBlock { - return elements.singleOrNull() ?: BlockList(elements) - } - - fun getIndent(): Int { - return blockIndents - } + private val lines = source.lines() + private var lineIndex = 0 + private var blockIndents = 0 + private val indentStack = Stack() + + private val blockParsers = mutableListOf() + private val inlineParsers = mutableListOf() + private val preprocessors = Stack() + private var peekedLine: String? = null + + fun findParserFor(line: String): BlockParser? { + return blockParsers.filter { it.detect(line) } + .maxByOrNull { it.prio } + } + + fun readChildBlock(): MarkdownBlock? { + val peek = peekLine() ?: return null + val blockParser = findParserFor(peek) ?: ParagraphParser + return blockParser.parse(this) + } + + fun pushPreProcessor(preProcessor: LinePreProcessor) { + peekedLine = null + preprocessors.push(preProcessor) + } + + fun popPreProcessor() { + peekedLine = null + preprocessors.pop() + } + + fun preProcessLine(string: String): String? { + var acc = string + for (processor in preprocessors) { + acc = processor.preprocess(lineIndex, acc) ?: return null + } + return acc + } + + fun pushIndent(newIndent: Int) { + indentStack.push(blockIndents) + blockIndents += newIndent + peekedLine = null + } + + fun popIndent() { + blockIndents = indentStack.pop() + peekedLine = null + } + + fun unpeekLine() { + peekedLine = null + } + + fun consumeLine(): String? { + val line = peekLine() + if (line != null) { + peekedLine = null + lineIndex++ + } + return line + } + + fun peekLine(): String? { + if (lineIndex !in lines.indices) return null + val line = peekedLine ?: preProcessLine(lines[lineIndex]) ?: return null + peekedLine = line + val indent = line.indentSize() + if (indent != null && indent < blockIndents) { + peekedLine = null + return null + } + return line.drop(blockIndents) + } + + fun parseInlineTextUntil( + text: String, + initialLookback: MarkdownFormat, + breakout: (lookback: MarkdownFormat, remaining: String) -> Boolean + ): Pair, String> { + val seq = mutableListOf() + var remaining = text + var lastToken: MarkdownFormat = initialLookback + while (remaining.isNotEmpty()) { + if (breakout(lastToken, remaining)) + break + val (tok, next) = parseInlineTextOnce(lastToken, remaining) + seq.add(tok) + lastToken = tok + remaining = next + } + return seq to remaining + } + + fun parseInlineTextOnce(lookback: MarkdownFormat, text: String): Pair { + require(text.isNotEmpty()) // TODO handle empty string + val parser = inlineParsers.find { it.detect(lookback, text) } + if (parser != null) { + return parser.parse(this, text) + } + if (text[0] == ' ') { + return Pair(Whitespace(), text.substring(1)) + } + val nextSpecial = text.indexOfFirst { it in inlineParsers.flatMap { it.specialSyntax } || it == ' ' } + if (nextSpecial == 0) { + return Pair(Word(text.substring(0, 1)), text.substring(1)) + } + if (nextSpecial == -1) { + return Pair(Word(text), "") + } + return Pair(Word(text.substring(0, nextSpecial)), text.substring(nextSpecial)) + } + + fun parseInlineText(text: String): MarkdownFormat { + val (tokens, rest) = parseInlineTextUntil(text, Begin()) { _, _ -> false } + require(rest.isEmpty()) + return collapseInlineFormat(tokens, true) + } + + private fun expandMarkdownFormats(sequence: List): List { + val elongated = mutableListOf() + for (markdownFormat in sequence) { + if (markdownFormat is FormatSequence) { + elongated.addAll(expandMarkdownFormats(markdownFormat.list)) + } else { + elongated.add(markdownFormat) + } + } + return elongated + } + + private fun collapseMarkdownFormats( + sequence: List, + trimWhitespace: Boolean + ): MutableList { + val shortened = mutableListOf() + var last: MarkdownFormat = if (trimWhitespace) Whitespace() else Begin() + for (format in sequence) { + if (format is Whitespace && last is Whitespace) { + continue + } + last = format + shortened.add(format) + } + return shortened + } + + fun collapseInlineFormat(sequence: List, trimWhitespace: Boolean): MarkdownFormat { + val formats = collapseMarkdownFormats(expandMarkdownFormats(sequence), trimWhitespace) + return formats.singleOrNull() ?: FormatSequence(formats) + } + + fun readDocument(): Document { + val list = mutableListOf() + while (true) { + val block = readChildBlock() ?: break + list.add(block) + } + return Document(list) + } + + fun addDefaultParsers() { + blockParsers.add(CodeBlockParser) + blockParsers.add(HeaderParser) + blockParsers.add(ListParser) + blockParsers.add(BlockQuoteParser) + inlineParsers.add(ItalicsParser) + inlineParsers.add(LinkParser) + inlineParsers.add(ImageParser) + } + + fun getLineIndex(): Int { + return lineIndex + } + + fun mergeBlocks(elements: List): MarkdownBlock { + return elements.singleOrNull() ?: BlockList(elements) + } + + fun getIndent(): Int { + return blockIndents + } + + fun addParser(blockParser: BlockParser) { + this.blockParsers.add(blockParser) + } + + fun addParser(formatParser: InlineParser) { + this.inlineParsers.add(formatParser) + } } diff --git a/src/main/kotlin/moe/nea/blog/md/ext/NoteBlocks.kt b/src/main/kotlin/moe/nea/blog/md/ext/NoteBlocks.kt new file mode 100644 index 0000000..a1f977f --- /dev/null +++ b/src/main/kotlin/moe/nea/blog/md/ext/NoteBlocks.kt @@ -0,0 +1,57 @@ +package moe.nea.blog.md.ext + +import moe.nea.blog.gen.DefHtmlFragmentGenerator +import moe.nea.blog.gen.HtmlDsl +import moe.nea.blog.gen.MD2HtmlGenerator +import moe.nea.blog.md.BlockParser +import moe.nea.blog.md.MarkdownBlock +import moe.nea.blog.md.MarkdownParser +import moe.nea.blog.util.indent +import moe.nea.blog.util.indentSize +import java.io.PrintStream + +data class NoteBlock(val noteType: String, val child: MarkdownBlock) : MarkdownBlock { + override fun debugFormat(indent: Int, printStream: PrintStream) { + printStream.indent(indent) + printStream.println("") + + child.debugFormat(indent + 2, printStream) + + printStream.indent(indent) + printStream.println("") + } +} + +object NoteBlockGenerator : DefHtmlFragmentGenerator() { + override fun HtmlDsl.makeHtml(generator: MD2HtmlGenerator, node: NoteBlock) { + element("div", mapOf("class" to "note-${node.noteType}"), generator.generateHtml(node.child)) + } +} + +object NoteBlockParser : BlockParser { + override fun detect(line: String): Boolean { + return line.startsWith("!!!") + } + + override fun parse(parser: MarkdownParser): MarkdownBlock { + val line = parser.consumeLine()!! + val noteType = line.substring(3).trim() + while ((parser.peekLine() ?: error("Unterminated !!! note")).isBlank()) { + parser.consumeLine() + } + val indent = parser.peekLine()!!.indentSize()!! + require(indent > 0) { "!!! note not indented" } + parser.pushIndent(indent) + val list = mutableListOf() + while (true) { + val block = parser.readChildBlock() ?: break + list.add(block) + } + parser.popIndent() + return NoteBlock(noteType, parser.mergeBlocks(list)) + } + + override val prio: Int + get() = 10 + +} -- cgit