├── .github ├── .markout └── workflows │ ├── .markout │ ├── check.yml │ ├── pages.yml │ └── release.yml ├── .gitignore ├── .markout ├── LICENSE ├── README.md ├── build-logic ├── build.gradle.kts └── src │ └── main │ └── kotlin │ ├── build-config.gradle.kts │ ├── conventions.gradle.kts │ ├── publish-1.8.gradle.kts │ ├── publish-17.gradle.kts │ ├── publish-plugin.gradle.kts │ └── publish.gradle.kts ├── build.gradle.kts ├── docs ├── .markout ├── FILES.md ├── MARKDOWN.md └── markout.png ├── docusaurus ├── .gitignore ├── babel.config.js ├── docs │ ├── .markout │ └── intro.md ├── docusaurus.config.js ├── linux.yarnrc ├── package.json ├── sidebars.js ├── static │ └── .nojekyll ├── tsconfig.json └── yarn.lock ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── markout-docusaurus-plugin ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── io │ └── koalaql │ └── markout │ └── docusaurus │ └── GradlePlugin.kt ├── markout-docusaurus ├── build.gradle.kts ├── src │ ├── main │ │ ├── kotlin │ │ │ └── io │ │ │ │ └── koalaql │ │ │ │ └── markout │ │ │ │ └── docusaurus │ │ │ │ ├── Docusaurus.kt │ │ │ │ ├── DocusaurusDirectory.kt │ │ │ │ ├── DocusaurusMarkdown.kt │ │ │ │ ├── DocusaurusMarkdownFile.kt │ │ │ │ ├── DocusaurusMarkdownWrapper.kt │ │ │ │ ├── DocusaurusRoot.kt │ │ │ │ ├── DocusaurusSettings.kt │ │ │ │ ├── Docusauruses.kt │ │ │ │ ├── JsonUtil.kt │ │ │ │ ├── MarkdownFileImpl.kt │ │ │ │ └── Resources.kt │ │ └── resources │ │ │ └── bootstrap │ │ │ ├── babel.config.js │ │ │ ├── gitignore │ │ │ ├── linux.yarnrc │ │ │ ├── package.json │ │ │ ├── sidebars.js │ │ │ ├── tsconfig.json │ │ │ └── yarn.lock │ └── test │ │ └── kotlin │ │ └── SiteTest.kt └── testing │ ├── .gitignore │ ├── README.md │ ├── babel.config.js │ ├── docs │ ├── .markout │ ├── basics │ │ ├── .markout │ │ ├── _category_.json │ │ ├── admonitions-and-mdx.mdx │ │ ├── code-blocks.md │ │ ├── code-with-highlight.md │ │ └── empty.md │ ├── extras │ │ ├── .markout │ │ └── _category_.json │ └── intro.md │ ├── docusaurus.config.js │ ├── linux.yarnrc │ ├── package.json │ ├── sidebars.js │ ├── static │ ├── .nojekyll │ └── img │ │ ├── docusaurus.png │ │ ├── favicon.ico │ │ ├── logo.svg │ │ ├── undraw_docusaurus_mountain.svg │ │ ├── undraw_docusaurus_react.svg │ │ └── undraw_docusaurus_tree.svg │ ├── tsconfig.json │ └── yarn.lock ├── markout-github-workflows-kt ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── io │ └── koalaql │ └── markout │ └── Workflows.kt ├── markout-markdown-plugin ├── build.gradle.kts ├── settings.gradle.kts └── src │ └── main │ └── kotlin │ └── GradlePlugin.kt ├── markout-markdown ├── build.gradle.kts ├── settings.gradle.kts └── src │ ├── main │ └── kotlin │ │ ├── MarkdownBuilder.kt │ │ └── io │ │ └── koalaql │ │ └── markout │ │ └── md │ │ ├── Bibliography.kt │ │ ├── Citation.kt │ │ ├── Markdown.kt │ │ ├── MarkdownBlock.kt │ │ ├── MarkdownCheckList.kt │ │ ├── MarkdownDottedList.kt │ │ ├── MarkdownInline.kt │ │ ├── MarkdownNumberedList.kt │ │ ├── MarkdownTable.kt │ │ ├── MarkdownTableRow.kt │ │ └── Markout.kt │ └── test │ └── kotlin │ ├── FilePathTests.kt │ └── MarkdownTests.kt ├── markout-plugin ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── io │ └── koalaql │ └── markout │ ├── GradlePlugin.kt │ └── MarkoutConfig.kt ├── markout ├── build.gradle.kts ├── src │ ├── main │ │ └── kotlin │ │ │ └── io │ │ │ └── koalaql │ │ │ └── markout │ │ │ ├── ActionableFiles.kt │ │ │ ├── ExecutionMode.kt │ │ │ ├── Markout.kt │ │ │ ├── MarkoutDsl.kt │ │ │ ├── files │ │ │ ├── AlreadyExistsError.kt │ │ │ ├── DeclareDirectory.kt │ │ │ ├── DeleteFile.kt │ │ │ ├── FileAction.kt │ │ │ ├── WriteMetadata.kt │ │ │ └── WriteToFile.kt │ │ │ ├── name │ │ │ ├── FileName.kt │ │ │ ├── TrackedName.kt │ │ │ └── UntrackedName.kt │ │ │ ├── output │ │ │ ├── Output.kt │ │ │ ├── OutputDirectory.kt │ │ │ ├── OutputEntry.kt │ │ │ └── OutputFile.kt │ │ │ ├── stream │ │ │ ├── StreamMatcher.kt │ │ │ └── StreamMode.kt │ │ │ └── text │ │ │ ├── AppendableLineWriter.kt │ │ │ ├── LineWriter.kt │ │ │ ├── OnWriteWriter.kt │ │ │ ├── ParagraphWriter.kt │ │ │ ├── PrefixedLineWriter.kt │ │ │ └── TrimLinesWriter.kt │ └── test │ │ └── kotlin │ │ ├── ApplyModeTests.kt │ │ ├── ExpectModeTests.kt │ │ ├── LineWriterTests.kt │ │ └── StreamTests.kt └── test-data │ ├── missing-dir │ └── .markout │ ├── missing-present │ ├── .markout │ └── case2 │ │ ├── .markout │ │ └── untracked.txt │ ├── tree │ ├── .markout │ ├── dir │ │ ├── .markout │ │ ├── file1.txt │ │ └── nested │ │ │ ├── .markout │ │ │ ├── empty.txt │ │ │ ├── file2.txt │ │ │ ├── file3.txt │ │ │ └── untracked.txt │ └── file0.txt │ └── unicode │ ├── .markout │ └── unicode.txt ├── readme ├── build.gradle.kts └── src │ └── main │ └── kotlin │ ├── Constants.kt │ ├── FileGen.kt │ ├── Main.kt │ ├── Markdown.kt │ ├── Util.kt │ ├── docusaurus │ ├── Docusaurus.kt │ ├── Intro.kt │ └── Util.kt │ └── workflows │ ├── Check.kt │ ├── GithubPages.kt │ └── Release.kt ├── settings.gradle.kts └── testing ├── build.gradle.kts ├── markdown-plugin ├── build.gradle.kts └── src │ ├── main │ └── kotlin │ │ └── Main.kt │ └── test │ └── kotlin │ └── IntegrationTests.kt └── settings.gradle.kts /.github/.markout: -------------------------------------------------------------------------------- 1 | workflows 2 | -------------------------------------------------------------------------------- /.github/workflows/.markout: -------------------------------------------------------------------------------- 1 | check.yml 2 | release.yml 3 | pages.yml 4 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | # This file was generated using a Kotlin DSL. 2 | # If you want to modify the workflow, please change the Kotlin source and regenerate this YAML file. 3 | # Generated with https://github.com/typesafegithub/github-workflows-kt 4 | 5 | name: 'Build and check' 6 | on: 7 | push: {} 8 | pull_request: {} 9 | jobs: 10 | build: 11 | runs-on: 'ubuntu-latest' 12 | steps: 13 | - id: 'step-0' 14 | uses: 'actions/checkout@v3' 15 | - id: 'step-1' 16 | uses: 'gradle/wrapper-validation-action@v1' 17 | - id: 'step-2' 18 | uses: 'actions/setup-java@v3' 19 | with: 20 | java-version: '19' 21 | distribution: 'temurin' 22 | - id: 'step-3' 23 | run: './gradlew check' 24 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Docs 2 | on: 3 | push: 4 | branches: [main] 5 | paths: docusaurus/** 6 | permissions: 7 | contents: read 8 | pages: write 9 | id-token: write 10 | concurrency: 11 | group: "pages" 12 | cancel-in-progress: true 13 | jobs: 14 | deploy: 15 | environment: 16 | name: github-pages 17 | url: ${{ steps.deployment.outputs.page_url }} 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v3 22 | - name: Configure JDK 23 | uses: actions/setup-java@v3 24 | with: 25 | distribution: 'temurin' 26 | java-version: 19 27 | - name: Build Pages 28 | run: ./gradlew :readme:docusaurusBuild 29 | - name: Setup Pages 30 | uses: actions/configure-pages@v1 31 | - name: Upload artifact 32 | uses: actions/upload-pages-artifact@v1 33 | with: 34 | path: docusaurus/build 35 | - name: Deploy to GitHub Pages 36 | id: deployment 37 | uses: actions/deploy-pages@v1 -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This file was generated using a Kotlin DSL. 2 | # If you want to modify the workflow, please change the Kotlin source and regenerate this YAML file. 3 | # Generated with https://github.com/typesafegithub/github-workflows-kt 4 | 5 | name: 'Publish plugins and dependencies' 6 | on: 7 | release: 8 | types: 9 | - 'published' 10 | branches: 11 | - 'main' 12 | jobs: 13 | staging_repository: 14 | name: 'Create staging repository' 15 | runs-on: 'ubuntu-latest' 16 | outputs: 17 | repository_id: '${{ steps.step-0.outputs.repository_id }}' 18 | steps: 19 | - id: 'step-0' 20 | uses: 'nexus-actions/create-nexus-staging-repo@main' 21 | with: 22 | username: '${{ secrets.SONATYPE_USERNAME }}' 23 | password: '${{ secrets.SONATYPE_PASSWORD }}' 24 | staging_profile_id: '${{ secrets.SONATYPE_PROFILE_ID }}' 25 | description: '${{ github.repository }}/${{ github.workflow }}#${{ github.run_number }}' 26 | base_url: 'https://s01.oss.sonatype.org/service/local/' 27 | publish: 28 | runs-on: 'ubuntu-latest' 29 | needs: 30 | - 'staging_repository' 31 | steps: 32 | - id: 'step-0' 33 | uses: 'actions/checkout@v3' 34 | - id: 'step-1' 35 | uses: 'actions/setup-java@v3' 36 | with: 37 | java-version: '19' 38 | distribution: 'temurin' 39 | - id: 'step-2' 40 | name: 'Publish Plugins and Libraries' 41 | env: 42 | REPOSITORY_ID: '${{ needs.staging_repository.outputs.repository_id }}' 43 | SONATYPE_USERNAME: '${{ secrets.SONATYPE_USERNAME }}' 44 | SONATYPE_PASSWORD: '${{ secrets.SONATYPE_PASSWORD }}' 45 | GPG_PRIVATE_KEY: '${{ secrets.GPG_PRIVATE_KEY }}' 46 | GPG_PRIVATE_PASSWORD: '${{ secrets.GPG_PRIVATE_PASSWORD }}' 47 | GRADLE_PUBLISH_KEY: '${{ secrets.GRADLE_PUBLISH_KEY }}' 48 | GRADLE_PUBLISH_SECRET: '${{ secrets.GRADLE_PUBLISH_SECRET }}' 49 | run: './gradlew publish' 50 | - id: 'step-3' 51 | uses: 'nexus-actions/release-nexus-staging-repo@main' 52 | with: 53 | username: '${{ secrets.SONATYPE_USERNAME }}' 54 | password: '${{ secrets.SONATYPE_PASSWORD }}' 55 | staging_repository_id: '${{ needs.staging_repository.outputs.repository_id }}' 56 | base_url: 'https://s01.oss.sonatype.org/service/local/' 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Gradle 2 | build/ 3 | .gradle/ 4 | 5 | # IDEA 6 | out/ 7 | .idea/ 8 | *.iml 9 | -------------------------------------------------------------------------------- /.markout: -------------------------------------------------------------------------------- 1 | .github 2 | docusaurus 3 | docs 4 | README.md 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Damien O'Hara 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Markout: Markdown DSL and File Generator 2 | 3 | Markout is a library for generating files, directories and Markdown documentation from Kotlin. 4 | It is designed for generating GitHub Flavored Markdown docs that live alongside code. 5 | Using [Kapshot](https://github.com/mfwgenerics/kapshot) with this project 6 | allows literate programming and "executable documentation" which ensures 7 | that documentation remains correct and up to date. 8 | 9 | 1. [Intro](#intro) 10 | 2. [Getting Started](#getting-started) 11 | 3. [Usage](#usage) 12 | 13 | ## Intro 14 | 15 | Markout provides a fully featured Markdown DSL to support documentation 16 | generation and automation. It is flexible, mixes easily with raw markdown and 17 | is intended to be built upon and used in conjunction with other tools. 18 | The Markdown DSL can build strings or output directly to a file. 19 | 20 | In addition to the Markdown DSL, Markout provides tools for managing 21 | generated files and directories. Files and directories can be declared using 22 | a DSL and then validated or synchronized. Snapshot testing can be performed on 23 | generated files. 24 | 25 | ## Getting Started 26 | 27 | Add the `markout` dependency 28 | 29 | ```kotlin 30 | /* build.gradle.kts */ 31 | dependencies { 32 | implementation("io.koalaql:markout:0.0.9") 33 | } 34 | ``` 35 | 36 | #### File Generation 37 | 38 | If you want to use Markout as a documentation generator, call 39 | the `markout` function directly from your main method. Pass a path 40 | to the directory where you want Markout to generate files. 41 | The path can be relative or absolute. 42 | 43 | ```kotlin 44 | fun main() = markout(Path(".")) { 45 | markdown("hello") { 46 | p("This file was generated using markout") 47 | 48 | p { 49 | i("Hello ") + "World!" 50 | } 51 | } 52 | } 53 | ``` 54 | 55 | Currently the Gradle application plugin is the best way to run a standalone Markout project 56 | 57 | ```shell 58 | ./gradlew :my-project:run 59 | ``` 60 | 61 | #### Markdown Strings 62 | 63 | If you only want to use Markout to generate Markdown strings then you can use 64 | `markdown` as a standalone function 65 | 66 | ```kotlin 67 | markdown { 68 | h1("My Markdown") 69 | 70 | -"Text with some *italics*." 71 | } 72 | ``` 73 | 74 | The above will produce the String 75 | 76 | ```markdown 77 | # My Markdown 78 | 79 | Text with some *italics*. 80 | ``` 81 | 82 | ## Usage 83 | 84 | 1. [File Generation](docs/FILES.md) 85 | 2. [Markdown](docs/MARKDOWN.md) 86 | -------------------------------------------------------------------------------- /build-logic/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `kotlin-dsl` 3 | } 4 | 5 | repositories { 6 | gradlePluginPortal() 7 | } 8 | 9 | dependencies { 10 | implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0") 11 | implementation("com.palantir.gradle.gitversion:gradle-git-version:0.15.0") 12 | implementation("com.gradle.publish:plugin-publish-plugin:1.1.0") 13 | implementation("com.github.gmazzo:gradle-buildconfig-plugin:3.1.0") 14 | } 15 | -------------------------------------------------------------------------------- /build-logic/src/main/kotlin/build-config.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("conventions") 3 | id("com.github.gmazzo.buildconfig") 4 | } 5 | 6 | buildConfig { 7 | buildConfigField("String", "VERSION", "\"${project.version}\"") 8 | } -------------------------------------------------------------------------------- /build-logic/src/main/kotlin/conventions.gradle.kts: -------------------------------------------------------------------------------- 1 | repositories { 2 | mavenCentral() 3 | } 4 | 5 | plugins { 6 | kotlin("jvm") 7 | 8 | id("com.palantir.git-version") 9 | } 10 | 11 | val gitVersion: groovy.lang.Closure<*> by extra 12 | 13 | group = "io.koalaql" 14 | version = gitVersion() 15 | 16 | check("$version".isNotBlank() && version != "unspecified") 17 | { "invalid version $version" } -------------------------------------------------------------------------------- /build-logic/src/main/kotlin/publish-1.8.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | plugins { 4 | id("publish") 5 | } 6 | 7 | java { 8 | targetCompatibility = JavaVersion.VERSION_1_8 9 | } 10 | 11 | tasks.withType().configureEach { 12 | kotlinOptions { 13 | jvmTarget = "1.8" 14 | } 15 | } -------------------------------------------------------------------------------- /build-logic/src/main/kotlin/publish-17.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | plugins { 4 | id("publish") 5 | } 6 | 7 | java { 8 | targetCompatibility = JavaVersion.VERSION_17 9 | } 10 | 11 | tasks.withType().configureEach { 12 | kotlinOptions { 13 | jvmTarget = "17" 14 | } 15 | } -------------------------------------------------------------------------------- /build-logic/src/main/kotlin/publish-plugin.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | plugins { 4 | id("build-config") 5 | 6 | id("java-gradle-plugin") 7 | id("com.gradle.plugin-publish") 8 | } 9 | 10 | java { 11 | targetCompatibility = JavaVersion.VERSION_1_8 12 | } 13 | 14 | tasks.withType().configureEach { 15 | kotlinOptions { 16 | jvmTarget = "1.8" 17 | } 18 | } 19 | 20 | tasks.getByName("publishPlugins") { 21 | doFirst { 22 | val version = "${project.version}" 23 | 24 | val isValid = !version.endsWith(".dirty") 25 | && version.count { it == '-' } < 2 26 | 27 | check(isValid) { 28 | "project.version ${project.version} does not appear to be a valid release version" 29 | } 30 | } 31 | } 32 | 33 | repositories { 34 | gradlePluginPortal() 35 | } 36 | 37 | dependencies { 38 | implementation(kotlin("gradle-plugin-api")) 39 | } -------------------------------------------------------------------------------- /build-logic/src/main/kotlin/publish.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("conventions") 3 | 4 | `java-library` 5 | `maven-publish` 6 | signing 7 | } 8 | 9 | java { 10 | withJavadocJar() 11 | withSourcesJar() 12 | } 13 | 14 | publishing { 15 | publications { 16 | create("markout") { 17 | from(components["java"]) 18 | 19 | artifactId = project.name 20 | 21 | versionMapping { 22 | usage("java-api") { 23 | fromResolutionOf("runtimeClasspath") 24 | } 25 | usage("java-runtime") { 26 | fromResolutionResult() 27 | } 28 | } 29 | 30 | pom { 31 | name.set("Markout") 32 | description.set("Markout is an executable documentation platform for Kotlin") 33 | url.set("https://markout.koalaql.io") 34 | 35 | licenses { 36 | license { 37 | name.set("MIT License") 38 | url.set("https://opensource.org/licenses/MIT") 39 | } 40 | } 41 | 42 | developers { 43 | developer { 44 | name.set("Damien O'Hara") 45 | url.set("https://github.com/mfwgenerics") 46 | } 47 | } 48 | 49 | scm { 50 | connection.set("scm:git@github.com:mfwgenerics/markout.git") 51 | developerConnection.set("scm:git@github.com:mfwgenerics/markout.git") 52 | url.set("https://github.com/mfwgenerics/markout") 53 | } 54 | } 55 | } 56 | } 57 | 58 | signing { 59 | useInMemoryPgpKeys( 60 | System.getenv("GPG_PRIVATE_KEY"), 61 | System.getenv("GPG_PRIVATE_PASSWORD") 62 | ) 63 | 64 | sign(publishing.publications["markout"]) 65 | } 66 | 67 | repositories { 68 | maven { 69 | val repoId = System.getenv("REPOSITORY_ID") 70 | 71 | name = "OSSRH" 72 | url = uri("https://s01.oss.sonatype.org/service/local/staging/deployByRepositoryId/$repoId/") 73 | 74 | credentials { 75 | username = System.getenv("SONATYPE_USERNAME") 76 | password = System.getenv("SONATYPE_PASSWORD") 77 | } 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | tasks.register("check") { 2 | dependsOn(gradle.includedBuilds.map { it.task(":check") }) 3 | } 4 | 5 | tasks.register("publish") { 6 | dependsOn(listOf( 7 | gradle.includedBuild("markout-plugin"), 8 | gradle.includedBuild("markout-markdown-plugin"), 9 | gradle.includedBuild("markout-docusaurus-plugin") 10 | ).map { it.task(":publishPlugins") }) 11 | 12 | dependsOn(listOf( 13 | gradle.includedBuild("markout"), 14 | gradle.includedBuild("markout-markdown"), 15 | gradle.includedBuild("markout-docusaurus") 16 | ).map { it.task(":publish") }) 17 | } -------------------------------------------------------------------------------- /docs/.markout: -------------------------------------------------------------------------------- 1 | MARKDOWN.md 2 | FILES.md 3 | -------------------------------------------------------------------------------- /docs/FILES.md: -------------------------------------------------------------------------------- 1 | # File Generation 2 | 3 | Markout can run in one of two modes 4 | 5 | 1. [Apply Mode](#apply-mode) 6 | 2. [Expect Mode](#expect-mode) 7 | 8 | ## Apply Mode 9 | 10 | Apply is the default mode. It generates files and directories and deletes 11 | previously generated files and directories that were not regenerated. 12 | 13 | ### Files and Directories 14 | 15 | Markout provides a straightforward DSL for generating files and directories 16 | 17 | ```kotlin 18 | markout(Path("..")) { 19 | directory("my-directory") { 20 | directory("inner") { 21 | file("inner.txt", "another plain text file") 22 | } 23 | 24 | file("plain.txt", "the contents of a plain text file") 25 | 26 | file("circle.svg", """ 27 | 28 | 29 | 30 | """.trimIndent()) 31 | } 32 | 33 | markdown("readme") { 34 | -"A markdown file" 35 | -"The .md extension is automatically added to the filename if it is not present" 36 | } 37 | } 38 | ``` 39 | 40 | When this code is run it generates the following file tree 41 | 42 | ``` 43 | my-directory 44 | ├─ inner 45 | │ └─ inner.txt 46 | ├─ circle.svg 47 | └─ plain.txt 48 | readme.md 49 | ``` 50 | 51 | ### File Tracking 52 | 53 | When Markout generates directories it includes a `.markout` file. This is 54 | how Markout keeps track of generated files. It should always be checked 55 | into git. Markout will never change or delete an existing file or directory 56 | unless it is tracked in `.markout` 57 | 58 | File tracking allows regular files to be mixed in with generated ones. 59 | For example, you might mix handwritten markdown into your docs directory. 60 | 61 | ## Expect Mode 62 | 63 | Running Markout in expect mode will cause it to fail when it encounters changes. 64 | This allows you to check that files have been generated and are consistent 65 | with the code. It is intended for use in CI workflows. 66 | 67 | To use Expect mode, run markout with the `MARKOUT_MODE` environment variable set to `expect`. 68 | 69 | ```shell 70 | MARKOUT_MODE=expect ./gradlew :readme:run 71 | ``` 72 | -------------------------------------------------------------------------------- /docs/markout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mfwgenerics/markout/38aed7629da23261815ecaad472650918ec24488/docs/markout.png -------------------------------------------------------------------------------- /docusaurus/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /docusaurus/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /docusaurus/docs/.markout: -------------------------------------------------------------------------------- 1 | intro.md 2 | -------------------------------------------------------------------------------- /docusaurus/docs/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | slug: / 4 | --- 5 | 6 | # Introduction 7 | 8 | Markout is an executable documentation platform for Kotlin that 9 | allows you to document Kotlin projects in code rather than text. 10 | Documentation written this way can be tested and verified on every build. 11 | Sample code becomes error proof, stays up-to-date and forms an 12 | extra test suite for your project. Markout can serve as an alternative 13 | to [kotlinx-knit](https://github.com/Kotlin/kotlinx-knit). 14 | 15 | ## Project purpose 16 | 17 | Documenting code is a time-consuming and error-prone process. 18 | Not only is handwritten sample code vulnerable to typos and syntax errors, 19 | it silently goes out of date as projects evolve. 20 | Results shown in documentation are also not guaranteed to match the 21 | real behavior of the code. Markout seeks to address this by allowing 22 | your docs to execute code from your project and embed the results. 23 | Your generated documentation is checked into Git and used to perform 24 | snapshot testing on future builds. 25 | 26 | Another goal of this project is to make it easy for Kotlin developers to use the 27 | [Docusaurus][1] static site generator to quickly build 28 | and deploy documentation on GitHub pages. Markout can create, configure, install, build 29 | and run Docusaurus projects without requiring Node.js to be installed. It integrates 30 | with Gradle's [Continuous Build](https://docs.gradle.org/current/userguide/command_line_interface.html#sec:continuous_build) 31 | to enable hot reloads and previews as you code. 32 | Docusaurus support is optional and provided through a separate Gradle plugin. 33 | 34 | Markout is designed to integrate with [Kapshot][2], 35 | a minimal Kotlin compiler plugin that allows source code to be 36 | captured and inspected at runtime. 37 | Kapshot is the magic ingredient that enables fully executable and testable sample code blocks. 38 | 39 | ## How it works 40 | 41 | Markout is designed around a core file generation layer that allows file trees to be declared in code. 42 | These file trees are reconciled into a target directory, which is your project root directory by default. 43 | Through extra plugins and libraries, the file generation layer can be extended with functionality for 44 | generating markdown, capturing source code and building Docusaurus websites. 45 | 46 | ### File generation 47 | 48 | Markout generates files by running code from Kotlin projects with the Markout Gradle plugin applied. 49 | You supply a `main` method which invokes a `markout` block to describe how files should be generated. 50 | This code runs every time files are generated or verified. 51 | 52 | ```kotlin title="Main.kt" 53 | fun main() = markout { 54 | file("README.txt", "Hello world!") 55 | 56 | directory("docs") { 57 | file("INTRO.txt", "Another text file!") 58 | file("OUTRO.txt", "A final text file") 59 | } 60 | } 61 | ``` 62 | 63 | When the code above is run using `:markout`, it generates the following file tree 64 | and creates it in the project directory. 65 | 66 | ``` 67 | my-project 68 | ├─ docs 69 | │ ├─ INTRO.txt 70 | │ └─ OUTRO.txt 71 | └─ README.txt 72 | ``` 73 | 74 | The `:markoutCheck` task then verifies that these files match subsequent runs of the code. 75 | 76 | ### Markdown DSL 77 | 78 | Markout is extended with a DSL for generating markdown files procedurally. 79 | The DSL can also be used to generate standalone Markdown strings. 80 | 81 | ````mdx-code-block 82 | import Tabs from '@theme/Tabs'; 83 | import TabItem from '@theme/TabItem'; 84 | 85 | 86 | 87 | 88 | ```kotlin 89 | val markoutVersion = "0.0.9" 90 | 91 | markout { 92 | markdown("README.md") { 93 | h2("Readme") 94 | 95 | p("Here's some *generated* Markdown with a list") 96 | 97 | p("Using Markout version `$markoutVersion`") 98 | 99 | ol { 100 | li("One") 101 | li("Two") 102 | } 103 | } 104 | } 105 | ``` 106 | 107 | 108 | 109 | 110 | ```markdown 111 | ## Readme 112 | 113 | Here's some *generated* Markdown with a list 114 | 115 | Using Markout version `0.0.9` 116 | 117 | 1. One 118 | 2. Two 119 | ``` 120 | 121 | 122 | 123 | 124 | > ## Readme 125 | > 126 | > Here's some *generated* Markdown with a list 127 | > 128 | > Using Markout version `0.0.9` 129 | > 130 | > 1. One 131 | > 2. Two 132 | 133 | 134 | 135 | 136 | ```` 137 | 138 | ### Source code capture 139 | 140 | Source code capture works using the [Kapshot][2] plugin. 141 | This allows you to execute your sample code blocks and use the results. 142 | 143 | `````mdx-code-block 144 | 145 | 146 | 147 | ```kotlin 148 | markout { 149 | markdown("EXAMPLE.md") { 150 | val block = code { 151 | fun square(x: Int) = x*x 152 | 153 | square(7) 154 | } 155 | 156 | p("The code above results in: ${block.invoke()}") 157 | p("If the result changes unexpectedly then `./gradlew check` will fail") 158 | } 159 | } 160 | ``` 161 | 162 | 163 | 164 | 165 | ````markdown 166 | ```kotlin 167 | fun square(x: Int) = x*x 168 | 169 | square(7) 170 | ``` 171 | 172 | The code above results in: 49 173 | 174 | If the result changes unexpectedly then `./gradlew check` will fail 175 | ```` 176 | 177 | 178 | 179 | 180 | > ```kotlin 181 | > fun square(x: Int) = x*x 182 | > 183 | > square(7) 184 | > ``` 185 | > 186 | > The code above results in: 49 187 | > 188 | > If the result changes unexpectedly then `./gradlew check` will fail 189 | 190 | 191 | 192 | 193 | ````` 194 | 195 | ### Docusaurus sites 196 | 197 | The Docusaurus plugin provides a `docusaurus` builder and Gradle tasks for building and running a [Docusaurus][1] site. 198 | 199 | ````mdx-code-block 200 | 201 | 202 | 203 | ```kotlin 204 | markout { 205 | docusaurus("my-site") { 206 | configure { 207 | title = "Example Site" 208 | } 209 | 210 | docs { 211 | markdown("hello.md") { 212 | h1("Hello Docusaurus!") 213 | } 214 | } 215 | } 216 | } 217 | ``` 218 | 219 | 220 | 221 | 222 | ``` 223 | my-project 224 | └─ my-site 225 | ├─ docs 226 | │ └─ hello.md 227 | ├─ static 228 | │ └─ .nojekyll 229 | ├─ .gitignore 230 | ├─ babel.config.js 231 | ├─ docusaurus.config.js 232 | ├─ linux.yarnrc 233 | ├─ package.json 234 | ├─ sidebars.js 235 | ├─ tsconfig.json 236 | └─ yarn.lock 237 | ``` 238 | 239 | 240 | 241 | 242 | ```` 243 | 244 | [1]: https://docusaurus.io/ 245 | [2]: https://github.com/mfwgenerics/kapshot 246 | -------------------------------------------------------------------------------- /docusaurus/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const lightCodeTheme = require('prism-react-renderer/themes/github'); 4 | const darkCodeTheme = require('prism-react-renderer/themes/dracula'); 5 | 6 | /** @type {import('@docusaurus/types').Config} */ 7 | const config = { 8 | title: 'Markout', 9 | url: 'https://mfwgenerics.github.io/', 10 | baseUrl: '/markout/', 11 | onBrokenLinks: 'throw', 12 | onBrokenMarkdownLinks: 'warn', 13 | favicon: 'img/favicon.ico', 14 | 15 | i18n: { 16 | defaultLocale: 'en', 17 | locales: ['en'], 18 | }, 19 | 20 | presets: [ 21 | [ 22 | 'classic', 23 | /** @type {import('@docusaurus/preset-classic').Options} */ 24 | ({ 25 | docs: { 26 | routeBasePath: '/', 27 | sidebarPath: require.resolve('./sidebars.js'), 28 | }, 29 | blog: false 30 | }), 31 | ], 32 | ], 33 | 34 | themeConfig: 35 | /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ 36 | ({ 37 | navbar: { 38 | title: 'Markout', 39 | items: [ 40 | { 41 | href: 'https://github.com/mfwgenerics/markout', 42 | label: 'GitHub', 43 | position: 'right', 44 | }, 45 | ], 46 | }, 47 | metadata: [ 48 | {"name": "google-site-verification", "content": "E-XuQoF0UqA8bzoXL3yY7bs9KuQFsQ2yrSkYuIp6Gqs"} 49 | ], 50 | prism: { 51 | theme: lightCodeTheme, 52 | darkTheme: darkCodeTheme, 53 | additionalLanguages: ["kotlin", "java"], 54 | }, 55 | }), 56 | }; 57 | module.exports = config; -------------------------------------------------------------------------------- /docusaurus/linux.yarnrc: -------------------------------------------------------------------------------- 1 | # workaround for Linux issue where yarn doesn't terminate node on shutdown when run through gradle-node-plugin 2 | # see https://github.com/node-gradle/gradle-node-plugin/issues/65#issuecomment-872062591 3 | 4 | # this file is used with --use-yarnrc 5 | 6 | script-shell "/usr/bin/bash" -------------------------------------------------------------------------------- /docusaurus/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-website", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc" 16 | }, 17 | "dependencies": { 18 | "@docusaurus/core": "2.2.0", 19 | "@docusaurus/preset-classic": "2.2.0", 20 | "@mdx-js/react": "^1.6.22", 21 | "clsx": "^1.2.1", 22 | "prism-react-renderer": "^1.3.5", 23 | "react": "^17.0.2", 24 | "react-dom": "^17.0.2" 25 | }, 26 | "devDependencies": { 27 | "@docusaurus/module-type-aliases": "2.2.0", 28 | "@tsconfig/docusaurus": "^1.0.5", 29 | "typescript": "^4.7.4" 30 | }, 31 | "browserslist": { 32 | "production": [ 33 | ">0.5%", 34 | "not dead", 35 | "not op_mini all" 36 | ], 37 | "development": [ 38 | "last 1 chrome version", 39 | "last 1 firefox version", 40 | "last 1 safari version" 41 | ] 42 | }, 43 | "engines": { 44 | "node": ">=16.14" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /docusaurus/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | // @ts-check 13 | 14 | /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ 15 | const sidebars = { 16 | // By default, Docusaurus generates a sidebar from the docs folder structure 17 | tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], 18 | 19 | // But you can create a sidebar manually 20 | /* 21 | tutorialSidebar: [ 22 | 'intro', 23 | 'hello', 24 | { 25 | type: 'category', 26 | label: 'Tutorial', 27 | items: ['tutorial-basics/create-a-document'], 28 | }, 29 | ], 30 | */ 31 | }; 32 | 33 | module.exports = sidebars; 34 | -------------------------------------------------------------------------------- /docusaurus/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mfwgenerics/markout/38aed7629da23261815ecaad472650918ec24488/docusaurus/static/.nojekyll -------------------------------------------------------------------------------- /docusaurus/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/docusaurus/tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "." 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | kapshot = "0.1.1" 3 | 4 | [libraries] 5 | kapshot-runtime = { module = "io.koalaql:kapshot-runtime", version.ref = "kapshot" } 6 | kapshot-plugin-gradle = { module = "io.koalaql:kapshot-plugin-gradle", version.ref = "kapshot" } 7 | 8 | [plugins] 9 | kapshot = { id = "io.koalaql.kapshot-plugin", version.ref = "kapshot" } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mfwgenerics/markout/38aed7629da23261815ecaad472650918ec24488/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Stop when "xargs" is not available. 209 | if ! command -v xargs >/dev/null 2>&1 210 | then 211 | die "xargs is not available" 212 | fi 213 | 214 | # Use "xargs" to parse quoted args. 215 | # 216 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 217 | # 218 | # In Bash we could simply go: 219 | # 220 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 221 | # set -- "${ARGS[@]}" "$@" 222 | # 223 | # but POSIX shell has neither arrays nor command substitution, so instead we 224 | # post-process each arg (as a line of input to sed) to backslash-escape any 225 | # character that might be a shell metacharacter, then use eval to reverse 226 | # that process (while maintaining the separation between arguments), and wrap 227 | # the whole thing up as a single "set" statement. 228 | # 229 | # This will of course break if any of these variables contains a newline or 230 | # an unmatched quote. 231 | # 232 | 233 | eval "set -- $( 234 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 235 | xargs -n1 | 236 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 237 | tr '\n' ' ' 238 | )" '"$@"' 239 | 240 | exec "$JAVACMD" "$@" 241 | -------------------------------------------------------------------------------- /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 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if %ERRORLEVEL% equ 0 goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if %ERRORLEVEL% equ 0 goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | set EXIT_CODE=%ERRORLEVEL% 84 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 85 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 86 | exit /b %EXIT_CODE% 87 | 88 | :mainEnd 89 | if "%OS%"=="Windows_NT" endlocal 90 | 91 | :omega 92 | -------------------------------------------------------------------------------- /markout-docusaurus-plugin/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("publish-plugin") 3 | } 4 | 5 | dependencies { 6 | implementation("com.github.node-gradle:gradle-node-plugin:3.5.1") 7 | implementation("io.koalaql:markout-markdown-plugin:${project.version}") 8 | } 9 | 10 | pluginBundle { 11 | website = "https://github.com/mfwgenerics/markout" 12 | vcsUrl = "https://github.com/mfwgenerics/markout.git" 13 | tags = listOf("kotlin", "markout", "markdown", "jvm", "documentation") 14 | } 15 | 16 | gradlePlugin { 17 | plugins { 18 | create("markoutPlugin") { 19 | id = "io.koalaql.markout-docusaurus" 20 | displayName = "Markout Docusaurus Plugin" 21 | description = "Plugin Support for Markout Powered Docusaurus Sites" 22 | implementationClass = "io.koalaql.markout.docusaurus.GradlePlugin" 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /markout-docusaurus-plugin/src/main/kotlin/io/koalaql/markout/docusaurus/GradlePlugin.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.docusaurus 2 | 3 | import com.github.gradle.node.NodeExtension 4 | import com.github.gradle.node.exec.NodeExecConfiguration 5 | import com.github.gradle.node.util.PlatformHelper 6 | import com.github.gradle.node.util.ProjectApiHelper 7 | import com.github.gradle.node.variant.VariantComputer 8 | import com.github.gradle.node.yarn.exec.YarnExecRunner 9 | import org.gradle.api.Plugin 10 | import org.gradle.api.Project 11 | import com.github.gradle.node.yarn.task.YarnTask 12 | import io.koalaql.markout_docusaurus_plugin.BuildConfig 13 | import org.gradle.api.Action 14 | import org.gradle.api.DefaultTask 15 | import org.gradle.api.model.ObjectFactory 16 | import org.gradle.api.provider.ProviderFactory 17 | import org.gradle.api.tasks.Input 18 | import org.gradle.api.tasks.Internal 19 | import org.gradle.api.tasks.Optional 20 | import org.gradle.api.tasks.TaskAction 21 | import org.gradle.deployment.internal.Deployment 22 | import org.gradle.deployment.internal.DeploymentHandle 23 | import org.gradle.deployment.internal.DeploymentRegistry 24 | import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform 25 | import org.gradle.process.ExecResult 26 | import org.gradle.process.ExecSpec 27 | import java.util.concurrent.atomic.AtomicBoolean 28 | import java.util.concurrent.atomic.AtomicReference 29 | import javax.inject.Inject 30 | import kotlin.concurrent.thread 31 | import kotlin.io.readLines 32 | 33 | class StopHandle { 34 | private companion object { 35 | val uninitMarker = { } 36 | val stoppedMarker = { } 37 | } 38 | 39 | private val onStop = AtomicReference(uninitMarker) 40 | 41 | val isStopped = onStop.get() === stoppedMarker 42 | 43 | fun onStop(stop: () -> Unit) { 44 | val calledYet = AtomicBoolean(false) 45 | 46 | val runsOnce = { 47 | if (!calledYet.getAndSet(true)) stop() 48 | } 49 | 50 | val marker = onStop.getAndSet(runsOnce) 51 | 52 | if (marker === stoppedMarker) { 53 | runsOnce() 54 | } else check (marker === uninitMarker) { 55 | "internal error: onStop was called twice" 56 | } 57 | } 58 | 59 | fun stop() { 60 | onStop.getAndSet(stoppedMarker).invoke() 61 | } 62 | } 63 | 64 | fun interface Spawn { 65 | operator fun invoke(dispose: StopHandle) 66 | } 67 | 68 | open class DocusaurusHandle @Inject constructor ( 69 | private val spawn: Spawn 70 | ): DeploymentHandle { 71 | private val handle = StopHandle() 72 | 73 | override fun start(deployment: Deployment) { spawn(handle) } 74 | override fun isRunning(): Boolean = !handle.isStopped 75 | override fun stop() { handle.stop() } 76 | } 77 | 78 | abstract class RunDocusaurus: DefaultTask() { 79 | @get:Inject 80 | abstract val objects: ObjectFactory 81 | 82 | @get:Inject 83 | abstract val providers: ProviderFactory 84 | 85 | @get:Optional 86 | @get:Input 87 | val yarnCommand = objects.listProperty(String::class.java) 88 | 89 | @get:Optional 90 | @get:Input 91 | val args = objects.listProperty(String::class.java) 92 | 93 | @get:Input 94 | val ignoreExitValue = objects.property(Boolean::class.java).convention(false) 95 | 96 | @get:Internal 97 | val workingDir = objects.directoryProperty() 98 | 99 | @get:Input 100 | val environment = objects.mapProperty(String::class.java, String::class.java) 101 | 102 | @get:Internal @Suppress("unchecked_cast") 103 | val execOverrides = objects.property( 104 | Action::class.java as Class> 105 | ) 106 | 107 | @get:Internal 108 | val projectHelper = ProjectApiHelper.newInstance(project) 109 | 110 | @get:Internal 111 | val nodeExtension = NodeExtension[project] 112 | 113 | @get:Internal 114 | var platformHelper = PlatformHelper.INSTANCE 115 | 116 | @get:Internal 117 | internal val variantComputer by lazy { 118 | VariantComputer(platformHelper) 119 | } 120 | 121 | private fun buildYarnStart(): () -> ExecResult { 122 | val os = DefaultNativePlatform 123 | .getCurrentOperatingSystem() 124 | 125 | val args = if (os.isLinux) { 126 | listOf("--non-interactive", "--use-yarnrc=linux.yarnrc", "--silent", "start") 127 | } else { 128 | listOf("--non-interactive", "--silent", "start") 129 | } 130 | 131 | val nodeExecConfiguration = NodeExecConfiguration( 132 | args, 133 | environment.get(), 134 | workingDir.asFile.orNull, 135 | ignoreExitValue.get(), 136 | execOverrides.orNull 137 | ) 138 | 139 | val yarnExecRunner = objects.newInstance(YarnExecRunner::class.java) 140 | 141 | return { 142 | yarnExecRunner.executeYarnCommand( 143 | projectHelper, 144 | nodeExtension, 145 | nodeExecConfiguration, 146 | variantComputer 147 | ) 148 | } 149 | } 150 | 151 | @TaskAction 152 | fun start() { 153 | if (project.gradle.startParameter.isContinuous) { 154 | val deploymentRegistry = services.get(DeploymentRegistry::class.java) 155 | 156 | val deploymentHandle = deploymentRegistry.get(path, DocusaurusHandle::class.java) 157 | 158 | if (deploymentHandle == null) { 159 | val start = buildYarnStart() 160 | 161 | deploymentRegistry.start( 162 | path, 163 | DeploymentRegistry.ChangeBehavior.NONE, 164 | DocusaurusHandle::class.java, 165 | Spawn { 166 | val thread = thread { start() } 167 | 168 | it.onStop { 169 | thread.interrupt() 170 | } 171 | } 172 | ) 173 | } 174 | } else { 175 | error(""" 176 | ERROR: $path was run non-continuously and will not hot reload changes 177 | ERROR: You want to run this with `./gradlew $path --continuous` instead 178 | """.trimIndent()) 179 | } 180 | } 181 | } 182 | 183 | class GradlePlugin: Plugin { 184 | override fun apply(target: Project) = with (target) { 185 | dependencies.add("api", "io.koalaql:markout-markdown:${BuildConfig.VERSION}") 186 | dependencies.add("api", "io.koalaql:markout-docusaurus:${BuildConfig.VERSION}") 187 | 188 | with(plugins) { 189 | apply("com.github.node-gradle.node") 190 | apply("io.koalaql.markout") 191 | apply("io.koalaql.kapshot-plugin") 192 | } 193 | 194 | extensions.configure(NodeExtension::class.java) { 195 | it.download.set(true) 196 | } 197 | 198 | val workingDirProvider = layout 199 | .buildDirectory 200 | .file("markout/paths.txt") 201 | .flatMap { file -> project.layout.buildDirectory.dir(file 202 | .asFile 203 | .readLines() 204 | .asSequence() 205 | .filter { it.endsWith("/docusaurus.config.js") } 206 | .firstOrNull() 207 | ?.removeSuffix("/docusaurus.config.js") 208 | ?: error("No docusaurus directory configured") 209 | ) } 210 | 211 | tasks.register("docusaurusInstall", YarnTask::class.java) { 212 | it.group = "markout" 213 | 214 | it.dependsOn("markout") 215 | it.workingDir.set(workingDirProvider) 216 | 217 | it.args.set(listOf( 218 | "--silent", 219 | "--non-interactive", 220 | "install", 221 | "--frozen-lockfile", 222 | )) 223 | } 224 | 225 | tasks.register("docusaurusCheckInstall", YarnTask::class.java) { 226 | it.group = "markout" 227 | 228 | it.dependsOn("markoutCheck") 229 | it.workingDir.set(workingDirProvider) 230 | 231 | it.args.set(listOf( 232 | "--silent", 233 | "--non-interactive", 234 | "install", 235 | "--frozen-lockfile", 236 | )) 237 | } 238 | 239 | tasks.register("docusaurusStart", RunDocusaurus::class.java) { 240 | it.group = "markout" 241 | 242 | it.dependsOn("docusaurusInstall") 243 | it.dependsOn("markout") 244 | it.workingDir.set(workingDirProvider) 245 | 246 | it.doLast { 247 | gradle.startParameter.setExcludedTaskNames( 248 | mutableSetOf("docusaurusInstall").apply { addAll(gradle.startParameter.excludedTaskNames) } 249 | ) 250 | } 251 | } 252 | 253 | tasks.register("docusaurusBuild", YarnTask::class.java) { 254 | it.group = "markout" 255 | 256 | it.dependsOn("markoutCheck") 257 | it.dependsOn("docusaurusCheckInstall") 258 | it.workingDir.set(workingDirProvider) 259 | 260 | it.args.set(listOf( 261 | "--silent", 262 | "--non-interactive", 263 | "build", 264 | )) 265 | } 266 | 267 | Unit 268 | } 269 | } -------------------------------------------------------------------------------- /markout-docusaurus/build.gradle.kts: -------------------------------------------------------------------------------- 1 | repositories { 2 | mavenCentral() 3 | } 4 | 5 | plugins { 6 | id("publish-1.8") 7 | } 8 | 9 | dependencies { 10 | api(kotlin("reflect")) 11 | 12 | api("io.koalaql:markout-markdown") 13 | 14 | testImplementation(kotlin("test")) 15 | } -------------------------------------------------------------------------------- /markout-docusaurus/src/main/kotlin/io/koalaql/markout/docusaurus/Docusaurus.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.docusaurus 2 | 3 | import io.koalaql.markout.MarkoutDsl 4 | 5 | @MarkoutDsl 6 | interface Docusaurus { 7 | @MarkoutDsl 8 | fun directory(name: String, builder: DocusaurusDirectory.() -> Unit) 9 | @MarkoutDsl 10 | fun file(name: String, contents: String) 11 | @MarkoutDsl 12 | fun markdown(name: String, builder: DocusaurusMarkdownFile.() -> Unit) 13 | } -------------------------------------------------------------------------------- /markout-docusaurus/src/main/kotlin/io/koalaql/markout/docusaurus/DocusaurusDirectory.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.docusaurus 2 | 3 | import io.koalaql.markout.MarkoutDsl 4 | 5 | @MarkoutDsl 6 | interface DocusaurusDirectory: Docusaurus { 7 | @MarkoutDsl 8 | var label: String 9 | 10 | @MarkoutDsl 11 | fun link( 12 | description: String = "" 13 | ) 14 | } -------------------------------------------------------------------------------- /markout-docusaurus/src/main/kotlin/io/koalaql/markout/docusaurus/DocusaurusMarkdown.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.docusaurus 2 | 3 | import io.koalaql.markout.MarkoutDsl 4 | import io.koalaql.markout.md.Markdown 5 | import io.koalaql.markout.md.markdown 6 | 7 | @MarkoutDsl 8 | interface DocusaurusMarkdown: Markdown { 9 | @MarkoutDsl 10 | fun code(lang: String, title: String, code: String) = 11 | code("$lang title=\"$title\"", code) 12 | 13 | @MarkoutDsl 14 | fun code(lang: String, title: String, highlight: ClosedRange, code: String) { 15 | val lines = if (highlight.start == highlight.endInclusive) { 16 | "{${highlight.start}}" 17 | } else { 18 | "{${highlight.start}-${highlight.endInclusive}}" 19 | } 20 | 21 | code("$lang title=\"$title\" $lines", code) 22 | } 23 | 24 | @MarkoutDsl 25 | fun code(lang: String, title: String, highlight: Int, code: String) = 26 | code(lang, title, highlight..highlight, code) 27 | 28 | @MarkoutDsl 29 | fun callout(type: String, title: String = "", block: DocusaurusMarkdown.() -> Unit) { 30 | val contents = markdown { 31 | DocusaurusMarkdownWrapper(this).block() 32 | } 33 | 34 | var delimiter = ":::" 35 | while (contents.contains(delimiter)) delimiter = "$delimiter:" 36 | 37 | raw("$delimiter$type${if (title.isNotBlank()) " $title" else ""}") 38 | raw(contents) 39 | raw(delimiter) 40 | } 41 | 42 | @MarkoutDsl 43 | fun note(title: String = "", block: DocusaurusMarkdown.() -> Unit) = 44 | callout("note", title, block) 45 | 46 | @MarkoutDsl 47 | fun tip(title: String = "", block: DocusaurusMarkdown.() -> Unit) = 48 | callout("tip", title, block) 49 | 50 | @MarkoutDsl 51 | fun info(title: String = "", block: DocusaurusMarkdown.() -> Unit) = 52 | callout("info", title, block) 53 | 54 | @MarkoutDsl 55 | fun caution(title: String = "", block: DocusaurusMarkdown.() -> Unit) = 56 | callout("caution", title, block) 57 | 58 | @MarkoutDsl 59 | fun danger(title: String = "", block: DocusaurusMarkdown.() -> Unit) = 60 | callout("danger", title, block) 61 | } -------------------------------------------------------------------------------- /markout-docusaurus/src/main/kotlin/io/koalaql/markout/docusaurus/DocusaurusMarkdownFile.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.docusaurus 2 | 3 | interface DocusaurusMarkdownFile: DocusaurusMarkdown { 4 | var slug: String 5 | } -------------------------------------------------------------------------------- /markout-docusaurus/src/main/kotlin/io/koalaql/markout/docusaurus/DocusaurusMarkdownWrapper.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.docusaurus 2 | 3 | import io.koalaql.markout.md.Markdown 4 | 5 | class DocusaurusMarkdownWrapper( 6 | private val markdown: Markdown 7 | ): DocusaurusMarkdown, Markdown by markdown -------------------------------------------------------------------------------- /markout-docusaurus/src/main/kotlin/io/koalaql/markout/docusaurus/DocusaurusRoot.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.docusaurus 2 | 3 | import io.koalaql.markout.MarkoutDsl 4 | 5 | @MarkoutDsl 6 | interface DocusaurusRoot { 7 | @MarkoutDsl 8 | fun configure(block: DocusaurusSettings.() -> Unit) 9 | @MarkoutDsl 10 | fun docs(block: Docusaurus.() -> Unit) 11 | } -------------------------------------------------------------------------------- /markout-docusaurus/src/main/kotlin/io/koalaql/markout/docusaurus/DocusaurusSettings.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.docusaurus 2 | 3 | import io.koalaql.markout.MarkoutDsl 4 | import io.koalaql.markout.text.LineWriter 5 | 6 | @MarkoutDsl 7 | interface DocusaurusLogo { 8 | var alt: String 9 | var src: String 10 | } 11 | 12 | @MarkoutDsl 13 | interface DocusaurusFooter { 14 | var copyright: String 15 | } 16 | 17 | @MarkoutDsl 18 | interface DocusaurusSettings { 19 | var title: String 20 | var tagline: String 21 | var url: String 22 | var baseUrl: String 23 | 24 | var github: String 25 | 26 | var metadata: Map 27 | 28 | @MarkoutDsl 29 | fun logo(block: DocusaurusLogo.() -> Unit) 30 | 31 | @MarkoutDsl 32 | fun footer(block: DocusaurusFooter.() -> Unit) 33 | } 34 | 35 | fun buildConfigJs(out: LineWriter, builder: DocusaurusSettings.() -> Unit) { 36 | val settings = object : DocusaurusSettings { 37 | override var title: String = "Docusaurus Site" 38 | override var tagline: String = "" 39 | override var url: String = "" 40 | override var baseUrl: String = "/" 41 | 42 | override var github: String = "" 43 | 44 | override var metadata: Map = emptyMap() 45 | 46 | var logo: DocusaurusLogo? = null 47 | var footer: DocusaurusFooter? = null 48 | 49 | override fun logo(block: DocusaurusLogo.() -> Unit) { 50 | logo = object : DocusaurusLogo { 51 | override var alt: String = "" 52 | override var src: String = "" 53 | }.apply(block) 54 | } 55 | 56 | override fun footer(block: DocusaurusFooter.() -> Unit) { 57 | footer = object : DocusaurusFooter { 58 | override var copyright: String = "" 59 | }.apply(block) 60 | } 61 | }.apply(builder) 62 | 63 | check(settings.url.isNotBlank()) { 64 | "Docusaurus should be configured with an url" 65 | } 66 | 67 | out.raw(""" 68 | // @ts-check 69 | 70 | const lightCodeTheme = require('prism-react-renderer/themes/github'); 71 | const darkCodeTheme = require('prism-react-renderer/themes/dracula'); 72 | 73 | /** @type {import('@docusaurus/types').Config} */ 74 | const config = { 75 | """.trimIndent()) 76 | 77 | out.newline() 78 | 79 | with (out.prefixed(" ")) { 80 | inline("title: '${settings.title}',") 81 | newline() 82 | 83 | settings.tagline 84 | .takeIf { it.isNotBlank() } 85 | ?.let { 86 | inline("tagline: '$it',") 87 | newline() 88 | } 89 | 90 | settings.url 91 | .takeIf { it.isNotBlank() } 92 | ?.let { 93 | inline("url: '$it',") 94 | newline() 95 | } 96 | } 97 | 98 | out.prefixed(" ").raw(""" 99 | baseUrl: '${settings.baseUrl}', 100 | onBrokenLinks: 'throw', 101 | onBrokenMarkdownLinks: 'warn', 102 | favicon: 'img/favicon.ico', 103 | 104 | i18n: { 105 | defaultLocale: 'en', 106 | locales: ['en'], 107 | }, 108 | 109 | presets: [ 110 | [ 111 | 'classic', 112 | /** @type {import('@docusaurus/preset-classic').Options} */ 113 | ({ 114 | docs: { 115 | routeBasePath: '/', 116 | sidebarPath: require.resolve('./sidebars.js'), 117 | }, 118 | blog: false 119 | }), 120 | ], 121 | ], 122 | 123 | 124 | """.trimIndent()) 125 | 126 | out.raw(""" 127 | themeConfig: 128 | /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ 129 | ({ 130 | navbar: { 131 | title: '${settings.title}', 132 | 133 | """.trimIndent()) 134 | 135 | with (out.prefixed(" ")) { 136 | settings.logo?.apply { 137 | line("logo: {") 138 | 139 | alt.takeIf { it.isNotBlank() }?.let { 140 | line(" alt: '${alt}',") 141 | } 142 | 143 | line(" src: '${src}'") 144 | line("},") 145 | } 146 | } 147 | 148 | with (out.prefixed(" ")) { 149 | settings.github.takeIf { it.isNotBlank() }?.let { 150 | raw(""" 151 | items: [ 152 | { 153 | href: '$it', 154 | label: 'GitHub', 155 | position: 'right', 156 | }, 157 | ], 158 | """.trimIndent()) 159 | } 160 | } 161 | 162 | out.newline() 163 | out.line(" },") 164 | 165 | with (out.prefixed(" ")) { 166 | settings.footer?.apply { 167 | line("footer: {") 168 | line(" style: 'dark',") 169 | 170 | copyright.takeIf { it.isNotBlank() }?.let { 171 | line(" copyright: '$it',") 172 | } 173 | 174 | line("},") 175 | } 176 | } 177 | 178 | with (out.prefixed(" ")) { 179 | if (settings.metadata.isNotEmpty()) { 180 | line("metadata: [") 181 | with (prefixed(" ")) { 182 | settings.metadata.forEach { (name, content) -> 183 | line("""{"name": "$name", "content": "$content"}""") 184 | } 185 | } 186 | line("],") 187 | } 188 | } 189 | 190 | out.raw(""" 191 | prism: { 192 | theme: lightCodeTheme, 193 | darkTheme: darkCodeTheme, 194 | additionalLanguages: ["kotlin", "java"], 195 | }, 196 | }), 197 | }; 198 | module.exports = config; 199 | """.trimIndent()) 200 | } -------------------------------------------------------------------------------- /markout-docusaurus/src/main/kotlin/io/koalaql/markout/docusaurus/Docusauruses.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.docusaurus 2 | 3 | import io.koalaql.markout.Markout 4 | import io.koalaql.markout.MarkoutDsl 5 | import io.koalaql.markout.md.markdownTo 6 | import io.koalaql.markout.md.withMdSuffix 7 | import io.koalaql.markout.text.AppendableLineWriter 8 | import java.io.OutputStream 9 | import kotlin.io.path.Path 10 | import kotlin.io.path.name 11 | 12 | private fun docusaurusMdFile( 13 | output: OutputStream, 14 | position: Int, 15 | builder: DocusaurusMarkdownFile.() -> Unit 16 | ) { 17 | lateinit var impl: MarkdownFileImpl 18 | 19 | val writer = output.writer() 20 | val lw = AppendableLineWriter(writer) 21 | 22 | var wrote = false 23 | 24 | markdownTo(lw.onWrite { 25 | lw.raw(impl.header()) 26 | lw.newline() 27 | wrote = true 28 | }) { 29 | impl = MarkdownFileImpl(this, position) 30 | 31 | impl.builder() 32 | } 33 | 34 | if (!wrote) { 35 | lw.raw(impl.header()) 36 | } else { 37 | lw.newline() 38 | } 39 | 40 | writer.flush() 41 | } 42 | 43 | private class DirectoryContext( 44 | private val markout: Markout, 45 | private val position: Int 46 | ): DocusaurusDirectory { 47 | private var sidebarPosition = 0 48 | 49 | override var label: String = "" 50 | 51 | private var linkImpl: String? = null 52 | 53 | override fun link(description: String) { 54 | linkImpl = description 55 | } 56 | 57 | override fun directory(name: String, builder: DocusaurusDirectory.() -> Unit) { 58 | val position = ++sidebarPosition 59 | markout.directory(name) { 60 | val ctx = DirectoryContext(this, position) 61 | ctx.builder() 62 | ctx.category() 63 | } 64 | } 65 | 66 | override fun file(name: String, contents: String) { 67 | markout.file(name, contents) 68 | } 69 | 70 | override fun markdown(name: String, builder: DocusaurusMarkdownFile.() -> Unit) { 71 | val position = ++sidebarPosition 72 | 73 | markout.file(withMdSuffix(name)) { 74 | docusaurusMdFile(it, position, builder) 75 | } 76 | } 77 | 78 | fun category() { 79 | markout.file("_category_.json", writeJson(indent = " ") { 80 | braces { 81 | if (label.isNotBlank()) "label" - label 82 | "position" - position 83 | 84 | if (linkImpl != null) { 85 | "link" braces { 86 | "type" - "generated-index" 87 | 88 | linkImpl?.takeIf { it.isNotBlank() }?.let { 89 | "description" - it 90 | } 91 | } 92 | } 93 | } 94 | 95 | write("\n") 96 | }) 97 | } 98 | } 99 | 100 | @MarkoutDsl 101 | fun Markout.docusaurus(block: DocusaurusRoot.() -> Unit) { 102 | object : DocusaurusRoot { 103 | override fun configure(block: DocusaurusSettings.() -> Unit) { 104 | this@docusaurus.directory(this@docusaurus.untracked("static")) { 105 | file(untracked(".nojekyll"), "") 106 | } 107 | 108 | fun copyResource(path: String, name: String = Path(path).name) { 109 | this@docusaurus.file(this@docusaurus.untracked(name)) { out -> 110 | Resources.open(path).use { it.copyTo(out) } 111 | } 112 | } 113 | 114 | this@docusaurus.file(this@docusaurus.untracked("docusaurus.config.js")) { 115 | val writer = it.writer() 116 | 117 | buildConfigJs( 118 | AppendableLineWriter(writer), 119 | block 120 | ) 121 | 122 | writer.flush() 123 | } 124 | 125 | copyResource("/bootstrap/gitignore", ".gitignore") 126 | copyResource("/bootstrap/babel.config.js") 127 | copyResource("/bootstrap/package.json") 128 | copyResource("/bootstrap/sidebars.js") 129 | copyResource("/bootstrap/tsconfig.json") 130 | copyResource("/bootstrap/yarn.lock") 131 | copyResource("/bootstrap/linux.yarnrc") 132 | } 133 | 134 | override fun docs(block: Docusaurus.() -> Unit) { 135 | this@docusaurus.directory(this@docusaurus.untracked("docs")) { 136 | val markout = this 137 | 138 | object : Docusaurus { 139 | private var sidebarPosition = 0 140 | 141 | override fun directory(name: String, builder: DocusaurusDirectory.() -> Unit) { 142 | val position = ++sidebarPosition 143 | 144 | markout.directory(name) { 145 | val ctx = DirectoryContext(this, position) 146 | ctx.builder() 147 | ctx.category() 148 | } 149 | } 150 | 151 | override fun file(name: String, contents: String) { 152 | markout.file(name, contents) 153 | } 154 | 155 | override fun markdown(name: String, builder: DocusaurusMarkdownFile.() -> Unit) { 156 | val position = ++sidebarPosition 157 | 158 | markout.file(withMdSuffix(name)) { 159 | docusaurusMdFile(it, position, builder) 160 | } 161 | } 162 | }.block() 163 | } 164 | } 165 | }.block() 166 | } 167 | 168 | @MarkoutDsl 169 | fun Markout.docusaurus(name: String, block: DocusaurusRoot.() -> Unit) = 170 | directory(name) { 171 | docusaurus(block) 172 | } -------------------------------------------------------------------------------- /markout-docusaurus/src/main/kotlin/io/koalaql/markout/docusaurus/JsonUtil.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.docusaurus 2 | 3 | import io.koalaql.markout.text.AppendableLineWriter 4 | import io.koalaql.markout.text.LineWriter 5 | 6 | class JsonBuilder( 7 | private val lines: LineWriter, 8 | private val indent: String 9 | ) { 10 | private var prefix = "" 11 | 12 | fun write(text: String) { 13 | lines.raw(text) 14 | } 15 | 16 | fun item(builder: JsonBuilder.() -> Unit) { 17 | lines.raw(prefix) 18 | prefix = ",\n" 19 | 20 | builder() 21 | } 22 | 23 | private fun writeString(value: String) { 24 | write("\"${value}\"") // TODO escape 25 | } 26 | 27 | infix fun String.braces(builder: JsonBuilder.() -> Unit) = item { 28 | writeString(this@braces) 29 | write(": ") 30 | braces(builder) 31 | } 32 | 33 | operator fun String.minus(value: String) = item { 34 | writeString(this@minus) 35 | write(": ") 36 | writeString(value) 37 | } 38 | 39 | operator fun String.minus(value: Number) = item { 40 | writeString(this@minus) 41 | write(": ") 42 | write("$value") 43 | } 44 | 45 | fun indented(builder: JsonBuilder.() -> Unit) { 46 | JsonBuilder(lines.prefixed(indent), indent).builder() 47 | } 48 | 49 | fun block(start: String, end: String, builder: JsonBuilder.() -> Unit) { 50 | lines.inline(start) 51 | lines.newline() 52 | indented(builder) 53 | lines.newline() 54 | lines.inline(end) 55 | } 56 | 57 | fun braces(builder: JsonBuilder.() -> Unit) = 58 | block("{", "}", builder) 59 | } 60 | 61 | fun writeJson( 62 | indent: String, 63 | builder: JsonBuilder.() -> Unit 64 | ): String { 65 | val sb = StringBuilder() 66 | 67 | JsonBuilder(AppendableLineWriter(sb), indent).builder() 68 | 69 | return "$sb" 70 | } -------------------------------------------------------------------------------- /markout-docusaurus/src/main/kotlin/io/koalaql/markout/docusaurus/MarkdownFileImpl.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.docusaurus 2 | 3 | import io.koalaql.markout.md.Markdown 4 | 5 | class MarkdownFileImpl( 6 | private val markdown: Markdown, 7 | private val position: Int, 8 | ): DocusaurusMarkdownFile, Markdown by markdown { 9 | override var slug: String = "" 10 | 11 | fun header(): String { 12 | val sb = StringBuilder() 13 | 14 | sb.append("---\n") 15 | sb.append("sidebar_position: $position\n") 16 | if (slug.isNotBlank()) { 17 | sb.append("slug: $slug\n") 18 | } 19 | sb.append("---\n") 20 | 21 | return "$sb" 22 | } 23 | } -------------------------------------------------------------------------------- /markout-docusaurus/src/main/kotlin/io/koalaql/markout/docusaurus/Resources.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.docusaurus 2 | 3 | import java.io.InputStream 4 | 5 | object Resources { 6 | fun open(path: String): InputStream = 7 | checkNotNull(javaClass.getResource(path)) { "no resource found at $path" }.openStream() 8 | } -------------------------------------------------------------------------------- /markout-docusaurus/src/main/resources/bootstrap/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /markout-docusaurus/src/main/resources/bootstrap/gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /markout-docusaurus/src/main/resources/bootstrap/linux.yarnrc: -------------------------------------------------------------------------------- 1 | # workaround for Linux issue where yarn doesn't terminate node on shutdown when run through gradle-node-plugin 2 | # see https://github.com/node-gradle/gradle-node-plugin/issues/65#issuecomment-872062591 3 | 4 | # this file is used with --use-yarnrc 5 | 6 | script-shell "/usr/bin/bash" -------------------------------------------------------------------------------- /markout-docusaurus/src/main/resources/bootstrap/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-website", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc" 16 | }, 17 | "dependencies": { 18 | "@docusaurus/core": "2.2.0", 19 | "@docusaurus/preset-classic": "2.2.0", 20 | "@mdx-js/react": "^1.6.22", 21 | "clsx": "^1.2.1", 22 | "prism-react-renderer": "^1.3.5", 23 | "react": "^17.0.2", 24 | "react-dom": "^17.0.2" 25 | }, 26 | "devDependencies": { 27 | "@docusaurus/module-type-aliases": "2.2.0", 28 | "@tsconfig/docusaurus": "^1.0.5", 29 | "typescript": "^4.7.4" 30 | }, 31 | "browserslist": { 32 | "production": [ 33 | ">0.5%", 34 | "not dead", 35 | "not op_mini all" 36 | ], 37 | "development": [ 38 | "last 1 chrome version", 39 | "last 1 firefox version", 40 | "last 1 safari version" 41 | ] 42 | }, 43 | "engines": { 44 | "node": ">=16.14" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /markout-docusaurus/src/main/resources/bootstrap/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | // @ts-check 13 | 14 | /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ 15 | const sidebars = { 16 | // By default, Docusaurus generates a sidebar from the docs folder structure 17 | tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], 18 | 19 | // But you can create a sidebar manually 20 | /* 21 | tutorialSidebar: [ 22 | 'intro', 23 | 'hello', 24 | { 25 | type: 'category', 26 | label: 'Tutorial', 27 | items: ['tutorial-basics/create-a-document'], 28 | }, 29 | ], 30 | */ 31 | }; 32 | 33 | module.exports = sidebars; 34 | -------------------------------------------------------------------------------- /markout-docusaurus/src/main/resources/bootstrap/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/docusaurus/tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "." 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /markout-docusaurus/src/test/kotlin/SiteTest.kt: -------------------------------------------------------------------------------- 1 | import io.koalaql.markout.ExecutionMode 2 | import io.koalaql.markout.docusaurus.docusaurus 3 | import io.koalaql.markout.markout 4 | import org.junit.Test 5 | import kotlin.io.path.Path 6 | 7 | class SiteTest { 8 | @Test 9 | fun `matches test site`() = markout( 10 | Path("./testing"), 11 | mode = ExecutionMode.EXPECT 12 | ) { 13 | docusaurus { 14 | configure { 15 | title = "Test Site" 16 | tagline = "Test Tagline" 17 | url = "http://localhost:3000" 18 | 19 | github = "https://github.com/mfwgenerics/markout" 20 | 21 | logo { 22 | alt = "My Logo" 23 | src = "img/logo.svg" 24 | } 25 | 26 | footer { 27 | copyright = "Copyright © 2023 My Project" 28 | } 29 | } 30 | 31 | docs { 32 | markdown("intro") { 33 | slug = "/" 34 | 35 | h1("Intro") 36 | 37 | p("Text") 38 | } 39 | 40 | directory("basics") { 41 | label = "Basics" 42 | 43 | link( 44 | description = "A basic description" 45 | ) 46 | 47 | markdown("code-blocks") { 48 | h1("Code Blocks") 49 | 50 | code("jsx", "src/pages/my-react-page.js", """ 51 | import React from 'react'; 52 | import Layout from '@theme/Layout'; 53 | 54 | export default function MyReactPage() { 55 | return ( 56 | 57 |

My React page

58 |

This is a React page

59 |
60 | ); 61 | } 62 | """.trimIndent()) 63 | 64 | code("mdx", "src/pages/my-markdown-page.md", """ 65 | # My Markdown page 66 | 67 | This is a Markdown page 68 | """.trimIndent()) 69 | } 70 | 71 | markdown("code-with-highlight") { 72 | h1("Code With Highlight") 73 | 74 | code("md", "docs/hello.md", 1..4, """ 75 | --- 76 | sidebar_label: 'Hi!' 77 | sidebar_position: 3 78 | --- 79 | 80 | # Hello 81 | 82 | This is my **first Docusaurus document**! 83 | """.trimIndent()) 84 | } 85 | 86 | markdown("empty") { } 87 | 88 | markdown("admonitions-and-mdx.mdx") { 89 | h1("Markdown Features") 90 | 91 | tip { 92 | -"Use this awesome feature option" 93 | } 94 | 95 | danger("Take Care") { 96 | -"This action is "+i("dangerous") 97 | } 98 | 99 | raw(""" 100 | export const Highlight = ({children, color}) => ( 101 | { 110 | alert(`You clicked the color ${"$"}{color} with label ${"$"}{children}`) 111 | }}> 112 | {children} 113 | 114 | ); 115 | 116 | This is Docusaurus green ! 117 | 118 | This is Facebook blue ! 119 | """.trimIndent()) 120 | } 121 | } 122 | 123 | directory("extras") { 124 | label = "Descriptionless Link" 125 | 126 | link() 127 | } 128 | } 129 | } 130 | } 131 | } -------------------------------------------------------------------------------- /markout-docusaurus/testing/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /markout-docusaurus/testing/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | Using SSH: 30 | 31 | ``` 32 | $ USE_SSH=true yarn deploy 33 | ``` 34 | 35 | Not using SSH: 36 | 37 | ``` 38 | $ GIT_USER= yarn deploy 39 | ``` 40 | 41 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 42 | -------------------------------------------------------------------------------- /markout-docusaurus/testing/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /markout-docusaurus/testing/docs/.markout: -------------------------------------------------------------------------------- 1 | intro.md 2 | basics 3 | extras 4 | -------------------------------------------------------------------------------- /markout-docusaurus/testing/docs/basics/.markout: -------------------------------------------------------------------------------- 1 | code-blocks.md 2 | code-with-highlight.md 3 | empty.md 4 | admonitions-and-mdx.mdx 5 | _category_.json 6 | -------------------------------------------------------------------------------- /markout-docusaurus/testing/docs/basics/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Basics", 3 | "position": 2, 4 | "link": { 5 | "type": "generated-index", 6 | "description": "A basic description" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /markout-docusaurus/testing/docs/basics/admonitions-and-mdx.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | # Markdown Features 6 | 7 | :::tip 8 | 9 | Use this awesome feature option 10 | 11 | ::: 12 | 13 | :::danger Take Care 14 | 15 | This action is *dangerous* 16 | 17 | ::: 18 | 19 | export const Highlight = ({children, color}) => ( 20 | { 29 | alert(`You clicked the color ${color} with label ${children}`) 30 | }}> 31 | {children} 32 | 33 | ); 34 | 35 | This is Docusaurus green ! 36 | 37 | This is Facebook blue ! 38 | -------------------------------------------------------------------------------- /markout-docusaurus/testing/docs/basics/code-blocks.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # Code Blocks 6 | 7 | ```jsx title="src/pages/my-react-page.js" 8 | import React from 'react'; 9 | import Layout from '@theme/Layout'; 10 | 11 | export default function MyReactPage() { 12 | return ( 13 | 14 |

My React page

15 |

This is a React page

16 |
17 | ); 18 | } 19 | ``` 20 | 21 | ```mdx title="src/pages/my-markdown-page.md" 22 | # My Markdown page 23 | 24 | This is a Markdown page 25 | ``` 26 | -------------------------------------------------------------------------------- /markout-docusaurus/testing/docs/basics/code-with-highlight.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Code With Highlight 6 | 7 | ```md title="docs/hello.md" {1-4} 8 | --- 9 | sidebar_label: 'Hi!' 10 | sidebar_position: 3 11 | --- 12 | 13 | # Hello 14 | 15 | This is my **first Docusaurus document**! 16 | ``` 17 | -------------------------------------------------------------------------------- /markout-docusaurus/testing/docs/basics/empty.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | -------------------------------------------------------------------------------- /markout-docusaurus/testing/docs/extras/.markout: -------------------------------------------------------------------------------- 1 | _category_.json 2 | -------------------------------------------------------------------------------- /markout-docusaurus/testing/docs/extras/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Descriptionless Link", 3 | "position": 3, 4 | "link": { 5 | "type": "generated-index" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /markout-docusaurus/testing/docs/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | slug: / 4 | --- 5 | 6 | # Intro 7 | 8 | Text 9 | -------------------------------------------------------------------------------- /markout-docusaurus/testing/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const lightCodeTheme = require('prism-react-renderer/themes/github'); 4 | const darkCodeTheme = require('prism-react-renderer/themes/dracula'); 5 | 6 | /** @type {import('@docusaurus/types').Config} */ 7 | const config = { 8 | title: 'Test Site', 9 | tagline: 'Test Tagline', 10 | url: 'http://localhost:3000', 11 | baseUrl: '/', 12 | onBrokenLinks: 'throw', 13 | onBrokenMarkdownLinks: 'warn', 14 | favicon: 'img/favicon.ico', 15 | 16 | i18n: { 17 | defaultLocale: 'en', 18 | locales: ['en'], 19 | }, 20 | 21 | presets: [ 22 | [ 23 | 'classic', 24 | /** @type {import('@docusaurus/preset-classic').Options} */ 25 | ({ 26 | docs: { 27 | routeBasePath: '/', 28 | sidebarPath: require.resolve('./sidebars.js'), 29 | }, 30 | blog: false 31 | }), 32 | ], 33 | ], 34 | 35 | themeConfig: 36 | /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ 37 | ({ 38 | navbar: { 39 | title: 'Test Site', 40 | logo: { 41 | alt: 'My Logo', 42 | src: 'img/logo.svg' 43 | }, 44 | items: [ 45 | { 46 | href: 'https://github.com/mfwgenerics/markout', 47 | label: 'GitHub', 48 | position: 'right', 49 | }, 50 | ], 51 | }, 52 | footer: { 53 | style: 'dark', 54 | copyright: 'Copyright © 2023 My Project', 55 | }, 56 | prism: { 57 | theme: lightCodeTheme, 58 | darkTheme: darkCodeTheme, 59 | additionalLanguages: ["kotlin", "java"], 60 | }, 61 | }), 62 | }; 63 | module.exports = config; -------------------------------------------------------------------------------- /markout-docusaurus/testing/linux.yarnrc: -------------------------------------------------------------------------------- 1 | # workaround for Linux issue where yarn doesn't terminate node on shutdown when run through gradle-node-plugin 2 | # see https://github.com/node-gradle/gradle-node-plugin/issues/65#issuecomment-872062591 3 | 4 | # this file is used with --use-yarnrc 5 | 6 | script-shell "/usr/bin/bash" -------------------------------------------------------------------------------- /markout-docusaurus/testing/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-website", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc" 16 | }, 17 | "dependencies": { 18 | "@docusaurus/core": "2.2.0", 19 | "@docusaurus/preset-classic": "2.2.0", 20 | "@mdx-js/react": "^1.6.22", 21 | "clsx": "^1.2.1", 22 | "prism-react-renderer": "^1.3.5", 23 | "react": "^17.0.2", 24 | "react-dom": "^17.0.2" 25 | }, 26 | "devDependencies": { 27 | "@docusaurus/module-type-aliases": "2.2.0", 28 | "@tsconfig/docusaurus": "^1.0.5", 29 | "typescript": "^4.7.4" 30 | }, 31 | "browserslist": { 32 | "production": [ 33 | ">0.5%", 34 | "not dead", 35 | "not op_mini all" 36 | ], 37 | "development": [ 38 | "last 1 chrome version", 39 | "last 1 firefox version", 40 | "last 1 safari version" 41 | ] 42 | }, 43 | "engines": { 44 | "node": ">=16.14" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /markout-docusaurus/testing/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | // @ts-check 13 | 14 | /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ 15 | const sidebars = { 16 | // By default, Docusaurus generates a sidebar from the docs folder structure 17 | tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], 18 | 19 | // But you can create a sidebar manually 20 | /* 21 | tutorialSidebar: [ 22 | 'intro', 23 | 'hello', 24 | { 25 | type: 'category', 26 | label: 'Tutorial', 27 | items: ['tutorial-basics/create-a-document'], 28 | }, 29 | ], 30 | */ 31 | }; 32 | 33 | module.exports = sidebars; 34 | -------------------------------------------------------------------------------- /markout-docusaurus/testing/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mfwgenerics/markout/38aed7629da23261815ecaad472650918ec24488/markout-docusaurus/testing/static/.nojekyll -------------------------------------------------------------------------------- /markout-docusaurus/testing/static/img/docusaurus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mfwgenerics/markout/38aed7629da23261815ecaad472650918ec24488/markout-docusaurus/testing/static/img/docusaurus.png -------------------------------------------------------------------------------- /markout-docusaurus/testing/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mfwgenerics/markout/38aed7629da23261815ecaad472650918ec24488/markout-docusaurus/testing/static/img/favicon.ico -------------------------------------------------------------------------------- /markout-docusaurus/testing/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /markout-docusaurus/testing/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/docusaurus/tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "." 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /markout-github-workflows-kt/build.gradle.kts: -------------------------------------------------------------------------------- 1 | repositories { 2 | mavenCentral() 3 | } 4 | 5 | plugins { 6 | id("publish-17") 7 | } 8 | 9 | dependencies { 10 | api(kotlin("reflect")) 11 | 12 | api("io.koalaql:markout") 13 | 14 | api("io.github.typesafegithub:github-workflows-kt:0.47.0") 15 | 16 | testImplementation(kotlin("test")) 17 | } -------------------------------------------------------------------------------- /markout-github-workflows-kt/src/main/kotlin/io/koalaql/markout/Workflows.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout 2 | 3 | import io.github.typesafegithub.workflows.domain.Concurrency 4 | import io.github.typesafegithub.workflows.domain.triggers.Trigger 5 | import io.github.typesafegithub.workflows.dsl.WorkflowBuilder 6 | import io.github.typesafegithub.workflows.dsl.workflow as workflowImpl 7 | import io.github.typesafegithub.workflows.yaml.toYaml 8 | import kotlin.io.path.Path 9 | 10 | fun Markout.workflow( 11 | filename: String, 12 | name: String, 13 | on: List, 14 | env: LinkedHashMap = linkedMapOf(), 15 | concurrency: Concurrency? = null, 16 | customArguments: Map = mapOf(), 17 | block: WorkflowBuilder.() -> Unit, 18 | ) { 19 | val extendedFn = if ( 20 | filename.endsWith(".yml", ignoreCase = true) || 21 | filename.endsWith(".yaml", ignoreCase = true) 22 | ) { 23 | filename 24 | } else { 25 | "$filename.yml" 26 | } 27 | 28 | val wf = workflowImpl( 29 | name = name, 30 | on = on, 31 | env = env, 32 | concurrency = concurrency, 33 | yamlConsistencyJobCondition = null, 34 | _customArguments = customArguments, 35 | block = block 36 | ) 37 | 38 | file(extendedFn, wf.toYaml(addConsistencyCheck = false)) 39 | } -------------------------------------------------------------------------------- /markout-markdown-plugin/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("publish-plugin") 3 | } 4 | 5 | dependencies { 6 | implementation(libs.kapshot.plugin.gradle) 7 | implementation("io.koalaql:markout-plugin:${project.version}") 8 | } 9 | 10 | pluginBundle { 11 | website = "https://github.com/mfwgenerics/markout" 12 | vcsUrl = "https://github.com/mfwgenerics/markout.git" 13 | tags = listOf("kotlin", "markout", "markdown", "jvm", "documentation") 14 | } 15 | 16 | gradlePlugin { 17 | plugins { 18 | create("markoutPlugin") { 19 | id = "io.koalaql.markout-markdown" 20 | displayName = "Markout Markdown Plugin" 21 | description = "Plugin Support for Markout Markdown Generation and Code Capture" 22 | implementationClass = "io.koalaql.markout.markdown.GradlePlugin" 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /markout-markdown-plugin/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencyResolutionManagement { 2 | versionCatalogs { 3 | create("libs") { 4 | from(files("../gradle/libs.versions.toml")) 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /markout-markdown-plugin/src/main/kotlin/GradlePlugin.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.markdown 2 | 3 | import io.koalaql.markout_markdown_plugin.BuildConfig 4 | import org.gradle.api.Plugin 5 | import org.gradle.api.Project 6 | 7 | class GradlePlugin: Plugin { 8 | override fun apply(target: Project) = with(target) { 9 | dependencies.add("api", "io.koalaql:markout-markdown:${BuildConfig.VERSION}") 10 | dependencies.add("api", "io.koalaql:markout-docusaurus:${BuildConfig.VERSION}") 11 | 12 | with(plugins) { 13 | apply("io.koalaql.markout") 14 | apply("io.koalaql.kapshot-plugin") 15 | } 16 | 17 | Unit 18 | } 19 | } -------------------------------------------------------------------------------- /markout-markdown/build.gradle.kts: -------------------------------------------------------------------------------- 1 | repositories { 2 | mavenCentral() 3 | } 4 | 5 | plugins { 6 | id("publish-1.8") 7 | } 8 | 9 | dependencies { 10 | api(kotlin("reflect")) 11 | api(libs.kapshot.runtime) 12 | 13 | api("io.koalaql:markout") 14 | 15 | testImplementation(kotlin("test")) 16 | } -------------------------------------------------------------------------------- /markout-markdown/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencyResolutionManagement { 2 | versionCatalogs { 3 | create("libs") { 4 | from(files("../gradle/libs.versions.toml")) 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /markout-markdown/src/main/kotlin/MarkdownBuilder.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout 2 | 3 | import io.koalaql.markout.md.* 4 | import io.koalaql.markout.text.AppendableLineWriter 5 | import io.koalaql.markout.text.LineWriter 6 | 7 | class MarkdownBuilder( 8 | private val writer: LineWriter, 9 | private val bibliography: Bibliography = Bibliography(), 10 | private val top: Boolean 11 | ) : Markdown { 12 | private sealed interface BuilderState { 13 | object Fresh : BuilderState 14 | object AfterBlock : BuilderState 15 | class Inline( 16 | val builder: MarkdownBuilder 17 | ) : BuilderState 18 | } 19 | 20 | private var state: BuilderState = BuilderState.Fresh 21 | 22 | override fun cite(href: String, title: String?): Citation { 23 | val sb = StringBuilder() 24 | 25 | sb.append(href) 26 | 27 | if (title != null) { 28 | sb.append(" \"") 29 | sb.append(title) 30 | sb.append("\"") 31 | } 32 | 33 | return bibliography.reference("$sb") 34 | } 35 | 36 | override fun footnote(note: Markdown.() -> Unit) = inlined { 37 | writer.inline(bibliography.footnote(note)) 38 | } 39 | 40 | private fun inlined(line: MarkdownBuilder.() -> Unit) { 41 | val builder = when (val current = state) { 42 | is BuilderState.Inline -> current.builder 43 | else -> { 44 | val writer = if (current == BuilderState.AfterBlock) { 45 | writer.onWrite { 46 | writer.newline() 47 | writer.newline() 48 | } 49 | } else { 50 | writer 51 | } 52 | 53 | val builder = MarkdownBuilder(writer.trimmedLines().paragraphRules(), bibliography, false) 54 | 55 | state = BuilderState.Inline(builder) 56 | 57 | builder 58 | } 59 | } 60 | 61 | builder.line() 62 | } 63 | 64 | private fun blocked(block: MarkdownBuilder.() -> Unit) { 65 | val writer = if (state == BuilderState.AfterBlock || state is BuilderState.Inline) { 66 | state = BuilderState.AfterBlock 67 | 68 | writer.onWrite { 69 | writer.newline() 70 | writer.newline() 71 | } 72 | } else { 73 | writer.onWrite { 74 | state = BuilderState.AfterBlock 75 | } 76 | } 77 | 78 | MarkdownBuilder(writer.trimmedLines(), bibliography, false).block() 79 | } 80 | 81 | override fun t(line: MarkdownInline.() -> Unit) = inlined(line) 82 | 83 | override fun t(text: String) = inlined { writer.raw(text) } 84 | 85 | override fun c(text: String) = inlined { writer.raw("`$text`") } 86 | 87 | private fun link(href: String, line: MarkdownInline.() -> Unit) = inlined { 88 | writer.inline("[") 89 | line() 90 | writer.inline("]") 91 | writer.inline(href) 92 | } 93 | 94 | override fun a(href: String, line: MarkdownInline.() -> Unit) { 95 | link("($href)", line) 96 | } 97 | 98 | override fun a(href: Citation, line: MarkdownInline.() -> Unit) { 99 | link(href.label, line) 100 | } 101 | 102 | override fun img(href: String, alt: String, title: String) { 103 | val suffix = if (title.isNotBlank()) " \"$title\"" else "" 104 | 105 | val raw = "![$alt]($href$suffix)" 106 | 107 | if (top) p(raw) else t(raw) 108 | } 109 | 110 | override fun i(block: MarkdownInline.() -> Unit) = inlined { 111 | writer.inline("*") 112 | block() 113 | writer.inline("*") 114 | } 115 | 116 | override fun b(block: MarkdownInline.() -> Unit) = inlined { 117 | writer.inline("**") 118 | block() 119 | writer.inline("**") 120 | } 121 | 122 | override fun s(block: MarkdownInline.() -> Unit) = inlined { 123 | writer.inline("~~") 124 | block() 125 | writer.inline("~~") 126 | } 127 | 128 | override fun p(block: MarkdownBlock.() -> Unit) = blocked { 129 | MarkdownBuilder(writer.paragraphRules(), bibliography, false).block() 130 | } 131 | 132 | override fun hr() = blocked { 133 | writer.inline("---") 134 | } 135 | 136 | override fun h(level: Int, block: MarkdownInline.() -> Unit) = blocked { 137 | writer.inline("#".repeat(level)) 138 | writer.inline(" ") 139 | block() 140 | } 141 | 142 | override fun quote(block: Markdown.() -> Unit) = blocked { 143 | MarkdownBuilder(writer.prefixed("> "), bibliography, true).block() 144 | } 145 | 146 | override fun code(lang: String, code: String) = blocked { 147 | var delimiter = "```" 148 | 149 | /* inefficient but good enough up to 10k backtick runs */ 150 | while (code.contains(delimiter)) delimiter = "$delimiter`" 151 | 152 | writer.inline(delimiter) 153 | writer.inline(lang) 154 | writer.newline() 155 | writer.raw(code) 156 | writer.newline() 157 | writer.inline(delimiter) 158 | } 159 | 160 | private fun interface GenericList { 161 | fun li(label: String, block: Markdown.() -> Unit) 162 | } 163 | 164 | private fun list(builder: GenericList.() -> Unit) = blocked { 165 | var prefix = "" 166 | 167 | builder { label, block -> 168 | writer.newline() 169 | writer.inline(label) 170 | 171 | if (prefix.length != label.length) prefix = " ".repeat(label.length) 172 | 173 | MarkdownBuilder(writer.prefixed(prefix, start = false), bibliography, true).block() 174 | } 175 | } 176 | 177 | override fun ol(builder: MarkdownNumberedList.() -> Unit) = list { 178 | var next = 1 179 | 180 | builder { number, block -> 181 | number?.let { next = it } 182 | li("${next++}. ", block) 183 | } 184 | } 185 | 186 | override fun ul(builder: MarkdownDottedList.() -> Unit) = list { 187 | builder { 188 | li("* ", it) 189 | } 190 | } 191 | 192 | override fun cl(builder: MarkdownCheckList.() -> Unit) = list { 193 | builder { checked, block -> 194 | li(if (checked) "- [x] " else "- [ ] ", block) 195 | } 196 | } 197 | 198 | private class Row( 199 | val cells: List, 200 | val pad: String 201 | ) 202 | 203 | override fun table(builder: MarkdownTable.() -> Unit) { 204 | val rows = arrayListOf() 205 | 206 | val lengths = arrayListOf() 207 | 208 | fun row(cells: List, pad: String) { 209 | if (cells.isEmpty()) return 210 | 211 | cells.forEachIndexed { ix, it -> 212 | /* TODO something less ASCII-brained than String.length? */ 213 | if (lengths.size == ix) { 214 | lengths.add(it.length) 215 | } else { 216 | lengths[ix] = maxOf(lengths[ix], it.length) 217 | } 218 | } 219 | 220 | rows.add(Row(cells, pad)) 221 | } 222 | 223 | fun cells(row: MarkdownTableRow.() -> Unit): List = arrayListOf().apply { 224 | row { 225 | val sb = StringBuilder() 226 | MarkdownBuilder(AppendableLineWriter(sb), bibliography, false).it() 227 | add("$sb") 228 | } 229 | } 230 | 231 | object : MarkdownTable { 232 | override fun th(row: MarkdownTableRow.() -> Unit) { 233 | val cells = cells(row) 234 | 235 | row(cells, " ") 236 | row(cells.map { "---" }, "-") 237 | } 238 | 239 | override fun tr(row: MarkdownTableRow.() -> Unit) { 240 | row(cells(row), " ") 241 | } 242 | }.builder() 243 | 244 | blocked { 245 | rows.forEach { row -> 246 | writer.newline() 247 | writer.inline("|") 248 | 249 | row.cells.forEachIndexed { ix, it -> 250 | writer.inline(" ") 251 | writer.inline(it) 252 | writer.inline(row.pad.repeat(lengths[ix] - it.length)) 253 | writer.inline(" |") 254 | } 255 | } 256 | } 257 | } 258 | 259 | override fun raw(markdown: String, block: Boolean) { 260 | if (block) blocked { 261 | writer.raw(markdown) 262 | } else { 263 | writer.raw(markdown) 264 | } 265 | } 266 | 267 | fun footer() { 268 | val footnotes = bibliography.footnotes 269 | val references = bibliography.references 270 | 271 | list { 272 | var i = 0 273 | 274 | while (i < footnotes.size) { 275 | /* no forEach - list may be appended to during loop */ 276 | 277 | val (label, write) = footnotes[i++] 278 | 279 | li("$label: ", write) 280 | } 281 | } 282 | 283 | list { 284 | references.forEach { (reference, cite) -> 285 | li("${cite.label}: ") { +reference } 286 | } 287 | } 288 | } 289 | } -------------------------------------------------------------------------------- /markout-markdown/src/main/kotlin/io/koalaql/markout/md/Bibliography.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.md 2 | 3 | class Bibliography { 4 | private val foots = arrayListOf Unit>>() 5 | private val refs = linkedMapOf() 6 | 7 | fun reference(reference: String): Citation = 8 | refs.getOrPut(reference) { Citation("[${refs.size + 1}]") } 9 | 10 | fun footnote(builder: Markdown.() -> Unit): String { 11 | val label = "[^${foots.size + 1}]" 12 | 13 | foots.add(Pair(label, builder)) 14 | 15 | return label 16 | } 17 | 18 | val footnotes: List Unit>> = foots 19 | val references: Map = refs 20 | } -------------------------------------------------------------------------------- /markout-markdown/src/main/kotlin/io/koalaql/markout/md/Citation.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.md 2 | 3 | class Citation( 4 | val label: String 5 | ) -------------------------------------------------------------------------------- /markout-markdown/src/main/kotlin/io/koalaql/markout/md/Markdown.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.md 2 | 3 | import io.koalaql.kapshot.Capturable 4 | import io.koalaql.kapshot.CapturedBlock 5 | import io.koalaql.markout.MarkoutDsl 6 | 7 | @MarkoutDsl 8 | interface Markdown: MarkdownBlock { 9 | @MarkoutDsl 10 | fun h(level: Int, block: MarkdownInline.() -> Unit) 11 | 12 | @MarkoutDsl 13 | fun h1(block: MarkdownInline.() -> Unit) = h(1, block) 14 | @MarkoutDsl 15 | fun h2(block: MarkdownInline.() -> Unit) = h(2, block) 16 | @MarkoutDsl 17 | fun h3(block: MarkdownInline.() -> Unit) = h(3, block) 18 | @MarkoutDsl 19 | fun h4(block: MarkdownInline.() -> Unit) = h(4, block) 20 | @MarkoutDsl 21 | fun h5(block: MarkdownInline.() -> Unit) = h(5, block) 22 | @MarkoutDsl 23 | fun h6(block: MarkdownInline.() -> Unit) = h(6, block) 24 | 25 | @MarkoutDsl 26 | fun h1(text: String) = h1 { t(text) } 27 | @MarkoutDsl 28 | fun h2(text: String) = h2 { t(text) } 29 | @MarkoutDsl 30 | fun h3(text: String) = h3 { t(text) } 31 | @MarkoutDsl 32 | fun h4(text: String) = h4 { t(text) } 33 | @MarkoutDsl 34 | fun h5(text: String) = h5 { t(text) } 35 | @MarkoutDsl 36 | fun h6(text: String) = h6 { t(text) } 37 | 38 | @MarkoutDsl 39 | fun p(block: MarkdownBlock.() -> Unit) 40 | @MarkoutDsl 41 | fun p(text: String) = p { t(text) } 42 | @MarkoutDsl 43 | fun p() = p { } 44 | 45 | @MarkoutDsl 46 | fun hr() 47 | 48 | @MarkoutDsl 49 | fun quote(block: Markdown.() -> Unit) 50 | 51 | @MarkoutDsl 52 | fun quote(text: String) = quote { t(text) } 53 | 54 | @MarkoutDsl 55 | fun code(lang: String, code: String) 56 | @MarkoutDsl 57 | fun code(code: String) = code("", code) 58 | 59 | @MarkoutDsl 60 | fun code(block: CapturedBlock): CapturedBlock = 61 | block.also { code("kotlin", it.source.text) } 62 | 63 | @MarkoutDsl 64 | fun ol(builder: MarkdownNumberedList.() -> Unit) 65 | @MarkoutDsl 66 | fun ul(builder: MarkdownDottedList.() -> Unit) 67 | @MarkoutDsl 68 | fun cl(builder: MarkdownCheckList.() -> Unit) 69 | 70 | @MarkoutDsl 71 | fun table(builder: MarkdownTable.() -> Unit) 72 | 73 | @MarkoutDsl 74 | fun raw(markdown: String, block: Boolean = true) 75 | } -------------------------------------------------------------------------------- /markout-markdown/src/main/kotlin/io/koalaql/markout/md/MarkdownBlock.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.md 2 | 3 | import io.koalaql.markout.MarkoutDsl 4 | 5 | @MarkoutDsl 6 | interface MarkdownBlock: MarkdownInline { 7 | @MarkoutDsl 8 | operator fun String.unaryMinus() { 9 | t("\n") 10 | t(this) 11 | } 12 | 13 | @MarkoutDsl 14 | fun br() { 15 | t(" \n") 16 | } 17 | } -------------------------------------------------------------------------------- /markout-markdown/src/main/kotlin/io/koalaql/markout/md/MarkdownCheckList.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.md 2 | 3 | import io.koalaql.markout.MarkoutDsl 4 | 5 | @MarkoutDsl 6 | fun interface MarkdownCheckList { 7 | @MarkoutDsl 8 | fun li(checked: Boolean, block: Markdown.() -> Unit) 9 | @MarkoutDsl 10 | fun li(checked: Boolean, text: String) = li(checked) { t(text) } 11 | } -------------------------------------------------------------------------------- /markout-markdown/src/main/kotlin/io/koalaql/markout/md/MarkdownDottedList.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.md 2 | 3 | import io.koalaql.markout.MarkoutDsl 4 | 5 | @MarkoutDsl 6 | fun interface MarkdownDottedList { 7 | @MarkoutDsl 8 | fun li(block: Markdown.() -> Unit) 9 | 10 | @MarkoutDsl 11 | fun li(text: String) = li { t(text) } 12 | } -------------------------------------------------------------------------------- /markout-markdown/src/main/kotlin/io/koalaql/markout/md/MarkdownInline.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.md 2 | 3 | import io.koalaql.markout.MarkoutDsl 4 | 5 | @MarkoutDsl 6 | interface MarkdownInline { 7 | operator fun Unit.plus(text: String) = +text 8 | operator fun Unit.plus(unit: Unit) = Unit 9 | 10 | @MarkoutDsl 11 | fun cite(href: String, title: String? = null): Citation 12 | 13 | @MarkoutDsl 14 | fun footnote(note: Markdown.() -> Unit) 15 | @MarkoutDsl 16 | fun footnote(note: String) = footnote { t(note) } 17 | 18 | @MarkoutDsl 19 | fun t(text: String) 20 | 21 | operator fun String.unaryPlus() = t(this) 22 | 23 | @MarkoutDsl 24 | fun c(text: String) 25 | 26 | @MarkoutDsl 27 | fun a(href: String, line: MarkdownInline.() -> Unit) 28 | @MarkoutDsl 29 | fun a(href: Citation, line: MarkdownInline.() -> Unit) 30 | 31 | @MarkoutDsl 32 | fun a(href: String, text: String) = a(href) { t(text) } 33 | @MarkoutDsl 34 | fun a(href: Citation, text: String) = a(href) { t(text) } 35 | 36 | @MarkoutDsl 37 | fun img(href: String, alt: String = "", title: String = "") 38 | 39 | @MarkoutDsl 40 | fun t(line: MarkdownInline.() -> Unit) 41 | 42 | @MarkoutDsl 43 | fun i(block: MarkdownInline.() -> Unit) 44 | @MarkoutDsl 45 | fun b(block: MarkdownInline.() -> Unit) 46 | @MarkoutDsl 47 | fun s(block: MarkdownInline.() -> Unit) 48 | 49 | @MarkoutDsl 50 | fun i(text: String) = i { t(text) } 51 | @MarkoutDsl 52 | fun b(text: String) = b { t(text) } 53 | @MarkoutDsl 54 | fun s(text: String) = s { t(text) } 55 | } -------------------------------------------------------------------------------- /markout-markdown/src/main/kotlin/io/koalaql/markout/md/MarkdownNumberedList.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.md 2 | 3 | import io.koalaql.markout.MarkoutDsl 4 | 5 | @MarkoutDsl 6 | fun interface MarkdownNumberedList { 7 | @MarkoutDsl 8 | fun li(number: Int?, block: Markdown.() -> Unit) 9 | @MarkoutDsl 10 | fun li(block: Markdown.() -> Unit) = li(null, block) 11 | 12 | @MarkoutDsl 13 | fun li(number: Int, text: String) = li(number) { t(text) } 14 | @MarkoutDsl 15 | fun li(text: String) = li { t(text) } 16 | } -------------------------------------------------------------------------------- /markout-markdown/src/main/kotlin/io/koalaql/markout/md/MarkdownTable.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.md 2 | 3 | import io.koalaql.markout.MarkoutDsl 4 | 5 | @MarkoutDsl 6 | interface MarkdownTable { 7 | @MarkoutDsl 8 | fun th(row: MarkdownTableRow.() -> Unit) 9 | @MarkoutDsl 10 | fun tr(row: MarkdownTableRow.() -> Unit) 11 | } -------------------------------------------------------------------------------- /markout-markdown/src/main/kotlin/io/koalaql/markout/md/MarkdownTableRow.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.md 2 | 3 | import io.koalaql.markout.MarkoutDsl 4 | 5 | @MarkoutDsl 6 | fun interface MarkdownTableRow { 7 | @MarkoutDsl 8 | fun td(cell: MarkdownInline.() -> Unit) 9 | @MarkoutDsl 10 | fun td(text: String) = td { t(text) } 11 | } -------------------------------------------------------------------------------- /markout-markdown/src/main/kotlin/io/koalaql/markout/md/Markout.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.md 2 | 3 | import io.koalaql.markout.MarkdownBuilder 4 | import io.koalaql.markout.Markout 5 | import io.koalaql.markout.MarkoutDsl 6 | import io.koalaql.markout.text.AppendableLineWriter 7 | import io.koalaql.markout.text.LineWriter 8 | 9 | fun markdownTo( 10 | writer: LineWriter, 11 | builder: Markdown.() -> Unit 12 | ) { 13 | MarkdownBuilder(writer, top = true).apply { 14 | builder() 15 | footer() 16 | } 17 | } 18 | 19 | fun markdown( 20 | builder: Markdown.() -> Unit 21 | ): String { 22 | val sb = StringBuilder() 23 | 24 | markdownTo(AppendableLineWriter(sb), builder) 25 | 26 | return "$sb" 27 | } 28 | 29 | fun withMdSuffix(name: String) = when { 30 | name.endsWith(".md", ignoreCase = true) || 31 | name.endsWith(".mdx", ignoreCase = true) -> name 32 | else -> "$name.md" 33 | } 34 | 35 | @MarkoutDsl 36 | fun Markout.markdown(name: String, contents: String) { 37 | file(withMdSuffix(name), contents) 38 | } 39 | 40 | @MarkoutDsl 41 | fun Markout.markdown(name: String, builder: Markdown.() -> Unit) { 42 | file(withMdSuffix(name)) { out -> 43 | val writer = out.writer() 44 | 45 | val lw = AppendableLineWriter(writer) 46 | 47 | markdownTo(lw, builder) 48 | 49 | lw.newline() 50 | 51 | writer.flush() 52 | } 53 | } -------------------------------------------------------------------------------- /markout-markdown/src/test/kotlin/FilePathTests.kt: -------------------------------------------------------------------------------- 1 | import io.koalaql.markout.buildOutput 2 | import io.koalaql.markout.md.markdown 3 | import io.koalaql.markout.output.OutputDirectory 4 | import io.koalaql.markout.text.AppendableLineWriter 5 | import io.koalaql.markout.text.LineWriter 6 | import kotlin.test.Test 7 | import kotlin.test.assertEquals 8 | 9 | class FilePathTests { 10 | private fun extractPaths(output: OutputDirectory, out: LineWriter) { 11 | output.entries().forEach { (name, entry) -> 12 | out.inline(name) 13 | out.newline() 14 | 15 | val output = entry.output 16 | 17 | if (output is OutputDirectory) { 18 | extractPaths(output, out.prefixed("$name/")) 19 | } 20 | } 21 | } 22 | 23 | private fun extractPaths(output: OutputDirectory) = "${StringBuilder().also { 24 | extractPaths(output, AppendableLineWriter(it).trimmedLines()) 25 | }}" 26 | 27 | @Test 28 | fun `md suffix handling`() { 29 | assertEquals(""" 30 | test-dir 31 | test-dir/test.md 32 | test-dir/test.mdx 33 | test-dir/nested 34 | test-dir/nested/some-file 35 | test-dir/nested/test.md.md 36 | test-dir/test3.md 37 | test.config.md 38 | some-file.txt 39 | """.trimIndent(), 40 | extractPaths(buildOutput { 41 | directory("test-dir") { 42 | markdown("test.md") { } 43 | markdown("test.mdx") { } 44 | 45 | directory("nested") { 46 | file("some-file", "") 47 | markdown("test.md.md") { } 48 | } 49 | 50 | markdown("test3") { } 51 | } 52 | 53 | markdown("test.config") { } 54 | file("some-file.txt", "") 55 | }) 56 | ) 57 | } 58 | } -------------------------------------------------------------------------------- /markout-plugin/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("publish-plugin") 3 | } 4 | 5 | pluginBundle { 6 | website = "https://github.com/mfwgenerics/markout" 7 | vcsUrl = "https://github.com/mfwgenerics/markout.git" 8 | tags = listOf("kotlin", "markout", "markdown", "jvm", "documentation") 9 | } 10 | 11 | gradlePlugin { 12 | plugins { 13 | create("markoutPlugin") { 14 | id = "io.koalaql.markout" 15 | displayName = "Markout Plugin" 16 | description = "Plugin Support for Markout: an executable documentation platform and Markdown DSL for Kotlin" 17 | implementationClass = "io.koalaql.markout.GradlePlugin" 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /markout-plugin/src/main/kotlin/io/koalaql/markout/GradlePlugin.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout 2 | 3 | import org.gradle.api.plugins.JavaPluginExtension 4 | import org.gradle.api.tasks.JavaExec 5 | import java.util.concurrent.Callable 6 | import io.koalaql.markout_plugin.BuildConfig 7 | import org.gradle.api.* 8 | import org.gradle.api.tasks.Internal 9 | import org.gradle.api.tasks.OutputFile 10 | 11 | open class MarkoutExecTask: JavaExec() { 12 | @Internal 13 | val markoutBuildDir = project 14 | .buildDir 15 | .toPath() 16 | .resolve("markout") 17 | 18 | @OutputFile 19 | val outputPath = project.buildDir.toPath().resolve("markout/paths.txt") 20 | 21 | override fun exec() { 22 | markoutBuildDir.toFile().apply { 23 | deleteRecursively() 24 | mkdir() 25 | } 26 | 27 | super.exec() 28 | } 29 | } 30 | 31 | class GradlePlugin: Plugin { 32 | override fun apply(target: Project) = with(target) { 33 | dependencies.add("api", "io.koalaql:markout:${BuildConfig.VERSION}") 34 | 35 | target.extensions.create("markout", MarkoutConfig::class.java) 36 | 37 | val configureTask = tasks.register("markoutConfigure", DefaultTask::class.java) { 38 | it.group = "markout" 39 | 40 | it.doLast { 41 | val ext = target.extensions.getByType(MarkoutConfig::class.java) 42 | 43 | checkNotNull(ext.mainClass) { "markout.mainClass was not set" } 44 | } 45 | } 46 | 47 | fun execTask(name: String, builder: (MarkoutExecTask) -> Unit) = tasks 48 | .register(name, MarkoutExecTask::class.java) { 49 | val ext = target.extensions.getByType(MarkoutConfig::class.java) 50 | 51 | it.group = "markout" 52 | 53 | it.classpath = project 54 | .files() 55 | .from(Callable { project 56 | .extensions 57 | .getByType(JavaPluginExtension::class.java) 58 | .sourceSets.getByName("main") 59 | .runtimeClasspath 60 | }) 61 | 62 | it.environment("MARKOUT_BUILD_DIR", "${it.markoutBuildDir}") 63 | it.environment("MARKOUT_PATH", (ext.rootDir ?: rootDir).absolutePath) 64 | 65 | if (ext.mainClass != null) it.mainClass.set(ext.mainClass) 66 | 67 | builder(it) 68 | 69 | it.dependsOn(configureTask) 70 | } 71 | 72 | val checkTask = execTask("markoutCheck") { 73 | it.description = "Check that Markout generated files are up-to-date." 74 | it.environment("MARKOUT_MODE", "expect") 75 | } 76 | 77 | execTask("markout") { 78 | it.description = "Generate and clean Markout files." 79 | it.environment("MARKOUT_MODE", "apply") 80 | } 81 | 82 | tasks 83 | .matching { it.name == "check" } 84 | .configureEach { it.dependsOn(checkTask) } 85 | } 86 | } -------------------------------------------------------------------------------- /markout-plugin/src/main/kotlin/io/koalaql/markout/MarkoutConfig.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout 2 | 3 | import java.io.File 4 | 5 | open class MarkoutConfig { 6 | var mainClass: String? = null 7 | var rootDir: File? = null 8 | } -------------------------------------------------------------------------------- /markout/build.gradle.kts: -------------------------------------------------------------------------------- 1 | repositories { 2 | mavenCentral() 3 | } 4 | 5 | plugins { 6 | id("publish-1.8") 7 | } 8 | 9 | dependencies { 10 | api(kotlin("reflect")) 11 | 12 | testImplementation(kotlin("test")) 13 | } -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/ActionableFiles.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout 2 | 3 | import io.koalaql.markout.files.FileAction 4 | import java.nio.file.Path 5 | 6 | class ActionableFiles( 7 | private val paths: Map 8 | ) { 9 | fun perform(): List { 10 | return paths.mapNotNull { (path, action) -> action.perform(path) } 11 | } 12 | 13 | fun paths(): List = 14 | paths.asSequence().map { it.key }.toList() 15 | 16 | fun expect(): List { 17 | val visited = linkedMapOf() 18 | 19 | val diffs = arrayListOf() 20 | 21 | /* we don't check paths if their parents have already failed */ 22 | fun visit(path: Path?): Boolean { 23 | if (path == null) return true /* handle null parent case */ 24 | visited[path]?.let { return it } 25 | 26 | val parentExpected = visit(path.parent) 27 | 28 | if (!parentExpected) { 29 | visited[path] = false 30 | return false 31 | } 32 | 33 | val diff = paths[path]?.expect(path) 34 | 35 | if (diff != null) diffs.add(diff) 36 | 37 | val result = diff == null 38 | 39 | visited[path] = result 40 | return result 41 | } 42 | 43 | paths.keys.forEach { visit(it) } 44 | 45 | return diffs 46 | } 47 | } -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/ExecutionMode.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout 2 | 3 | enum class ExecutionMode { 4 | APPLY, 5 | EXPECT 6 | } -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/Markout.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout 2 | 3 | import io.koalaql.markout.files.* 4 | import io.koalaql.markout.name.FileName 5 | import io.koalaql.markout.name.TrackedName 6 | import io.koalaql.markout.name.UntrackedName 7 | import io.koalaql.markout.output.Output 8 | import io.koalaql.markout.output.OutputDirectory 9 | import io.koalaql.markout.output.OutputEntry 10 | import io.koalaql.markout.output.OutputFile 11 | import java.io.InputStream 12 | import java.io.OutputStream 13 | import java.nio.file.Files 14 | import java.nio.file.NoSuchFileException 15 | import java.nio.file.Path 16 | import java.nio.file.StandardOpenOption 17 | import kotlin.io.path.* 18 | 19 | @MarkoutDsl 20 | interface Markout { 21 | @MarkoutDsl 22 | fun untracked(name: String) = UntrackedName(name) 23 | 24 | @MarkoutDsl 25 | fun directory(name: FileName, builder: Markout.() -> Unit) 26 | 27 | @MarkoutDsl 28 | fun file(name: FileName, output: OutputFile) 29 | @MarkoutDsl 30 | fun file(name: FileName, contents: String) = file(name) { out -> 31 | out.writer().apply { 32 | append(contents) 33 | flush() 34 | } 35 | } 36 | 37 | @MarkoutDsl 38 | fun directory(name: String, builder: Markout.() -> Unit) = 39 | directory(TrackedName(name), builder) 40 | 41 | @MarkoutDsl 42 | fun file(name: String, output: OutputFile) = 43 | file(TrackedName(name), output) 44 | 45 | @MarkoutDsl 46 | fun file(name: String, contents: String) = 47 | file(TrackedName(name), contents) 48 | } 49 | 50 | fun buildOutput( 51 | builder: Markout.() -> Unit 52 | ): OutputDirectory = OutputDirectory { 53 | val entries = linkedMapOf() 54 | 55 | fun set(name: FileName, output: Output) { 56 | entries[name.name] = OutputEntry( 57 | tracked = when (name) { 58 | is TrackedName -> true 59 | is UntrackedName -> false 60 | }, 61 | output = output 62 | ) 63 | } 64 | 65 | object : Markout { 66 | override fun directory(name: FileName, builder: Markout.() -> Unit) { 67 | set(name, buildOutput(builder)) 68 | } 69 | 70 | override fun file(name: FileName, output: OutputFile) { 71 | set(name, output) 72 | } 73 | }.builder() 74 | 75 | entries 76 | } 77 | 78 | val METADATA_FILE_NAME = Path(".markout") 79 | 80 | private fun validMetadataPath(dir: Path, path: String): Path? { 81 | if (path.isBlank()) return null 82 | 83 | return dir 84 | .resolve(path) 85 | .normalize() 86 | .takeIf { it.parent == dir } 87 | } 88 | 89 | /* order is important here: metadata path should be the last to be deleted to allow crash recovery */ 90 | fun metadataPaths(dir: Path, untracked: Sequence = emptySequence()): Sequence { 91 | if (!Files.isDirectory(dir)) return emptySequence() 92 | 93 | val metadata = dir.resolve(METADATA_FILE_NAME) 94 | 95 | val tracked = try { 96 | metadata 97 | .readText() 98 | .splitToSequence("\n") 99 | } catch (ex: NoSuchFileException) { 100 | emptySequence() 101 | } 102 | 103 | return (tracked + untracked) 104 | .mapNotNull { validMetadataPath(dir, it) } 105 | .plusElement(metadata) /* plusElement rather than plus bc Path : Iterable */ 106 | .distinct() 107 | } 108 | 109 | enum class DiffType { 110 | MISMATCH, 111 | UNTRACKED, 112 | EXPECTED, 113 | UNEXPECTED 114 | } 115 | 116 | data class Diff( 117 | val type: DiffType, 118 | val path: Path 119 | ) { 120 | override fun toString(): String = 121 | "${"$type".lowercase()}\t$path" 122 | } 123 | 124 | 125 | val MODE_ENV_VAR = "MARKOUT_MODE" 126 | val PATH_ENV_VAR = "MARKOUT_PATH" 127 | 128 | private fun executionModeProperty(): ExecutionMode { 129 | return when (val value = System.getenv(MODE_ENV_VAR)) { 130 | null, "", "apply" -> ExecutionMode.APPLY 131 | "expect" -> ExecutionMode.EXPECT 132 | else -> error("unexpected value `$value` for property $MODE_ENV_VAR") 133 | } 134 | } 135 | 136 | fun actionableFiles(output: OutputDirectory, dir: Path): ActionableFiles { 137 | val paths = linkedMapOf() 138 | 139 | fun markDeletions(path: Path) { 140 | metadataPaths(path).forEach { 141 | markDeletions(it) 142 | } 143 | 144 | paths[path] = DeleteFile 145 | } 146 | 147 | fun reconcile(output: OutputDirectory, dir: Path) { 148 | val entries = output.entries().toMutableMap() 149 | val remaining = entries.toMutableMap() 150 | 151 | metadataPaths( 152 | dir, 153 | entries.asSequence().filterNot { it.value.tracked }.map { it.key } 154 | ).forEach { path -> 155 | val entry = remaining.remove(path.name) 156 | val output = entry?.output 157 | 158 | when (output) { 159 | is OutputDirectory -> { 160 | paths[path] = DeclareDirectory 161 | 162 | reconcile(output, path) 163 | } 164 | else -> { 165 | markDeletions(path) 166 | 167 | if (output is OutputFile) { 168 | paths[path] = WriteToFile(output) 169 | } 170 | } 171 | } 172 | } 173 | 174 | remaining.iterator().let { 175 | it.forEach { (name, _) -> 176 | val path = dir.resolve(name) 177 | 178 | if (Files.exists(path) && !Files.isDirectory(path)) { 179 | it.remove() 180 | entries.remove(name) 181 | 182 | paths[path] = AlreadyExistsError 183 | } 184 | } 185 | } 186 | 187 | val metadataPath = dir.resolve(METADATA_FILE_NAME) 188 | 189 | paths[metadataPath] = WriteMetadata(entries 190 | .asSequence() 191 | .filter { it.value.tracked } 192 | .map { it.key } 193 | .toList() 194 | ) 195 | 196 | remaining.forEach { (name, entry) -> 197 | val path = dir.resolve(name) 198 | 199 | val output = entry.output 200 | 201 | when (output) { 202 | is OutputDirectory -> { 203 | paths[path] = DeclareDirectory 204 | 205 | reconcile(output, path) 206 | } 207 | is OutputFile -> { 208 | paths[path] = WriteToFile(output) 209 | } 210 | } 211 | } 212 | } 213 | 214 | reconcile(output, dir) 215 | 216 | return ActionableFiles(paths) 217 | } 218 | 219 | fun markout( 220 | path: Path, 221 | mode: ExecutionMode = executionModeProperty(), 222 | builder: Markout.() -> Unit 223 | ) { 224 | val output = buildOutput(builder) 225 | 226 | val normalized = path.normalize() 227 | 228 | val actions = actionableFiles(output, normalized) 229 | 230 | when (mode) { 231 | ExecutionMode.APPLY -> { 232 | actions.perform().forEach { println(it) } 233 | } 234 | ExecutionMode.EXPECT -> { 235 | val diffs = actions.expect() 236 | 237 | if (diffs.isNotEmpty()) error(diffs.joinToString("\n")) 238 | } 239 | } 240 | 241 | System.getenv("MARKOUT_BUILD_DIR") 242 | ?.takeIf { it.isNotBlank() } 243 | ?.let(::Path) 244 | ?.resolve("paths.txt") 245 | ?.writeLines(actions.paths().asSequence().map { "$it" }) 246 | } 247 | 248 | fun markout( 249 | mode: ExecutionMode = executionModeProperty(), 250 | builder: Markout.() -> Unit 251 | ) = markout( 252 | checkNotNull(System.getenv(PATH_ENV_VAR) 253 | ?.takeIf { it.isNotBlank() } 254 | ?.let { Path(it) }) { 255 | "Missing path. Specify a path explicitly or set the $PATH_ENV_VAR environment variable" 256 | }, 257 | mode, 258 | builder 259 | ) -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/MarkoutDsl.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout 2 | 3 | @DslMarker 4 | annotation class MarkoutDsl 5 | -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/files/AlreadyExistsError.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.files 2 | 3 | import io.koalaql.markout.Diff 4 | import io.koalaql.markout.DiffType 5 | import java.nio.file.Path 6 | 7 | object AlreadyExistsError: FileAction { 8 | override fun perform(path: Path): Nothing { 9 | error("$path already exists") 10 | } 11 | 12 | override fun expect(path: Path): Diff { 13 | return Diff(DiffType.UNTRACKED, path) 14 | } 15 | } -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/files/DeclareDirectory.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.files 2 | 3 | import io.koalaql.markout.Diff 4 | import io.koalaql.markout.DiffType 5 | import java.nio.file.Files 6 | import java.nio.file.Path 7 | 8 | object DeclareDirectory: FileAction { 9 | override fun perform(path: Path): Diff? { 10 | if (Files.isDirectory(path)) return null 11 | 12 | if (Files.deleteIfExists(path)) return Diff(DiffType.MISMATCH, path) 13 | 14 | Files.createDirectory(path) 15 | return Diff(DiffType.EXPECTED, path) 16 | } 17 | 18 | override fun expect(path: Path): Diff? { 19 | if (Files.notExists(path)) { 20 | return Diff(DiffType.EXPECTED, path) 21 | } 22 | 23 | if (!Files.isDirectory(path)) { 24 | return Diff(DiffType.MISMATCH, path) 25 | } 26 | 27 | return null 28 | } 29 | } -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/files/DeleteFile.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.files 2 | 3 | import io.koalaql.markout.Diff 4 | import io.koalaql.markout.DiffType 5 | import java.nio.file.Files 6 | import java.nio.file.Path 7 | 8 | object DeleteFile: FileAction { 9 | private fun isEmpty(dir: Path) = 10 | Files.newDirectoryStream(dir).use { directory -> !directory.iterator().hasNext() } 11 | 12 | override fun perform(path: Path): Diff? { 13 | if (Files.isDirectory(path)) { 14 | if (isEmpty(path)) { 15 | Files.delete(path) 16 | 17 | return Diff(DiffType.UNEXPECTED, path) 18 | } 19 | } else { 20 | if (Files.deleteIfExists(path)) return Diff( 21 | DiffType.UNEXPECTED, 22 | path 23 | ) 24 | } 25 | 26 | return null 27 | } 28 | 29 | override fun expect(path: Path): Diff? { 30 | if (Files.notExists(path)) return null 31 | 32 | return Diff( 33 | DiffType.UNEXPECTED, 34 | path 35 | ) 36 | } 37 | } -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/files/FileAction.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.files 2 | 3 | import io.koalaql.markout.Diff 4 | import java.nio.file.Path 5 | 6 | sealed interface FileAction { 7 | fun perform(path: Path): Diff? 8 | fun expect(path: Path): Diff? 9 | } -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/files/WriteMetadata.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.files 2 | 3 | import java.nio.file.Path 4 | import kotlin.io.path.deleteExisting 5 | import kotlin.io.path.deleteIfExists 6 | import kotlin.io.path.writeText 7 | 8 | data class WriteMetadata( 9 | private val keys: Collection 10 | ): FileAction { 11 | override fun perform(path: Path): Nothing? { 12 | if (keys.isEmpty()) { 13 | path.deleteIfExists() 14 | } else { 15 | path.writeText(keys.joinToString( 16 | separator = "\n", 17 | postfix = "\n" 18 | )) 19 | } 20 | 21 | return null 22 | } 23 | 24 | override fun expect(path: Path): Nothing? = null 25 | } 26 | -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/files/WriteToFile.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.files 2 | 3 | import io.koalaql.markout.Diff 4 | import io.koalaql.markout.DiffType 5 | import io.koalaql.markout.stream.StreamMatcher 6 | import io.koalaql.markout.output.OutputFile 7 | import io.koalaql.markout.stream.StreamMode 8 | import java.nio.file.Files 9 | import java.nio.file.Path 10 | import java.nio.file.StandardOpenOption 11 | 12 | data class WriteToFile( 13 | private val source: OutputFile 14 | ): FileAction { 15 | override fun perform(path: Path): Diff? { 16 | val needsCreation = Files.notExists(path) 17 | 18 | val matcher = StreamMatcher( 19 | if (needsCreation) { 20 | Files.newByteChannel(path, StandardOpenOption.READ, StandardOpenOption.WRITE, StandardOpenOption.CREATE) 21 | } else { 22 | Files.newByteChannel(path, StandardOpenOption.READ, StandardOpenOption.WRITE) 23 | }, 24 | StreamMode.OVERWRITE 25 | ) 26 | 27 | matcher.use { source.writeTo(it) } 28 | 29 | if (needsCreation) return Diff(DiffType.EXPECTED, path) 30 | if (!matcher.matched()) return Diff(DiffType.MISMATCH, path) 31 | 32 | return null 33 | } 34 | 35 | override fun expect(path: Path): Diff? { 36 | if (Files.notExists(path)) return Diff(DiffType.EXPECTED, path) 37 | if (Files.isDirectory(path)) return Diff(DiffType.MISMATCH, path) 38 | 39 | val matcher = StreamMatcher(Files.newByteChannel(path, StandardOpenOption.READ)) 40 | 41 | matcher.use { source.writeTo(it) } 42 | 43 | if (!matcher.matched()) return Diff(DiffType.MISMATCH, path) 44 | 45 | return null 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/name/FileName.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.name 2 | 3 | sealed class FileName { 4 | abstract val name: String 5 | 6 | override fun toString(): String = name 7 | } -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/name/TrackedName.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.name 2 | 3 | class TrackedName( 4 | override val name: String 5 | ): FileName() -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/name/UntrackedName.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.name 2 | 3 | class UntrackedName( 4 | override val name: String 5 | ): FileName() -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/output/Output.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.output 2 | 3 | sealed interface Output -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/output/OutputDirectory.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.output 2 | 3 | fun interface OutputDirectory: Output { 4 | fun entries(): Map 5 | } -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/output/OutputEntry.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.output 2 | 3 | class OutputEntry( 4 | val tracked: Boolean, 5 | val output: Output 6 | ) -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/output/OutputFile.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.output 2 | 3 | import java.io.OutputStream 4 | 5 | fun interface OutputFile: Output { 6 | fun writeTo(output: OutputStream) 7 | } -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/stream/StreamMatcher.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.stream 2 | 3 | import java.io.OutputStream 4 | import java.nio.ByteBuffer 5 | import java.nio.channels.SeekableByteChannel 6 | 7 | class StreamMatcher( 8 | private val channel: SeekableByteChannel, 9 | private val mode: StreamMode = StreamMode.CHECK 10 | ): OutputStream() { 11 | private var buffer = ByteArray(0) 12 | 13 | private var matches = true 14 | 15 | private fun ensureEnoughBuffer(needed: Int) { 16 | if (buffer.size >= needed) return 17 | buffer = ByteArray(needed) 18 | } 19 | 20 | override fun write(b: ByteArray, off: Int, len: Int) { 21 | if (matches) { 22 | ensureEnoughBuffer(len) 23 | 24 | val existing = ByteBuffer.wrap(buffer, 0, len) 25 | val incoming = ByteBuffer.wrap(b, off, len) 26 | 27 | var read = channel.read(existing) 28 | 29 | if (read < 0) read = 0 30 | 31 | if (read == len) { 32 | existing.rewind() 33 | 34 | if (existing.mismatch(incoming) == -1) return 35 | } 36 | 37 | channel.position(channel.position() - read.toLong()) 38 | 39 | matches = false 40 | } 41 | 42 | if (mode == StreamMode.OVERWRITE) { 43 | channel.write(ByteBuffer.wrap(b, off, len)) 44 | } 45 | } 46 | 47 | override fun write(byte: Int) { 48 | write(byteArrayOf(byte.toByte())) 49 | } 50 | 51 | override fun close() { 52 | if (channel.size() != channel.position()) { 53 | if (mode == StreamMode.OVERWRITE) channel.truncate(channel.position()) 54 | matches = false 55 | } 56 | 57 | channel.close() 58 | } 59 | 60 | fun matched(): Boolean { 61 | return matches 62 | } 63 | } -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/stream/StreamMode.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.stream 2 | 3 | enum class StreamMode { 4 | CHECK, 5 | OVERWRITE 6 | } -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/text/AppendableLineWriter.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.text 2 | 3 | class AppendableLineWriter( 4 | private val appendable: Appendable 5 | ): LineWriter { 6 | override fun inline(text: String) { appendable.append(text) } 7 | override fun newline() { appendable.append("\n") } 8 | override fun raw(text: String) { appendable.append(text) } 9 | } -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/text/LineWriter.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.text 2 | 3 | interface LineWriter { 4 | fun prefixed(prefix: String, start: Boolean = true): LineWriter = 5 | PrefixedLineWriter(this, prefix, start) 6 | 7 | fun onWrite(action: () -> Unit): LineWriter = 8 | OnWriteWriter(this, action) 9 | 10 | fun trimmedLines(): LineWriter = 11 | TrimLinesWriter(this) 12 | 13 | fun paragraphRules(): LineWriter = 14 | ParagraphWriter(this) 15 | 16 | fun inline(text: String) 17 | fun newline() 18 | 19 | fun line(text: String) { 20 | inline(text) 21 | newline() 22 | } 23 | 24 | fun line() = newline() 25 | 26 | fun raw(text: String) { 27 | if (text.isEmpty()) return 28 | 29 | val iter = text.splitToSequence("\n").iterator() 30 | 31 | inline(iter.next()) /* guaranteed next due to non-empty text */ 32 | 33 | /* remaining lines */ 34 | iter.forEach { 35 | newline() 36 | inline(it) 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/text/OnWriteWriter.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.text 2 | 3 | class OnWriteWriter( 4 | private val of: LineWriter, 5 | private val action: () -> Unit 6 | ): LineWriter { 7 | private var written = false 8 | 9 | private fun write() { 10 | if (!written) { 11 | action() 12 | written = true 13 | } 14 | } 15 | 16 | override fun inline(text: String) { 17 | if (text.isEmpty()) return 18 | 19 | write() 20 | of.inline(text) 21 | } 22 | 23 | override fun newline() { 24 | write() 25 | of.newline() 26 | } 27 | } -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/text/ParagraphWriter.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.text 2 | 3 | class ParagraphWriter( 4 | private val of: LineWriter 5 | ): LineWriter { 6 | private var startOfLine = true 7 | 8 | override fun inline(text: String) { 9 | if (!startOfLine) { 10 | of.inline(text) 11 | return 12 | } 13 | 14 | val trimmed = text.trimStart() 15 | 16 | if (trimmed.isEmpty()) return 17 | 18 | of.inline(trimmed) 19 | startOfLine = false 20 | } 21 | 22 | override fun newline() { 23 | if (startOfLine) return 24 | of.newline() 25 | startOfLine = true 26 | } 27 | } -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/text/PrefixedLineWriter.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.text 2 | 3 | class PrefixedLineWriter( 4 | private val of: LineWriter, 5 | private val prefix: String, 6 | private var start: Boolean = true 7 | ): LineWriter { 8 | private fun emit() { 9 | if (start) { 10 | of.inline(prefix) 11 | start = false 12 | } 13 | } 14 | 15 | override fun inline(text: String) { 16 | if (text.isEmpty()) return 17 | 18 | emit() 19 | of.inline(text) 20 | } 21 | 22 | override fun newline() { 23 | emit() 24 | of.newline() 25 | start = true 26 | } 27 | } -------------------------------------------------------------------------------- /markout/src/main/kotlin/io/koalaql/markout/text/TrimLinesWriter.kt: -------------------------------------------------------------------------------- 1 | package io.koalaql.markout.text 2 | 3 | class TrimLinesWriter( 4 | private val of: LineWriter 5 | ): LineWriter { 6 | private var trimLeft = true 7 | private var saved = arrayListOf<() -> Unit>() 8 | 9 | override fun inline(text: String) { 10 | if (text.isBlank()) { 11 | if (trimLeft) return 12 | 13 | saved.add { of.inline(text) } 14 | } else { 15 | trimLeft = false 16 | 17 | saved.forEach { it() } 18 | saved.clear() 19 | 20 | of.inline(text) 21 | } 22 | } 23 | 24 | override fun newline() { 25 | if (trimLeft) return 26 | 27 | saved.add { of.newline() } 28 | } 29 | } -------------------------------------------------------------------------------- /markout/src/test/kotlin/ApplyModeTests.kt: -------------------------------------------------------------------------------- 1 | import io.koalaql.markout.markout 2 | import org.junit.Rule 3 | import org.junit.Test 4 | import org.junit.rules.TemporaryFolder 5 | import kotlin.io.path.* 6 | import kotlin.test.assertEquals 7 | 8 | class ApplyModeTests { 9 | @JvmField 10 | @Rule 11 | val temp = TemporaryFolder() 12 | 13 | @Test 14 | fun `existing files are never overwritten by tracked write`() { 15 | val untrackedContents = "test generated file" 16 | 17 | val rootDir = Path(temp.root.path) 18 | .apply { 19 | resolve("untracked.txt").writeText(untrackedContents) 20 | resolve(".markout").deleteIfExists() 21 | } 22 | 23 | repeat(3) { ix -> 24 | try { 25 | markout(rootDir) { 26 | file("untracked.txt", "changed contents") 27 | } 28 | 29 | assert(false) { "failed to fail on run #${ix+1}" } 30 | } catch (ex: IllegalStateException) { 31 | assertEquals("${temp.root.path}/untracked.txt already exists", ex.message) 32 | } 33 | 34 | assertEquals( 35 | untrackedContents, 36 | rootDir.resolve("untracked.txt").readText(), 37 | message = "run #${ix+1}:" 38 | ) 39 | } 40 | } 41 | 42 | @Test 43 | fun `explicit untracked files can be overwritten`() { 44 | val untrackedContents = "test generated file" 45 | 46 | val rootDir = Path(temp.root.path) 47 | .apply { 48 | resolve("untracked.txt").writeText(untrackedContents) 49 | resolve(".markout").deleteIfExists() 50 | } 51 | 52 | markout(rootDir) { 53 | file(untracked("untracked.txt"), "changed contents") 54 | } 55 | 56 | assertEquals( 57 | "changed contents", 58 | rootDir.resolve("untracked.txt").readText() 59 | ) 60 | } 61 | 62 | @Test 63 | fun `untracked files are created but not removed`() { 64 | val rootDir = Path(temp.root.path) 65 | 66 | markout(rootDir) { 67 | file("tracked.txt", "I won't exist soon") 68 | file(untracked("untracked.txt"), "I will still exist") 69 | } 70 | 71 | assertEquals( 72 | "I will still exist", 73 | rootDir.resolve("untracked.txt").readText() 74 | ) 75 | 76 | assertEquals( 77 | "tracked.txt\n", 78 | rootDir.resolve(".markout").readText() 79 | ) 80 | 81 | markout(rootDir) { } 82 | 83 | assertEquals( 84 | "I will still exist", 85 | rootDir.resolve("untracked.txt").readText() 86 | ) 87 | 88 | assert(rootDir.resolve("tracked.txt").notExists()) 89 | } 90 | 91 | @Test 92 | fun `tracked files can become untracked`() { 93 | val rootDir = Path(temp.root.path) 94 | 95 | markout(rootDir) { 96 | file("tracked.txt", "I won't exist soon") 97 | file("untracked.txt", "I will still exist") 98 | } 99 | 100 | assertEquals( 101 | "tracked.txt\nuntracked.txt\n", 102 | rootDir.resolve(".markout").readText() 103 | ) 104 | 105 | markout(rootDir) { 106 | file("tracked.txt", "I won't exist soon") 107 | file(untracked("untracked.txt"), "I will still exist") 108 | } 109 | 110 | assertEquals( 111 | "I will still exist", 112 | rootDir.resolve("untracked.txt").readText() 113 | ) 114 | 115 | assertEquals( 116 | "tracked.txt\n", 117 | rootDir.resolve(".markout").readText() 118 | ) 119 | 120 | markout(rootDir) { } 121 | 122 | assertEquals( 123 | "I will still exist", 124 | rootDir.resolve("untracked.txt").readText() 125 | ) 126 | 127 | assert(rootDir.resolve("tracked.txt").notExists()) 128 | } 129 | 130 | @Test 131 | fun `files created, removed and overwritten`() { 132 | val rootDir = Path(temp.root.path) 133 | 134 | // fresh state 135 | markout(rootDir) { 136 | file("modify-me.txt", "unmodified") 137 | file("delete-me.txt", "undeleted") 138 | 139 | directory("nested") { 140 | file("modify-me.txt", "unmodified") 141 | file("delete-me.txt", "undeleted") 142 | } 143 | } 144 | 145 | rootDir 146 | .apply { 147 | assertEquals(resolve(".markout").readText(), "modify-me.txt\ndelete-me.txt\nnested\n") 148 | assert(resolve("create-me.txt").notExists()) 149 | assertEquals(resolve("modify-me.txt").readText(), "unmodified") 150 | assertEquals(resolve("delete-me.txt").readText(), "undeleted") 151 | 152 | resolve("nested").apply { 153 | assert(exists() && isDirectory()) 154 | 155 | assertEquals(resolve(".markout").readText(), "modify-me.txt\ndelete-me.txt\n") 156 | assert(resolve("create-me.txt").notExists()) 157 | assertEquals(resolve("modify-me.txt").readText(), "unmodified") 158 | assertEquals(resolve("delete-me.txt").readText(), "undeleted") 159 | } 160 | } 161 | 162 | markout(rootDir) { 163 | file("create-me.txt", "created") 164 | file("modify-me.txt", "modified") 165 | 166 | directory("nested") { 167 | file("create-me.txt", "created") 168 | file("modify-me.txt", "modified") 169 | } 170 | } 171 | 172 | rootDir 173 | .apply { 174 | assertEquals(resolve(".markout").readText(), "create-me.txt\nmodify-me.txt\nnested\n") 175 | assertEquals(resolve("create-me.txt").readText(), "created") 176 | assertEquals(resolve("modify-me.txt").readText(), "modified") 177 | assert(resolve("delete-me.txt").notExists()) 178 | 179 | resolve("nested").apply { 180 | assert(exists() && isDirectory()) 181 | 182 | assertEquals(resolve(".markout").readText(), "create-me.txt\nmodify-me.txt\n") 183 | assertEquals(resolve("create-me.txt").readText(), "created") 184 | assertEquals(resolve("modify-me.txt").readText(), "modified") 185 | assert(resolve("delete-me.txt").notExists()) 186 | } 187 | } 188 | } 189 | 190 | @Test 191 | fun `can use existing directory`() { 192 | val rootDir = Path(temp.root.path).apply { 193 | resolve("existing-dir").createDirectory() 194 | } 195 | 196 | markout(rootDir) { 197 | directory("existing-dir") { 198 | file("new-file.txt", "successfully created") 199 | } 200 | } 201 | 202 | rootDir.apply { 203 | assertEquals(resolve(".markout").readText(), "existing-dir\n") 204 | 205 | resolve("existing-dir").apply { 206 | assertEquals(resolve(".markout").readText(), "new-file.txt\n") 207 | 208 | assert(isDirectory()) 209 | assertEquals("successfully created", resolve("new-file.txt").readText()) 210 | } 211 | } 212 | } 213 | 214 | @Test 215 | fun `directories are cleaned up`() { 216 | val rootDir = Path(temp.root.path) 217 | 218 | markout(rootDir) { 219 | directory("existing-dir") { 220 | directory("inner-dir") { 221 | 222 | } 223 | file("new-file.txt", "successfully created") 224 | } 225 | } 226 | 227 | rootDir.apply { 228 | assertEquals(resolve(".markout").readText(), "existing-dir\n") 229 | 230 | resolve("existing-dir").apply { 231 | assertEquals(resolve(".markout").readText(), "inner-dir\nnew-file.txt\n") 232 | 233 | assert(isDirectory()) 234 | assertEquals("successfully created", resolve("new-file.txt").readText()) 235 | } 236 | } 237 | 238 | markout(rootDir) { } 239 | 240 | rootDir.apply { 241 | resolve("existing-dir").apply { 242 | assert(notExists()) 243 | } 244 | } 245 | } 246 | 247 | @Test 248 | fun `no empty dotfiles`() { 249 | val rootDir = Path(temp.root.path) 250 | 251 | markout(rootDir) { 252 | directory("new-dir") { } 253 | } 254 | 255 | assert(rootDir.resolve("new-dir/.markout").notExists()) 256 | 257 | markout(rootDir) { 258 | directory("new-dir") { 259 | file("temp.txt", "temporarily exists") 260 | } 261 | } 262 | 263 | assertEquals( 264 | "new-dir\n", 265 | rootDir.resolve(".markout").readText() 266 | ) 267 | 268 | assertEquals( 269 | "temp.txt\n", 270 | rootDir.resolve("new-dir/.markout").readText() 271 | ) 272 | 273 | markout(rootDir) { 274 | directory("new-dir") { } 275 | } 276 | 277 | assert(rootDir.resolve("new-dir/.markout").notExists()) 278 | 279 | markout(rootDir) { } 280 | 281 | assert(rootDir.resolve(".markout").notExists()) 282 | } 283 | } -------------------------------------------------------------------------------- /markout/src/test/kotlin/ExpectModeTests.kt: -------------------------------------------------------------------------------- 1 | import io.koalaql.markout.ExecutionMode 2 | import io.koalaql.markout.markout 3 | import kotlin.io.path.Path 4 | import kotlin.test.Test 5 | import kotlin.test.assertEquals 6 | import kotlin.test.assertFailsWith 7 | 8 | class ExpectModeTests { 9 | private inline fun expectFailure( 10 | expected: String, 11 | block: () -> Unit 12 | ) { 13 | try { 14 | block() 15 | assert(false) { "expect mode shouldn't have succeeded!" } 16 | } catch (ex: IllegalStateException) { 17 | assertEquals(expected, ex.message) 18 | } 19 | } 20 | 21 | @Test 22 | fun `missing directory`() { 23 | expectFailure("expected\ttest-data/missing-dir/case1") { 24 | markout( 25 | Path("./test-data/missing-dir"), 26 | ExecutionMode.EXPECT 27 | ) { 28 | directory("case1") { 29 | file("test.txt", "TEST CONTENT") 30 | } 31 | } 32 | } 33 | } 34 | 35 | @Test 36 | fun `one present one missing`() { 37 | expectFailure( 38 | """ 39 | unexpected test-data/missing-present/case2/untracked.txt 40 | expected test-data/missing-present/case2/test.txt 41 | """.trimIndent() 42 | ) { 43 | markout( 44 | Path("./test-data/missing-present"), 45 | ExecutionMode.EXPECT 46 | ) { 47 | directory("case2") { 48 | file("test.txt", "TEST CONTENT") 49 | } 50 | } 51 | } 52 | } 53 | 54 | @Test 55 | fun `missing one file`() { 56 | expectFailure("unexpected\ttest-data/tree/file0.txt") { 57 | markout( 58 | Path("./test-data/tree"), 59 | ExecutionMode.EXPECT 60 | ) { 61 | directory("dir") { 62 | directory("nested") { 63 | file("empty.txt", "") 64 | file("file2.txt", "content...") 65 | file("file3.txt", "content!") 66 | } 67 | 68 | file("file1.txt", "content\n\n") 69 | } 70 | } 71 | } 72 | } 73 | 74 | @Test 75 | fun `matches perfectly`() { 76 | markout( 77 | Path("./test-data/tree"), 78 | ExecutionMode.EXPECT 79 | ) { 80 | directory("dir") { 81 | directory("nested") { 82 | file("empty.txt", "") 83 | file("file2.txt", "content...") 84 | file("file3.txt", "content!") 85 | } 86 | 87 | file("file1.txt", "content\n\n") 88 | } 89 | 90 | file("file0.txt", "file0") 91 | } 92 | } 93 | 94 | @Test 95 | fun `contents mismatch`() { 96 | expectFailure( 97 | """ 98 | mismatch test-data/tree/dir/nested/file2.txt 99 | mismatch test-data/tree/dir/file1.txt 100 | mismatch test-data/tree/file0.txt 101 | """.trimIndent(), 102 | ) { 103 | markout( 104 | Path("./test-data/tree"), 105 | ExecutionMode.EXPECT 106 | ) { 107 | directory("dir") { 108 | directory("nested") { 109 | file("empty.txt", "") 110 | file("file2.txt", "content doesn't match") 111 | file("file3.txt", "content!") 112 | } 113 | 114 | file("file1.txt", "content") 115 | } 116 | 117 | file("file0.txt", "") 118 | } 119 | } 120 | } 121 | 122 | @Test 123 | fun `treating file as directory, directory as file`() { 124 | expectFailure( 125 | """ 126 | mismatch test-data/tree/dir 127 | mismatch test-data/tree/file0.txt 128 | """.trimIndent() 129 | ) { 130 | markout( 131 | Path("./test-data/tree"), 132 | ExecutionMode.EXPECT 133 | ) { 134 | file("dir", "") 135 | 136 | directory("file0.txt") { } 137 | } 138 | } 139 | } 140 | 141 | @Test 142 | fun `unicode smoke test`() { 143 | markout( 144 | Path("./test-data/unicode"), 145 | ExecutionMode.EXPECT 146 | ) { 147 | file("unicode.txt", "\uD83D\uDE80\uD83C\uDF3B\uD83C\uDF55\uD83C\uDF89\uD83D\uDE97\uD83C\uDF0A\uD83C\uDF3A\uD83C\uDFB8\uD83D\uDD25\uD83C\uDF08") 148 | } 149 | } 150 | } -------------------------------------------------------------------------------- /markout/src/test/kotlin/LineWriterTests.kt: -------------------------------------------------------------------------------- 1 | import io.koalaql.markout.text.AppendableLineWriter 2 | import io.koalaql.markout.text.LineWriter 3 | import kotlin.test.Test 4 | import kotlin.test.assertEquals 5 | 6 | class LineWriterTests { 7 | @Test 8 | fun `raw text splitting`() { 9 | fun test(case: String) { 10 | val sb = StringBuilder() 11 | 12 | val lw = object : LineWriter { 13 | override fun inline(text: String) { sb.append(text) } 14 | override fun newline() { sb.append("\n") } 15 | } 16 | 17 | lw.raw(case) 18 | 19 | assertEquals(case, "$sb") 20 | } 21 | 22 | test("\nlines\n\n\n \n more") 23 | test(" lines more") 24 | test("") 25 | } 26 | 27 | @Test 28 | fun `newline trimming`() { 29 | fun trimLines(case: String): String = "${StringBuilder().also { 30 | AppendableLineWriter(it) 31 | .trimmedLines() 32 | .raw(case) 33 | }}" 34 | 35 | fun assertTrimmed(expected: String, case: String) { 36 | val trimmed = trimLines(case) 37 | 38 | assertEquals(expected, trimmed) 39 | assertEquals(trimmed, trimLines(trimmed)) // idempotent 40 | } 41 | 42 | assertTrimmed("", trimLines("\n\n\n")) 43 | 44 | assertTrimmed("a", trimLines("\na\n\n")) 45 | assertTrimmed("a\n\nb", trimLines("\n\na\n\nb\n\n")) 46 | assertTrimmed(" a", trimLines(" \n \t\n a\n \n ")) 47 | } 48 | 49 | @Test 50 | fun `paragraph rules`() { 51 | fun paragraphize(case: String): String = "${StringBuilder().also { 52 | AppendableLineWriter(it) 53 | .paragraphRules() 54 | .raw(case) 55 | }}" 56 | 57 | assertEquals("", paragraphize("")) 58 | assertEquals("", paragraphize(" ")) 59 | assertEquals("", paragraphize(" \n\n\n")) 60 | assertEquals("a \n", paragraphize(" \n a \n\n")) 61 | assertEquals(""" 62 | I'm an 63 | untrimmed paragraph 64 | with alignments all off 65 | a markdown line break 66 | (i.e. previous space has two whitespace after) 67 | 68 | """.trimIndent(), paragraphize(""" 69 | I'm an 70 | untrimmed paragraph 71 | 72 | with alignments all off 73 | 74 | a markdown line break 75 | (i.e. previous space has two whitespace after) 76 | 77 | 78 | """)) 79 | } 80 | } -------------------------------------------------------------------------------- /markout/src/test/kotlin/StreamTests.kt: -------------------------------------------------------------------------------- 1 | import io.koalaql.markout.stream.StreamMatcher 2 | import io.koalaql.markout.stream.StreamMode 3 | import org.junit.Rule 4 | import org.junit.rules.TemporaryFolder 5 | import java.nio.file.Files 6 | import java.nio.file.StandardOpenOption 7 | import kotlin.io.path.Path 8 | import kotlin.io.path.readText 9 | import kotlin.io.path.writeText 10 | import kotlin.test.Test 11 | import kotlin.test.assertEquals 12 | 13 | class StreamTests { 14 | @JvmField 15 | @Rule 16 | val temp = TemporaryFolder() 17 | 18 | @Test 19 | fun `stream checks files`() { 20 | val rootDir = Path(temp.root.path) 21 | 22 | rootDir.apply { 23 | val match = resolve("match.txt") 24 | 25 | match.writeText("this should match") 26 | 27 | assert( 28 | with(StreamMatcher(Files.newByteChannel(match, StandardOpenOption.READ))) { 29 | use { write("this should match".toByteArray()) } 30 | matched() 31 | } 32 | ) 33 | 34 | assert( 35 | with(StreamMatcher(Files.newByteChannel(match, StandardOpenOption.READ))) { 36 | use { write("this shouldn't match".toByteArray()) } 37 | !matched() 38 | } 39 | ) 40 | } 41 | } 42 | 43 | @Test 44 | fun `stream overwrites files`() { 45 | val rootDir = Path(temp.root.path) 46 | 47 | rootDir.apply { 48 | val overwrite0 = resolve("overwrite0.txt") 49 | 50 | overwrite0.writeText("this should be overwritten") 51 | 52 | assert( 53 | with(StreamMatcher( 54 | Files.newByteChannel(overwrite0, StandardOpenOption.READ, StandardOpenOption.WRITE), 55 | StreamMode.OVERWRITE 56 | )) { 57 | use { write("overwritten!".toByteArray()) } 58 | !matched() 59 | } 60 | ) 61 | 62 | assertEquals( 63 | "overwritten!", 64 | overwrite0.readText() 65 | ) 66 | 67 | assert( 68 | with(StreamMatcher( 69 | Files.newByteChannel(overwrite0, StandardOpenOption.READ, StandardOpenOption.WRITE), 70 | StreamMode.OVERWRITE 71 | )) { 72 | use { write("overwritten with much longer text".toByteArray()) } 73 | !matched() 74 | } 75 | ) 76 | 77 | assertEquals( 78 | "overwritten with much longer text", 79 | overwrite0.readText() 80 | ) 81 | 82 | assert( 83 | with(StreamMatcher( 84 | /* leave out WRITE option since overwrite shouldn't actually write in match */ 85 | Files.newByteChannel(overwrite0, StandardOpenOption.READ), 86 | StreamMode.OVERWRITE 87 | )) { 88 | use { write("overwritten with much longer text".toByteArray()) } 89 | matched() 90 | } 91 | ) 92 | } 93 | } 94 | } -------------------------------------------------------------------------------- /markout/test-data/missing-dir/.markout: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mfwgenerics/markout/38aed7629da23261815ecaad472650918ec24488/markout/test-data/missing-dir/.markout -------------------------------------------------------------------------------- /markout/test-data/missing-present/.markout: -------------------------------------------------------------------------------- 1 | case2 -------------------------------------------------------------------------------- /markout/test-data/missing-present/case2/.markout: -------------------------------------------------------------------------------- 1 | untracked.txt -------------------------------------------------------------------------------- /markout/test-data/missing-present/case2/untracked.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mfwgenerics/markout/38aed7629da23261815ecaad472650918ec24488/markout/test-data/missing-present/case2/untracked.txt -------------------------------------------------------------------------------- /markout/test-data/tree/.markout: -------------------------------------------------------------------------------- 1 | dir 2 | file0.txt -------------------------------------------------------------------------------- /markout/test-data/tree/dir/.markout: -------------------------------------------------------------------------------- 1 | nested 2 | file1.txt -------------------------------------------------------------------------------- /markout/test-data/tree/dir/file1.txt: -------------------------------------------------------------------------------- 1 | content 2 | 3 | -------------------------------------------------------------------------------- /markout/test-data/tree/dir/nested/.markout: -------------------------------------------------------------------------------- 1 | empty.txt 2 | file2.txt 3 | file3.txt -------------------------------------------------------------------------------- /markout/test-data/tree/dir/nested/empty.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mfwgenerics/markout/38aed7629da23261815ecaad472650918ec24488/markout/test-data/tree/dir/nested/empty.txt -------------------------------------------------------------------------------- /markout/test-data/tree/dir/nested/file2.txt: -------------------------------------------------------------------------------- 1 | content... -------------------------------------------------------------------------------- /markout/test-data/tree/dir/nested/file3.txt: -------------------------------------------------------------------------------- 1 | content! -------------------------------------------------------------------------------- /markout/test-data/tree/dir/nested/untracked.txt: -------------------------------------------------------------------------------- 1 | untracked -------------------------------------------------------------------------------- /markout/test-data/tree/file0.txt: -------------------------------------------------------------------------------- 1 | file0 -------------------------------------------------------------------------------- /markout/test-data/unicode/.markout: -------------------------------------------------------------------------------- 1 | unicode.txt -------------------------------------------------------------------------------- /markout/test-data/unicode/unicode.txt: -------------------------------------------------------------------------------- 1 | 🚀🌻🍕🎉🚗🌊🌺🎸🔥🌈 -------------------------------------------------------------------------------- /readme/build.gradle.kts: -------------------------------------------------------------------------------- 1 | repositories { 2 | mavenCentral() 3 | } 4 | 5 | plugins { 6 | id("conventions") 7 | 8 | id("io.koalaql.markout-docusaurus") 9 | } 10 | 11 | markout { 12 | mainClass = "MainKt" 13 | } 14 | 15 | dependencies { 16 | implementation("io.koalaql:markout-github-workflows-kt") 17 | 18 | implementation(kotlin("reflect")) 19 | } 20 | -------------------------------------------------------------------------------- /readme/src/main/kotlin/Constants.kt: -------------------------------------------------------------------------------- 1 | const val MARKOUT_VERSION: String = "0.0.9" -------------------------------------------------------------------------------- /readme/src/main/kotlin/FileGen.kt: -------------------------------------------------------------------------------- 1 | import io.koalaql.markout.MODE_ENV_VAR 2 | import io.koalaql.markout.Markout 3 | import io.koalaql.markout.buildOutput 4 | import io.koalaql.markout.md.markdown 5 | import java.nio.file.Path 6 | import kotlin.io.path.Path 7 | 8 | fun Markout.fileGen() = markdown("FILES") { 9 | h1("File Generation") 10 | 11 | -"Markout can run in one of two modes" 12 | 13 | sectioned { 14 | section("Apply Mode") { 15 | -"Apply is the default mode. It generates files and directories and deletes" 16 | -"previously generated files and directories that were not regenerated." 17 | 18 | h3("Files and Directories") 19 | 20 | -"Markout provides a straightforward DSL for generating files and directories" 21 | 22 | fun markout(ignored: Path, builder: Markout.() -> Unit) = 23 | buildOutput(builder) 24 | 25 | val output = code { 26 | markout(Path("..")) { 27 | directory("my-directory") { 28 | directory("inner") { 29 | file("inner.txt", "another plain text file") 30 | } 31 | 32 | file("plain.txt", "the contents of a plain text file") 33 | 34 | file("circle.svg", """ 35 | 36 | 37 | 38 | """.trimIndent()) 39 | } 40 | 41 | markdown("readme") { 42 | -"A markdown file" 43 | -"The .md extension is automatically added to the filename if it is not present" 44 | } 45 | } 46 | } 47 | 48 | -"When this code is run it generates the following file tree" 49 | 50 | code(drawFileTree(output.invoke())) 51 | 52 | h3("File Tracking") 53 | 54 | p { 55 | -"When Markout generates directories it includes a `.markout` file. This is" 56 | -"how Markout keeps track of generated files. It should always be checked" 57 | -"into git. Markout will never change or delete an existing file or directory" 58 | -"unless it is tracked in `.markout`" 59 | } 60 | 61 | p { 62 | -"File tracking allows regular files to be mixed in with generated ones." 63 | -"For example, you might mix handwritten markdown into your docs directory." 64 | } 65 | } 66 | 67 | section("Expect Mode") { 68 | p { 69 | -"Running Markout in expect mode will cause it to fail when it encounters changes." 70 | -"This allows you to check that files have been generated and are consistent" 71 | -"with the code. It is intended for use in CI workflows." 72 | } 73 | 74 | p { 75 | -"To use Expect mode, run markout with the `$MODE_ENV_VAR` environment variable set to `expect`." 76 | } 77 | 78 | code("shell", "$MODE_ENV_VAR=expect ./gradlew :readme:run") 79 | } 80 | } 81 | } -------------------------------------------------------------------------------- /readme/src/main/kotlin/Main.kt: -------------------------------------------------------------------------------- 1 | import docusaurus.setupDocusaurus 2 | import io.koalaql.markout.markout 3 | import io.koalaql.markout.md.markdown 4 | import workflows.checkYml 5 | import workflows.deployGhPagesYml 6 | import workflows.releaseYml 7 | import kotlin.io.path.Path 8 | 9 | fun main() = markout { 10 | directory(".github") { 11 | directory("workflows") { 12 | checkYml() 13 | releaseYml() 14 | deployGhPagesYml() 15 | } 16 | } 17 | 18 | directory("docusaurus") { 19 | setupDocusaurus() 20 | } 21 | 22 | directory("docs") { 23 | markdownDocs() 24 | fileGen() 25 | } 26 | 27 | markdown("README") { 28 | h1("Markout: Markdown DSL and File Generator") 29 | 30 | -"Markout is a library for generating files, directories and Markdown documentation from Kotlin." 31 | -"It is designed for generating GitHub Flavored Markdown docs that live alongside code." 32 | -"Using "+a("https://github.com/mfwgenerics/kapshot", "Kapshot")+" with this project" 33 | -"allows literate programming and \"executable documentation\" which ensures" 34 | -"that documentation remains correct and up to date." 35 | 36 | sectioned { 37 | section("Intro") { 38 | p { 39 | -"Markout provides a fully featured Markdown DSL to support documentation" 40 | -"generation and automation. It is flexible, mixes easily with raw markdown and" 41 | -"is intended to be built upon and used in conjunction with other tools." 42 | -"The Markdown DSL can build strings or output directly to a file." 43 | } 44 | 45 | p { 46 | -"In addition to the Markdown DSL, Markout provides tools for managing" 47 | -"generated files and directories. Files and directories can be declared using" 48 | -"a DSL and then validated or synchronized. Snapshot testing can be performed on" 49 | -"generated files." 50 | } 51 | } 52 | 53 | section("Getting Started") { 54 | -"Add the `markout` dependency" 55 | 56 | code( 57 | "kotlin", 58 | """ 59 | /* build.gradle.kts */ 60 | dependencies { 61 | implementation("io.koalaql:markout:$MARKOUT_VERSION") 62 | } 63 | """.trimIndent() 64 | ) 65 | 66 | h4("File Generation") 67 | 68 | -"If you want to use Markout as a documentation generator, call" 69 | -"the `markout` function directly from your main method. Pass a path" 70 | -"to the directory where you want Markout to generate files." 71 | -"The path can be relative or absolute." 72 | 73 | code { 74 | fun main() = markout(Path(".")) { 75 | markdown("hello") { 76 | p("This file was generated using markout") 77 | 78 | p { 79 | i("Hello ") + "World!" 80 | } 81 | } 82 | } 83 | } 84 | 85 | -"Currently the Gradle application plugin is the best way to run a standalone Markout project" 86 | 87 | code("shell", "./gradlew :my-project:run") 88 | 89 | h4("Markdown Strings") 90 | 91 | -"If you only want to use Markout to generate Markdown strings then you can use" 92 | -"`markdown` as a standalone function" 93 | 94 | val result = code { 95 | markdown { 96 | h1("My Markdown") 97 | 98 | -"Text with some *italics*." 99 | } 100 | } 101 | 102 | -"The above will produce the String" 103 | 104 | code("markdown", result.invoke()) 105 | } 106 | 107 | section("Usage") { 108 | ol { 109 | li { a("docs/FILES.md", "File Generation") } 110 | li { a("docs/MARKDOWN.md", "Markdown") } 111 | } 112 | } 113 | } 114 | } 115 | } -------------------------------------------------------------------------------- /readme/src/main/kotlin/Markdown.kt: -------------------------------------------------------------------------------- 1 | import io.koalaql.kapshot.CapturedBlock 2 | import io.koalaql.markout.Markout 3 | import io.koalaql.markout.MarkoutDsl 4 | import io.koalaql.markout.md.Markdown 5 | import io.koalaql.markout.md.markdown 6 | 7 | @MarkoutDsl 8 | fun Markdown.code(lang: String, code: CapturedBlock): T { 9 | code(lang, code.source.text) 10 | 11 | return code() 12 | } 13 | 14 | fun Markout.markdownDocs() = markdown("MARKDOWN") { 15 | h1("Markdown") 16 | 17 | sectioned { 18 | section("Headers") { 19 | example { 20 | h1("Header 1") 21 | h2("Header 2") 22 | h3("Header 3") 23 | h4("Header 4") 24 | h5("Header 5") 25 | h6("Header 6") 26 | } 27 | } 28 | 29 | section("Paragraphs") { 30 | example { 31 | p { 32 | -"This text will appear in a paragraph." 33 | 34 | -"This sentence will be grouped with the preceding one." 35 | } 36 | 37 | p(""" 38 | A second paragraph. 39 | Line breaks won't 40 | affect the rendered markdown 41 | and indent is trimmed 42 | """) 43 | } 44 | } 45 | 46 | section("Emphasis") { 47 | example { 48 | p { 49 | b("Bold") 50 | +", " 51 | i("Italics") 52 | +" and " 53 | b { i("Bold italics") } 54 | +"." 55 | } 56 | 57 | p { 58 | /* same as above, using `+` syntax sugar */ 59 | b("Bold") + ", " + i("Italics") + " and " + b { i("Bold italics") } + "." 60 | } 61 | 62 | p { 63 | +"For inline text styling you can *still* use **raw markdown**" 64 | } 65 | } 66 | } 67 | 68 | section("Blockquotes") { 69 | example { 70 | +"I'm about to quote something" 71 | 72 | quote { 73 | +"Here's the quote with a nested quote inside" 74 | 75 | quote { 76 | +"A final inner quote" 77 | } 78 | } 79 | } 80 | } 81 | 82 | section("Lists") { 83 | example { 84 | +"Dot points" 85 | 86 | ul { 87 | li("Dot point 1") 88 | li("Another point") 89 | li("A third point") 90 | } 91 | 92 | +"Numbered" 93 | ol { 94 | li("Item 1") 95 | li { 96 | p { 97 | +"You can nest any markdown inside list items" 98 | } 99 | 100 | p { 101 | +"Multiple paragraphs" 102 | } 103 | 104 | quote { 105 | +"Or even a quote" 106 | } 107 | } 108 | li { 109 | ol { 110 | li("This includes") 111 | li("Lists themselves") 112 | } 113 | } 114 | } 115 | 116 | +"Task lists" 117 | cl { 118 | li(true, "Create a markdown DSL") 119 | li(true, "Add task list support") 120 | li(false, "Solve all of the world's problems") 121 | } 122 | } 123 | } 124 | 125 | section("Code") { 126 | example { 127 | c("Inline code block") 128 | 129 | code("multiline\ncode\nblocks") 130 | 131 | code("kotlin", """ 132 | fun main() { 133 | println("Syntax hinted code!") 134 | } 135 | """.trimIndent()) 136 | 137 | val block = code { 138 | /* this code block is captured */ 139 | fun square(x: Int) = x*x 140 | 141 | square(7) 142 | } 143 | 144 | +"Code executed with result: " 145 | c("${block.invoke()}") 146 | } 147 | } 148 | 149 | section("Horizontal Rules") { 150 | example { 151 | t("Separated") 152 | hr() 153 | t("By") 154 | hr() 155 | t("Hrs") 156 | } 157 | } 158 | 159 | section("Links") { 160 | example { 161 | p { 162 | +"Visit " 163 | a("https://example.com", "Example Website") 164 | } 165 | 166 | p { 167 | a("https://example.com") { 168 | +"Links " 169 | i("can contain") 170 | +" " 171 | b("inner formatting") 172 | } 173 | } 174 | 175 | p { 176 | a(cite("https://example.com"), "Reference style link") 177 | } 178 | 179 | p { 180 | +"Reference " 181 | a(cite("https://example.com"), "links") 182 | +" are de-duplicated" 183 | } 184 | 185 | p { 186 | a(cite("https://example.com", "Example"), "References") 187 | +" can be titled" 188 | } 189 | } 190 | } 191 | 192 | section("Images") { 193 | example { 194 | p { 195 | +"In inline contexts images will " 196 | img("markout.png") 197 | +" be shown inline " 198 | img("markout.png", "Alt text", "Title text is displayed on hover") 199 | } 200 | 201 | +"At top level images will be treated as blocks and vertically separated" 202 | img("markout.png") 203 | img("markout.png") 204 | img("unknown.png", "Alt text is displayed when the image can't be displayed") 205 | } 206 | } 207 | 208 | section("Tables") { 209 | example { 210 | table { 211 | th { 212 | td("Column 1") 213 | td { i("Italic Column") } 214 | } 215 | 216 | tr { 217 | td("1997") 218 | td("Non-italic") 219 | } 220 | 221 | tr { 222 | td("2023") 223 | td { i("Italic") } 224 | } 225 | } 226 | } 227 | } 228 | 229 | section("Footnotes") { 230 | example { 231 | fun note() = footnote(""" 232 | At the moment there is no way to re-use footnotes 233 | and the requirement for the note text to appear at 234 | the site of the footnote call is less than ideal 235 | """) 236 | 237 | +"The syntax is a work in progress" + note() + " but footnotes are possible." 238 | } 239 | } 240 | } 241 | } -------------------------------------------------------------------------------- /readme/src/main/kotlin/Util.kt: -------------------------------------------------------------------------------- 1 | import io.koalaql.kapshot.Capturable 2 | import io.koalaql.kapshot.Source 3 | import io.koalaql.markout.MarkoutDsl 4 | import io.koalaql.markout.md.Markdown 5 | import io.koalaql.markout.md.markdown 6 | import io.koalaql.markout.output.Output 7 | import io.koalaql.markout.output.OutputDirectory 8 | import io.koalaql.markout.output.OutputEntry 9 | import io.koalaql.markout.output.OutputFile 10 | import io.koalaql.markout.text.AppendableLineWriter 11 | import io.koalaql.markout.text.LineWriter 12 | 13 | fun interface CapturedBuilderBlock: Capturable { 14 | fun Markdown.build() 15 | 16 | override fun withSource(source: Source): CapturedBuilderBlock = object : CapturedBuilderBlock by this { 17 | override val source = source 18 | } 19 | } 20 | 21 | fun Markdown.example(block: CapturedBuilderBlock) { 22 | h3("Kotlin") 23 | code("kotlin", block.source.text) 24 | 25 | val rendered = markdown { with(block) { build() } } 26 | 27 | h3("Generated") 28 | code("markdown", rendered) 29 | 30 | h3("Rendered") 31 | quote { 32 | with (block) { build() } 33 | } 34 | } 35 | 36 | @MarkoutDsl 37 | fun interface Sections { 38 | @MarkoutDsl 39 | fun section(title: String, contents: Markdown.() -> Unit) 40 | } 41 | 42 | @MarkoutDsl 43 | fun Markdown.sectioned(builder: Sections.() -> Unit) { 44 | val actions = arrayListOf Unit>() 45 | 46 | ol { 47 | builder { title, contents -> 48 | li { a("#${title.lowercase().replace(' ', '-')}", title) } 49 | 50 | actions.add { h2(title) } 51 | actions.add(contents) 52 | } 53 | } 54 | 55 | actions.forEach { it() } 56 | } 57 | 58 | private class PrefixPair( 59 | val indent: String = "", 60 | val before: String = "", 61 | ) 62 | 63 | private class Prefix( 64 | val pre: PrefixPair = PrefixPair(), 65 | val post: PrefixPair = PrefixPair(), 66 | ) 67 | 68 | private val NO_PREFIX = Prefix( 69 | PrefixPair("", ""), 70 | PrefixPair("", "") 71 | ) 72 | 73 | private val PIPES_PREFIX = Prefix( 74 | PrefixPair("│ ", "├─ "), 75 | PrefixPair(" ", "└─ ") 76 | ) 77 | 78 | private fun drawFileTree( 79 | prefix: Prefix, 80 | output: Output, 81 | writer: LineWriter 82 | ) { 83 | when (output) { 84 | is OutputDirectory -> { 85 | val entries = output.entries() 86 | .asSequence() 87 | .sortedBy { 88 | "${it.value.output !is OutputDirectory}-${it.key}" 89 | } 90 | .toList() 91 | 92 | entries.forEachIndexed { ix, (key, entry) -> 93 | val p = if (ix < entries.size - 1) prefix.pre else prefix.post 94 | 95 | writer.inline(p.before) 96 | writer.inline(key) 97 | writer.newline() 98 | 99 | drawFileTree(PIPES_PREFIX, entry.output, writer.prefixed(p.indent)) 100 | } 101 | } 102 | is OutputFile -> { } 103 | } 104 | } 105 | 106 | fun drawFileTree(output: Output): String = 107 | "${StringBuilder().also { drawFileTree(NO_PREFIX, output, AppendableLineWriter(it).trimmedLines()) }}" 108 | -------------------------------------------------------------------------------- /readme/src/main/kotlin/docusaurus/Docusaurus.kt: -------------------------------------------------------------------------------- 1 | package docusaurus 2 | 3 | import io.koalaql.markout.Markout 4 | import io.koalaql.markout.docusaurus.docusaurus 5 | 6 | fun Markout.setupDocusaurus() = docusaurus { 7 | configure { 8 | url = "https://mfwgenerics.github.io/" 9 | baseUrl = "/markout/" 10 | 11 | title = "Markout" 12 | 13 | github = "https://github.com/mfwgenerics/markout" 14 | 15 | metadata = mapOf("google-site-verification" to "E-XuQoF0UqA8bzoXL3yY7bs9KuQFsQ2yrSkYuIp6Gqs") 16 | } 17 | 18 | docs { 19 | intro() 20 | } 21 | } -------------------------------------------------------------------------------- /readme/src/main/kotlin/docusaurus/Intro.kt: -------------------------------------------------------------------------------- 1 | package docusaurus 2 | 3 | import MARKOUT_VERSION 4 | import drawFileTree 5 | import io.koalaql.kapshot.CapturedBlock 6 | import io.koalaql.markout.Markout 7 | import io.koalaql.markout.buildOutput 8 | import io.koalaql.markout.docusaurus.Docusaurus 9 | import io.koalaql.markout.docusaurus.DocusaurusMarkdown 10 | import io.koalaql.markout.docusaurus.docusaurus 11 | import io.koalaql.markout.md.Markdown 12 | import io.koalaql.markout.md.markdown 13 | import io.koalaql.markout.output.Output 14 | import io.koalaql.markout.output.OutputDirectory 15 | import io.koalaql.markout.output.OutputEntry 16 | import io.koalaql.markout.output.OutputFile 17 | import java.io.ByteArrayOutputStream 18 | 19 | private fun drawProjectFileTree(output: Output) = drawFileTree(object : OutputDirectory { 20 | override fun entries(): Map = mapOf("my-project" to OutputEntry( 21 | tracked = false, 22 | output 23 | )) 24 | }) 25 | 26 | private fun DocusaurusMarkdown.markdownDslExample() { 27 | lateinit var markoutOutput: Pair 28 | 29 | fun markout(builder: Markout.() -> Unit) { 30 | markoutOutput = buildOutput(builder).entries().mapValues { entry -> 31 | with (entry.value.output as OutputFile) { 32 | ByteArrayOutputStream() 33 | .also { writeTo(it) } 34 | .toByteArray() 35 | .toString(Charsets.UTF_8) 36 | .trim() 37 | } 38 | }.entries.map { (x,y) -> x to y }.first() 39 | } 40 | 41 | val markoutVersion = MARKOUT_VERSION 42 | 43 | val source = execBlock { 44 | markout { 45 | markdown("README.md") { 46 | h2("Readme") 47 | 48 | p("Here's some *generated* Markdown with a list") 49 | 50 | p("Using Markout version `$markoutVersion`") 51 | 52 | ol { 53 | li("One") 54 | li("Two") 55 | } 56 | } 57 | } 58 | } 59 | 60 | tabbed(imports = true, mapOf( 61 | "Main.kt" to { 62 | code("kotlin", "val markoutVersion = \"$MARKOUT_VERSION\"\n\n${source}") 63 | }, 64 | markoutOutput.first to { 65 | code("markdown", markoutOutput.second) 66 | }, 67 | "Rendered" to { 68 | quote { 69 | raw(markoutOutput.second) 70 | } 71 | } 72 | )) 73 | } 74 | 75 | private fun DocusaurusMarkdown.sourceCaptureExample() { 76 | lateinit var markoutOutput: Pair 77 | 78 | fun markout(builder: Markout.() -> Unit) { 79 | markoutOutput = buildOutput(builder).entries().mapValues { entry -> 80 | with (entry.value.output as OutputFile) { 81 | ByteArrayOutputStream() 82 | .also { writeTo(it) } 83 | .toByteArray() 84 | .toString(Charsets.UTF_8) 85 | .trim() 86 | } 87 | }.entries.map { (x,y) -> x to y }.first() 88 | } 89 | 90 | val source = execBlock { 91 | markout { 92 | markdown("EXAMPLE.md") { 93 | val block = code { 94 | fun square(x: Int) = x*x 95 | 96 | square(7) 97 | } 98 | 99 | p("The code above results in: ${block.invoke()}") 100 | p("If the result changes unexpectedly then `./gradlew check` will fail") 101 | } 102 | } 103 | } 104 | 105 | tabbed(imports = false, mapOf( 106 | "Main.kt" to { 107 | code("kotlin", source) 108 | }, 109 | markoutOutput.first to { 110 | code("markdown", markoutOutput.second) 111 | }, 112 | "Rendered" to { 113 | quote { 114 | raw(markoutOutput.second) 115 | } 116 | } 117 | )) 118 | } 119 | 120 | private fun DocusaurusMarkdown.docusaurusExample() { 121 | lateinit var fileTree: String 122 | 123 | fun markout(builder: Markout.() -> Unit) { 124 | fileTree = drawProjectFileTree(buildOutput(builder)) 125 | } 126 | 127 | val source = execBlock { 128 | markout { 129 | docusaurus("my-site") { 130 | configure { 131 | title = "Example Site" 132 | } 133 | 134 | docs { 135 | markdown("hello.md") { 136 | h1("Hello Docusaurus!") 137 | } 138 | } 139 | } 140 | } 141 | } 142 | 143 | tabbed(imports = false, mapOf( 144 | "Main.kt" to { 145 | code("kotlin", source) 146 | }, 147 | "Generated Files" to { 148 | code(fileTree) 149 | } 150 | )) 151 | } 152 | 153 | fun Docusaurus.intro() = markdown("intro") { 154 | slug = "/" 155 | 156 | h1("Introduction") 157 | 158 | p { 159 | -"Markout is an executable documentation platform for Kotlin that" 160 | -"allows you to document Kotlin projects in code rather than text." 161 | -"Documentation written this way can be tested and verified on every build." 162 | -"Sample code becomes error proof, stays up-to-date and forms an" 163 | -"extra test suite for your project. Markout can serve as an alternative" 164 | -"to "+a("https://github.com/Kotlin/kotlinx-knit", "kotlinx-knit")+"." 165 | } 166 | 167 | h2("Project purpose") 168 | 169 | p { 170 | -"Documenting code is a time-consuming and error-prone process." 171 | -"Not only is handwritten sample code vulnerable to typos and syntax errors," 172 | -"it silently goes out of date as projects evolve." 173 | -"Results shown in documentation are also not guaranteed to match the" 174 | -"real behavior of the code. Markout seeks to address this by allowing" 175 | -"your docs to execute code from your project and embed the results." 176 | -"Your generated documentation is checked into Git and used to perform" 177 | -"snapshot testing on future builds." 178 | } 179 | 180 | p { 181 | -"Another goal of this project is to make it easy for Kotlin developers to use the" 182 | -" "+a(cite("https://docusaurus.io/"), "Docusaurus")+" static site generator to quickly build" 183 | -"and deploy documentation on GitHub pages. Markout can create, configure, install, build" 184 | -"and run Docusaurus projects without requiring Node.js to be installed. It integrates" 185 | -"with Gradle's " 186 | a( 187 | "https://docs.gradle.org/current/userguide/command_line_interface.html#sec:continuous_build", 188 | "Continuous Build" 189 | ) 190 | -"to enable hot reloads and previews as you code." 191 | -"Docusaurus support is optional and provided through a separate Gradle plugin." 192 | } 193 | 194 | p { 195 | -"Markout is designed to integrate with "+a(cite("https://github.com/mfwgenerics/kapshot"), "Kapshot")+"," 196 | -"a minimal Kotlin compiler plugin that allows source code to be" 197 | -"captured and inspected at runtime." 198 | -"Kapshot is the magic ingredient that enables fully executable and testable sample code blocks." 199 | } 200 | 201 | h2("How it works") 202 | 203 | -"Markout is designed around a core file generation layer that allows file trees to be declared in code." 204 | -"These file trees are reconciled into a target directory, which is your project root directory by default." 205 | -"Through extra plugins and libraries, the file generation layer can be extended with functionality for" 206 | -"generating markdown, capturing source code and building Docusaurus websites." 207 | 208 | h3("File generation") 209 | 210 | -"Markout generates files by running code from Kotlin projects with the Markout Gradle plugin applied." 211 | -"You supply a `main` method which invokes a `markout` block to describe how files should be generated." 212 | -"This code runs every time files are generated or verified." 213 | 214 | lateinit var markoutOutput: OutputDirectory 215 | 216 | fun markout(builder: Markout.() -> Unit) { 217 | markoutOutput = buildOutput(builder) 218 | } 219 | 220 | code("kotlin", "Main.kt", "fun main() = ${execBlock { 221 | markout { 222 | file("README.txt", "Hello world!") 223 | 224 | directory("docs") { 225 | file("INTRO.txt", "Another text file!") 226 | file("OUTRO.txt", "A final text file") 227 | } 228 | } 229 | }}") 230 | 231 | -"When the code above is run using `:markout`, it generates the following file tree" 232 | -"and creates it in the project directory." 233 | 234 | code(drawProjectFileTree(markoutOutput)) 235 | 236 | -"The `:markoutCheck` task then verifies that these files match subsequent runs of the code." 237 | 238 | h3("Markdown DSL") 239 | 240 | -"Markout is extended with a DSL for generating markdown files procedurally." 241 | -"The DSL can also be used to generate standalone Markdown strings." 242 | 243 | markdownDslExample() 244 | 245 | h3("Source code capture") 246 | 247 | -"Source code capture works using the "+a(cite("https://github.com/mfwgenerics/kapshot"), "Kapshot")+" plugin." 248 | -"This allows you to execute your sample code blocks and use the results." 249 | 250 | sourceCaptureExample() 251 | 252 | h3("Docusaurus sites") 253 | 254 | -"The Docusaurus plugin provides a `docusaurus` builder and Gradle tasks for building and running a " 255 | a(cite("https://docusaurus.io/"), "Docusaurus")+" site." 256 | 257 | docusaurusExample() 258 | } -------------------------------------------------------------------------------- /readme/src/main/kotlin/docusaurus/Util.kt: -------------------------------------------------------------------------------- 1 | package docusaurus 2 | 3 | import io.koalaql.kapshot.CapturedBlock 4 | import io.koalaql.markout.docusaurus.DocusaurusMarkdown 5 | import io.koalaql.markout.md.Markdown 6 | import io.koalaql.markout.md.markdown 7 | import io.koalaql.markout.text.AppendableLineWriter 8 | 9 | fun execBlock(block: CapturedBlock): String = 10 | block.source.text.also { block.invoke() } 11 | 12 | fun DocusaurusMarkdown.tabbed( 13 | imports: Boolean, 14 | tabs: Map Unit> 15 | ) { 16 | val builder = StringBuilder() 17 | 18 | AppendableLineWriter(builder).apply { 19 | if (imports) { 20 | line("import Tabs from '@theme/Tabs';") 21 | line("import TabItem from '@theme/TabItem';") 22 | line() 23 | } 24 | 25 | line("") 26 | tabs.forEach { (k, v) -> 27 | line("") 28 | line() 29 | raw(markdown(v)) 30 | line() 31 | line() 32 | line("") 33 | } 34 | line("") 35 | } 36 | 37 | code("mdx-code-block", "$builder") 38 | } -------------------------------------------------------------------------------- /readme/src/main/kotlin/workflows/Check.kt: -------------------------------------------------------------------------------- 1 | package workflows 2 | 3 | import io.koalaql.markout.Markout 4 | import io.koalaql.markout.workflow 5 | import io.github.typesafegithub.workflows.actions.actions.CheckoutV3 6 | import io.github.typesafegithub.workflows.actions.actions.SetupJavaV3 7 | import io.github.typesafegithub.workflows.actions.gradle.WrapperValidationActionV1 8 | import io.github.typesafegithub.workflows.domain.RunnerType 9 | import io.github.typesafegithub.workflows.domain.triggers.PullRequest 10 | import io.github.typesafegithub.workflows.domain.triggers.Push 11 | 12 | fun Markout.checkYml() = workflow("check", 13 | name = "Build and check", 14 | on = listOf(Push(), PullRequest()), 15 | ) { 16 | job(id = "build", runsOn = RunnerType.UbuntuLatest) { 17 | uses(action = CheckoutV3()) 18 | uses(action = WrapperValidationActionV1()) 19 | uses(action = SetupJavaV3( 20 | javaVersion = "19", 21 | distribution = SetupJavaV3.Distribution.Temurin 22 | )) 23 | run(command = "./gradlew check") 24 | } 25 | } -------------------------------------------------------------------------------- /readme/src/main/kotlin/workflows/GithubPages.kt: -------------------------------------------------------------------------------- 1 | package workflows 2 | 3 | import io.koalaql.markout.Markout 4 | import io.koalaql.markout.workflow 5 | import io.github.typesafegithub.workflows.actions.actions.CheckoutV3 6 | import io.github.typesafegithub.workflows.actions.actions.SetupNodeV3 7 | import io.github.typesafegithub.workflows.domain.Concurrency 8 | import io.github.typesafegithub.workflows.domain.RunnerType 9 | import io.github.typesafegithub.workflows.domain.triggers.Push 10 | 11 | fun Markout.deployGhPagesYml() = file("pages.yml", 12 | """ 13 | name: Deploy Docs 14 | on: 15 | push: 16 | branches: [main] 17 | paths: docusaurus/** 18 | permissions: 19 | contents: read 20 | pages: write 21 | id-token: write 22 | concurrency: 23 | group: "pages" 24 | cancel-in-progress: true 25 | jobs: 26 | deploy: 27 | environment: 28 | name: github-pages 29 | url: ${"$"}{{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v3 34 | - name: Configure JDK 35 | uses: actions/setup-java@v3 36 | with: 37 | distribution: 'temurin' 38 | java-version: 19 39 | - name: Build Pages 40 | run: ./gradlew :readme:docusaurusBuild 41 | - name: Setup Pages 42 | uses: actions/configure-pages@v1 43 | - name: Upload artifact 44 | uses: actions/upload-pages-artifact@v1 45 | with: 46 | path: docusaurus/build 47 | - name: Deploy to GitHub Pages 48 | id: deployment 49 | uses: actions/deploy-pages@v1 50 | """.trimIndent() 51 | ) 52 | -------------------------------------------------------------------------------- /readme/src/main/kotlin/workflows/Release.kt: -------------------------------------------------------------------------------- 1 | package workflows 2 | 3 | import io.koalaql.markout.Markout 4 | import io.koalaql.markout.workflow 5 | import io.github.typesafegithub.workflows.actions.actions.CheckoutV3 6 | import io.github.typesafegithub.workflows.actions.actions.SetupJavaV3 7 | import io.github.typesafegithub.workflows.actions.gradle.WrapperValidationActionV1 8 | import io.github.typesafegithub.workflows.domain.JobOutputs 9 | import io.github.typesafegithub.workflows.domain.RunnerType 10 | import io.github.typesafegithub.workflows.domain.actions.Action 11 | import io.github.typesafegithub.workflows.domain.actions.RegularAction 12 | import io.github.typesafegithub.workflows.domain.triggers.PullRequest 13 | import io.github.typesafegithub.workflows.domain.triggers.Push 14 | import io.github.typesafegithub.workflows.domain.triggers.Release 15 | import io.github.typesafegithub.workflows.dsl.expressions.Contexts 16 | import io.github.typesafegithub.workflows.dsl.expressions.expr 17 | 18 | class CreateNexusStagingRepo( 19 | val username: String, 20 | val password: String, 21 | val stagingProfileId: String, 22 | val description: String, 23 | val baseUrl: String 24 | ) : RegularAction( 25 | "nexus-actions", 26 | "create-nexus-staging-repo", 27 | "main" 28 | ) { 29 | override fun toYamlArguments() = linkedMapOf( 30 | "username" to username, 31 | "password" to password, 32 | "staging_profile_id" to stagingProfileId, 33 | "description" to description, 34 | "base_url" to baseUrl 35 | ) 36 | 37 | override fun buildOutputObject(stepId: String) = Outputs(stepId) 38 | 39 | class Outputs(private val stepId: String) : Action.Outputs(stepId) { 40 | val repositoryId: String = "steps.$stepId.outputs.repository_id" 41 | } 42 | } 43 | 44 | class ReleaseNexusStagingRepo( 45 | val username: String, 46 | val password: String, 47 | val stagingRepoId: String, 48 | val baseUrl: String 49 | ): RegularAction( 50 | "nexus-actions", 51 | "release-nexus-staging-repo", 52 | "main" 53 | ) { 54 | override fun toYamlArguments() = linkedMapOf( 55 | "username" to username, 56 | "password" to password, 57 | "staging_repository_id" to stagingRepoId, 58 | "base_url" to baseUrl 59 | ) 60 | 61 | override fun buildOutputObject(stepId: String) = Outputs(stepId) 62 | 63 | class Outputs(private val stepId: String) : Action.Outputs(stepId) { 64 | val repositoryId: String = "steps.$stepId.outputs.repository_id" 65 | } 66 | } 67 | 68 | fun Markout.releaseYml() = workflow("release", 69 | name = "Publish plugins and dependencies", 70 | on = listOf(Release( 71 | mapOf( 72 | "types" to listOf("published"), 73 | "branches" to listOf("main") 74 | ), 75 | )), 76 | ) { 77 | val staging = job(id = "staging_repository", 78 | name = "Create staging repository", 79 | runsOn = RunnerType.UbuntuLatest, 80 | outputs = object : JobOutputs() { 81 | var repository_id by output() 82 | } 83 | ) { 84 | val step = uses(action = CreateNexusStagingRepo( 85 | username = expr { secrets.getValue("SONATYPE_USERNAME") }, 86 | password = expr { secrets.getValue("SONATYPE_PASSWORD") }, 87 | stagingProfileId = expr { secrets.getValue("SONATYPE_PROFILE_ID") }, 88 | description = "${expr { github.repository }}/${expr { github.workflow }}#${expr { github.run_number }}", 89 | baseUrl = "https://s01.oss.sonatype.org/service/local/" 90 | )) 91 | 92 | jobOutputs.repository_id = step.outputs.repositoryId 93 | } 94 | 95 | job(id = "publish", 96 | runsOn = RunnerType.UbuntuLatest, 97 | needs = listOf(staging) 98 | ) { 99 | uses(action = CheckoutV3()) 100 | uses( 101 | action = SetupJavaV3( 102 | javaVersion = "19", 103 | distribution = SetupJavaV3.Distribution.Temurin 104 | ) 105 | ) 106 | 107 | run( 108 | name = "Publish Plugins and Libraries", 109 | command = "./gradlew publish", 110 | env = linkedMapOf( 111 | "REPOSITORY_ID" to expr { staging.outputs.repository_id }, 112 | "SONATYPE_USERNAME" to expr { secrets.getValue("SONATYPE_USERNAME") }, 113 | "SONATYPE_PASSWORD" to expr { secrets.getValue("SONATYPE_PASSWORD") }, 114 | "GPG_PRIVATE_KEY" to expr { secrets.getValue("GPG_PRIVATE_KEY") }, 115 | "GPG_PRIVATE_PASSWORD" to expr { secrets.getValue("GPG_PRIVATE_PASSWORD") }, 116 | "GRADLE_PUBLISH_KEY" to expr { secrets.getValue("GRADLE_PUBLISH_KEY") }, 117 | "GRADLE_PUBLISH_SECRET" to expr { secrets.getValue("GRADLE_PUBLISH_SECRET") } 118 | ) 119 | ) 120 | 121 | uses(action = ReleaseNexusStagingRepo( 122 | username = expr { secrets.getValue("SONATYPE_USERNAME") }, 123 | password = expr { secrets.getValue("SONATYPE_PASSWORD") }, 124 | stagingRepoId = expr { staging.outputs.repository_id }, 125 | baseUrl = "https://s01.oss.sonatype.org/service/local/" 126 | )) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | includeBuild("build-logic") 2 | includeBuild("markout") 3 | includeBuild("markout-plugin") 4 | includeBuild("markout-markdown") 5 | includeBuild("markout-markdown-plugin") 6 | includeBuild("markout-docusaurus") 7 | includeBuild("markout-docusaurus-plugin") 8 | includeBuild("markout-github-workflows-kt") 9 | 10 | includeBuild("testing") 11 | 12 | include("readme") 13 | -------------------------------------------------------------------------------- /testing/build.gradle.kts: -------------------------------------------------------------------------------- 1 | tasks.register("check") { 2 | dependsOn(":markdown-plugin:check") 3 | } -------------------------------------------------------------------------------- /testing/markdown-plugin/build.gradle.kts: -------------------------------------------------------------------------------- 1 | repositories { 2 | mavenCentral() 3 | } 4 | 5 | plugins { 6 | kotlin("jvm") version "1.8.10" 7 | 8 | id("io.koalaql.markout-markdown") 9 | } 10 | 11 | markout { 12 | mainClass = "MainKt" 13 | } 14 | 15 | dependencies { 16 | testImplementation(kotlin("test")) 17 | } -------------------------------------------------------------------------------- /testing/markdown-plugin/src/main/kotlin/Main.kt: -------------------------------------------------------------------------------- 1 | import io.koalaql.markout.markout 2 | 3 | fun main() = markout { 4 | 5 | } -------------------------------------------------------------------------------- /testing/markdown-plugin/src/test/kotlin/IntegrationTests.kt: -------------------------------------------------------------------------------- 1 | import io.koalaql.markout.md.markdown 2 | import kotlin.test.Test 3 | import kotlin.test.assertEquals 4 | 5 | class IntegrationTests { 6 | @Test 7 | fun `integrates with kapshot`() { 8 | assertEquals( 9 | """ 10 | ```kotlin 11 | h1("test") 12 | 13 | 1 + 2 14 | ``` 15 | 16 | # test 17 | """.trimIndent(), 18 | markdown { 19 | val block = code { 20 | h1("test") 21 | 22 | 1 + 2 23 | } 24 | 25 | assertEquals(3, block.invoke()) 26 | } 27 | ) 28 | } 29 | } -------------------------------------------------------------------------------- /testing/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | include("markdown-plugin") --------------------------------------------------------------------------------