├── src ├── jsMain │ ├── resources │ │ ├── CNAME │ │ ├── favicon │ │ │ ├── favicon.ico │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── about.txt │ │ │ └── site.webmanifest │ │ ├── og-bitdowntoc.png │ │ └── index.html │ ├── kotlin │ │ ├── impl.kt │ │ ├── main.kt │ │ └── toc.kt │ └── css │ │ ├── codemirror.css │ │ └── index.css ├── jvmMain │ ├── resources │ │ └── resources-config.json │ └── kotlin │ │ └── ch │ │ └── derlin │ │ └── bitdowntoc │ │ ├── impl.kt │ │ └── main.kt ├── commonMain │ └── kotlin │ │ └── ch │ │ └── derlin │ │ └── bitdowntoc │ │ ├── exceptions.kt │ │ ├── Toc.kt │ │ ├── Commenter.kt │ │ ├── BitOptions.kt │ │ ├── BitGenerator.kt │ │ └── AnchorGenerator.kt ├── jvmTest │ ├── resources │ │ ├── toc-gitlab.md │ │ ├── toc-github.md │ │ ├── input.md │ │ └── output.md │ └── kotlin │ │ └── ch │ │ └── derlin │ │ └── bitdowntoc │ │ ├── utils.kt │ │ ├── GenerateFromFileTest.kt │ │ └── CliTest.kt └── commonTest │ └── kotlin │ └── ch.derlin.bitdowntoc │ ├── WarningsTest.kt │ ├── TocTest.kt │ ├── UpdateAnchorsTest.kt │ ├── AnchorsGeneratorTest.kt │ └── GenerateTest.kt ├── settings.gradle.kts ├── .gitignore ├── other ├── hashnode.psd ├── bitdowntoc-old.graffle ├── bitdowntoc-social-preview.psd └── kotlin-multiplatform-overview.png ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── .github ├── workflows │ ├── build.yml │ ├── native_test.yml │ ├── deploy.yml │ ├── release.yml │ └── native_reusable.yml └── actions │ └── gradlew │ └── action.yml ├── LICENSE ├── gradlew.bat ├── CHANGELOG.md ├── gradlew └── README.md /src/jsMain/resources/CNAME: -------------------------------------------------------------------------------- 1 | bitdowntoc.derlin.ch 2 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "bitdowntoc" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | 3 | build 4 | *.iml 5 | 6 | html/scripts -------------------------------------------------------------------------------- /other/hashnode.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/derlin/bitdowntoc/HEAD/other/hashnode.psd -------------------------------------------------------------------------------- /other/bitdowntoc-old.graffle: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/derlin/bitdowntoc/HEAD/other/bitdowntoc-old.graffle -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/derlin/bitdowntoc/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /other/bitdowntoc-social-preview.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/derlin/bitdowntoc/HEAD/other/bitdowntoc-social-preview.psd -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | kotlin.js.generate.executable.default=false 3 | kotlin.mpp.stability.nowarn=true 4 | -------------------------------------------------------------------------------- /other/kotlin-multiplatform-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/derlin/bitdowntoc/HEAD/other/kotlin-multiplatform-overview.png -------------------------------------------------------------------------------- /src/jsMain/resources/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/derlin/bitdowntoc/HEAD/src/jsMain/resources/favicon/favicon.ico -------------------------------------------------------------------------------- /src/jsMain/resources/og-bitdowntoc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/derlin/bitdowntoc/HEAD/src/jsMain/resources/og-bitdowntoc.png -------------------------------------------------------------------------------- /src/jsMain/resources/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/derlin/bitdowntoc/HEAD/src/jsMain/resources/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /src/jsMain/resources/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/derlin/bitdowntoc/HEAD/src/jsMain/resources/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /src/jsMain/resources/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/derlin/bitdowntoc/HEAD/src/jsMain/resources/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /src/jsMain/resources/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/derlin/bitdowntoc/HEAD/src/jsMain/resources/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /src/jsMain/resources/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/derlin/bitdowntoc/HEAD/src/jsMain/resources/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/jvmMain/resources/resources-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "resources": { 3 | "includes": [ 4 | { 5 | "pattern": "git.properties" 6 | } 7 | ] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/impl.kt: -------------------------------------------------------------------------------- 1 | package ch.derlin.bitdowntoc 2 | 3 | actual fun String.removeDiacritics(): String = 4 | this.normalize().replace("\\p{Diacritic}", "", ignoreCase = true) 5 | 6 | fun String.normalize(): String = asDynamic().normalize("NFD") -------------------------------------------------------------------------------- /src/jvmMain/kotlin/ch/derlin/bitdowntoc/impl.kt: -------------------------------------------------------------------------------- 1 | package ch.derlin.bitdowntoc 2 | 3 | import java.text.Normalizer 4 | 5 | actual fun String.removeDiacritics(): String = 6 | Normalizer.normalize(this, Normalizer.Form.NFD) 7 | .replace("\\p{Mn}+".toRegex(), "") -------------------------------------------------------------------------------- /src/jsMain/resources/favicon/about.txt: -------------------------------------------------------------------------------- 1 | This favicon was generated using the following font: 2 | 3 | - Font Title: Langar 4 | - Font Author: undefined 5 | - Font Source: https://fonts.gstatic.com/s/langar/v27/kJEyBukW7AIlgjGVrTVZ99sqrQ.ttf 6 | - Font License: undefined) 7 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/ch/derlin/bitdowntoc/exceptions.kt: -------------------------------------------------------------------------------- 1 | package ch.derlin.bitdowntoc 2 | 3 | open class BitException(override val message: String) : RuntimeException(message) 4 | 5 | class MissingTocEndException : BitException("The document has a TOC start, but is missing a TOC end.") 6 | -------------------------------------------------------------------------------- /src/jsMain/resources/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' # all branches 7 | - '!main' # except main 8 | 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | # Avoid duplicate runs 16 | if: github.event_name == 'push' || (github.event.name == 'pull_request' && github.event.pull_request.head.repo.fork) 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - uses: ./.github/actions/gradlew 22 | id: gradlew 23 | 24 | - run: echo "Built ${{ steps.gradlew.outputs.jar_file }}" 25 | -------------------------------------------------------------------------------- /.github/workflows/native_test.yml: -------------------------------------------------------------------------------- 1 | name: Test Native Executable Build 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | ref: 7 | description: git ref to build 8 | type: string 9 | default: main 10 | 11 | jobs: 12 | build-jar: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | ref: ${{ inputs.ref }} 18 | 19 | - uses: ./.github/actions/gradlew 20 | id: gradlew 21 | with: 22 | run_checks: false 23 | 24 | build-native: 25 | needs: build-jar 26 | uses: ./.github/workflows/native_reusable.yml 27 | -------------------------------------------------------------------------------- /src/jvmTest/resources/toc-gitlab.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | - [A heading with spaces](#a-heading-with-spaces) 4 | * [Some '??&%' strange :) characters](#some-strange-characters) 5 | * [😋 Get Emoji — All Emojis to Copy and 📋 Paste 👌](#-get-emoji-all-emojis-to-copy-and-paste-) 6 | + [Russian text is not supported](#russian-text-is-not-supported) 7 | - [dès la matinée, ça gït](#dès-la-matinée-ça-gït) 8 | - [this is a duplicate](#this-is-a-duplicate) 9 | - [this is a duplicate](#this-is-a-duplicate-1) 10 | - [this is a duplicate](#this-is-a-duplicate-2) 11 | * [?)= ALKJDFEEE*ç](#-alkjdfeeeç) 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/jvmTest/resources/toc-github.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | - [A heading with spaces](#a-heading---with---spaces) 4 | * [Some '??&%' strange :) characters](#some--strange--characters) 5 | * [😋 Get Emoji — All Emojis to Copy and 📋 Paste 👌](#-get-emoji--all-emojis-to-copy-and--paste-) 6 | + [Russian text is not supported](#russian-text-is-not-supported) 7 | - [dès la matinée, ça gït](#dès-la-matinée-ça-gït) 8 | - [this is a duplicate](#this-is-a-duplicate) 9 | - [this is a duplicate](#this-is-a-duplicate-1) 10 | - [this is a duplicate](#this-is---a-duplicate) 11 | * [?)= ALKJDFEEE*ç](#-alkjdfeeeç) 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/jvmTest/kotlin/ch/derlin/bitdowntoc/utils.kt: -------------------------------------------------------------------------------- 1 | package ch.derlin.bitdowntoc 2 | 3 | const val TEST_INPUT_FILE: String = "input.md" 4 | const val TEST_OUTPUT_FILE: String = "output.md" 5 | const val TOC_GITLAB: String = "toc-gitlab.md" 6 | const val TOC_GITHUB: String = "toc-github.md" 7 | 8 | internal fun output() = TEST_OUTPUT_FILE.load() 9 | internal fun outputGitlab() = TOC_GITLAB.load() + "\n" + TEST_INPUT_FILE.load() 10 | internal fun outputGithub() = TOC_GITHUB.load() + "\n" + TEST_INPUT_FILE.load() 11 | 12 | internal fun String.load(): String = 13 | GenerateFromFileTest::class.java.classLoader.getResource(this)!!.readText() 14 | 15 | internal fun String.getPath(): String = 16 | GenerateFromFileTest::class.java.classLoader.getResource(this)!!.path 17 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/ch.derlin.bitdowntoc/WarningsTest.kt: -------------------------------------------------------------------------------- 1 | package ch.derlin.bitdowntoc 2 | 3 | import ch.derlin.bitdowntoc.BitGenerator.Params 4 | import ch.derlin.bitdowntoc.BitGenerator.getWarnings 5 | import kotlin.test.Test 6 | import kotlin.test.assertContains 7 | import kotlin.test.assertEquals 8 | import kotlin.test.assertNotNull 9 | import kotlin.test.assertNull 10 | 11 | class WarningsTest { 12 | 13 | @Test 14 | fun testGetWarnings() { 15 | assertNull(getWarnings(Params(generateAnchors = true), tocOnly = false)) 16 | assertNull(getWarnings(Params(generateAnchors = false), tocOnly = true)) 17 | 18 | val warnings = getWarnings(Params(generateAnchors = true), tocOnly = true) 19 | assertNotNull(warnings) 20 | assertEquals(warnings.size, 1) 21 | assertContains(warnings[0], "This TOC requires anchors to exist in the markdown content") 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | “Commons Clause” License Condition v1.0 2 | 3 | The Software is provided to you by the Licensor under the License, as defined below, subject to the following condition. 4 | 5 | Without limiting other conditions in the License, the grant of rights under the License will not include, and the License does not grant to you, the right to Sell the Software. 6 | 7 | For purposes of the foregoing, “Sell” means practicing any or all of the rights granted to you under the License to provide to third parties, for a fee or other consideration (including without limitation fees for hosting or consulting/ support services related to the Software), a product or service whose value derives, entirely or substantially, from the functionality of the Software. Any license notice or attribution required by the License must also include this Commons Clause License Condition notice. 8 | 9 | Software: bitdowntoc 10 | License: Apache 2.0 11 | Licensor: Lucy Linder @ derlin 12 | -------------------------------------------------------------------------------- /src/jvmTest/kotlin/ch/derlin/bitdowntoc/GenerateFromFileTest.kt: -------------------------------------------------------------------------------- 1 | package ch.derlin.bitdowntoc 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import ch.derlin.bitdowntoc.BitGenerator.Params 6 | import org.junit.jupiter.api.Test 7 | 8 | class GenerateFromFileTest { 9 | 10 | private val input = TEST_INPUT_FILE.load() 11 | 12 | @Test 13 | fun `test full with anchors`() { 14 | assertThat(BitGenerator.generate(input)).isEqualTo( 15 | output() 16 | ) 17 | } 18 | 19 | @Test 20 | fun `test toc gitlab`() { 21 | assertThat(BitGenerator.generate(input, Params(generateAnchors = false))).isEqualTo( 22 | outputGitlab() 23 | ) 24 | } 25 | 26 | @Test 27 | fun `test toc github`() { 28 | assertThat(BitGenerator.generate(input, Params(generateAnchors = false, concatSpaces = false))).isEqualTo( 29 | outputGithub() 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build & Deploy 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | # Skip on release, but run after the release when bumping to the next SNAPSHOT version 11 | if: | 12 | !startsWith(github.event.head_commit.message, 'chore(main): release') || contains(github.event.head_commit.message, 'SNAPSHOT') 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: ./.github/actions/gradlew 18 | id: gradlew 19 | 20 | - name: Deploy to Github Pages 21 | if: success() 22 | uses: crazy-max/ghaction-github-pages@v4 23 | with: 24 | target_branch: gh-pages 25 | build_dir: build/web 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | 29 | - name: Update nightly release 30 | if: success() 31 | uses: pyTooling/Actions/releaser@main 32 | with: 33 | tag: nightly 34 | rm: true 35 | token: ${{ secrets.GITHUB_TOKEN }} 36 | files: ${{ steps.gradlew.outputs.jar_file }} 37 | 38 | - name: Publish package 39 | run: ./gradlew publish 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | -------------------------------------------------------------------------------- /.github/actions/gradlew/action.yml: -------------------------------------------------------------------------------- 1 | name: Gradle Test and Build 2 | description: Setup gradle and test+build artifacts 3 | 4 | inputs: 5 | run_checks: 6 | description: Whether to run tests 7 | required: false 8 | default: 'true' 9 | upload_jar: 10 | description: Wether to upload the jar as a build artifact 11 | required: false 12 | default: 'true' 13 | 14 | outputs: 15 | jar_file: 16 | description: Path to the jar 17 | value: ${{ steps.out.outputs.jar }} 18 | 19 | runs: 20 | using: "composite" 21 | steps: 22 | - name: Set up JDK 17 23 | uses: actions/setup-java@v4 24 | with: 25 | distribution: zulu 26 | java-version: 17 27 | cache: gradle 28 | 29 | - name: Test with Gradle 30 | if: ${{ inputs.run_checks == 'true' }} 31 | shell: bash 32 | run: | 33 | ./gradlew check 34 | 35 | - name: Build with Gradle 36 | shell: bash 37 | run: | 38 | ./gradlew bitdowntoc 39 | 40 | - name: Upload JAR Artifact 41 | if: ${{ inputs.upload_jar == 'true' }} 42 | uses: actions/upload-artifact@v4 43 | with: 44 | name: bitdowntoc-jar 45 | path: build/libs/bitdowntoc-jvm-*.jar 46 | 47 | - name: Set output 48 | shell: bash 49 | id: out 50 | run: | 51 | jar=$(ls build/libs/bitdowntoc-jvm-*.jar) 52 | echo "Found jar: $jar" 53 | echo "jar=$jar" >> $GITHUB_OUTPUT 54 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/ch/derlin/bitdowntoc/Toc.kt: -------------------------------------------------------------------------------- 1 | package ch.derlin.bitdowntoc 2 | 3 | import ch.derlin.bitdowntoc.AnchorAlgorithm.DEFAULT 4 | 5 | 6 | class Toc( 7 | val concatSpaces: Boolean = false, 8 | levelBoundaries: Pair? = null, 9 | val anchorsGenerator: AnchorAlgorithm = DEFAULT, 10 | val anchorsPrefix: String = "", 11 | ) { 12 | 13 | internal val links: MutableMap = mutableMapOf() 14 | internal val entries: MutableList = mutableListOf() 15 | internal val levelBoundaries = levelBoundaries ?: Pair(0, Int.MAX_VALUE) 16 | 17 | data class TocEntry(val indent: Int, val title: String, val link: String) 18 | 19 | internal fun shouldBeAdded(indent: Int): Boolean = 20 | levelBoundaries.let { (min, max) -> indent in min..max } 21 | 22 | fun addTocEntry(indent: Int, title: String): TocEntry? { 23 | // always keep track of the links, to properly handle duplicates even if skipped 24 | var link = anchorsPrefix + anchorsGenerator.toAnchor(title, concatSpaces = concatSpaces) 25 | val linkCount = links.getOrElse(link) { 0 } 26 | links[link] = linkCount + 1 27 | 28 | return if (!shouldBeAdded(indent)) null else { 29 | // add numbers at the end of the link if it is a duplicate 30 | if (linkCount > 0) { 31 | link += "-$linkCount" 32 | } 33 | 34 | // create the entry 35 | val entry = TocEntry(indent - 1, anchorsGenerator.escapeTitle(title), link) 36 | entries += entry 37 | entry 38 | } 39 | } 40 | 41 | fun generateToc(indentCharacters: String, indentSpaces: Int, trimTocIndent: Boolean): String { 42 | if (this.entries.isEmpty()) return "" 43 | val minIndent = if (trimTocIndent) entries.minOf { it.indent } else 0 44 | return entries.joinToString("\n") { (indent, text, link) -> 45 | (indent - minIndent).let { 46 | " ".repeat(it * indentSpaces) + "${indentCharacters[it % indentCharacters.length]} [$text](#$link)" 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/ch/derlin/bitdowntoc/Commenter.kt: -------------------------------------------------------------------------------- 1 | package ch.derlin.bitdowntoc 2 | 3 | import ch.derlin.bitdowntoc.Commenter.Companion.ANCHOR_FMT 4 | import ch.derlin.bitdowntoc.Commenter.Companion.ANCHOR_LINK_PLACEHOLDER 5 | 6 | interface Commenter { 7 | 8 | companion object { 9 | const val ANCHOR_LINK_PLACEHOLDER = ".*" 10 | const val ANCHOR_FMT = "" 11 | } 12 | 13 | fun wrapToc(toc: String): List 14 | fun anchorStart(): String 15 | fun toAnchor(link: String) = 16 | // use replace here, since String.format is not available 17 | anchorStart() + ANCHOR_FMT.replace(ANCHOR_LINK_PLACEHOLDER, link) 18 | 19 | fun isTocStart(line: String): Boolean 20 | fun isTocEnd(line: String): Boolean 21 | fun isAnchor(line: String): Boolean 22 | 23 | } 24 | 25 | class NoComment : Commenter { 26 | override fun wrapToc(toc: String): List = listOf(toc) 27 | override fun anchorStart(): String = "" 28 | 29 | override fun isTocStart(line: String): Boolean = false 30 | override fun isTocEnd(line: String): Boolean = false 31 | override fun isAnchor(line: String): Boolean = false 32 | } 33 | 34 | private const val COMMENT_TOC_START = "TOC start" 35 | private const val COMMENT_TOC_END = "TOC end" 36 | private const val COMMENT_ANCHOR = "TOC" 37 | 38 | enum class CommentStyle( 39 | private val format: (String) -> String, 40 | private val formatForRegex: (String) -> String = format, 41 | ) : Commenter { 42 | HTML({ "" }), 43 | LIQUID({ "{%- # $it -%}" }, { "\\{%- # $it -%\\}" }); 44 | 45 | private val tocEnd = format(COMMENT_TOC_END) 46 | private val anchorStart = format(COMMENT_ANCHOR) 47 | 48 | override fun wrapToc(toc: String): List = 49 | listOf(format("$COMMENT_TOC_START (generated with $BITDOWNTOC_URL)"), "", toc, "", tocEnd) 50 | 51 | override fun anchorStart(): String = anchorStart 52 | 53 | override fun isTocStart(line: String): Boolean = values().any { 54 | Regex(it.formatForRegex("$COMMENT_TOC_START.*")).matches(line) 55 | } 56 | 57 | override fun isTocEnd(line: String): Boolean = values().any { it.tocEnd == line } 58 | 59 | override fun isAnchor(line: String): Boolean = values().any { 60 | line.startsWith(it.anchorStart) && line.contains(ANCHOR_FMT.substringBefore(ANCHOR_LINK_PLACEHOLDER)) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/jvmTest/resources/input.md: -------------------------------------------------------------------------------- 1 | # A heading with spaces 2 | 3 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam et elit elementum ligula vestibulum blandit non consequat ipsum. Vivamus tristique tortor venenatis lacus laoreet, vel viverra magna malesuada. Pellentesque pellentesque nisi sit amet tortor rhoncus, nec egestas mauris volutpat. Vivamus vel lorem quam. Duis non libero vehicula est dictum elementum ut id ligula. Donec quis magna lorem. Mauris tempus imperdiet eros ut porttitor. Quisque vel urna pharetra, rutrum elit vitae, sagittis arcu. Nulla augue sapien, suscipit in dapibus a, semper malesuada dui. Integer facilisis, velit ac commodo tincidunt, nisi massa interdum ex, eu facilisis sapien felis at diam. Etiam sit amet commodo est. Donec id mi in quam gravida tempus. 4 | 5 | Fusce nec dolor eu elit feugiat pellentesque. Ut id scelerisque enim. Aenean ac ex varius, hendrerit massa ullamcorper, sollicitudin augue. Cras fringilla cursus tortor et pretium. Maecenas cursus velit libero, quis porta eros blandit vitae. Ut sit amet pharetra leo. Nulla sit amet elit dui. Curabitur accumsan feugiat sapien, quis vulputate mauris ullamcorper consequat. Nunc fringilla ex faucibus sapien malesuada sollicitudin. Nulla eget finibus mauris, et consectetur elit. Sed hendrerit finibus diam, vitae aliquam diam rhoncus sed. Duis viverra tortor ullamcorper neque tristique scelerisque. Pellentesque velit nisi, tristique fermentum ante non, semper elementum diam. 6 | 7 | ## Some '??&%' strange :) characters 8 | 9 | Praesent tempus egestas elit, nec lacinia turpis tincidunt nec. 10 | 11 | ```js 12 | # this is a comment in coe 13 | function lala() { 14 | } 15 | 16 | # TOC 17 | ``` 18 | ## 😋 Get Emoji — All Emojis to Copy and 📋 Paste 👌 19 | 20 | Emoji are ideograms and smileys used in electronic messages and web pages. Some examples of emoji are 😃, 🧘🏻‍♂️, 🌍, 🍞, 🚗, 📞, 🎉, ♥️, 🍆, and 🏁. 21 | 22 | ### Russian text is not supported 23 | 24 | Встре́ча с медве́дем мо́жет быть о́чень опа́сна. Ру́сские лю́ди лю́бят ходи́ть в лес и собира́ть грибы́ и я́годы. Они́ де́лают э́то с осторо́жностью, так как медве́ди то́же о́чень лю́бят я́годы и мо́гут напа́сть на челове́ка. Медве́дь ест всё: я́годы, ры́бу, мя́со и да́же насеко́мых. Осо́бенно он лю́бит мёд. 25 | 26 | #### dès la matinée, ça gït 27 | 28 | Lorem ipsum dolor sit amet, [TOC] # test. 29 | 30 | > this is a quote 31 | 32 | ```bash 33 | ## comment 34 | echo [TOC] 35 | ``` 36 | 37 | # this is a duplicate 38 | 39 | # this is a duplicate 40 | 41 | # this is a duplicate 42 | 43 | ## ?)= ALKJDFEEE*ç 44 | 45 | * https://ecotrust-canada.github.io/markdown-toc/ 46 | * https://www.http4k.org/quickstart/ 47 | -------------------------------------------------------------------------------- /src/jsMain/css/codemirror.css: -------------------------------------------------------------------------------- 1 | html.dark .CodeMirror .CodeMirror-activeline-background { 2 | background: #253540 3 | } 4 | 5 | html.dark .CodeMirror { 6 | background:var(--editor-background); /*#0f192a;*/ 7 | color: var(--editor-text-color); 8 | } 9 | 10 | html.dark .CodeMirror div.CodeMirror-selected { 11 | background: #314d67 12 | } 13 | 14 | html.dark .CodeMirror .CodeMirror-line::selection,html.dark .CodeMirror .CodeMirror-line>span::selection,html.dark .CodeMirror .CodeMirror-line>span>span::selection { 15 | background: rgba(49,77,103,.99) 16 | } 17 | 18 | html.dark .CodeMirror .CodeMirror-line::-moz-selection,html.dark .CodeMirror .CodeMirror-line>span::-moz-selection,html.dark .CodeMirror .CodeMirror-line>span>span::-moz-selection { 19 | background: rgba(49,77,103,.99) 20 | } 21 | 22 | /** line numbers 23 | html.dark .CodeMirror .CodeMirror-gutters { 24 | background: #0f192a; 25 | border-right: 1px solid 26 | } 27 | html.dark .CodeMirror .CodeMirror-guttermarker { 28 | color: #fff 29 | } 30 | html.dark .CodeMirror .CodeMirror-guttermarker-subtle { 31 | color: #d0d0d0 32 | } 33 | html.dark .CodeMirror .CodeMirror-linenumber { 34 | color: #d0d0d0 35 | } 36 | */ 37 | html.dark .CodeMirror .CodeMirror-cursor { 38 | border-left: 1px solid #f8f8f0 39 | } 40 | 41 | /* unused 42 | html.dark .CodeMirror span.cm-property { 43 | color: #a6e22e 44 | } 45 | html.dark .CodeMirror span.cm-keyword { 46 | color: #e83737 47 | } 48 | html.dark .CodeMirror span.cm-def { 49 | color: #4dd 50 | } 51 | html.dark .CodeMirror span.cm-bracket { 52 | color: #d1edff 53 | } 54 | html.dark .CodeMirror span.cm-error { 55 | background: #f92672; 56 | color: #f8f8f0 57 | } 58 | html.dark .CodeMirror .CodeMirror-matchingbracket { 59 | text-decoration: underline; 60 | color: #fff!important 61 | } 62 | html.dark .CodeMirror span.cm-number { 63 | color: #d1edff 64 | } 65 | */ 66 | 67 | .CodeMirror, 68 | html .CodeMirror span.cm-variable, 69 | html .CodeMirror span.cm-variable-2, 70 | html .CodeMirror span.cm-variable-3, 71 | html .CodeMirror span.cm-keyword { 72 | color: var(--editor-text-color) 73 | } 74 | 75 | html .CodeMirror span.cm-string { 76 | color: var(--secondary) 77 | } 78 | 79 | html .CodeMirror span.cm-link { 80 | color: var(--primary) 81 | } 82 | 83 | html .CodeMirror span.cm-attribute { 84 | color: #8f8fff; 85 | } 86 | html .CodeMirror span.cm-tag { 87 | color: #8ed040; 88 | } 89 | 90 | html .CodeMirror span.cm-header { 91 | color: black 92 | } 93 | html.dark .CodeMirror span.cm-header { 94 | color: #f8f8f8 95 | } 96 | 97 | html .CodeMirror span.cm-comment { 98 | color: #428bdd 99 | } 100 | 101 | html.dark .CodeMirror span.cm-atom { 102 | color: #ae81ff 103 | } 104 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Please 2 | on: 3 | push: 4 | branches: [ main ] 5 | jobs: 6 | 7 | release-please: 8 | runs-on: ubuntu-latest 9 | outputs: 10 | release_created: ${{ steps.rp.outputs.release_created }} 11 | tag_name: ${{ steps.rp.outputs.tag_name }} 12 | steps: 13 | - uses: google-github-actions/release-please-action@v3 14 | id: rp 15 | with: 16 | release-type: java 17 | changelog-types: '[{"type":"feat","section":"🚀 Features","hidden":false},{"type":"fix","section":"🐛 Bug Fixes","hidden":false},{"type":"docs","section":"💬 Documentation","hidden":false},{"type":"ci","section":"🦀 Build and CI","hidden":false}, {"type":"style","section":"🌈 Styling","hidden":false}]' 18 | extra-files: build.gradle.kts 19 | 20 | upload-jar: 21 | runs-on: ubuntu-latest 22 | needs: release-please 23 | if: ${{ needs.release-please.outputs.release_created }} 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - uses: ./.github/actions/gradlew 29 | id: gradlew 30 | with: 31 | run_checks: false 32 | 33 | - name: Add JAR to Release 34 | uses: softprops/action-gh-release@v2 35 | with: 36 | tag_name: ${{ needs.release-please.outputs.tag_name }} 37 | files: ${{ steps.gradlew.outputs.jar_file }} 38 | 39 | publish-maven-package: 40 | runs-on: ubuntu-latest 41 | needs: release-please 42 | if: ${{ needs.release-please.outputs.release_created }} 43 | 44 | steps: 45 | - uses: actions/checkout@v4 46 | 47 | - uses: ./.github/actions/gradlew 48 | id: gradlew 49 | with: 50 | run_checks: false 51 | upload_jar: false # done in the other upload-jar job! 52 | 53 | - name: Publish package 54 | run: ./gradlew publish 55 | continue-on-error: true # TODO: understand why multiple packages are pushed 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | 59 | build-native: 60 | needs: [ release-please, upload-jar ] 61 | uses: ./.github/workflows/native_reusable.yml 62 | with: 63 | tag_name: ${{ needs.release-please.outputs.tag_name }} 64 | 65 | update_homebrew: 66 | runs-on: ubuntu-latest 67 | needs: [ release-please, upload-jar ] 68 | steps: 69 | - name: trigger homebrew update 70 | # The PAT should have actions:read-write 71 | run: | 72 | curl -L \ 73 | -X POST \ 74 | --fail-with-body \ 75 | -H "Accept: application/vnd.github+json" \ 76 | -H "Authorization: Bearer ${{ secrets.HOMEBREW_PAT }}" \ 77 | -H "X-GitHub-Api-Version: 2022-11-28" \ 78 | https://api.github.com/repos/derlin/homebrew-bitdowntoc/actions/workflows/${{ secrets.HOMEBREW_WORKFLOW_ID }}/dispatches \ 79 | -d '{"ref":"main","inputs":{}}' 80 | -------------------------------------------------------------------------------- /src/jvmTest/kotlin/ch/derlin/bitdowntoc/CliTest.kt: -------------------------------------------------------------------------------- 1 | package ch.derlin.bitdowntoc 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.contains 5 | import assertk.assertions.isEmpty 6 | import assertk.assertions.isEqualTo 7 | import assertk.assertions.matches 8 | import com.github.ajalt.clikt.testing.test 9 | import org.junit.jupiter.api.Test 10 | import java.io.File 11 | import java.nio.file.Files 12 | 13 | class CliTest { 14 | 15 | @Test 16 | fun `version flag works`() { 17 | assertThat(versionMessage()) 18 | .matches(".*\n commit\\.id=[\\da-f]{40}\n.*".toRegex(RegexOption.DOT_MATCHES_ALL)) 19 | } 20 | 21 | @Test 22 | fun `invalid arguments`() { 23 | assertFailWith("", "Missing argument PATH") 24 | assertFailWith("-i -", "--inplace", "incompatible", "stdin") 25 | assertFailWith("${TEST_INPUT_FILE.getPath()} -o output.md -i", "mutually exclusive") 26 | assertFailWith("input.md", "doesn't exist") 27 | assertFailWith(".", "not a file") 28 | } 29 | 30 | @Test 31 | fun `basic command`() { 32 | val result = Cli().test(TEST_INPUT_FILE.getPath()) 33 | assertThat(result.statusCode).isEqualTo(0) 34 | assertThat(result.stdout).isEqualTo(output() + System.lineSeparator()) 35 | assertThat(result.stderr).isEmpty() 36 | } 37 | 38 | @Test 39 | fun `use profile`() { 40 | val result = Cli().test("${TEST_INPUT_FILE.getPath()} -p github") 41 | assertThat(result.statusCode).isEqualTo(0) 42 | assertThat(result.stdout).isEqualTo(outputGithub() + System.lineSeparator()) 43 | assertThat(result.stderr).isEmpty() 44 | } 45 | 46 | @Test 47 | fun `read from stdin`() { 48 | val result = Cli(readFromStdin = { TEST_INPUT_FILE.load() }).test("-") 49 | assertThat(result.statusCode).isEqualTo(0) 50 | assertThat(result.stdout).isEqualTo(output() + System.lineSeparator()) 51 | assertThat(result.stderr).isEmpty() 52 | } 53 | 54 | @Test 55 | fun `TOC only with warnings`() { 56 | val result = Cli().test("--toc-only --anchors --no-concat-spaces ${TEST_INPUT_FILE.getPath()}") 57 | assertThat(result.statusCode).isEqualTo(0) 58 | assertThat(result.stdout).isEqualTo(TOC_GITHUB.load()) 59 | assertThat(result.stderr).contains("This TOC requires anchors to exist in the markdown content") 60 | } 61 | 62 | @Test 63 | fun `output to file`() { 64 | Files.createTempDirectory("bt").let { "$it/output.md" }.let { outFile -> 65 | val result = Cli().test("${TEST_INPUT_FILE.getPath()} -o $outFile") 66 | assertThat(result.statusCode).isEqualTo(0) 67 | assertThat(result.stdout).isEmpty() 68 | assertThat(result.stderr).isEmpty() 69 | assertThat(File(outFile).readText()).isEqualTo(output()) 70 | } 71 | } 72 | 73 | private fun assertFailWith(args: String, vararg msg: String) { 74 | val result = Cli().test(args) 75 | assertThat(result.statusCode, name = args).isEqualTo(1) 76 | assertThat(result.stderr, name = args).contains(*msg) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/ch.derlin.bitdowntoc/TocTest.kt: -------------------------------------------------------------------------------- 1 | package ch.derlin.bitdowntoc 2 | 3 | import kotlin.test.Test 4 | import kotlin.test.assertEquals 5 | import kotlin.test.assertNotNull 6 | import kotlin.test.assertNull 7 | 8 | 9 | class TocTest { 10 | 11 | @Test 12 | fun testDuplicateLinkGeneration() { 13 | val toc = Toc(levelBoundaries = 1 to 3) 14 | 15 | toc.addTocEntry(1, "Heading") 16 | toc.addTocEntry(4, "Heading") 17 | toc.addTocEntry(2, "Heading") 18 | toc.addTocEntry(5, "Heading") 19 | toc.addTocEntry(6, "Heading") 20 | toc.addTocEntry(3, "Heading") 21 | 22 | assertEquals(3, toc.entries.size, "3 entries should be registered") 23 | assertEquals(1, toc.links.size, "all entries should have the same link") 24 | assertEquals(mapOf( 25 | "Heading" to "heading", 26 | "Heading" to "heading-2", 27 | "Heading" to "heading-5" 28 | ), toc.entries.associate { Pair(it.title, it.link) }) 29 | } 30 | 31 | @Test 32 | fun testTitleToLinkConversionGithub() { 33 | val toc = Toc(concatSpaces = false) 34 | 35 | val expected = listOf( 36 | "Some '??&%`%\"\\/^' strange :) # characters" to "some--strange---characters", 37 | "dès la matinée, ça gït" to "dès-la-matinée-ça-gït", 38 | " this has spaces " to "this-has--spaces", 39 | " 😋 emojis 📋 and 👌" to "-emojis--and-", 40 | ) 41 | 42 | expected.forEach { (title, expected) -> 43 | val tocEntry = toc.addTocEntry(1, title) 44 | assertNotNull(tocEntry) 45 | assertEquals(expected, tocEntry.link) 46 | } 47 | 48 | } 49 | 50 | @Test 51 | fun testTitleToLinkConversionGitlab() { 52 | val toc = Toc(concatSpaces = true) 53 | 54 | val expected = listOf( 55 | "Some '??&%`%\"\\/^' strange :) # characters" to "some-strange-characters", 56 | "dès la matinée, ça gït" to "dès-la-matinée-ça-gït", 57 | " this has spaces " to "this-has-spaces", 58 | " 😋 emojis 📋 and 👌" to "-emojis-and-", 59 | ) 60 | 61 | expected.forEach { (title, expected) -> 62 | val tocEntry = toc.addTocEntry(1, title) 63 | assertNotNull(tocEntry) 64 | assertEquals(expected, tocEntry.link) 65 | } 66 | } 67 | 68 | @Test 69 | fun testBoundariesAreRespected() { 70 | val maxLevel = 3 71 | val toc = Toc(levelBoundaries = null) 72 | val tocBoundaries = Toc(levelBoundaries = Pair(1, maxLevel)) 73 | (1..5).forEach { level -> 74 | assertNotNull(toc.addTocEntry(level, "test")) 75 | if (level <= 3) { 76 | assertNotNull(tocBoundaries.addTocEntry(level, "test")) 77 | } else { 78 | assertNull(tocBoundaries.addTocEntry(level, "test")) 79 | } 80 | } 81 | } 82 | 83 | @Test 84 | fun testAnchorPrefixIsAdded() { 85 | listOf("xxx", "heading-", "").forEach { 86 | assertEquals(Toc(anchorsPrefix = it).addTocEntry(1, "A test")?.link, "${it}a-test") 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /.github/workflows/native_reusable.yml: -------------------------------------------------------------------------------- 1 | name: Build Native Executables 2 | on: 3 | workflow_call: 4 | inputs: 5 | jar_artifact_name: 6 | description: Name of the artifact to download to get the Jar 7 | type: string 8 | required: false 9 | default: bitdowntoc-jar 10 | output_name: 11 | description: name of the generated executables 12 | type: string 13 | required: false 14 | default: bitdowntoc 15 | tag_name: 16 | description: release tag to attach executables to (leave empty if not a release) 17 | type: string 18 | required: false 19 | default: '' 20 | 21 | jobs: 22 | build-native: 23 | name: ${{ matrix.label }} executable (tag=${{ inputs.tag_name }}) 24 | strategy: 25 | fail-fast: false # Do not cancel other jobs when one fails 26 | matrix: 27 | os: [ 'windows-latest', 'ubuntu-latest', 'macos-latest' ] 28 | include: 29 | # the label is for the output file (_