diff --git a/.editorconfig b/.editorconfig index 0f49d9d..5b75fc4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -32,6 +32,9 @@ indent_size = 4 ij_kotlin_name_count_to_use_star_import = 999 ij_kotlin_name_count_to_use_star_import_for_members = 999 ij_java_class_count_to_use_import_on_demand = 999 +ktlint_standard_trailing-comma-on-call-site=disabled +ktlint_standard_trailing-comma-on-declaration-site=disabled +ktlint_function_signature_wrapping_rule_always_with_minimum_parameters = 3 [*.kts] ij_kotlin_imports_layout = * @@ -40,5 +43,5 @@ ij_kotlin_name_count_to_use_star_import = 999 ij_kotlin_name_count_to_use_star_import_for_members = 999 ij_java_class_count_to_use_import_on_demand = 999 -[*.{tf, yml, yaml}] +[*.{tf,yml,yaml}] indent_size = 2 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..dd84ea7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 53dd2fb..5f95a3b 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -15,8 +15,9 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew + # Disable ios simulator tests for some modules for now because they are flaky - name: Build with Gradle - run: ./gradlew build --no-configuration-cache + run: ./gradlew build -x :history:iosSimulatorArm64Test -x :transform:iosSimulatorArm64Test --no-configuration-cache # Populates ARTIFACTORY_USERNAME and ARTIFACTORY_API_KEY with # temporary username/password for publishing to packages.atlassian.com diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b864742..b4e3ff1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -33,6 +33,6 @@ jobs: with: output-modes: environment - # Publishes to Artifactory only on push to the "release" branch. + # Publishes to Artifactory only when a tag is pushed - name: "Publish" run: ./gradlew assemble --no-configuration-cache && ./gradlew publish --no-configuration-cache --stacktrace diff --git a/.github/workflows/publish_ios.yml b/.github/workflows/publish_ios.yml new file mode 100644 index 0000000..d2a11da --- /dev/null +++ b/.github/workflows/publish_ios.yml @@ -0,0 +1,205 @@ +name: Create release and upload iOS artifacts + +on: + push: + branches: [ "release" ] + +jobs: + build: + runs-on: macos-latest + + permissions: + contents: write + id-token: write + + env: + SIGNING_KEY: ${{ secrets.SIGNING_KEY }} + SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} + + steps: + + - name: "Checkout sources" + uses: actions/checkout@v4 + + - name: "Setup Java" + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'adopt' + + - name: "Build xcframework" + run: ./gradlew assembleXCFramework + + - name: release + uses: actions/create-release@v1 + id: create_release + with: + release_name: ${{ steps.version.outputs.version }} + tag_name: ${{ github.ref }} + body: "Release ${{ github.ref }}" + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: "Release data:" + run: echo id ${{steps.create_release.outputs.id}} html_url ${{steps.create_release.outputs.html_url}} upload_url ${{steps.create_release.outputs.upload_url}} + + - name: "Prepare xcframework artifacts for collab module" + run: bash ${GITHUB_WORKSPACE}/gradle/pack_xcframework.sh collab ${{steps.create_release.outputs.html_url}} + + - name: "Prepare xcframework artifacts for history module" + run: bash ${GITHUB_WORKSPACE}/gradle/pack_xcframework.sh history ${{steps.create_release.outputs.html_url}} + + - name: "Prepare xcframework artifacts for model module" + run: bash ${GITHUB_WORKSPACE}/gradle/pack_xcframework.sh model ${{steps.create_release.outputs.html_url}} + + - name: "Prepare xcframework artifacts for state module" + run: bash ${GITHUB_WORKSPACE}/gradle/pack_xcframework.sh state ${{steps.create_release.outputs.html_url}} + + - name: "Prepare xcframework artifacts for test-builder module" + run: bash ${GITHUB_WORKSPACE}/gradle/pack_xcframework.sh test-builder ${{steps.create_release.outputs.html_url}} + + - name: "Prepare xcframework artifacts for transform module" + run: bash ${GITHUB_WORKSPACE}/gradle/pack_xcframework.sh transform ${{steps.create_release.outputs.html_url}} + + - name: "Prepare xcframework artifacts for util module" + run: bash ${GITHUB_WORKSPACE}/gradle/pack_xcframework.sh util ${{steps.create_release.outputs.html_url}} + + - name: upload xcframework artifacts for collab module + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./collab/build/collab.xcframework.zip + asset_name: collab.xcframework.zip + asset_content_type: application/zip + + - name: upload Package.swift artifacts for collab module + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./collab/build/Package.swift + asset_name: collab.package.swift + asset_content_type: text/plain + + - name: upload xcframework artifacts for history module + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./history/build/history.xcframework.zip + asset_name: history.xcframework.zip + asset_content_type: application/zip + + - name: upload Package.swift artifacts for history module + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./history/build/Package.swift + asset_name: history.package.swift + asset_content_type: text/plain + + - name: upload xcframework artifacts for model module + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./model/build/model.xcframework.zip + asset_name: model.xcframework.zip + asset_content_type: application/zip + + - name: upload Package.swift artifacts for model module + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./model/build/Package.swift + asset_name: model.package.swift + asset_content_type: text/plain + + - name: upload xcframework artifacts for state module + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./state/build/state.xcframework.zip + asset_name: state.xcframework.zip + asset_content_type: application/zip + + - name: upload Package.swift artifacts for state module + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./state/build/Package.swift + asset_name: state.package.swift + asset_content_type: text/plain + + - name: upload xcframework artifacts for test-builder module + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./test-builder/build/test_builder.xcframework.zip + asset_name: test-builder.xcframework.zip + asset_content_type: application/zip + + - name: upload Package.swift artifacts for test-builder module + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./test-builder/build/Package.swift + asset_name: test-builder.package.swift + asset_content_type: text/plain + + - name: upload xcframework artifacts for transform module + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./transform/build/transform.xcframework.zip + asset_name: transform.xcframework.zip + asset_content_type: application/zip + + - name: upload Package.swift artifacts for transform module + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./transform/build/Package.swift + asset_name: transform.package.swift + asset_content_type: text/plain + + - name: upload xcframework artifacts for util module + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./util/build/util.xcframework.zip + asset_name: util.xcframework.zip + asset_content_type: application/zip + + - name: upload Package.swift artifacts for util module + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./util/build/Package.swift + asset_name: util.package.swift + asset_content_type: text/plain diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 82e9699..a6e2d59 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,3 +16,12 @@ Prior to accepting your contributions we ask that you please follow the appropri * [CLA for corporate contributors](https://opensource.atlassian.com/corporate) * [CLA for individuals](https://opensource.atlassian.com/individual) + +## Releases +To create a release, follow these steps: +1. Update the version in the `build.gradle.kts` file. +2. Create a PR with these changes, targeting the `main` branch. +3. Once the PR is merged, create a PR to merge `main` into `release` branch. +4. Once the 2nd PR is merged, the CI/CD pipeline will create a release, upload iOS assets and publish Android binaries to Artifactory. +5. Create a new tag with the format `v*` (e.g. `v1.1.0`) on the `release` branch. +4. Once the release is created, edit the release in the Github UI to add release notes for this release (using `Generate release notes`). diff --git a/README.md b/README.md index db426f1..7ba85af 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,25 @@ Java/Kotlin implementation of [Prosemirror](https://prosemirror.net/) Contributions to prosemirror-kotlin are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details. +## Maven / Gradle dependency +Add prosemirror-kotlin dependencies using prosemirror. in place of the normal artifact specifier. + +Check the latest packages at Maven central on: https://packages.atlassian.com/maven-central/com/atlassian/prosemirror/. + +### Maven: +```xml + + com.atlassian.prosemirror + {moduleIdentifier} + {latest} + +``` + +### Gradle: +```kotlin +implementation("com.atlassian.prosemirror:{moduleIdentifier}:{latest}") // this version number should be updated to update the version of prosemirror-kotlin +``` + ## License Copyright (c) 2024 Atlassian and others. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..fa80b9e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,12 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 1.0.0 | :x: | +| > 1.0.0 | :white_check_mark: | + +## Reporting a Vulnerability + +Please report Vulnerability on the issues section. diff --git a/build.gradle.kts b/build.gradle.kts index bc1d946..24f9ef0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.kotlinMultiplatform).apply(false) alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ktlint) alias(libs.plugins.dokka) } @@ -20,7 +21,7 @@ dependencies { allprojects { group = "com.atlassian.prosemirror" - version = "1.0.4" + version = "1.1.1" } val javaVersion = JavaVersion.VERSION_17 diff --git a/collab/README.md b/collab/README.md index 1e1b491..3f47409 100644 --- a/collab/README.md +++ b/collab/README.md @@ -1,21 +1,3 @@ This module implements an API into which a communication channel for collaborative editing can be hooked. See [the guide](/docs/guide/#collab) for more details and an example. - -## Maven / Gradle dependency - -Check the latest package at Maven central on: https://packages.atlassian.com/maven-central/com/atlassian/prosemirror/collab. - -### Maven: -```xml - - com.atlassian.prosemirror - collab - 1.0.2 - -``` - -### Gradle: -```kotlin -implementation("com.atlassian.prosemirror:collab:1.0.2") -``` diff --git a/collab/build.gradle.kts b/collab/build.gradle.kts index 8e8b65d..4b648d2 100644 --- a/collab/build.gradle.kts +++ b/collab/build.gradle.kts @@ -1,103 +1,148 @@ +import java.net.URL +import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework + plugins { - alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.ktlint) alias(libs.plugins.dokka) id("maven-publish") id("signing") } -repositories { - mavenCentral() -} +kotlin { + // Java + jvm { + withJava() + testRuns["test"].executionTask.configure { + useJUnitPlatform() + } + } -java { - withSourcesJar() - withJavadocJar() -} + // iOS + val xcframeworkName = "collab" + val xcf = XCFramework(xcframeworkName) + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ).forEach { + it.binaries.framework { + baseName = xcframeworkName + binaryOption("bundleId", "com.atlassian.prosemirror.$xcframeworkName") + xcf.add(this) + isStatic = true + } + } -dependencies { - implementation(libs.kotlin.stdlib) - implementation(project(":state")) - implementation(project(":transform")) - implementation(project(":model")) - testImplementation(project(":test-builder")) - testImplementation(kotlin("test")) - testImplementation(libs.test.assertj) + sourceSets { + commonMain.dependencies { + implementation(project(":model")) + implementation(project(":state")) + implementation(project(":transform")) + } + commonTest.dependencies { + implementation(project(":test-builder")) + implementation(libs.kotlin.test) + implementation(libs.test.assertk) + } + } -} + tasks.dokkaHtml { + dokkaSourceSets { + val commonMain by getting { + sourceLink { + // Unix based directory relative path to the root of the project (where you execute gradle respectively). + localDirectory.set(file("src/commonMain/kotlin")) -description = "prosemirror-collab" + // URL showing where the source code can be accessed through the web browser + remoteUrl.set(URL("https://github.com/atlassian-labs/prosemirror-kotlin/collab/src/main/src/commonMain/kotlin")) -val javaVersion = JavaVersion.VERSION_17 + // Suffix which is used to append the line number to the URL. Use #L for GitHub + remoteLineSuffix.set("#lines-") + } + } -tasks.withType { - options.encoding = "UTF-8" - sourceCompatibility = javaVersion.toString() - targetCompatibility = javaVersion.toString() -} + val jvmMain by getting { + sourceLink { + // Unix based directory relative path to the root of the project (where you execute gradle respectively). + localDirectory.set(file("src/jvmMain/kotlin")) -tasks { + // URL showing where the source code can be accessed through the web browser + remoteUrl.set(URL("https://github.com/atlassian-labs/prosemirror-kotlin/collab/src/main/src/jvmMain/kotlin")) - jar { - archiveBaseName.set("prosemirror-collab") - } + // Suffix which is used to append the line number to the URL. Use #L for GitHub + remoteLineSuffix.set("#lines-") + } + } - // This task is added by Gradle when we use java.withJavadocJar() - named("javadocJar") { - from(dokkaJavadoc) - } + val nativeMain by getting { + sourceLink { + // Unix based directory relative path to the root of the project (where you execute gradle respectively). + localDirectory.set(file("src/nativeMain/kotlin")) - test { - useJUnitPlatform() + // URL showing where the source code can be accessed through the web browser + remoteUrl.set(URL("https://github.com/atlassian-labs/prosemirror-kotlin/collab/src/main/src/nativeMain/kotlin/")) + + // Suffix which is used to append the line number to the URL. Use #L for GitHub + remoteLineSuffix.set("#lines-") + } + } + } } +} + +description = "prosemirror-state" - publishing { - publications { - create("release") { - from(project.components["java"]) - pom { - packaging = "jar" - name.set(project.name) - description.set("Collaborative editing for ProseMirror") - url.set("https://github.com/atlassian-labs/prosemirror-kotlin/tree/collab/") - scm { - connection.set("git@github.com:atlassian-labs/prosemirror-kotlin.git") - url.set("https://github.com/atlassian-labs/prosemirror-kotlin.git") +publishing { + publications { + publications.withType { + pom { + name.set(project.name) + description.set("Collaborative editing for ProseMirror") + url.set("https://github.com/atlassian-labs/prosemirror-kotlin/tree/collab/") + + scm { + connection.set("git@github.com:atlassian-labs/prosemirror-kotlin.git") + url.set("https://github.com/atlassian-labs/prosemirror-kotlin.git") + } + developers { + developer { + id.set("dmarques") + name.set("Douglas Marques") + email.set("dmarques@atlassian.com") } - developers { - developer { - id.set("dmarques") - name.set("Douglas Marques") - email.set("dmarques@atlassian.com") - } + developer { + id.set("achernykh") + name.set("Aleksei Chernykh") + email.set("achernykh@atlassian.com") } - licenses { - license { - name.set("Apache License 2.0") - url.set("https://www.apache.org/licenses/LICENSE-2.0") - distribution.set("repo") - } + } + licenses { + license { + name.set("Apache License 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0") + distribution.set("repo") } } } } + } - repositories { - maven { - url = uri("https://packages.atlassian.com/maven-central") - credentials { - username = System.getenv("ARTIFACTORY_USERNAME") - password = System.getenv("ARTIFACTORY_API_KEY") - } + repositories { + maven { + url = uri("https://packages.atlassian.com/maven-central") + credentials { + username = System.getenv("ARTIFACTORY_USERNAME") + password = System.getenv("ARTIFACTORY_API_KEY") } } } +} - signing { - useInMemoryPgpKeys( - System.getenv("SIGNING_KEY"), - System.getenv("SIGNING_PASSWORD"), - ) - sign(publishing.publications["release"]) - } +signing { + useInMemoryPgpKeys( + System.getenv("SIGNING_KEY"), + System.getenv("SIGNING_PASSWORD"), + ) + sign(publishing.publications) } diff --git a/collab/config/ktlint/baseline.xml b/collab/config/ktlint/baseline.xml index d10d306..8a93630 100644 --- a/collab/config/ktlint/baseline.xml +++ b/collab/config/ktlint/baseline.xml @@ -1,92 +1,54 @@ - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/collab/src/main/kotlin/com/atlassian/prosemirror/collab/Collab.kt b/collab/src/commonMain/kotlin/com/atlassian/prosemirror/collab/Collab.kt similarity index 98% rename from collab/src/main/kotlin/com/atlassian/prosemirror/collab/Collab.kt rename to collab/src/commonMain/kotlin/com/atlassian/prosemirror/collab/Collab.kt index 3bc1fdf..7ae16b5 100644 --- a/collab/src/main/kotlin/com/atlassian/prosemirror/collab/Collab.kt +++ b/collab/src/commonMain/kotlin/com/atlassian/prosemirror/collab/Collab.kt @@ -102,7 +102,7 @@ class CollabPlugin( CollabPluginSpec( CollabConfig( version = config.version, - clientID = config.clientID ?: Random.nextInt(0, Integer.MAX_VALUE).toString() + clientID = config.clientID ?: Random.nextInt().toString() ) ) ) diff --git a/collab/src/main/kotlin/com/atlassian/prosemirror/collab/collab.ts b/collab/src/commonMain/kotlin/com/atlassian/prosemirror/collab/collab.ts similarity index 100% rename from collab/src/main/kotlin/com/atlassian/prosemirror/collab/collab.ts rename to collab/src/commonMain/kotlin/com/atlassian/prosemirror/collab/collab.ts diff --git a/collab/src/test/kotlin/com/atlassian/prosemirror/collab/CollabTest.kt b/collab/src/commonTest/kotlin/com/atlassian/prosemirror/collab/CollabTest.kt similarity index 97% rename from collab/src/test/kotlin/com/atlassian/prosemirror/collab/CollabTest.kt rename to collab/src/commonTest/kotlin/com/atlassian/prosemirror/collab/CollabTest.kt index cf6a5e6..a3bc881 100644 --- a/collab/src/test/kotlin/com/atlassian/prosemirror/collab/CollabTest.kt +++ b/collab/src/commonTest/kotlin/com/atlassian/prosemirror/collab/CollabTest.kt @@ -1,5 +1,7 @@ package com.atlassian.prosemirror.collab +import assertk.assertThat +import assertk.assertions.isEqualTo import com.atlassian.prosemirror.model.Node import com.atlassian.prosemirror.state.EmptyEditorStateConfig import com.atlassian.prosemirror.state.PMEditorState @@ -10,7 +12,6 @@ import com.atlassian.prosemirror.testbuilder.PMNodeBuilder.Companion.doc import com.atlassian.prosemirror.testbuilder.schema import com.atlassian.prosemirror.transform.Step import kotlin.test.Test -import org.assertj.core.api.Assertions.assertThat class DummyServer { val states = mutableListOf() @@ -25,7 +26,7 @@ class DummyServer { this.plugins.add(plugin) this.states.add( PMEditorState.create( - EmptyEditorStateConfig(doc = doc, schema = schema, plugins = listOf(plugin)) + EmptyEditorStateConfig(doc = doc, schema = schema, plugins = listOf(plugin)) ) ) } diff --git a/collab/src/test/kotlin/com/atlassian/prosemirror/collab/RebaseTest.kt b/collab/src/commonTest/kotlin/com/atlassian/prosemirror/collab/RebaseTest.kt similarity index 99% rename from collab/src/test/kotlin/com/atlassian/prosemirror/collab/RebaseTest.kt rename to collab/src/commonTest/kotlin/com/atlassian/prosemirror/collab/RebaseTest.kt index 42d4fe2..3f295fb 100644 --- a/collab/src/test/kotlin/com/atlassian/prosemirror/collab/RebaseTest.kt +++ b/collab/src/commonTest/kotlin/com/atlassian/prosemirror/collab/RebaseTest.kt @@ -1,5 +1,7 @@ package com.atlassian.prosemirror.collab +import assertk.assertThat +import assertk.assertions.isEqualTo import com.atlassian.prosemirror.model.Node import com.atlassian.prosemirror.model.NodeBase import com.atlassian.prosemirror.testbuilder.PMNodeBuilder.Companion.doc @@ -7,7 +9,6 @@ import com.atlassian.prosemirror.testbuilder.PMNodeBuilder.Companion.pos import com.atlassian.prosemirror.testbuilder.PMNodeBuilder.Companion.tags import com.atlassian.prosemirror.testbuilder.schema import com.atlassian.prosemirror.transform.Transform -import org.assertj.core.api.Assertions.assertThat import kotlin.test.Test fun runRebase(transforms: List, expected: Node) { diff --git a/collab/src/test/kotlin/com/atlassian/prosemirror/collab/test-collab.ts b/collab/src/commonTest/kotlin/com/atlassian/prosemirror/collab/test-collab.ts similarity index 100% rename from collab/src/test/kotlin/com/atlassian/prosemirror/collab/test-collab.ts rename to collab/src/commonTest/kotlin/com/atlassian/prosemirror/collab/test-collab.ts diff --git a/collab/src/test/kotlin/com/atlassian/prosemirror/collab/test-rebase.ts b/collab/src/commonTest/kotlin/com/atlassian/prosemirror/collab/test-rebase.ts similarity index 100% rename from collab/src/test/kotlin/com/atlassian/prosemirror/collab/test-rebase.ts rename to collab/src/commonTest/kotlin/com/atlassian/prosemirror/collab/test-rebase.ts diff --git a/config/ktlint/baseline.xml b/config/ktlint/baseline.xml index 5373025..9814207 100644 --- a/config/ktlint/baseline.xml +++ b/config/ktlint/baseline.xml @@ -1,6 +1,3 @@ - - - diff --git a/gradle.properties b/gradle.properties index 56e69e7..c624e5a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ #Gradle -org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M" +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx4g" org.gradle.caching=true org.gradle.configuration-cache=true org.gradle.daemon=true diff --git a/gradle/pack_xcframework.sh b/gradle/pack_xcframework.sh new file mode 100644 index 0000000..f6a5dcd --- /dev/null +++ b/gradle/pack_xcframework.sh @@ -0,0 +1,13 @@ +#!/bin/bash +module=$1 +file_name=${module//[-]/_} +release_url=$2 +url_prefix=${release_url//tag/download} +zip -r ${module}/build/${file_name}.xcframework.zip ${module}/build/XCFrameworks/release/${file_name}.xcframework +# Prepare Package.swift file +CHECKSUM=`swift package compute-checksum ${module}/build/${file_name}.xcframework.zip` + +export MODULE=$module +export CHECKSUM=$CHECKSUM +export XCFRAMEWORK_URL="$url_prefix/${file_name}.xcframework.zip" +cat gradle/templates/package.swift.template | envsubst '$MODULE $CHECKSUM $XCFRAMEWORK_URL' > ${module}/build/Package.swift diff --git a/gradle/templates/package.swift.template b/gradle/templates/package.swift.template new file mode 100644 index 0000000..fc7031b --- /dev/null +++ b/gradle/templates/package.swift.template @@ -0,0 +1,18 @@ +// swift-tools-version:5.3 +import PackageDescription + +let package = Package( + name: "$MODULE", + platforms: [ + .iOS(.v14), + ], + products: [ + .library(name: "$MODULE", targets: ["$MODULE"]) + ], + targets: [ + .binaryTarget( + name: "$MODULE", + url: "$XCFRAMEWORK_URL", + checksum:"$CHECKSUM") + ] +) diff --git a/history/README.md b/history/README.md index b39fe2c..05b1af1 100644 --- a/history/README.md +++ b/history/README.md @@ -4,25 +4,6 @@ previous state but can undo some changes while keeping other, later changes intact. (This is necessary for collaborative editing, and comes up in other situations as well.) -## Maven / Gradle dependency - -Check the latest package at Maven central on: https://packages.atlassian.com/maven-central/com/atlassian/prosemirror/history. - -### Maven: -```xml - - com.atlassian.prosemirror - history - 1.0.2 - -``` - -### Gradle: -```kotlin -implementation("com.atlassian.prosemirror:history:1.0.2") -``` - -### Versioning - -- Implemented changes to match version [1.4.1](https://github.com/ProseMirror/prosemirror-history/releases/tag/1.4.1) +## Versioning +This module is a port of version [1.4.1](https://github.com/ProseMirror/prosemirror-history/releases/tag/1.4.1) of prosemirror-history diff --git a/history/build.gradle.kts b/history/build.gradle.kts index b9f7afc..5a0e14d 100644 --- a/history/build.gradle.kts +++ b/history/build.gradle.kts @@ -1,105 +1,150 @@ +import java.net.URL +import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework + plugins { - alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.ktlint) alias(libs.plugins.dokka) id("maven-publish") id("signing") } -repositories { - mavenCentral() -} +kotlin { + // Java + jvm { + withJava() + testRuns["test"].executionTask.configure { + useJUnitPlatform() + } + } -java { - withSourcesJar() - withJavadocJar() -} + // iOS + val xcframeworkName = "history" + val xcf = XCFramework(xcframeworkName) + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ).forEach { + it.binaries.framework { + baseName = xcframeworkName + binaryOption("bundleId", "com.atlassian.prosemirror.$xcframeworkName") + xcf.add(this) + isStatic = true + } + } -dependencies { - implementation(libs.kotlin.stdlib) - implementation(libs.kotlinx.serialization.json) - implementation(project(":state")) - implementation(project(":transform")) - implementation(project(":util")) - implementation(project(":model")) - testImplementation(project(":test-builder")) - testImplementation(kotlin("test")) - testImplementation(libs.test.assertj) + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.json) + implementation(project(":model")) + implementation(project(":state")) + implementation(project(":transform")) + implementation(project(":util")) + } + commonTest.dependencies { + implementation(project(":test-builder")) + implementation(libs.kotlin.test) + implementation(libs.test.assertk) + } + } -} + tasks.dokkaHtml { + dokkaSourceSets { + val commonMain by getting { + sourceLink { + // Unix based directory relative path to the root of the project (where you execute gradle respectively). + localDirectory.set(file("src/commonMain/kotlin")) -description = "prosemirror-history" + // URL showing where the source code can be accessed through the web browser + remoteUrl.set(URL("https://github.com/atlassian-labs/prosemirror-kotlin/history/src/main/src/commonMain/kotlin")) -val javaVersion = JavaVersion.VERSION_17 + // Suffix which is used to append the line number to the URL. Use #L for GitHub + remoteLineSuffix.set("#lines-") + } + } -tasks.withType { - options.encoding = "UTF-8" - sourceCompatibility = javaVersion.toString() - targetCompatibility = javaVersion.toString() -} + val jvmMain by getting { + sourceLink { + // Unix based directory relative path to the root of the project (where you execute gradle respectively). + localDirectory.set(file("src/jvmMain/kotlin")) -tasks { + // URL showing where the source code can be accessed through the web browser + remoteUrl.set(URL("https://github.com/atlassian-labs/prosemirror-kotlin/history/src/main/src/jvmMain/kotlin")) - jar { - archiveBaseName.set("prosemirror-history") - } + // Suffix which is used to append the line number to the URL. Use #L for GitHub + remoteLineSuffix.set("#lines-") + } + } - // This task is added by Gradle when we use java.withJavadocJar() - named("javadocJar") { - from(dokkaJavadoc) - } + val nativeMain by getting { + sourceLink { + // Unix based directory relative path to the root of the project (where you execute gradle respectively). + localDirectory.set(file("src/nativeMain/kotlin")) - test { - useJUnitPlatform() + // URL showing where the source code can be accessed through the web browser + remoteUrl.set(URL("https://github.com/atlassian-labs/prosemirror-kotlin/history/src/main/src/nativeMain/kotlin/")) + + // Suffix which is used to append the line number to the URL. Use #L for GitHub + remoteLineSuffix.set("#lines-") + } + } + } } +} + +description = "prosemirror-history" + +publishing { + publications { + publications.withType { + pom { + name.set(project.name) + description.set("Undo history for ProseMirror") + url.set("https://github.com/atlassian-labs/prosemirror-kotlin/tree/history/") - publishing { - publications { - create("release") { - from(project.components["java"]) - pom { - packaging = "jar" - name.set(project.name) - description.set("Undo history for ProseMirror") - url.set("https://github.com/atlassian-labs/prosemirror-kotlin/tree/history/") - scm { - connection.set("git@github.com:atlassian-labs/prosemirror-kotlin.git") - url.set("https://github.com/atlassian-labs/prosemirror-kotlin.git") + scm { + connection.set("git@github.com:atlassian-labs/prosemirror-kotlin.git") + url.set("https://github.com/atlassian-labs/prosemirror-kotlin.git") + } + developers { + developer { + id.set("dmarques") + name.set("Douglas Marques") + email.set("dmarques@atlassian.com") } - developers { - developer { - id.set("dmarques") - name.set("Douglas Marques") - email.set("dmarques@atlassian.com") - } + developer { + id.set("achernykh") + name.set("Aleksei Chernykh") + email.set("achernykh@atlassian.com") } - licenses { - license { - name.set("Apache License 2.0") - url.set("https://www.apache.org/licenses/LICENSE-2.0") - distribution.set("repo") - } + } + licenses { + license { + name.set("Apache License 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0") + distribution.set("repo") } } } } + } - repositories { - maven { - url = uri("https://packages.atlassian.com/maven-central") - credentials { - username = System.getenv("ARTIFACTORY_USERNAME") - password = System.getenv("ARTIFACTORY_API_KEY") - } + repositories { + maven { + url = uri("https://packages.atlassian.com/maven-central") + credentials { + username = System.getenv("ARTIFACTORY_USERNAME") + password = System.getenv("ARTIFACTORY_API_KEY") } } } +} - signing { - useInMemoryPgpKeys( - System.getenv("SIGNING_KEY"), - System.getenv("SIGNING_PASSWORD"), - ) - sign(publishing.publications["release"]) - } +signing { + useInMemoryPgpKeys( + System.getenv("SIGNING_KEY"), + System.getenv("SIGNING_PASSWORD"), + ) + sign(publishing.publications) } diff --git a/history/config/ktlint/baseline.xml b/history/config/ktlint/baseline.xml index 30ef56d..1a2951f 100644 --- a/history/config/ktlint/baseline.xml +++ b/history/config/ktlint/baseline.xml @@ -1,19 +1,13 @@ - - - - + - - - @@ -22,25 +16,15 @@ - - - - - - - - - - @@ -53,23 +37,18 @@ - - - - - - + @@ -94,7 +73,6 @@ - @@ -109,13 +87,11 @@ - - @@ -125,56 +101,51 @@ - + - - + - - - - + + - - + - - - - + + + + - - - - + + + + - + - @@ -182,8 +153,8 @@ + - @@ -192,8 +163,8 @@ + - @@ -210,8 +181,8 @@ + - @@ -222,30 +193,28 @@ + - + - - - + - - + + - @@ -253,38 +222,38 @@ + - - + + - - - - + + + - + + - - + @@ -293,16 +262,16 @@ + - + - + - @@ -313,13 +282,13 @@ + - - + @@ -330,23 +299,23 @@ + - - + + - @@ -368,12 +337,11 @@ - + - @@ -381,8 +349,8 @@ + - @@ -407,40 +375,40 @@ + - - + + - - + + - @@ -451,8 +419,8 @@ + - @@ -467,16 +435,16 @@ + - + - @@ -484,8 +452,8 @@ + - @@ -501,15 +469,15 @@ + - - - + + @@ -517,9 +485,7 @@ - - @@ -527,15 +493,15 @@ + - - - + + @@ -544,14 +510,12 @@ - - - + @@ -561,15 +525,15 @@ + - - - + + @@ -578,34 +542,32 @@ - - - + - + + - + - + - @@ -616,8 +578,8 @@ + - @@ -629,26 +591,26 @@ + - + - + - - + @@ -657,31 +619,29 @@ + + - - + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/history/src/main/kotlin/com/atlassian/prosemirror/history/History.kt b/history/src/commonMain/kotlin/com/atlassian/prosemirror/history/History.kt similarity index 100% rename from history/src/main/kotlin/com/atlassian/prosemirror/history/History.kt rename to history/src/commonMain/kotlin/com/atlassian/prosemirror/history/History.kt diff --git a/history/src/main/kotlin/com/atlassian/prosemirror/history/history.ts b/history/src/commonMain/kotlin/com/atlassian/prosemirror/history/history.ts similarity index 100% rename from history/src/main/kotlin/com/atlassian/prosemirror/history/history.ts rename to history/src/commonMain/kotlin/com/atlassian/prosemirror/history/history.ts diff --git a/history/src/main/kotlin/com/atlassian/prosemirror/history/ropesequence/README.md b/history/src/commonMain/kotlin/com/atlassian/prosemirror/history/ropesequence/README.md similarity index 100% rename from history/src/main/kotlin/com/atlassian/prosemirror/history/ropesequence/README.md rename to history/src/commonMain/kotlin/com/atlassian/prosemirror/history/ropesequence/README.md diff --git a/history/src/main/kotlin/com/atlassian/prosemirror/history/ropesequence/RopeSequence.kt b/history/src/commonMain/kotlin/com/atlassian/prosemirror/history/ropesequence/RopeSequence.kt similarity index 97% rename from history/src/main/kotlin/com/atlassian/prosemirror/history/ropesequence/RopeSequence.kt rename to history/src/commonMain/kotlin/com/atlassian/prosemirror/history/ropesequence/RopeSequence.kt index f54c965..712ac4a 100644 --- a/history/src/main/kotlin/com/atlassian/prosemirror/history/ropesequence/RopeSequence.kt +++ b/history/src/commonMain/kotlin/com/atlassian/prosemirror/history/ropesequence/RopeSequence.kt @@ -74,7 +74,7 @@ abstract class RopeSequence { // Create a rope repesenting a sub-sequence of this rope. fun slice(from: Int = 0, to: Int = this.length): RopeSequence { if (from >= to) return empty() - return this.sliceInner(Math.max(0, from), Math.min(this.length, to)) + return this.sliceInner(max(0, from), min(this.length, to)) } // :: (number) → T @@ -194,7 +194,7 @@ class Append( val right: RopeSequence ) : RopeSequence() { override val length = left.length + right.length - override val depth = Math.max(left.depth, right.depth) + 1 + override val depth = max(left.depth, right.depth) + 1 override fun flatten(): List { return this.left.flatten() + this.right.flatten() @@ -272,7 +272,7 @@ class Append( } override fun toString(): String { - return "Append {left: ${left.javaClass.simpleName}, right: ${right.javaClass.simpleName}, " + + return "Append {left: ${left::class.simpleName}, right: ${right::class.simpleName}, " + "lenght: $length, depth: $depth}" } } diff --git a/history/src/main/kotlin/com/atlassian/prosemirror/history/ropesequence/index.js b/history/src/commonMain/kotlin/com/atlassian/prosemirror/history/ropesequence/index.js similarity index 100% rename from history/src/main/kotlin/com/atlassian/prosemirror/history/ropesequence/index.js rename to history/src/commonMain/kotlin/com/atlassian/prosemirror/history/ropesequence/index.js diff --git a/history/src/test/kotlin/com/atlassian/prosemirror/history/HistoryTest.kt b/history/src/commonTest/kotlin/com/atlassian/prosemirror/history/HistoryTest.kt similarity index 99% rename from history/src/test/kotlin/com/atlassian/prosemirror/history/HistoryTest.kt rename to history/src/commonTest/kotlin/com/atlassian/prosemirror/history/HistoryTest.kt index 4685e77..a78c1f5 100644 --- a/history/src/test/kotlin/com/atlassian/prosemirror/history/HistoryTest.kt +++ b/history/src/commonTest/kotlin/com/atlassian/prosemirror/history/HistoryTest.kt @@ -1,6 +1,9 @@ package com.atlassian.prosemirror.history +import assertk.assertThat import com.atlassian.prosemirror.testbuilder.schema as testSchema +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse import com.atlassian.prosemirror.model.Fragment import com.atlassian.prosemirror.model.Node import com.atlassian.prosemirror.model.Slice @@ -13,7 +16,6 @@ import com.atlassian.prosemirror.state.Transaction import com.atlassian.prosemirror.testbuilder.PMNodeBuilder.Companion.doc import com.atlassian.prosemirror.testbuilder.PMNodeBuilder.Companion.schema import com.atlassian.prosemirror.transform.ReplaceStep -import org.assertj.core.api.Assertions.assertThat import kotlin.test.Test class HistoryTest { diff --git a/history/src/test/kotlin/com/atlassian/prosemirror/history/TestPlugin.kt b/history/src/commonTest/kotlin/com/atlassian/prosemirror/history/TestPlugin.kt similarity index 100% rename from history/src/test/kotlin/com/atlassian/prosemirror/history/TestPlugin.kt rename to history/src/commonTest/kotlin/com/atlassian/prosemirror/history/TestPlugin.kt diff --git a/history/src/test/kotlin/com/atlassian/prosemirror/history/ropesequence/RopeSequenceTest.kt b/history/src/commonTest/kotlin/com/atlassian/prosemirror/history/ropesequence/RopeSequenceTest.kt similarity index 55% rename from history/src/test/kotlin/com/atlassian/prosemirror/history/ropesequence/RopeSequenceTest.kt rename to history/src/commonTest/kotlin/com/atlassian/prosemirror/history/ropesequence/RopeSequenceTest.kt index d64bf85..063d522 100644 --- a/history/src/test/kotlin/com/atlassian/prosemirror/history/ropesequence/RopeSequenceTest.kt +++ b/history/src/commonTest/kotlin/com/atlassian/prosemirror/history/ropesequence/RopeSequenceTest.kt @@ -1,10 +1,11 @@ package com.atlassian.prosemirror.history.ropesequence -import org.assertj.core.api.Assertions.assertThat -import kotlin.test.Test -import kotlin.math.ceil +import assertk.assertThat +import assertk.assertions.isEqualTo import kotlin.math.floor import kotlin.math.min +import kotlin.random.Random +import kotlin.test.Test class RopeSequenceTest { @@ -40,39 +41,42 @@ class RopeSequenceTest { fun checkForEach(rope: RopeSequence, name: String, start: Int, end: Int, offset: Int) { var cur = start rope.forEach({ elt, i -> - assertThat(elt).overridingErrorMessage("Proper element at $cur in $name").isEqualTo(cur + offset) - assertThat(cur).overridingErrorMessage("Accurate index passed").isEqualTo(i) + assertThat(elt, displayActual = { "Proper element at $cur in $name. Expected ${cur + offset} but was $it." }) + .isEqualTo(cur + offset) + assertThat(cur, displayActual = { "Accurate index passed. Expected $i but was $it." }).isEqualTo(i) cur += 1 true }, start, end) - assertThat(cur).overridingErrorMessage("Enough elements iterated in $name").isEqualTo(end) + assertThat(cur, displayActual = { "Enough elements iterated in $name. Expected $end but was $it." }).isEqualTo(end) rope.forEach({ elt, i -> cur -= 1 - assertThat(elt) - .overridingErrorMessage("Proper element during reverse iter at $cur in $name") - .isEqualTo(cur + offset) - assertThat(cur) - .overridingErrorMessage("Accurate index passed by reverse iter") + assertThat( + elt, + displayActual = { "Proper element during reverse iter at $cur in $name. Expected ${cur + offset} but was $it." } + ) + .isEqualTo(cur + offset) + assertThat(cur, displayActual = { "Accurate index passed by reverse iter. Expected $i but was $it." }) .isEqualTo(i) true }, end, start) - assertThat(cur) - .overridingErrorMessage("Enough elements reverse-iterated in $name -- $cur $start") + assertThat(cur, displayActual = { "Enough elements reverse-iterated in $name -- $cur $start. Expected $start but was $it." }) .isEqualTo(start) } fun check(rope: RopeSequence, size: Int, name: String, offset: Int = 0) { - assertThat(rope.length) - .overridingErrorMessage("Size of $name should be ${rope.length} but was $size") + assertThat(rope.length, displayActual = { "Size of $name should be ${rope.length} but was $size." }) .isEqualTo(size) for (i in 0 until rope.length) { - assertThat(rope.get(i)).overridingErrorMessage("Field at $i in $name").isEqualTo(offset + i) + assertThat( + rope.get(i), + displayActual = { "Field at $i in $name. Expected ${offset + i} but was $it." } + ).isEqualTo(offset + i) } checkForEach(rope, name, 0, rope.length, offset) val e = min(10, floor(size.toDouble() / 100).toInt()) for (i in 0 until e) { - var start = floor(Math.random() * size).toInt() - val end = start + ceil(Math.random() * (size - start)).toInt() + val start = Random.nextInt(size - 1) + val end = start + Random.nextInt(size - start) checkForEach(rope, "$name-$start-$end", start, end, offset) check(rope.slice(start, end), end - start, "$name-sliced-$start-$end", offset + start) } @@ -97,10 +101,10 @@ class RopeSequenceTest { fun checkSmalAndEmpty() { val small = RopeSequence.from(listOf(1, 2, 4)) val empty = RopeSequence.empty() - assertThat(small.append(empty)).overridingErrorMessage("ID append").isEqualTo(small) - assertThat(small.prepend(empty)).overridingErrorMessage("ID prepend").isEqualTo(small) - assertThat(empty.append(empty)).overridingErrorMessage("empty append").isEqualTo(empty) - assertThat(small.slice(0, 0)).overridingErrorMessage("empty slice").isEqualTo(empty) + assertThat(small.append(empty), displayActual = { "ID append. Expected $small but was $it." }).isEqualTo(small) + assertThat(small.prepend(empty), displayActual = { "ID prepend. Expected $small but was $it." }).isEqualTo(small) + assertThat(empty.append(empty), displayActual = { "Empty append. Expected $empty but was $it." }).isEqualTo(empty) + assertThat(small.slice(0, 0), displayActual = { "Empty slice. Expected $empty but was $it." }).isEqualTo(empty) var sum = 0 small.forEach( @@ -113,9 +117,12 @@ class RopeSequenceTest { } } ) - assertThat(sum).overridingErrorMessage("abort iteration").isEqualTo(1) + assertThat(sum, displayActual = { "abort iteration. Expected 1 but was $it." }).isEqualTo(1) - assertThat(small.map({ x, _ -> x + 1 })).overridingErrorMessage("mapping").isEqualTo(listOf(2, 3, 5)) + assertThat( + small.map({ x, _ -> x + 1 }), + displayActual = { "mapping. Expected ${listOf(2, 3, 5)} but was $it." } + ).isEqualTo(listOf(2, 3, 5)) } companion object { diff --git a/history/src/test/kotlin/com/atlassian/prosemirror/history/ropesequence/test.js b/history/src/commonTest/kotlin/com/atlassian/prosemirror/history/ropesequence/test.js similarity index 100% rename from history/src/test/kotlin/com/atlassian/prosemirror/history/ropesequence/test.js rename to history/src/commonTest/kotlin/com/atlassian/prosemirror/history/ropesequence/test.js diff --git a/history/src/test/kotlin/com/atlassian/prosemirror/history/test-history.ts b/history/src/commonTest/kotlin/com/atlassian/prosemirror/history/test-history.ts similarity index 100% rename from history/src/test/kotlin/com/atlassian/prosemirror/history/test-history.ts rename to history/src/commonTest/kotlin/com/atlassian/prosemirror/history/test-history.ts diff --git a/libs.versions.toml b/libs.versions.toml index 4ba5490..544c85d 100644 --- a/libs.versions.toml +++ b/libs.versions.toml @@ -1,24 +1,31 @@ [versions] +assertk = "0.28.1" +atomicfu = "0.25.0" dokkaPlugin = "1.9.20" -kotlin = "2.0.0" -ktlintPlugin = "12.1.1" +kotlin = "2.0.20" +kotlinDatetime = "0.6.1" kotlinxJson = "1.6.3" +ktlintPlugin = "12.1.1" +ksoup="0.1.6-alpha1" +statelyConcurrency = "2.0.0" [libraries] # kotlin kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } +kotlin-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinDatetime" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxJson" } -jsoup = { module = "org.jsoup:jsoup", version = "1.17.2" } +ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } -test-assertk = { module = "com.willowtreeapps.assertk:assertk", version = "0.28.1" } +stately-concurrent-collections = { module = "co.touchlab:stately-concurrent-collections", version.ref = "statelyConcurrency" } # test -test-assertj = { module = "org.assertj:assertj-core", version = "1.7.1" } # ignore-update +test-assertk = { module = "com.willowtreeapps.assertk:assertk", version.ref = "assertk" } [plugins] kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } -kotlinCocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" } +kotlin-atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicfu" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintPlugin" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokkaPlugin" } diff --git a/model/README.md b/model/README.md index a573693..f36b9d8 100644 --- a/model/README.md +++ b/model/README.md @@ -1,22 +1,6 @@ This is a core module of ProseMirror. ProseMirror is a well-behaved rich semantic content editor based on contentEditable, with support for collaborative editing and custom document schemas. -## Maven / Gradle dependency - -Check the latest package at Maven central on: https://packages.atlassian.com/maven-central/com/atlassian/prosemirror/model. - -### Maven: -```xml - - com.atlassian.prosemirror - model - 1.0.2 - -``` - -### Gradle: -```kotlin -implementation("com.atlassian.prosemirror:model:1.0.2") -``` - -### Versioning +## Versioning +This module is a port of version [1.22.3](https://github.com/ProseMirror/prosemirror-model/releases/tag/1.22.3) + of prosemirror-model diff --git a/model/build.gradle.kts b/model/build.gradle.kts index d515904..14e6d73 100644 --- a/model/build.gradle.kts +++ b/model/build.gradle.kts @@ -1,103 +1,151 @@ +import java.net.URL +import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework + plugins { - alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.kotlin.atomicfu) alias(libs.plugins.ktlint) alias(libs.plugins.dokka) + id("kotlinx-serialization") id("maven-publish") id("signing") } -repositories { - mavenCentral() -} +kotlin { + // Java + jvm { + withJava() + testRuns["test"].executionTask.configure { + useJUnitPlatform() + } + } -java { - withSourcesJar() - withJavadocJar() -} + // iOS + val xcframeworkName = "model" + val xcf = XCFramework(xcframeworkName) + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ).forEach { + it.binaries.framework { + baseName = xcframeworkName + binaryOption("bundleId", "com.atlassian.prosemirror.$xcframeworkName") + xcf.add(this) + isStatic = true + } + } -dependencies { - implementation(libs.kotlin.stdlib) - implementation(libs.kotlinx.serialization.json) - implementation(project(":util")) - api(libs.jsoup) - testImplementation(project(":test-builder")) - testImplementation(kotlin("test")) - testImplementation(libs.test.assertj) + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.json) + implementation(libs.stately.concurrent.collections) + implementation(project(":util")) + api(libs.ksoup) + } + commonTest.dependencies { + implementation(project(":test-builder")) + implementation(libs.kotlin.test) + implementation(libs.test.assertk) + } + } -} + tasks.dokkaHtml { + dokkaSourceSets { + val commonMain by getting { + sourceLink { + // Unix based directory relative path to the root of the project (where you execute gradle respectively). + localDirectory.set(file("src/commonMain/kotlin")) -description = "prosemirror-model" + // URL showing where the source code can be accessed through the web browser + remoteUrl.set(URL("https://github.com/atlassian-labs/prosemirror-kotlin/model/src/main/src/commonMain/kotlin")) -val javaVersion = JavaVersion.VERSION_17 + // Suffix which is used to append the line number to the URL. Use #L for GitHub + remoteLineSuffix.set("#lines-") + } + } -tasks.withType { - options.encoding = "UTF-8" - sourceCompatibility = javaVersion.toString() - targetCompatibility = javaVersion.toString() -} + val jvmMain by getting { + sourceLink { + // Unix based directory relative path to the root of the project (where you execute gradle respectively). + localDirectory.set(file("src/jvmMain/kotlin")) -tasks { + // URL showing where the source code can be accessed through the web browser + remoteUrl.set(URL("https://github.com/atlassian-labs/prosemirror-kotlin/model/src/main/src/jvmMain/kotlin")) - jar { - archiveBaseName.set("prosemirror-model") - } + // Suffix which is used to append the line number to the URL. Use #L for GitHub + remoteLineSuffix.set("#lines-") + } + } - // This task is added by Gradle when we use java.withJavadocJar() - named("javadocJar") { - from(dokkaJavadoc) - } + val nativeMain by getting { + sourceLink { + // Unix based directory relative path to the root of the project (where you execute gradle respectively). + localDirectory.set(file("src/nativeMain/kotlin")) - test { - useJUnitPlatform() + // URL showing where the source code can be accessed through the web browser + remoteUrl.set(URL("https://github.com/atlassian-labs/prosemirror-kotlin/model/src/main/src/nativeMain/kotlin/")) + + // Suffix which is used to append the line number to the URL. Use #L for GitHub + remoteLineSuffix.set("#lines-") + } + } + } } +} + +description = "prosemirror-model" + +publishing { + publications { + publications.withType { + pom { + name.set(project.name) + description.set("ProseMirror's document model") + url.set("https://github.com/atlassian-labs/prosemirror-kotlin/tree/model/") - publishing { - publications { - create("release") { - from(project.components["java"]) - pom { - packaging = "jar" - name.set(project.name) - description.set("ProseMirror's document model") - url.set("https://github.com/atlassian-labs/prosemirror-kotlin/tree/model/") - scm { - connection.set("git@github.com:atlassian-labs/prosemirror-kotlin.git") - url.set("https://github.com/atlassian-labs/prosemirror-kotlin.git") + scm { + connection.set("git@github.com:atlassian-labs/prosemirror-kotlin.git") + url.set("https://github.com/atlassian-labs/prosemirror-kotlin.git") + } + developers { + developer { + id.set("dmarques") + name.set("Douglas Marques") + email.set("dmarques@atlassian.com") } - developers { - developer { - id.set("dmarques") - name.set("Douglas Marques") - email.set("dmarques@atlassian.com") - } + developer { + id.set("achernykh") + name.set("Aleksei Chernykh") + email.set("achernykh@atlassian.com") } - licenses { - license { - name.set("Apache License 2.0") - url.set("https://www.apache.org/licenses/LICENSE-2.0") - distribution.set("repo") - } + } + licenses { + license { + name.set("Apache License 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0") + distribution.set("repo") } } } } + } - repositories { - maven { - url = uri("https://packages.atlassian.com/maven-central") - credentials { - username = System.getenv("ARTIFACTORY_USERNAME") - password = System.getenv("ARTIFACTORY_API_KEY") - } + repositories { + maven { + url = uri("https://packages.atlassian.com/maven-central") + credentials { + username = System.getenv("ARTIFACTORY_USERNAME") + password = System.getenv("ARTIFACTORY_API_KEY") } } } +} - signing { - useInMemoryPgpKeys( - System.getenv("SIGNING_KEY"), - System.getenv("SIGNING_PASSWORD"), - ) - sign(publishing.publications["release"]) - } +signing { + useInMemoryPgpKeys( + System.getenv("SIGNING_KEY"), + System.getenv("SIGNING_PASSWORD"), + ) + sign(publishing.publications) } diff --git a/model/config/ktlint/baseline.xml b/model/config/ktlint/baseline.xml index abfdf17..f177809 100644 --- a/model/config/ktlint/baseline.xml +++ b/model/config/ktlint/baseline.xml @@ -1,15 +1,11 @@ - - - - + - - + @@ -26,7 +22,6 @@ - @@ -63,14 +58,11 @@ - - - - + @@ -81,251 +73,258 @@ - + - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + - @@ -333,7 +332,6 @@ - @@ -376,186 +374,193 @@ - - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - + @@ -642,7 +647,6 @@ - @@ -661,299 +665,353 @@ - - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - + + + + + - - - - - - - + + + + + + + - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/CompareDeep.kt b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/CompareDeep.kt similarity index 100% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/CompareDeep.kt rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/CompareDeep.kt diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/Content.kt b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Content.kt similarity index 100% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/Content.kt rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Content.kt diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/Diff.kt b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Diff.kt similarity index 100% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/Diff.kt rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Diff.kt diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/Dom.kt b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Dom.kt similarity index 100% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/Dom.kt rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Dom.kt diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/Fragment.kt b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Fragment.kt similarity index 93% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/Fragment.kt rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Fragment.kt index bb28f26..e2d03d8 100644 --- a/model/src/main/kotlin/com/atlassian/prosemirror/model/Fragment.kt +++ b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Fragment.kt @@ -100,24 +100,23 @@ class Fragment { leafText: ((leafNode: Node) -> String?)? ): String { var text = "" - var separated = true + var first = true val func: (node: Node, start: Int, parent: Node?, index: Int) -> Boolean = { node, pos, parent, index -> - if (node.isText) { - node.text?.let { - text += it.slice(max(from, pos) - pos, to - pos) - } - separated = blockSeparator == null - } else if (node.isLeaf) { - if (leafText != null) { - text += leafText(node) - } else if (node.type.spec.leafText != null) { - text += node.type.spec.leafText!!.invoke(node) + val nodeText = when { + node.isText -> node.text?.slice(max(from, pos) - pos, to - pos) ?: "" + !node.isLeaf -> "" + leafText != null -> leafText(node) + node.type.spec.leafText != null -> node.type.spec.leafText!!.invoke(node) + else -> "" + } + if (node.isBlock && (node.isLeaf && nodeText != null || node.isTextblock) && blockSeparator != null) { + if (first) { + first = false + } else { + text += blockSeparator } - separated = blockSeparator == null - } else if (!separated && node.isBlock) { - text += blockSeparator - separated = true } + text += nodeText true } this.nodesBetween( @@ -255,8 +254,8 @@ class Fragment { } // Find the index and inner offset corresponding to a given relative position in this fragment. - // The result object will be reused (overwritten) the next time the function is called. (Not public.) - fun findIndex(pos: Int, round: Int = -1): Index { + // The result object will be reused (overwritten) the next time the function is called. @internal + internal fun findIndex(pos: Int, round: Int = -1): Index { if (pos == 0) return retIndex(0, pos) if (pos == this.size) return retIndex(this.content.size, pos) if (pos > this.size || pos < 0) throw RangeError("Position $pos outside of fragment ($this)") @@ -279,7 +278,7 @@ class Fragment { return if (verbose) { "<${toStringInner()}>" } else { - "Fragment#${System.identityHashCode(this)} content size: ${content.size}" + "Fragment#${super.toString()} content size: ${content.size}" } } diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/FromDom.kt b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/FromDom.kt similarity index 69% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/FromDom.kt rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/FromDom.kt index 67198be..ae58754 100644 --- a/model/src/main/kotlin/com/atlassian/prosemirror/model/FromDom.kt +++ b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/FromDom.kt @@ -1,17 +1,16 @@ package com.atlassian.prosemirror.model -import org.jsoup.Jsoup -import org.jsoup.nodes.Element -import java.util.Locale -import javax.xml.xpath.XPathConstants -import javax.xml.xpath.XPathFactory +import com.fleeksoft.ksoup.nodes.Node as DOMNode +import com.atlassian.prosemirror.model.util.contains +import com.fleeksoft.ksoup.Ksoup +import com.fleeksoft.ksoup.nodes.Element +import kotlin.jvm.JvmInline import kotlin.math.max -import org.jsoup.nodes.Node as DOMNode // TODO move all regex patterns here to avoid parsing multiple times object RegexPatterns { - // val re = /\s*([\w-]+)\s*:\s*([^;]+)/g - val STYLE_REGEX = "\\s*([\\w-]+)\\s*:\\s*([^;]+)".toRegex() + // /[^=]*/ + val STYLE_PROP_REGEX = "[^=]*".toRegex() } data class ParseOptionPosition(val node: DOMNode, val offset: Int, var pos: Int?) @@ -56,7 +55,7 @@ interface ParseOptions { // given [top node](#model.ParseOptions.topNode). val context: ResolvedPos? - val ruleFromNode: ((node: DOMNode) -> ParseRule?)? + val ruleFromNode: ((node: DOMNode) -> ParseOptionsRule?)? val topOpen: Boolean? } @@ -69,32 +68,13 @@ data class ParseOptionsImpl( override val topNode: Node? = null, override val topMatch: ContentMatch? = null, override val context: ResolvedPos? = null, - override val ruleFromNode: ((node: DOMNode) -> ParseRule?)? = null, + override val ruleFromNode: ((node: DOMNode) -> ParseOptionsRule?)? = null, override val topOpen: Boolean? = null ) : ParseOptions -// A value that describes how to parse a given DOM node or inline -// style as a ProseMirror node or mark. -interface ParseRule { - // A CSS selector describing the kind of DOM elements to match. A - // single rule should have _either_ a `tag` or a `style` property. - val tag: String? - - // The namespace to match. This should be used with `tag`. - // Nodes are only matched when the namespace matches or this property - // is null. - val namespace: String? - - // A CSS property name to match. When given, this rule matches - // inline styles that list that property. May also have the form - // `"property=value"`, in which case the rule only matches if the - // property's value exactly matches the given value. (For more - // complicated filters, use [`getAttrs`](#model.ParseRule.getAttrs) - // and return false to indicate that the match failed.) Rules - // matching styles may only produce [marks](#model.ParseRule.mark), - // not nodes. - val style: String? - +// Fields that may be present in both [tag](#model.TagParseRule) and +// [style](#model.StyleParseRule) parse rules. +interface BaseParseRule { // Can be used to change the order in which the parse rules in a // schema are tried. Those with higher priority come first. Rules // without a priority are counted as having priority 50. This @@ -121,22 +101,9 @@ interface ParseRule { // character, as in `"blockquote/|list_item/"`. val context: String? - // The name of the node type to create when this rule matches. Only - // valid for rules with a `tag` property, not for style rules. Each - // rule should have one of a `node`, `mark`, `clearMark`, or - // `ignore` property (except when it appears in a - // [node](#model.NodeSpec.parseDOM) or [mark - // spec](#model.MarkSpec.parseDOM), in which case the `node` or - // `mark` property will be derived from its position). - var node: String? - // The name of the mark type to wrap the matched content in. var mark: String? - // [Style](#model.ParseRule.style) rules can remove marks from the - // set of active marks. - var clearMark: ((mark: Mark) -> Boolean)? - // When true, ignore content that matches this rule. val ignore: Boolean? @@ -144,31 +111,53 @@ interface ParseRule { // the current node. val closeParent: Boolean? + // Attributes for the node or mark created by this rule. When + // `getAttrs` is provided, it takes precedence. + var attrs: Attrs? + + fun hasSkip(): Boolean +} + +interface ParseRule: BaseParseRule { // When true, ignore the node that matches this rule, but do parse // its content. val skip: Boolean? - // Attributes for the node or mark created by this rule. When - // `getAttrs` is provided, it takes precedence. - var attrs: Attrs? + fun copyRule(): ParseRule + + override fun hasSkip(): Boolean = skip == true +} + +// Parse rule targeting a DOM element. +interface TagParseRule: ParseRule { + // A CSS selector describing the kind of DOM elements to match. + val tag: String + + // The namespace to match. Nodes are only matched when the + // namespace matches or this property is null. + val namespace: String? + + // The name of the node type to create when this rule matches. Each + // rule should have either a `node`, `mark`, or `ignore` property + // (except when it appears in a [node](#model.NodeSpec.parseDOM) or + // [mark spec](#model.MarkSpec.parseDOM), in which case the `node` + // or `mark` property will be derived from its position). + var node: String? // A function used to compute the attributes for the node or mark // created by this rule. Can also be used to describe further // conditions the DOM element or style must match. When it returns // `false`, the rule won't match. When it returns null or undefined, // that is interpreted as an empty/default set of attributes. - // - // Called with a DOM Element for `tag` rules, and with a string (the - // style's value) for `style` rules. - val getStyleAttrs: ((style: String) -> ParseRuleMatch)? val getNodeAttrs: ((node: Element) -> ParseRuleMatch)? - // For `tag` rules that produce non-leaf nodes or marks, by default - // the content of the DOM element is parsed as content of the mark - // or node. If the child nodes are in a descendent node, this may be - // a CSS selector string that the parser must use to find the actual - // content element, or a function that returns the actual content - // element to the parser. + + // For rules that produce non-leaf nodes, by default the content of + // the DOM element is parsed as content of the node. If the child + // nodes are in a descendent node, this may be a CSS selector + // string that the parser must use to find the actual content + // element, or a function that returns the actual content element + // to the parser. val contentElement: ContentElement? // Can be used to override the content of a matched node. When @@ -182,31 +171,130 @@ interface ParseRule { // but newlines normalized to spaces, and `"full"` means that // newlines should also be preserved. val preserveWhitespace: PreserveWhitespace? +} - fun copyRule(): ParseRule +interface ParseOptionsRule: BaseParseRule { + val skip: DOMNode? + + // The namespace to match. Nodes are only matched when the + // namespace matches or this property is null. + val namespace: String? + + // The name of the node type to create when this rule matches. Each + // rule should have either a `node`, `mark`, or `ignore` property + // (except when it appears in a [node](#model.NodeSpec.parseDOM) or + // [mark spec](#model.MarkSpec.parseDOM), in which case the `node` + // or `mark` property will be derived from its position). + var node: String? + + // A function used to compute the attributes for the node or mark + // created by this rule. Can also be used to describe further + // conditions the DOM element or style must match. When it returns + // `false`, the rule won't match. When it returns null or undefined, + // that is interpreted as an empty/default set of attributes. + val getNodeAttrs: ((node: Element) -> ParseRuleMatch)? + + // For rules that produce non-leaf nodes, by default the content of + // the DOM element is parsed as content of the node. If the child + // nodes are in a descendent node, this may be a CSS selector + // string that the parser must use to find the actual content + // element, or a function that returns the actual content element + // to the parser. + val contentElement: ContentElement? + + // Can be used to override the content of a matched node. When + // present, instead of parsing the node's child nodes, the result of + // this function is used. + val getContent: ((node: DOMNode, schema: Schema) -> Fragment?)? + + // Controls whether whitespace should be preserved when parsing the + // content inside the matched element. `false` means whitespace may + // be collapsed, `true` means that whitespace should be preserved + // but newlines normalized to spaces, and `"full"` means that + // newlines should also be preserved. + val preserveWhitespace: PreserveWhitespace? + + override fun hasSkip(): Boolean = skip != null } -data class ParseRuleImpl( - override val tag: String? = null, +// A parse rule targeting a style property. +interface StyleParseRule: ParseRule { + // A CSS property name to match. This rule will match inline styles + // that list that property. May also have the form + // `"property=value"`, in which case the rule only matches if the + // property's value exactly matches the given value. (For more + // complicated filters, use [`getAttrs`](#model.ParseRule.getAttrs) + // and return false to indicate that the match failed.) Rules + // matching styles may only produce [marks](#model.ParseRule.mark), + // not nodes. + val style: String + + // Given to make TS see ParseRule as a tagged union @hide +// val tag: Any? + + // Style rules can remove marks from the set of active marks. + val clearMark: ((mark: Mark) -> Boolean)? + + // A function used to compute the attributes for the node or mark + // created by this rule. Called with the style's value. + val getStyleAttrs: ((style: String) -> ParseRuleMatch)? +} + +fun isTagRule(rule: ParseRule) = (rule as? TagParseRule)?.tag != null +fun isStyleRule(rule: ParseRule) = (rule as? StyleParseRule)?.style != null + +data class TagParseRuleImpl( + override val tag: String, override val namespace: String? = null, - override val style: String? = null, override val priority: Int? = null, override val consuming: Boolean? = null, override val context: String? = null, override var node: String? = null, override var mark: String? = null, - override var clearMark: ((mark: Mark) -> Boolean)? = null, override val ignore: Boolean? = null, override val closeParent: Boolean? = null, override val skip: Boolean? = null, override var attrs: Attrs? = null, - override val getStyleAttrs: ((style: String) -> ParseRuleMatch)? = null, override val getNodeAttrs: ((node: Element) -> ParseRuleMatch)? = null, override val contentElement: ContentElement? = null, override val getContent: ((node: DOMNode, schema: Schema) -> Fragment?)? = null, override val preserveWhitespace: PreserveWhitespace? = null -) : ParseRule { - override fun copyRule(): ParseRule = this.copy() +) : TagParseRule, ParseRule { + override fun copyRule() = this.copy() +} + +data class ParseOptionsRuleImpl( + override val namespace: String? = null, + override val priority: Int? = null, + override val consuming: Boolean? = null, + override val context: String? = null, + override var node: String? = null, + override var mark: String? = null, + override val ignore: Boolean? = null, + override val closeParent: Boolean? = null, + override val skip: DOMNode? = null, + override var attrs: Attrs? = null, + override val getNodeAttrs: ((node: Element) -> ParseRuleMatch)? = null, + override val contentElement: ContentElement? = null, + override val getContent: ((node: DOMNode, schema: Schema) -> Fragment?)? = null, + override val preserveWhitespace: PreserveWhitespace? = null +) : ParseOptionsRule + +data class StyleParseRuleImpl( +// override val tag: String? = null, + override val style: String, + override val priority: Int? = null, + override val consuming: Boolean? = null, + override val context: String? = null, + override var mark: String? = null, + override var clearMark: ((mark: Mark) -> Boolean)? = null, + override val ignore: Boolean? = null, + override val closeParent: Boolean? = null, + override val skip: Boolean? = null, + override var attrs: Attrs? = null, + override val getStyleAttrs: ((style: String) -> ParseRuleMatch)? = null, +) : StyleParseRule, ParseRule { + override fun copyRule() = this.copy() } data class ParseRuleMatch(val attrs: Attrs?, val matches: Boolean = true) { @@ -234,19 +322,25 @@ class DOMParser( val schema: Schema, // The set of [parse rules](#model.ParseRule) that the parser // uses, in order of precedence. - val rules: List + rules: List ) { - internal val tags = mutableListOf() - internal val styles = mutableListOf() + internal val tags = mutableListOf() + internal val styles = mutableListOf() internal val normalizeLists: Boolean + internal val matchedStyles = mutableListOf() // Create a parser that targets the given schema, using the given // parsing rules. init { rules.forEach { rule -> - if (rule.tag != null) { - this.tags.add(rule) - } else if (rule.style != null) { + if (isTagRule(rule)) { + this.tags.add(rule as TagParseRule) + } else if (isStyleRule(rule)) { + rule as StyleParseRule + val prop = RegexPatterns.STYLE_PROP_REGEX.find(rule.style)?.groups?.firstOrNull()?.value + if (prop != null && prop !in this.matchedStyles) { + this.matchedStyles.add(prop) + } this.styles.add(rule) } } @@ -254,7 +348,7 @@ class DOMParser( // Only normalize list elements when lists in the schema can't directly contain themselves this.normalizeLists = this.tags.firstOrNull { r -> val regex = "^(ul|ol)\\b".toRegex() - if (!regex.containsMatchIn(r.tag!!) || r.node == null) { + if (!regex.containsMatchIn(r.tag) || r.node == null) { false } else { val node = schema.nodes[r.node]!! @@ -265,14 +359,14 @@ class DOMParser( @Suppress("UnusedPrivateMember") fun parseHtml(html: String, options: ParseOptions = ParseOptionsImpl()): Node { - val derivedDOM = Jsoup.parse(html).body() + val derivedDOM = Ksoup.parse(html).body() return parse(derivedDOM, options) } // Parse a document from the content of a DOM node. fun parse(dom: DOMNode, options: ParseOptions = ParseOptionsImpl()): Node { val context = ParseContext(this, options, false) - context.addAll(dom, options.from, options.to) + context.addAll(dom, Mark.none, options.from, options.to) return context.finish() as Node } @@ -284,17 +378,17 @@ class DOMParser( // the left of the input and the end of nodes at the end. fun parseSlice(dom: DOMNode, options: ParseOptions = ParseOptionsImpl()): Slice { val context = ParseContext(this, options, true) - context.addAll(dom, options.from, options.to) + context.addAll(dom, Mark.none, options.from, options.to) return Slice.maxOpen(context.finish() as Fragment) } @Suppress("NestedBlockDepth", "ComplexCondition") - internal fun matchTag(dom: DOMNode, context: ParseContext, after: ParseRule?): ParseRule? { + internal fun matchTag(dom: DOMNode, context: ParseContext, after: TagParseRule?): TagParseRule? { val start = if (after != null) this.tags.indexOf(after) + 1 else 0 for (i in start until this.tags.size) { val rule = this.tags[i] if (matches(dom, rule.tag!!) && - (rule.namespace == null || dom.baseUri() == rule.namespace) && + (rule.namespace == null || (dom as? Element)?.tag()?.namespace() == rule.namespace) && (rule.context == null || context.matchesContext(rule.context!!)) ) { val getNodeAttrs = rule.getNodeAttrs @@ -310,7 +404,7 @@ class DOMParser( } @Suppress("ComplexCondition", "LoopWithTooManyJumpStatements") - internal fun matchStyle(prop: String, value: String, context: ParseContext, after: ParseRule?): ParseRule? { + internal fun matchStyle(prop: String, value: String, context: ParseContext, after: StyleParseRule?): StyleParseRule? { val start = if (after != null) { this.styles.indexOf(after) + 1 } else { @@ -325,7 +419,7 @@ class DOMParser( // or has an '=' sign after the prop, followed by the given // value. style.length > prop.length && - (style[prop.length] != 61.toChar() || style.slice(prop.length + 1 until style.length) != value) + (style[prop.length] != '=' || style.slice(prop.length + 1 until style.length) != value) ) { continue } @@ -361,16 +455,16 @@ class DOMParser( value.spec.parseDOM?.forEach { rule -> val ruleToInsert = rule.copyRule() insert(rule = ruleToInsert) - if (!(rule.mark != null || rule.ignore != null || rule.clearMark != null)) { + if (!(rule.mark != null || rule.ignore != null || (rule as? StyleParseRule)?.clearMark != null)) { ruleToInsert.mark = key } } } schema.nodes.forEach { (key, value) -> value.spec.parseDOM?.forEach { rule -> - val ruleToInsert = rule.copyRule() - insert(ruleToInsert) - if (!(rule.node != null || rule.ignore != null || rule.mark != null)) { + val ruleToInsert = rule.copyRule() as TagParseRule + insert(ruleToInsert as ParseRule) + if (!((rule as? TagParseRule)?.node != null || rule.ignore != null || rule.mark != null)) { ruleToInsert.node = key } } @@ -419,10 +513,7 @@ fun wsOptionsFor(type: NodeType?, preserveWhitespace: PreserveWhitespace?, base: class NodeContext( val type: NodeType?, val attrs: Attrs?, - // Marks applied to this node itself val marks: List, - // Marks that can't apply here, but will be used in children if possible - var pendingMarks: List, val solid: Boolean, match: ContentMatch?, val options: Int @@ -433,9 +524,6 @@ class NodeContext( // Marks applied to the node's children var activeMarks: List = Mark.none - // Nested Marks with same type - var stashMarks = mutableListOf() - @Suppress("ReturnCount") fun findWrapping(node: Node): List? { if (this.match == null) { @@ -486,39 +574,12 @@ class NodeContext( return this.type?.create(this.attrs, content, this.marks) ?: content } - fun popFromStashMark(mark: Mark): Mark? { - var ind = -1 - for (i in this.stashMarks.size - 1 downTo 0) { - if (mark == this.stashMarks[i]) { - ind = i - break - } - } - if (ind >= 0) { - return this.stashMarks.removeAt(ind) - } - return null - } - - fun applyPending(nextType: NodeType) { - val pending = this.pendingMarks - for (i in 0 until pending.size) { - val mark = pending[i] - if ((this.type?.allowsMarkType(mark.type) ?: markMayApply(mark.type, nextType)) && - !mark.isInSet(this.activeMarks) - ) { - this.activeMarks = mark.addToSet(this.activeMarks) - this.pendingMarks = mark.removeFromSet(this.pendingMarks) - } - } - } - @Suppress("ReturnCount") fun inlineContext(node: DOMNode): Boolean { if (this.type != null) return this.type.inlineContent if (this.content.isNotEmpty()) return this.content[0].isInline - val name = node.parentNode()?.nodeName()?.lowercase(Locale.getDefault()) ?: false - return blockTags.contains(name) + val name = node.parentNode()?.nodeName()?.lowercase() ?: false + return !blockTags.contains(name) } } @@ -544,13 +605,13 @@ class ParseContext( (if (isOpen) OPT_OPEN_LEFT else 0) if (topNode != null) { topContext = NodeContext( - topNode.type, topNode.attrs, Mark.none, Mark.none, true, + topNode.type, topNode.attrs, Mark.none, true, options.topMatch ?: topNode.type.contentMatch, topOptions ) } else if (isOpen) { - topContext = NodeContext(null, null, Mark.none, Mark.none, true, null, topOptions) + topContext = NodeContext(null, null, Mark.none, true, null, topOptions) } else { - topContext = NodeContext(parser.schema.topNodeType, null, Mark.none, Mark.none, true, null, topOptions) + topContext = NodeContext(parser.schema.topNodeType, null, Mark.none, true, null, topOptions) } this.nodes = mutableListOf(topContext) this.find = options.findPositions @@ -560,38 +621,17 @@ class ParseContext( // Add a DOM node to the content. Text is inserted as text node, // otherwise, the node is passed to `addElement` or, if it has a // `style` attribute, `addElementWithStyles`. - fun addDOM(dom: DOMNode) { - if (dom is org.jsoup.nodes.TextNode) { - this.addTextNode(dom) + fun addDOM(dom: DOMNode, marks: List) { + if (dom is com.fleeksoft.ksoup.nodes.TextNode) { + this.addTextNode(dom, marks) } else if (dom is Element) { - this.addElement(dom) - } - } - - fun withStyleRules(dom: Element, f: () -> Unit) { - val style = dom.attribute("style")?.value ?: return f() - val marks = this.readStyles(parseStyles(style)) ?: return // A style with ignore: true - val (addMarks, removeMarks) = marks - val top = this.top - - removeMarks.forEach { - this.removePendingMark(it, top) - } - addMarks.forEach { - this.addPendingMark(it) - } - f() - addMarks.forEach { - this.removePendingMark(it, top) - } - removeMarks.forEach { - this.addPendingMark(it) + this.addElement(dom, marks) } } @Suppress("NestedBlockDepth", "ComplexCondition") - fun addTextNode(dom: org.jsoup.nodes.TextNode) { - var value = dom.wholeText + fun addTextNode(dom: com.fleeksoft.ksoup.nodes.TextNode, marks: List) { + var value = dom.getWholeText() val top = this.top if ( (top.options and OPT_PRESERVE_WS_FULL) != 0 || @@ -624,7 +664,7 @@ class ParseContext( value = value.replace("\\r\\n?".toRegex(), "\n") } if (value.isNotEmpty()) { - this.insertNode(this.parser.schema.text(value)) + this.insertNode(this.parser.schema.text(value), marks) } this.findInText(dom) } else { @@ -635,24 +675,23 @@ class ParseContext( // Try to find a handler for the given tag and use that to parse. If // none is found, the element's content nodes are added directly. @Suppress("ComplexMethod") - fun addElement(dom: Element, matchAfter: ParseRule? = null) { - val name = dom.nodeName().lowercase(Locale.getDefault()) - var ruleID: ParseRule? = null - if (listTags.contains(name) && this.parser.normalizeLists) normalizeList(dom) - val ruleFromNode = this.options.ruleFromNode?.invoke(dom) - val rule = ruleFromNode ?: this.parser.matchTag(dom, this, matchAfter).also { ruleID = it } + fun addElement(dom: Element, marks: List, matchAfter: TagParseRule? = null) { + var current = dom + val name = current.nodeName().lowercase() + var ruleID: TagParseRule? = null + if (listTags.contains(name) && this.parser.normalizeLists) normalizeList(current) + val ruleFromNode = this.options.ruleFromNode?.invoke(current) + val rule = ruleFromNode ?: this.parser.matchTag(current, this, matchAfter).also { ruleID = it } if (rule?.ignore ?: ignoreTags.contains(name)) { - this.findInside(dom) - this.ignoreFallback(dom) - } else if (rule == null || rule.skip == true || rule.closeParent == true) { + this.findInside(current) + this.ignoreFallback(current, marks) + } else if (rule == null || rule.hasSkip() || rule.closeParent == true) { + val ruleSkip = (rule as? ParseOptionsRule)?.skip if (rule?.closeParent == true) { this.open = max(0, this.open - 1) + } else if (ruleSkip is Element) { + current = ruleSkip } - // TODO block below does not make sense since rule.skip is defined as Boolean so it can't have nodeType - // ever -// else if (rule && (rule.skip as any).nodeType) { -// dom = rule.skip as any as Element -// } var sync = false var top = this.top val oldNeedsBlock = this.needsBlock @@ -665,131 +704,144 @@ class ParseContext( if (top.type == null) { this.needsBlock = true } - } else if (dom.firstChild() == null) { - this.leafFallback(dom) + } else if (current.firstChild() == null) { + this.leafFallback(current, marks) return } - if (rule?.skip == true) { - this.addAll(dom) + val innerMarks = if (rule != null && rule.hasSkip()) { + marks } else { - this.withStyleRules(dom) { - addAll(dom) - } + this.readStyles(current, marks) + } + if (innerMarks != null) { + this.addAll(current, innerMarks) } if (sync) { this.sync(top) } this.needsBlock = oldNeedsBlock } else { - this.withStyleRules(dom) { - this.addElementByRule(dom, rule, ruleID?.takeIf { rule.consuming == false }) + val innerMarks = this.readStyles(current, marks) + if (innerMarks != null) { + this.addElementByRule(current, rule as TagParseRule, innerMarks, ruleID?.takeIf { rule.consuming == false }) } } } // Called for leaf DOM nodes that would otherwise be ignored - fun leafFallback(dom: DOMNode) { + fun leafFallback(dom: DOMNode, marks: List) { if (dom.nodeName().equals("br", true) && this.top.type?.inlineContent == true) { - this.addTextNode(org.jsoup.nodes.TextNode("\n")) + this.addTextNode(com.fleeksoft.ksoup.nodes.TextNode("\n"), marks) } } // Called for ignored nodes - fun ignoreFallback(dom: DOMNode) { + fun ignoreFallback(dom: DOMNode, marks: List) { // Ignored BR nodes should at least create an inline context if (dom.nodeName().equals("br", true) && (this.top.type?.inlineContent == false)) { - this.findPlace(this.parser.schema.text("-")) + this.findPlace(this.parser.schema.text("-"), marks) } } // Run any style parser associated with the node's styles. Either - // return an array of marks, or null to indicate some of the styles - // had a rule with `ignore` set. + // return an updated array of marks, or null to indicate some of the + // styles had a rule with `ignore` set. @Suppress("LoopWithTooManyJumpStatements", "NestedBlockDepth") - fun readStyles(styles: List): Pair, List>? { - var add = Mark.none - var remove = Mark.none - style@ for (i in styles.indices step 2) { - var after: ParseRule? = null - while (true) { - val rule = this.parser.matchStyle(styles[i], styles[i + 1], this, after) ?: break - if (rule.ignore == true) { - return null - } - if (rule.clearMark != null) { - (this.top.pendingMarks + this.top.activeMarks).forEach { m -> - if (rule.clearMark!!(m)) remove = m.addToSet(remove) + fun readStyles(dom: Element, marks: List): List? { + var result = marks.toMutableList() + val styles = dom.styles() + // Because many properties will only show up in 'normalized' form + // in `style.item` (i.e. text-decoration becomes + // text-decoration-line, text-decoration-color, etc), we directly + // query the styles mentioned in our rules instead of iterating + // over the items. + if (!styles.isNullOrEmpty()) { + for (i in this.parser.matchedStyles.indices) { + val name = this.parser.matchedStyles[i] + val value = styles[name] + if (value != null) { + var after: StyleParseRule? = null + while (true) { + val rule = this.parser.matchStyle(name, value, this, after) ?: break + if (rule.ignore == true) { + return null + } + if (rule.clearMark != null) { + result = result.filter { m -> !rule.clearMark!!(m) }.toMutableList() + } else { + result += this.parser.schema.marks[rule.mark]!!.create(rule.attrs) + } + if (rule.consuming == false) { + after = rule + } else { + break + } } - } else { - add = this.parser.schema.marks[rule.mark]!!.create(rule.attrs).addToSet(add) - } - if (rule.consuming == false) { - after = rule - } else { - break } } } - return add to remove + return result } // Look up a handler for the given node. If none are found, return // false. Otherwise, apply it, use its return value to drive the way // the node's content is wrapped, and return true. @Suppress("ComplexMethod") - fun addElementByRule(dom: Element, rule: ParseRule, continueAfter: ParseRule?) { + fun addElementByRule(dom: Element, rule: TagParseRule, marks: List, continueAfter: TagParseRule?) { var sync = false var nodeType: NodeType? = null - var mark: Mark? = null val ruleNode = rule.node + var updatedMarks = marks if (ruleNode != null) { nodeType = this.parser.schema.nodeType(ruleNode) if (!nodeType.isLeaf) { - sync = this.enter(nodeType, rule.attrs?.takeIf { it.isNotEmpty() }, rule.preserveWhitespace) - } else if (!this.insertNode(nodeType.create(rule.attrs))) { - this.leafFallback(dom) + val inner = this.enter(nodeType, rule.attrs, updatedMarks, rule.preserveWhitespace) + if (inner != null) { + sync = true + updatedMarks = inner + } + } else if (!this.insertNode(nodeType.create(rule.attrs), updatedMarks)) { + this.leafFallback(dom, updatedMarks) } } else { - val markType = this.parser.schema.marks[rule.mark!!] - mark = markType?.create(rule.attrs)?.also { addPendingMark(it) } + val markType = this.parser.schema.marks[rule.mark!!]!! + updatedMarks = updatedMarks + markType.create(rule.attrs) } val startIn = this.top if (nodeType?.isLeaf == true) { this.findInside(dom) } else if (continueAfter != null) { - this.addElement(dom, continueAfter) + this.addElement(dom, updatedMarks, continueAfter) } else if (rule.getContent != null) { this.findInside(dom) - rule.getContent!!.invoke(dom, this.parser.schema)?.forEach { node, _, _ -> this.insertNode(node) } + rule.getContent!!.invoke(dom, this.parser.schema)?.forEach { node, _, _ -> this.insertNode(node, updatedMarks) } } else { var contentDOM = dom val contentElement = rule.contentElement if (contentElement is ContentElement.StringContentElement) { - val xPath = XPathFactory.newInstance().newXPath() - contentDOM = xPath.evaluate(contentElement.s, dom, XPathConstants.NODE) as Element + contentDOM = evaluateXpathNode(contentElement.s, dom) } else if (contentElement is ContentElement.FunctionContentElement) { contentDOM = contentElement.func(dom) } else if (contentElement is ContentElement.ElementContentElement) { contentDOM = contentElement.element } this.findAround(dom, contentDOM, true) - this.addAll(contentDOM) + this.addAll(contentDOM, updatedMarks) } if (sync && this.sync(startIn)) this.open-- - if (mark != null) this.removePendingMark(mark, startIn) } // Add all child nodes between `startIndex` and `endIndex` (or the // whole node, if not given). If `sync` is passed, use it to // synchronize after every block element. - fun addAll(parent: DOMNode, startIndex: Int? = null, endIndex: Int? = null) { + fun addAll(parent: DOMNode, marks: List, startIndex: Int? = null, endIndex: Int? = null) { var index = startIndex ?: 0 var dom: DOMNode? = if (startIndex != null) parent.childNode(startIndex) else parent.firstChild() val end = endIndex?.let { parent.childNode(endIndex) } while (dom != end) { this.findAtPoint(parent, index) - this.addDOM(dom!!) + this.addDOM(dom!!, marks) dom = dom.nextSibling() ++index } @@ -800,9 +852,10 @@ class ParseContext( // context. May add intermediate wrappers and/or leave non-solid // nodes that we're in. @Suppress("LoopWithTooManyJumpStatements") - fun findPlace(node: Node): Boolean { + fun findPlace(node: Node, marks: List): List? { var route: List? = null var sync: NodeContext? = null + var updateMarks = marks for (depth in this.open downTo 0) { val cx = this.nodes[depth] val found = cx.findWrapping(node) @@ -813,32 +866,36 @@ class ParseContext( } if (cx.solid) break } - if (route == null) return false + if (route == null) return null this.sync(sync!!) route.forEach { - this.enterInner(it, null, false) + updateMarks = this.enterInner(it, null, updateMarks, false) } - return true + return updateMarks } // Try to insert the given node, adjusting the context when needed. - fun insertNode(node: Node): Boolean { + fun insertNode(node: Node, marks: List): Boolean { + var updatedMarks = marks if (node.isInline && this.needsBlock && this.top.type == null) { val block = this.textblockFromContext() - if (block != null) this.enterInner(block) + if (block != null) updatedMarks = this.enterInner(block, null, marks) } - if (this.findPlace(node)) { + val innerMarks = this.findPlace(node, updatedMarks) + if (innerMarks != null) { this.closeExtra() val top = this.top - top.applyPending(node.type) if (top.match != null) top.match = top.match!!.matchType(node.type) - var marks = top.activeMarks - for (i in 0 until node.marks.size) { - if (top.type == null || top.type.allowsMarkType(node.marks[i].type)) { - marks = node.marks[i].addToSet(marks) + var nodeMarks = Mark.none + for (m in innerMarks + node.marks) { + val add = if (top.type != null) { + top.type.allowsMarkType(m.type) + } else { + markMayApply(m.type, node.type) } + if (add) nodeMarks = m.addToSet(nodeMarks) } - top.content.add(node.mark(marks)) + top.content.add(node.mark(nodeMarks)) return true } return false @@ -846,29 +903,44 @@ class ParseContext( // Try to start a node of the given type, adjusting the context when // necessary. - fun enter(type: NodeType, attrs: Attrs?, preserveWS: PreserveWhitespace?): Boolean { - val ok = this.findPlace(type.create(attrs)) - if (ok) this.enterInner(type, attrs, true, preserveWS) - return ok + fun enter(type: NodeType, attrs: Attrs?, marks: List, preserveWS: PreserveWhitespace?): List? { + var innerMarks = this.findPlace(type.create(attrs), marks) + if (innerMarks != null) innerMarks = this.enterInner(type, attrs, marks, true, preserveWS) + return innerMarks } // Open a node of the given type fun enterInner( type: NodeType, attrs: Attrs? = null, + marks: List, solid: Boolean = false, preserveWS: PreserveWhitespace? = null - ) { + ): List { this.closeExtra() val top = this.top - top.applyPending(type) top.match = top.match?.matchType(type) var options = wsOptionsFor(type, preserveWS, top.options) if ((top.options and OPT_OPEN_LEFT) != 0 && top.content.size == 0) { options = options or OPT_OPEN_LEFT } - this.nodes.add(NodeContext(type, attrs, top.activeMarks, top.pendingMarks, solid, null, options)) + var applyMarks = Mark.none + val result = marks.filter { m -> + val ok = if (top.type != null) { + top.type.allowsMarkType(m.type) + } else { + markMayApply(m.type, type) + } + if (ok) { + applyMarks = m.addToSet(applyMarks) + false + } else { + true + } + } + this.nodes.add(NodeContext(type, attrs, applyMarks, solid, null, options)) this.open++ + return result } // Make sure all nodes above this.open are finished and added to @@ -923,7 +995,7 @@ class ParseContext( fun findInside(parent: DOMNode) { this.find?.forEach { - if (it.pos == null && (parent is Element) && parent.children().contains(it.node)) { + if (it.pos == null && (parent is Element) && parent.contains(it.node)) { it.pos = this.currentPos } } @@ -943,10 +1015,10 @@ class ParseContext( // } } - fun findInText(textNode: org.jsoup.nodes.TextNode) { + fun findInText(textNode: com.fleeksoft.ksoup.nodes.TextNode) { this.find?.forEach { if (it.node == textNode) { - it.pos = this.currentPos - (textNode.wholeText.length - it.offset) + it.pos = this.currentPos - (textNode.getWholeText().length - it.offset) } } } @@ -1008,40 +1080,15 @@ class ParseContext( if (context != null) { for (d in context.depth downTo 0) { val deflt = context.node(d).contentMatchAt(context.indexAfter(d)).defaultType - if (deflt != null && deflt.isTextblock && deflt.defaultAttrs.isNotEmpty()) return deflt + if (deflt != null && deflt.isTextblock) return deflt } } for (name in this.parser.schema.nodes) { val type = this.parser.schema.nodeType(name.key) - if (type.isTextblock && type.defaultAttrs.isNotEmpty()) return type + if (type.isTextblock) return type } return null } - - fun addPendingMark(mark: Mark) { - val found = findSameMarkInSet(mark, this.top.pendingMarks) - if (found != null) this.top.stashMarks.add(found) - this.top.pendingMarks = mark.addToSet(this.top.pendingMarks) - } - - fun removePendingMark(mark: Mark, upto: NodeContext) { - for (depth in this.open downTo 0) { - val level = this.nodes[depth] - val found = level.pendingMarks.lastIndexOf(mark) - if (found > -1) { - level.pendingMarks = mark.removeFromSet(level.pendingMarks) - } else { - level.activeMarks = mark.removeFromSet(level.activeMarks) - val stashMark = level.popFromStashMark(mark) - if (stashMark != null && level.type != null && level.type.allowsMarkType(stashMark.type)) { - level.activeMarks = stashMark.addToSet(level.activeMarks) - } - } - if (level == upto) { - break - } - } - } } // Kludge to work around directly nested list nodes produced by some @@ -1070,16 +1117,6 @@ fun matches(dom: DOMNode, selector: String): Boolean { // return htmlDomElementMatches(dom, selector) } -// Tokenize a style attribute into property/value pairs. -fun parseStyles(style: String): List { - return buildList { - RegexPatterns.STYLE_REGEX.findAll(style).forEach { - add(it.groupValues[1]) - add(it.groupValues[2]) - } - } -} - // fun copy(obj: {[prop: string]: any}) { // val copy: {[prop: string]: any} = {} // for (val prop in obj) copy[prop] = obj[prop] @@ -1111,4 +1148,11 @@ private fun scan(nodeType: NodeType, seen: MutableList, match: Con return false } -fun findSameMarkInSet(mark: Mark, set: List) = set.firstOrNull { it == mark } +fun Element.styles(): Map? { + val style = attribute("style")?.value ?: return null + return style.split(";") + .map { if (it.contains(":")) it else "$it:" } + .associate { item -> + item.split(":", limit = 2).let { it[0].trim() to it[1].trim() } + } +} diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/Mark.kt b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Mark.kt similarity index 93% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/Mark.kt rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Mark.kt index f40a15c..39be245 100644 --- a/model/src/main/kotlin/com/atlassian/prosemirror/model/Mark.kt +++ b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Mark.kt @@ -1,7 +1,6 @@ package com.atlassian.prosemirror.model import com.atlassian.prosemirror.model.parser.JSON -import java.io.Serializable import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObjectBuilder import kotlinx.serialization.json.buildJsonObject @@ -10,14 +9,17 @@ import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put -import java.util.UUID +import kotlin.jvm.JvmInline +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid interface UnsupportedMark { var originalMarkName: String? } @JvmInline -value class MarkId(val id: String) : Serializable +@kotlinx.serialization.Serializable +value class MarkId(val id: String) // A mark is a piece of information that can be attached to a node, such as it being emphasized, in // code font, or a link. It has a type and optionally a set of attributes that provide further @@ -30,7 +32,8 @@ open class Mark constructor( // The attributes associated with this mark. val attrs: Attrs ) { - var markId: MarkId = MarkId("${type.name}&${UUID.randomUUID()}") + @OptIn(ExperimentalUuidApi::class) + var markId: MarkId = MarkId("${type.name}&${Uuid.random()}") // Given a set of marks, create a new set which contains this one as well, in the right // position. If this mark is already in the set, the set itself is returned. If any marks that @@ -109,7 +112,7 @@ open class Mark constructor( ) val attrs: Attrs? = json["attrs"]?.let { JSON.decodeFromJsonElement(it) } val id = json["id"]?.jsonPrimitive?.contentOrNull - return if (withId && id != null) { + val mark = if (withId && id != null) { type.create(attrs).also { it.markId = MarkId(id) (it as? UnsupportedMark)?.originalMarkName = jsonType @@ -117,6 +120,8 @@ open class Mark constructor( } else { type.create(attrs).also { (it as? UnsupportedMark)?.originalMarkName = jsonType } } + type.checkAttrs(mark.attrs) + return mark } // Test whether two sets of marks are identical. diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/Node.kt b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Node.kt similarity index 92% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/Node.kt rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Node.kt index 4c6d225..c9e0e81 100644 --- a/model/src/main/kotlin/com/atlassian/prosemirror/model/Node.kt +++ b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Node.kt @@ -5,8 +5,9 @@ package com.atlassian.prosemirror.model import com.atlassian.prosemirror.model.parser.JSON import com.atlassian.prosemirror.util.slice import com.atlassian.prosemirror.util.verbose -import java.io.Serializable -import java.util.* +import kotlin.jvm.JvmInline +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement @@ -37,7 +38,9 @@ interface UnsupportedNode { } @JvmInline -value class NodeId(val id: String) : Serializable +@kotlinx.serialization.Serializable +value class NodeId(val id: String) + /** * This class represents a node in the tree that makes up a ProseMirror document. So a document is @@ -63,7 +66,8 @@ open class Node constructor( val marks: List = Mark.none ) : NodeBase(type, attrs) { - private var unknownFields: Map? = null + var unknownFields: Map? = null + private set // A container holding the node's children. val content: Fragment @@ -71,7 +75,8 @@ open class Node constructor( // For text nodes, this contains the node's text content. open val text: String? = null - var nodeId: NodeId = NodeId("${type.name}&${UUID.randomUUID()}") + @OptIn(ExperimentalUuidApi::class) + var nodeId: NodeId = NodeId("${type.name}&${Uuid.random()}") init { this.content = content ?: Fragment.empty @@ -155,13 +160,28 @@ open class Node constructor( } fun computeAttr(name: String): Any? { - return if (attrs.containsKey(name)) attrs[name] else type.defaultAttrs[name] + return if (attrs.containsKey(name)) attrs[name] else defaultAttr(name) } + // Allows for access by inline fun below + fun defaultAttr(name: String): Any? = type.defaultAttrs[name] + + /** + * If is nullable, then return null where attribute doesn't exist and doesn't have a NodeType defaultAttr, or when casting to T + * failed + * + * If is not nullable, then additionally try falling back to NodeType defaultAttr if attr value is null before throwing an exception + */ inline fun attr(name: String, default: T? = null): T { - return (computeAttr(name) as T? ?: default) as T + return computeAttr(name) as? T? ?: if (null is T) { + default as T // safely nullable as (null is T) means T is nullable + } else { + default ?: defaultAttr(name) as? T ?: throw IllegalArgumentException( + "Cannot resolve attribute $name for node ${this.type.name} - attribute doesn't exist or is null, and is not nullable " + + "but there is no non-null default to return" + ) + } } - // Get the child node at the given index. Raises an error when the index is out of range. fun child(index: Int): Node { return this.content.child(index) @@ -177,11 +197,14 @@ open class Node constructor( this.content.forEach(f) } - // Invoke a callback for all descendant nodes recursively between the given two positions that - // are relative to start of this node's content. The callback is invoked with the node, its - // parent-relative position, its parent node, and its child index. - // When the callback returns false for a given node, that node's children will not be recursed - // over. The last parameter can be used to specify a starting position to count from. + // Invoke a callback for all descendant nodes recursively between + // the given two positions that are relative to start of this + // node's content. The callback is invoked with the node, its + // position relative to the original node (method receiver), + // its parent node, and its child index. When the callback returns + // false for a given node, that node's children will not be + // recursed over. The last parameter can be used to specify a + // starting position to count from. fun nodesBetween( from: Int, to: Int, @@ -539,14 +562,15 @@ open class Node constructor( } } - // Check whether this node and its descendants conform to the schema, and raise error when they do not. + // Check whether this node and its descendants conform to the + // schema, and raise an exception when they do not. @Suppress("MagicNumber") fun check() { - if (!this.type.validContent(this.content)) { - throw RangeError("Invalid content for node ${this.type.name}: ${this.content.toString().slice(0, 50)}") - } + this.type.checkContent(this.content) + this.type.checkAttrs(this.attrs) var copy = Mark.none for (mark in marks) { + mark.type.checkAttrs(mark.attrs) copy = mark.addToSet(copy) } if (!Mark.sameSet(copy, this.marks)) { @@ -626,7 +650,7 @@ open class Node constructor( if (it.value is JsonNull) null else JSON.decodeFromJsonElement(it.value) } val id = json["id"]?.jsonPrimitive?.contentOrNull - return try { + val node = try { schema.nodeType(type!!).create(attrs, content, marks).also { if (withId && id != null) { it.nodeId = NodeId(id) @@ -650,6 +674,8 @@ open class Node constructor( // for round tripping node.unknownFields = json.fieldsExcept("marks", "type", "content", "attrs") } + node.type.checkAttrs(node.attrs) + return node } } } diff --git a/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Platform.kt b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Platform.kt new file mode 100644 index 0000000..5b139ef --- /dev/null +++ b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Platform.kt @@ -0,0 +1,10 @@ +package com.atlassian.prosemirror.model + +import com.fleeksoft.ksoup.nodes.Element + +interface Platform { + val name: String +} + +expect fun evaluateXpathNode(s: String, dom: Element): Element +expect fun getPlatform(): Platform diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/Replace.kt b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Replace.kt similarity index 97% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/Replace.kt rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Replace.kt index 60afd8d..ca8b4d2 100644 --- a/model/src/main/kotlin/com/atlassian/prosemirror/model/Replace.kt +++ b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Replace.kt @@ -201,13 +201,7 @@ fun addRange(_start: ResolvedPos?, _end: ResolvedPos?, depth: Int, target: Mutab } fun close(node: Node, content: Fragment): Node { - if (!node.type.validContent(content)) { - if (verbose) { - throw ReplaceError("Invalid content for node ${node.type.name} content: ${content.toJSON()}") - } else { - throw ReplaceError("Invalid content for node ${node.type.name}") - } - } + node.type.checkContent(content) return node.copy(content) } diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/ResolvedPos.kt b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/ResolvedPos.kt similarity index 95% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/ResolvedPos.kt rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/ResolvedPos.kt index 79e26a6..8f1a3a2 100644 --- a/model/src/main/kotlin/com/atlassian/prosemirror/model/ResolvedPos.kt +++ b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/ResolvedPos.kt @@ -1,5 +1,9 @@ package com.atlassian.prosemirror.model +import co.touchlab.stately.collections.ConcurrentMutableList +import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.update + // You can [_resolve_](#model.Node.resolve) a position to get more information about it. Objects of // this class represent such a resolved position, providing various pieces of context information, // and some helper methods. @@ -270,23 +274,28 @@ class ResolvedPos( internal fun resolveCached(doc: Node, pos: Int): ResolvedPos { val resolveCache = doc.type.schema.resolveCache - val resolveCachePos = doc.type.schema.resolveCachePos - - resolveCache.forEach { cached -> - if (cached.pos == pos && cached.doc === doc) return cached + var cache = resolveCache[doc.nodeId] + if (cache != null) { + cache.elts.firstOrNull() { it.pos == pos }?.let { return it } + } else { + cache = ResolveCache() + resolveCache[doc.nodeId] = cache } val result = resolve(doc, pos) - if (resolveCachePos.get() >= resolveCache.size) { - resolveCache.add(result) - } else { - resolveCache[resolveCachePos.get()] = result + cache.i.update { + cache.elts.add(it, result) + (it + 1) % RESOLVE_CACHE_SIZE } - resolveCachePos.set((resolveCachePos.get() + 1) % RESOLVE_CACHE_SIZE) return result } } } +class ResolveCache { + val elts: ConcurrentMutableList = ConcurrentMutableList() + val i = atomic(0) +} + private const val RESOLVE_CACHE_SIZE = 12 // Represents a flat range of content, i.e. one that starts and ends in the same node. diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/Schema.kt b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Schema.kt similarity index 88% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/Schema.kt rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Schema.kt index e0de0a6..20ded3e 100644 --- a/model/src/main/kotlin/com/atlassian/prosemirror/model/Schema.kt +++ b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/Schema.kt @@ -2,10 +2,11 @@ package com.atlassian.prosemirror.model +import co.touchlab.stately.collections.ConcurrentMutableMap +import com.atlassian.prosemirror.util.slice import com.atlassian.prosemirror.util.verbose +import kotlinx.atomicfu.atomic import kotlinx.serialization.json.JsonObject -import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.atomic.AtomicInteger // An object holding the attributes of a node. typealias Attrs = Map @@ -25,12 +26,20 @@ fun defaultAttrs(attrs: Map, includeNullValues: Boolean = fal return if (res.isEmpty()) EmptyAttrs else res.toMap() } +fun checkAttrs(attrs: Map, values: Attrs, type: String, name: String) { + values.keys.forEach { name -> + if (name !in attrs) throw RangeError("Unsupported attribute $name for $type of type $name") + } + attrs.forEach { (name, attr) -> + attr.validate?.invoke(values[name]) + } +} + // computeAttrs function not needed anymore - for correct round trip we don't combine attributes // on creation - -fun initAttrs(attrs: Map?): Map { - return attrs?.mapValues { - Attribute(it.value) +fun initAttrs(typeName: String, attrs: Map?): Map { + return attrs?.mapValues { (name, value) -> + Attribute(typeName, name, value) } ?: emptyMap() } @@ -93,7 +102,7 @@ class NodeType internal constructor( init { this.groups = spec.group?.let { listOf(it) } ?: emptyList() - this.attrs = initAttrs(spec.attrs) + this.attrs = initAttrs(name, spec.attrs) this.defaultAttrs = defaultAttrs(this.attrs) this.defaultAttrsIncludingNullValues = defaultAttrs(this.attrs, includeNullValues = true) @@ -138,15 +147,7 @@ class NodeType internal constructor( // content restrictions, and throw an error if it doesn't match. fun createChecked(attrs: Attrs? = null, content: Fragment? = null, marks: List? = null): Node { val thisContent = Fragment.from(content) - if (!this.validContent(thisContent)) { - throw RangeError( - if (verbose) { - "Invalid content for node type $name: $thisContent" - } else { - "Invalid content for node type $name" - } - ) - } + this.checkContent(thisContent) return creator.create(this, this.computeAttrs(attrs), thisContent, Mark.setFrom(marks)) } @@ -218,8 +219,8 @@ class NodeType internal constructor( return creator.create(this, attrs, content.append(after), Mark.setFrom(marks)) } - // Returns true if the given fragment is valid content for this node type with the given - // attributes. + // Returns true if the given fragment is valid content for this node + // type. fun validContent(content: Fragment): Boolean { val result = this.contentMatch.matchFragment(content) if (result == null || !result.validEnd) { @@ -233,6 +234,24 @@ class NodeType internal constructor( return true } + // Throws a RangeError if the given fragment is not valid content for this + // node type. + internal fun checkContent(content: Fragment) { + if (!this.validContent(content)) { + throw RangeError( + if (verbose) { + "Invalid content for node type $name: ${content.toString().slice(0, 50)}" + } else { + "Invalid content for node type $name" + } + ) + } + } + + internal fun checkAttrs(attrs: Attrs) { + checkAttrs(this.attrs, attrs, "node", this.name) + } + // Check whether the given mark type is allowed in this node. fun allowsMarkType(markType: MarkType): Boolean { val markSet = this.markSet @@ -254,7 +273,7 @@ class NodeType internal constructor( var copy: MutableList? = null marks.forEachIndexed { i, mark -> if (!this.allowsMarkType(mark.type)) { - if (copy == null) copy = marks.slice(0..i).toMutableList() + if (copy == null) copy = marks.slice(0.. Unit { + val types = type.split("|") + return { value -> + val name = value?.let { value::class.simpleName } ?: "null" + if (types.indexOf(name) < 0) { + throw RangeError("Expected value of type $types for attribute $attrName on type $typeName, got $name") + } + } +} + // To be type safe every NodeType should be created through create method. Otherwise it would be // of generic type Node interface NodeCreator { @@ -328,16 +357,25 @@ enum class Whitespace { } // Attribute descriptors -class Attribute(options: AttributeSpec) { +class Attribute( + typeName: String, + attrName: String, + options: AttributeSpec +) { val hasDefault: Boolean val default: Any? + val validate: ((Any?) -> Unit)? val isRequired: Boolean get() = !this.hasDefault init { - this.hasDefault = options.hasDefault + this.hasDefault = options.default != null //Object.prototype.hasOwnProperty.call(options, "default") this.default = options.default + + this.validate = options.validateString?.let { validateType(typeName, attrName, it) } + ?: options.validateFunction + } } @@ -358,7 +396,7 @@ class MarkType internal constructor( var creator: MarkCreator = MarkCreator.DEFAULT // @internal - internal val attrs: Map = initAttrs(spec.attrs) + internal val attrs: Map = initAttrs(name, spec.attrs) // ;(this as any).excluded = null internal var excluded: List? = null @@ -387,6 +425,10 @@ class MarkType internal constructor( return set.firstOrNull { it.type == this } } + internal fun checkAttrs(attrs: Attrs) { + checkAttrs(this.attrs, attrs, "mark", this.name) + } + // Queries whether a given mark type is [excluded](#model.MarkSpec.excludes) by this one. fun excludes(other: MarkType): Boolean { val excluded = this.excluded @@ -410,7 +452,7 @@ class MarkType internal constructor( } // An object describing a schema, as passed to the [`Schema`](#model.Schema) constructor. -class SchemaSpec( +data class SchemaSpec( // The node types in this schema. Maps names to [`NodeSpec`](#model.NodeSpec) objects that // describe the node type associated with that name. Their order is significant—it determines // which [parse rules](#model.NodeSpec.parseDOM) take precedence by default, and which nodes @@ -518,7 +560,7 @@ interface NodeSpec { // [`DOMParser.fromSchema`](#model.DOMParser^fromSchema) to automatically derive a parser. The // `node` field in the rules is implied (the name of this node will be filled in automatically). // If you supply your own parser, you do not need to also specify parsing rules in your schema. - val parseDOM: List? + val parseDOM: List? // Defines the default way a node of this type should be serialized to a string representation // for debugging (e.g. in error messages). @@ -529,6 +571,15 @@ interface NodeSpec { // [`Node.textContent`](#model.Node^textContent)). val leafText: ((node: Node) -> String)? + // A single inline node in a schema can be set to be a linebreak + // equivalent. When converting between block types that support the + // node and block types that don't but have + // [`whitespace`](#model.NodeSpec.whitespace) set to `"pre"`, + // [`setBlockType`](#transform.Transform.setBlockType) will convert + // between newline characters to or from linebreak nodes as + // appropriate. + val linebreakReplacement: Boolean? + // Determines whether this node is automatically focused during navigation. Mainly used for navigation with arrow // key and backspace/delete key. Defaults to false. val autoFocusable: Boolean? @@ -587,7 +638,16 @@ interface AttributeSpec { // that have no default must be provided whenever a node or mark of a type that has them is // created. val default: Any? - val hasDefault: Boolean + // A function or type name used to validate values of this + // attribute. This will be used when deserializing the attribute + // from JSON, and when running [`Node.check`](#model.Node.check). + // When a function, it should raise an exception if the value isn't + // of the expected type or shape. When a string, it should be a + // `|`-separated string of primitive types (`"number"`, `"string"`, + // `"boolean"`, `"null"`, and `"undefined"`), and the library will + // raise an error when the value is not one of those types. + val validateString: String? + val validateFunction: ((value: Any?) -> Unit)? } // A document schema. Holds [node](#model.NodeType) and [mark type](#model.MarkType) objects for the @@ -607,23 +667,22 @@ class Schema { // A map from mark names to mark type objects. val marks: Map + // The [linebreak + // replacement](#model.NodeSpec.linebreakReplacement) node defined + // in this schema, if any. + var linebreakReplacement: NodeType? = null + /** * From some testing on mix-contents.json (will differ based on document etc.); - * Initial Render: 3:1 interating (reads) vs writing + * Initial Render: 3:1 iterating (reads) vs writing * Selection: ~10:1 * Typing: ~5:1 - * - * Therefore, CopyOnWriteArrayList is probably preferable to synchronizing out traversals (w/synchronizedList), - * despite the penalty of having to copy on write (the size is only 12 anyway). */ - val resolveCache = CopyOnWriteArrayList() - - @Suppress("MayBeConst") - var resolveCachePos = AtomicInteger(0) + val resolveCache = ConcurrentMutableMap() // Construct a schema from a schema [specification](#model.SchemaSpec). constructor(spec: SchemaSpec) { - this.spec = spec + this.spec = spec.copy(nodes = spec.nodes.toMap(), marks = spec.marks.toMap()) this.nodes = NodeType.compile(this.spec.nodes, this) this.marks = MarkType.compile(this.spec.marks, this) @@ -639,6 +698,11 @@ class Schema { ContentMatch.parse(contentExpr, this.nodes) } type.inlineContent = type.contentMatch.inlineContent + if (type.spec.linebreakReplacement == true) { + if (linebreakReplacement != null) throw RangeError("Multiple linebreak nodes defined") + if (!type.isInline || !type.isLeaf) throw RangeError("Linebreak replacement nodes must be inline leaf nodes") + linebreakReplacement = type + } type.markSet = when { markExpr == "_" -> null !markExpr.isNullOrEmpty() -> gatherMarks(this, markExpr.split(" ")) diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/ToDom.kt b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/ToDom.kt similarity index 60% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/ToDom.kt rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/ToDom.kt index 2c439f3..33f864a 100644 --- a/model/src/main/kotlin/com/atlassian/prosemirror/model/ToDom.kt +++ b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/ToDom.kt @@ -1,9 +1,12 @@ package com.atlassian.prosemirror.model -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element -import org.jsoup.nodes.Node as DOMNode -import org.jsoup.nodes.TextNode +import com.fleeksoft.ksoup.nodes.Document +import com.fleeksoft.ksoup.nodes.Element +import com.fleeksoft.ksoup.nodes.Node as DOMNode +import com.atlassian.prosemirror.model.util.mutableWeakMapOf +import com.fleeksoft.ksoup.nodes.TextNode +import com.fleeksoft.ksoup.parser.ParseSettings +import com.fleeksoft.ksoup.parser.Tag // A description of a DOM structure. Can be either a string, which is // interpreted as a text node, a DOM node, which is interpreted as @@ -97,7 +100,9 @@ open class DOMSerializer( internal fun serializeNodeInner(node: Node, document: Document?): DOMNode { val (dom, contentDOM) = renderSpec( doc(document), - nodes[node.type.name]?.invoke(node) ?: DOMOutputSpec.TextNodeDOMOutputSpec("") + nodes[node.type.name]?.invoke(node) ?: DOMOutputSpec.TextNodeDOMOutputSpec(""), + null, + node.attrs ) if (contentDOM != null) { if (node.isLeaf) { @@ -113,89 +118,28 @@ open class DOMSerializer( // document. To serialize a whole document, use // [`serializeFragment`](#model.DOMSerializer.serializeFragment) on // its [content](#model.Node.content). + fun serializeNode(node: Node, options: Document): DOMNode { + var dom = this.serializeNodeInner(node, options) + for (i in node.marks.indices.reversed()) { + val wrap = this.serializeMark(node.marks[i], node.isInline, options) + if (wrap != null) { + ((wrap.contentDOM ?: wrap.domNode) as? Element)?.appendChild(dom) + dom = wrap.domNode + } + } + return dom + } + internal fun serializeMark( mark: Mark, inline: Boolean, document: Document? ): DOMOutputSpec.ComplexNodeDOMOutputSpec? { val toDOM = this.marks[mark.type.name] ?: return null - return renderSpec(doc(document), toDOM(mark, inline)) + return renderSpec(doc(document), toDOM(mark, inline), null, mark.attrs) } companion object { - // Render an [output spec](#model.DOMOutputSpec) to a DOM node. If - // the spec has a hole (zero) in it, `contentDOM` will point at the - // node with the hole. - @Suppress("ComplexMethod", "NestedBlockDepth", "ReturnCount", "LongMethod") - fun renderSpec( - doc: Document, - structure: DOMOutputSpec, - xmlNamespace: String? = null - ): DOMOutputSpec.ComplexNodeDOMOutputSpec { - if (structure is DOMOutputSpec.TextNodeDOMOutputSpec) { - return DOMOutputSpec.ComplexNodeDOMOutputSpec(domNode = TextNode(structure.content)) - } - if (structure is DOMOutputSpec.DomNodeDOMOutputSpec) { - return DOMOutputSpec.ComplexNodeDOMOutputSpec(domNode = structure.domNode) - } - if (structure is DOMOutputSpec.ComplexNodeDOMOutputSpec) { - return structure.copy() - } - structure as DOMOutputSpec.ArrayDOMOutputSpec - var tagName = structure.content.first() as String - val space = tagName.indexOf(" ") - var xmlNS = xmlNamespace - if (space > 0) { - xmlNS = tagName.slice(0 until space) - tagName = tagName.slice(space + 1 until tagName.length) - } - var contentDOM: Element? = null -// val dom = if (xmlNS != null) doc.createElementNS(xmlNS, tagName) else doc.createElement(tagName) - val dom = doc.createElement(tagName) - val attrs = structure.content.getOrNull(1) - var start = 1 - // attrs != null && typeof attrs == "object" && attrs.nodeType == null && !Array.isArray(attrs) - if (attrs is Map<*, *>) { - start = 2 - for (name in attrs.keys) { - if (attrs[name] != null) { - val name = name.toString() - val space = name.indexOf(" ") - if (space > 0) { - dom.attr( -// name.slice(0 until space), - name.slice(space + 1..space + 1), - attrs[name].toString() - ) - } else { - dom.attr(name, attrs[name].toString()) - } - } - } - } - for (i in start until structure.content.size) { - val child = structure.content[i] - if (child == 0) { - if (i < structure.content.size - 1 || i > start) { - throw RangeError("Content hole must be the only child of its parent node") - } - return DOMOutputSpec.ComplexNodeDOMOutputSpec(dom, dom) - } else { - val spec = renderSpec(doc, child as DOMOutputSpec, xmlNS) - val inner = spec.domNode - val innerContent = spec.contentDOM - dom.appendChild(inner) - if (innerContent != null) { - if (contentDOM != null) { - throw RangeError("Multiple content holes") - } - contentDOM = innerContent as Element - } - } - } - return DOMOutputSpec.ComplexNodeDOMOutputSpec(dom, contentDOM) - } - // Build a serializer using the [`toDOM`](#model.NodeSpec.toDOM) // properties in a schema's node and mark specs. fun fromSchema(schema: Schema): DOMSerializer { @@ -245,3 +189,118 @@ fun gatherMarksToDOM(obj: Map): Map?>() + +fun suspiciousAttributes(attrs: Map): List? { + return suspiciousAttributeCache.getOrPut(attrs) { + suspiciousAttributesInner(attrs) + } +} + +fun suspiciousAttributesInner(attrs: Map): List? { + var result: MutableList? = null + fun scan(value: Any) { + if (value is List<*>) { + if (value.isNotEmpty() && value[0] is String) { + if (result == null) { + result = mutableListOf() + } + result?.add(value) + } else { + for (i in value.indices) { + scan(value[i]!!) + } + } + } else if (value is Map<*, *>) { + for (v in value.values) { + v?.let { scan(it) } + } + } + } + scan(attrs) + return result +} + +fun renderSpec( + doc: Document, + structure: DOMOutputSpec, + xmlNamespace: String? = null, + blockArraysIn: Map? = null +): DOMOutputSpec.ComplexNodeDOMOutputSpec { + var xmlNS = xmlNamespace + if (structure is DOMOutputSpec.TextNodeDOMOutputSpec) { + return DOMOutputSpec.ComplexNodeDOMOutputSpec(domNode = TextNode(structure.content)) + } + if (structure is DOMOutputSpec.DomNodeDOMOutputSpec) { + return DOMOutputSpec.ComplexNodeDOMOutputSpec(domNode = structure.domNode) + } + if (structure is DOMOutputSpec.ComplexNodeDOMOutputSpec) { + return structure + } + structure as DOMOutputSpec.ArrayDOMOutputSpec + var tagName = structure.content.first() as? String ?: throw RangeError("Invalid array passed to renderSpec") + val suspicious = blockArraysIn?.let { suspiciousAttributes(it) } + if (blockArraysIn != null && suspicious != null && suspicious.contains(structure.content)) { + throw RangeError("Using an array from an attribute object as a DOM spec. This may be an attempted cross site scripting attack.") + } + val space = tagName.indexOf(" ") + if (space > 0) { + xmlNS = tagName.substring(0, space) + tagName = tagName.substring(space + 1) + } + var contentDOM: Element? = null + val dom = if (xmlNS != null) doc.createElementNS(xmlNS, tagName) else doc.createElement(tagName) + val attrs = structure.content.getOrNull(1) + var start = 1 + if (attrs is Map<*, *>) { + start = 2 + for (name in attrs.keys) { + if (attrs[name] != null) { + val name = name.toString() + val space = name.indexOf(" ") + if (space > 0) { + dom.attr( +// name.substring(0, space), + name.substring(space + 1), + attrs[name].toString() + ) + } else { + dom.attr(name, attrs[name].toString()) + } + } + } + } + for (i in start until structure.content.size) { + val child = structure.content[i] as? DOMOutputSpec ?: 0 + if (child == 0) { + if (i < structure.content.size - 1 || i > start) { + throw RangeError("Content hole must be the only child of its parent node") + } + return DOMOutputSpec.ComplexNodeDOMOutputSpec(dom, dom) + } else { + val spec = renderSpec(doc, child as DOMOutputSpec, xmlNS, blockArraysIn) + val inner = spec.domNode + val innerContent = spec.contentDOM + dom.appendChild(inner) + if (innerContent != null) { + if (contentDOM != null) { + throw RangeError("Multiple content holes") + } + contentDOM = innerContent + } + } + } + return DOMOutputSpec.ComplexNodeDOMOutputSpec(dom, contentDOM) +} + +fun Document.createElementNS(namespace: String, tagName: String): Element { + return Element( + Tag.valueOf( + tagName, + namespace, + ParseSettings.preserveCase, + ), + this.baseUri(), + ) +} diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/comparedeep.ts b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/comparedeep.ts similarity index 100% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/comparedeep.ts rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/comparedeep.ts diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/content.ts b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/content.ts similarity index 98% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/content.ts rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/content.ts index e5a4255..9b13a10 100644 --- a/model/src/main/kotlin/com/atlassian/prosemirror/model/content.ts +++ b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/content.ts @@ -49,7 +49,7 @@ export class ContentMatch { /// @internal get inlineContent() { - return this.next.length && this.next[0].type.isInline + return this.next.length != 0 && this.next[0].type.isInline } /// Get the first matching node type at this match position that can @@ -197,14 +197,14 @@ type Expr = {type: "name", value: NodeType} function parseExpr(stream: TokenStream): Expr { - let exprs = [] + let exprs: Expr[] = [] do { exprs.push(parseExprSeq(stream)) } while (stream.eat("|")) return exprs.length == 1 ? exprs[0] : {type: "choice", exprs} } function parseExprSeq(stream: TokenStream): Expr { - let exprs = [] + let exprs: Expr[] = [] do { exprs.push(parseExprSubscript(stream)) } while (stream.next && stream.next != ")" && stream.next != "|") return exprs.length == 1 ? exprs[0] : {type: "seq", exprs} @@ -246,7 +246,7 @@ function parseExprRange(stream: TokenStream, expr: Expr): Expr { function resolveName(stream: TokenStream, name: string): readonly NodeType[] { let types = stream.nodeTypes, type = types[name] if (type) return [type] - let result = [] + let result: NodeType[] = [] for (let typeName in types) { let type = types[typeName] if (type.groups.indexOf(name) > -1) result.push(type) @@ -401,7 +401,7 @@ function dfa(nfa: Edge[][]): ContentMatch { function checkForDeadEnds(match: ContentMatch, stream: TokenStream) { for (let i = 0, work = [match]; i < work.length; i++) { - let state = work[i], dead = !state.validEnd, nodes = [] + let state = work[i], dead = !state.validEnd, nodes: string[] = [] for (let j = 0; j < state.next.length; j++) { let {type, next} = state.next[j] nodes.push(type.name) diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/diff.ts b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/diff.ts similarity index 100% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/diff.ts rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/diff.ts diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/dom.ts b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/dom.ts similarity index 100% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/dom.ts rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/dom.ts diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/fragment.ts b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/fragment.ts similarity index 93% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/fragment.ts rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/fragment.ts index 731e606..a0cd4a8 100644 --- a/model/src/main/kotlin/com/atlassian/prosemirror/model/fragment.ts +++ b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/fragment.ts @@ -45,29 +45,25 @@ export class Fragment { /// Call the given callback for every descendant node. `pos` will be /// relative to the start of the fragment. The callback may return /// `false` to prevent traversal of a given node's children. - descendants(f: (node: Node, pos: number, parent: Node | null) => boolean | void) { + descendants(f: (node: Node, pos: number, parent: Node | null, index: number) => boolean | void) { this.nodesBetween(0, this.size, f) } /// Extract the text between `from` and `to`. See the same method on /// [`Node`](#model.Node.textBetween). textBetween(from: number, to: number, blockSeparator?: string | null, leafText?: string | null | ((leafNode: Node) => string)) { - let text = "", separated = true + let text = "", first = true this.nodesBetween(from, to, (node, pos) => { - if (node.isText) { - text += node.text!.slice(Math.max(from, pos) - pos, to - pos) - separated = !blockSeparator - } else if (node.isLeaf) { - if (leafText) { - text += typeof leafText === "function" ? leafText(node) : leafText; - } else if (node.type.spec.leafText) { - text += node.type.spec.leafText(node); - } - separated = !blockSeparator; - } else if (!separated && node.isBlock) { - text += blockSeparator - separated = true + let nodeText = node.isText ? node.text!.slice(Math.max(from, pos) - pos, to - pos) + : !node.isLeaf ? "" + : leafText ? (typeof leafText === "function" ? leafText(node) : leafText) + : node.type.spec.leafText ? node.type.spec.leafText(node) + : "" + if (node.isBlock && (node.isLeaf && nodeText || node.isTextblock) && blockSeparator) { + if (first) first = false + else text += blockSeparator } + text += nodeText }, 0) return text } @@ -89,7 +85,7 @@ export class Fragment { /// Cut out the sub-fragment between the two given positions. cut(from: number, to = this.size) { if (from == 0 && to == this.size) return this - let result = [], size = 0 + let result: Node[] = [], size = 0 if (to > from) for (let i = 0, pos = 0; pos < to; i++) { let child = this.content[i], end = pos + child.nodeSize if (end > from) { @@ -193,7 +189,7 @@ export class Fragment { /// Find the index and inner offset corresponding to a given relative /// position in this fragment. The result object will be reused - /// (overwritten) the next time the function is called. (Not public.) + /// (overwritten) the next time the function is called. @internal findIndex(pos: number, round = -1): {index: number, offset: number} { if (pos == 0) return retIndex(0, pos) if (pos == this.size) return retIndex(this.content.length, pos) diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/from_dom.ts b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/from_dom.ts similarity index 75% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/from_dom.ts rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/from_dom.ts index fba9639..1f903fa 100644 --- a/model/src/main/kotlin/com/atlassian/prosemirror/model/from_dom.ts +++ b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/from_dom.ts @@ -45,33 +45,14 @@ export interface ParseOptions { context?: ResolvedPos /// @internal - ruleFromNode?: (node: DOMNode) => ParseRule | null + ruleFromNode?: (node: DOMNode) => Omit | null /// @internal topOpen?: boolean } -/// A value that describes how to parse a given DOM node or inline -/// style as a ProseMirror node or mark. -export interface ParseRule { - /// A CSS selector describing the kind of DOM elements to match. A - /// single rule should have _either_ a `tag` or a `style` property. - tag?: string - - /// The namespace to match. This should be used with `tag`. - /// Nodes are only matched when the namespace matches or this property - /// is null. - namespace?: string - - /// A CSS property name to match. When given, this rule matches - /// inline styles that list that property. May also have the form - /// `"property=value"`, in which case the rule only matches if the - /// property's value exactly matches the given value. (For more - /// complicated filters, use [`getAttrs`](#model.ParseRule.getAttrs) - /// and return false to indicate that the match failed.) Rules - /// matching styles may only produce [marks](#model.ParseRule.mark), - /// not nodes. - style?: string - +/// Fields that may be present in both [tag](#model.TagParseRule) and +/// [style](#model.StyleParseRule) parse rules. +export interface GenericParseRule { /// Can be used to change the order in which the parse rules in a /// schema are tried. Those with higher priority come first. Rules /// without a priority are counted as having priority 50. This @@ -98,22 +79,9 @@ export interface ParseRule { /// character, as in `"blockquote/|list_item/"`. context?: string - /// The name of the node type to create when this rule matches. Only - /// valid for rules with a `tag` property, not for style rules. Each - /// rule should have one of a `node`, `mark`, `clearMark`, or - /// `ignore` property (except when it appears in a - /// [node](#model.NodeSpec.parseDOM) or [mark - /// spec](#model.MarkSpec.parseDOM), in which case the `node` or - /// `mark` property will be derived from its position). - node?: string - /// The name of the mark type to wrap the matched content in. mark?: string - /// [Style](#model.ParseRule.style) rules can remove marks from the - /// set of active marks. - clearMark?: (mark: Mark) => boolean - /// When true, ignore content that matches this rule. ignore?: boolean @@ -128,23 +96,37 @@ export interface ParseRule { /// Attributes for the node or mark created by this rule. When /// `getAttrs` is provided, it takes precedence. attrs?: Attrs +} + +/// Parse rule targeting a DOM element. +export interface TagParseRule extends GenericParseRule { + /// A CSS selector describing the kind of DOM elements to match. + tag: string + + /// The namespace to match. Nodes are only matched when the + /// namespace matches or this property is null. + namespace?: string + + /// The name of the node type to create when this rule matches. Each + /// rule should have either a `node`, `mark`, or `ignore` property + /// (except when it appears in a [node](#model.NodeSpec.parseDOM) or + /// [mark spec](#model.MarkSpec.parseDOM), in which case the `node` + /// or `mark` property will be derived from its position). + node?: string /// A function used to compute the attributes for the node or mark /// created by this rule. Can also be used to describe further /// conditions the DOM element or style must match. When it returns /// `false`, the rule won't match. When it returns null or undefined, /// that is interpreted as an empty/default set of attributes. - /// - /// Called with a DOM Element for `tag` rules, and with a string (the - /// style's value) for `style` rules. - getAttrs?: (node: HTMLElement | string) => Attrs | false | null - - /// For `tag` rules that produce non-leaf nodes or marks, by default - /// the content of the DOM element is parsed as content of the mark - /// or node. If the child nodes are in a descendent node, this may be - /// a CSS selector string that the parser must use to find the actual - /// content element, or a function that returns the actual content - /// element to the parser. + getAttrs?: (node: HTMLElement) => Attrs | false | null + + /// For rules that produce non-leaf nodes, by default the content of + /// the DOM element is parsed as content of the node. If the child + /// nodes are in a descendent node, this may be a CSS selector + /// string that the parser must use to find the actual content + /// element, or a function that returns the actual content element + /// to the parser. contentElement?: string | HTMLElement | ((node: DOMNode) => HTMLElement) /// Can be used to override the content of a matched node. When @@ -160,14 +142,46 @@ export interface ParseRule { preserveWhitespace?: boolean | "full" } +/// A parse rule targeting a style property. +export interface StyleParseRule extends GenericParseRule { + /// A CSS property name to match. This rule will match inline styles + /// that list that property. May also have the form + /// `"property=value"`, in which case the rule only matches if the + /// property's value exactly matches the given value. (For more + /// complicated filters, use [`getAttrs`](#model.ParseRule.getAttrs) + /// and return false to indicate that the match failed.) Rules + /// matching styles may only produce [marks](#model.ParseRule.mark), + /// not nodes. + style: string + + /// Given to make TS see ParseRule as a tagged union @hide + tag?: undefined + + /// Style rules can remove marks from the set of active marks. + clearMark?: (mark: Mark) => boolean + + /// A function used to compute the attributes for the node or mark + /// created by this rule. Called with the style's value. + getAttrs?: (node: string) => Attrs | false | null +} + +/// A value that describes how to parse a given DOM node or inline +/// style as a ProseMirror node or mark. +export type ParseRule = TagParseRule | StyleParseRule + +function isTagRule(rule: ParseRule): rule is TagParseRule { return (rule as TagParseRule).tag != null } +function isStyleRule(rule: ParseRule): rule is StyleParseRule { return (rule as StyleParseRule).style != null } + /// A DOM parser represents a strategy for parsing DOM content into a /// ProseMirror document conforming to a given schema. Its behavior is /// defined by an array of [rules](#model.ParseRule). export class DOMParser { /// @internal - tags: ParseRule[] = [] + tags: TagParseRule[] = [] + /// @internal + styles: StyleParseRule[] = [] /// @internal - styles: ParseRule[] = [] + matchedStyles: readonly string[] /// @internal normalizeLists: boolean @@ -180,9 +194,15 @@ export class DOMParser { /// uses, in order of precedence. readonly rules: readonly ParseRule[] ) { + let matchedStyles: string[] = this.matchedStyles = [] rules.forEach(rule => { - if (rule.tag) this.tags.push(rule) - else if (rule.style) this.styles.push(rule) + if (isTagRule(rule)) { + this.tags.push(rule) + } else if (isStyleRule(rule)) { + let prop = /[^=]*/.exec(rule.style)![0] + if (matchedStyles.indexOf(prop) < 0) matchedStyles.push(prop) + this.styles.push(rule) + } }) // Only normalize list elements when lists in the schema can't directly contain themselves @@ -196,7 +216,7 @@ export class DOMParser { /// Parse a document from the content of a DOM node. parse(dom: DOMNode, options: ParseOptions = {}): Node { let context = new ParseContext(this, options, false) - context.addAll(dom, options.from, options.to) + context.addAll(dom, Mark.none, options.from, options.to) return context.finish() as Node } @@ -208,12 +228,12 @@ export class DOMParser { /// the left of the input and the end of nodes at the end. parseSlice(dom: DOMNode, options: ParseOptions = {}) { let context = new ParseContext(this, options, true) - context.addAll(dom, options.from, options.to) + context.addAll(dom, Mark.none, options.from, options.to) return Slice.maxOpen(context.finish() as Fragment) } /// @internal - matchTag(dom: DOMNode, context: ParseContext, after?: ParseRule) { + matchTag(dom: DOMNode, context: ParseContext, after?: TagParseRule) { for (let i = after ? this.tags.indexOf(after) + 1 : 0; i < this.tags.length; i++) { let rule = this.tags[i] if (matches(dom, rule.tag!) && @@ -230,7 +250,7 @@ export class DOMParser { } /// @internal - matchStyle(prop: string, value: string, context: ParseContext, after?: ParseRule) { + matchStyle(prop: string, value: string, context: ParseContext, after?: StyleParseRule) { for (let i = after ? this.styles.indexOf(after) + 1 : 0; i < this.styles.length; i++) { let rule = this.styles[i], style = rule.style! if (style.indexOf(prop) != 0 || @@ -265,16 +285,16 @@ export class DOMParser { for (let name in schema.marks) { let rules = schema.marks[name].spec.parseDOM if (rules) rules.forEach(rule => { - insert(rule = copy(rule)) - if (!(rule.mark || rule.ignore || rule.clearMark)) + insert(rule = copy(rule) as ParseRule) + if (!(rule.mark || rule.ignore || (rule as StyleParseRule).clearMark)) rule.mark = name }) } for (let name in schema.nodes) { let rules = schema.nodes[name].spec.parseDOM if (rules) rules.forEach(rule => { - insert(rule = copy(rule)) - if (!(rule.node || rule.ignore || rule.mark)) + insert(rule = copy(rule) as TagParseRule) + if (!((rule as TagParseRule).node || rule.ignore || rule.mark)) rule.node = name }) } @@ -319,16 +339,11 @@ class NodeContext { // Marks applied to the node's children activeMarks: readonly Mark[] = Mark.none - // Nested Marks with same type - stashMarks: Mark[] = [] constructor( readonly type: NodeType | null, readonly attrs: Attrs | null, - // Marks applied to this node itself readonly marks: readonly Mark[], - // Marks that can't apply here, but will be used in children if possible - public pendingMarks: readonly Mark[], readonly solid: boolean, match: ContentMatch | null, readonly options: number @@ -370,22 +385,6 @@ class NodeContext { return this.type ? this.type.create(this.attrs, content, this.marks) : content } - popFromStashMark(mark: Mark) { - for (let i = this.stashMarks.length - 1; i >= 0; i--) - if (mark.eq(this.stashMarks[i])) return this.stashMarks.splice(i, 1)[0] - } - - applyPending(nextType: NodeType) { - for (let i = 0, pending = this.pendingMarks; i < pending.length; i++) { - let mark = pending[i] - if ((this.type ? this.type.allowsMarkType(mark.type) : markMayApply(mark.type, nextType)) && - !mark.isInSet(this.activeMarks)) { - this.activeMarks = mark.addToSet(this.activeMarks) - this.pendingMarks = mark.removeFromSet(this.pendingMarks) - } - } - } - inlineContext(node: DOMNode) { if (this.type) return this.type.inlineContent if (this.content.length) return this.content[0].isInline @@ -409,12 +408,12 @@ class ParseContext { let topNode = options.topNode, topContext: NodeContext let topOptions = wsOptionsFor(null, options.preserveWhitespace, 0) | (isOpen ? OPT_OPEN_LEFT : 0) if (topNode) - topContext = new NodeContext(topNode.type, topNode.attrs, Mark.none, Mark.none, true, + topContext = new NodeContext(topNode.type, topNode.attrs, Mark.none, true, options.topMatch || topNode.type.contentMatch, topOptions) else if (isOpen) - topContext = new NodeContext(null, null, Mark.none, Mark.none, true, null, topOptions) + topContext = new NodeContext(null, null, Mark.none, true, null, topOptions) else - topContext = new NodeContext(parser.schema.topNodeType, null, Mark.none, Mark.none, true, null, topOptions) + topContext = new NodeContext(parser.schema.topNodeType, null, Mark.none, true, null, topOptions) this.nodes = [topContext] this.find = options.findPositions this.needsBlock = false @@ -427,25 +426,12 @@ class ParseContext { // Add a DOM node to the content. Text is inserted as text node, // otherwise, the node is passed to `addElement` or, if it has a // `style` attribute, `addElementWithStyles`. - addDOM(dom: DOMNode) { - if (dom.nodeType == 3) this.addTextNode(dom as Text) - else if (dom.nodeType == 1) this.addElement(dom as HTMLElement) - } - - withStyleRules(dom: HTMLElement, f: () => void) { - let style = dom.getAttribute("style") - if (!style) return f() - let marks = this.readStyles(parseStyles(style)) - if (!marks) return // A style with ignore: true - let [addMarks, removeMarks] = marks, top = this.top - for (let i = 0; i < removeMarks.length; i++) this.removePendingMark(removeMarks[i], top) - for (let i = 0; i < addMarks.length; i++) this.addPendingMark(addMarks[i]) - f() - for (let i = 0; i < addMarks.length; i++) this.removePendingMark(addMarks[i], top) - for (let i = 0; i < removeMarks.length; i++) this.addPendingMark(removeMarks[i]) + addDOM(dom: DOMNode, marks: readonly Mark[]) { + if (dom.nodeType == 3) this.addTextNode(dom as Text, marks) + else if (dom.nodeType == 1) this.addElement(dom as HTMLElement, marks) } - addTextNode(dom: Text) { + addTextNode(dom: Text, marks: readonly Mark[]) { let value = dom.nodeValue! let top = this.top if (top.options & OPT_PRESERVE_WS_FULL || @@ -469,7 +455,7 @@ class ParseContext { } else { value = value.replace(/\r\n?/g, "\n") } - if (value) this.insertNode(this.parser.schema.text(value)) + if (value) this.insertNode(this.parser.schema.text(value), marks) this.findInText(dom) } else { this.findInside(dom) @@ -478,14 +464,14 @@ class ParseContext { // Try to find a handler for the given tag and use that to parse. If // none is found, the element's content nodes are added directly. - addElement(dom: HTMLElement, matchAfter?: ParseRule) { - let name = dom.nodeName.toLowerCase(), ruleID: ParseRule | undefined + addElement(dom: HTMLElement, marks: readonly Mark[], matchAfter?: TagParseRule) { + let name = dom.nodeName.toLowerCase(), ruleID: TagParseRule | undefined if (listTags.hasOwnProperty(name) && this.parser.normalizeLists) normalizeList(dom) let rule = (this.options.ruleFromNode && this.options.ruleFromNode(dom)) || (ruleID = this.parser.matchTag(dom, this, matchAfter)) if (rule ? rule.ignore : ignoreTags.hasOwnProperty(name)) { this.findInside(dom) - this.ignoreFallback(dom) + this.ignoreFallback(dom, marks) } else if (!rule || rule.skip || rule.closeParent) { if (rule && rule.closeParent) this.open = Math.max(0, this.open - 1) else if (rule && (rule.skip as any).nodeType) dom = rule.skip as any as HTMLElement @@ -498,105 +484,110 @@ class ParseContext { sync = true if (!top.type) this.needsBlock = true } else if (!dom.firstChild) { - this.leafFallback(dom) + this.leafFallback(dom, marks) return } - if (rule && rule.skip) this.addAll(dom) - else this.withStyleRules(dom, () => this.addAll(dom)) + let innerMarks = rule && rule.skip ? marks : this.readStyles(dom, marks) + if (innerMarks) this.addAll(dom, innerMarks) if (sync) this.sync(top) this.needsBlock = oldNeedsBlock } else { - this.withStyleRules(dom, () => { - this.addElementByRule(dom, rule!, rule!.consuming === false ? ruleID : undefined) - }) + let innerMarks = this.readStyles(dom, marks) + if (innerMarks) + this.addElementByRule(dom, rule as TagParseRule, innerMarks, rule!.consuming === false ? ruleID : undefined) } } // Called for leaf DOM nodes that would otherwise be ignored - leafFallback(dom: DOMNode) { + leafFallback(dom: DOMNode, marks: readonly Mark[]) { if (dom.nodeName == "BR" && this.top.type && this.top.type.inlineContent) - this.addTextNode(dom.ownerDocument!.createTextNode("\n")) + this.addTextNode(dom.ownerDocument!.createTextNode("\n"), marks) } // Called for ignored nodes - ignoreFallback(dom: DOMNode) { + ignoreFallback(dom: DOMNode, marks: readonly Mark[]) { // Ignored BR nodes should at least create an inline context if (dom.nodeName == "BR" && (!this.top.type || !this.top.type.inlineContent)) - this.findPlace(this.parser.schema.text("-")) + this.findPlace(this.parser.schema.text("-"), marks) } // Run any style parser associated with the node's styles. Either - // return an array of marks, or null to indicate some of the styles - // had a rule with `ignore` set. - readStyles(styles: readonly string[]) { - let add = Mark.none, remove = Mark.none - for (let i = 0; i < styles.length; i += 2) { - for (let after = undefined;;) { - let rule = this.parser.matchStyle(styles[i], styles[i + 1], this, after) + // return an updated array of marks, or null to indicate some of the + // styles had a rule with `ignore` set. + readStyles(dom: HTMLElement, marks: readonly Mark[]) { + let styles = dom.style + // Because many properties will only show up in 'normalized' form + // in `style.item` (i.e. text-decoration becomes + // text-decoration-line, text-decoration-color, etc), we directly + // query the styles mentioned in our rules instead of iterating + // over the items. + if (styles && styles.length) for (let i = 0; i < this.parser.matchedStyles.length; i++) { + let name = this.parser.matchedStyles[i], value = styles.getPropertyValue(name) + if (value) for (let after: StyleParseRule | undefined = undefined;;) { + let rule = this.parser.matchStyle(name, value, this, after) if (!rule) break if (rule.ignore) return null - if (rule.clearMark) { - this.top.pendingMarks.concat(this.top.activeMarks).forEach(m => { - if (rule!.clearMark!(m)) remove = m.addToSet(remove) - }) - } else { - add = this.parser.schema.marks[rule.mark!].create(rule.attrs).addToSet(add) - } + if (rule.clearMark) + marks = marks.filter(m => !rule!.clearMark!(m)) + else + marks = marks.concat(this.parser.schema.marks[rule.mark!].create(rule.attrs)) if (rule.consuming === false) after = rule else break } } - return [add, remove] + return marks } // Look up a handler for the given node. If none are found, return // false. Otherwise, apply it, use its return value to drive the way // the node's content is wrapped, and return true. - addElementByRule(dom: HTMLElement, rule: ParseRule, continueAfter?: ParseRule) { - let sync, nodeType, mark + addElementByRule(dom: HTMLElement, rule: TagParseRule, marks: readonly Mark[], continueAfter?: TagParseRule) { + let sync, nodeType if (rule.node) { nodeType = this.parser.schema.nodes[rule.node] if (!nodeType.isLeaf) { - sync = this.enter(nodeType, rule.attrs || null, rule.preserveWhitespace) - } else if (!this.insertNode(nodeType.create(rule.attrs))) { - this.leafFallback(dom) + let inner = this.enter(nodeType, rule.attrs || null, marks, rule.preserveWhitespace) + if (inner) { + sync = true + marks = inner + } + } else if (!this.insertNode(nodeType.create(rule.attrs), marks)) { + this.leafFallback(dom, marks) } } else { let markType = this.parser.schema.marks[rule.mark!] - mark = markType.create(rule.attrs) - this.addPendingMark(mark) + marks = marks.concat(markType.create(rule.attrs)) } let startIn = this.top if (nodeType && nodeType.isLeaf) { this.findInside(dom) } else if (continueAfter) { - this.addElement(dom, continueAfter) + this.addElement(dom, marks, continueAfter) } else if (rule.getContent) { this.findInside(dom) - rule.getContent(dom, this.parser.schema).forEach(node => this.insertNode(node)) + rule.getContent(dom, this.parser.schema).forEach(node => this.insertNode(node, marks)) } else { let contentDOM = dom if (typeof rule.contentElement == "string") contentDOM = dom.querySelector(rule.contentElement)! else if (typeof rule.contentElement == "function") contentDOM = rule.contentElement(dom) else if (rule.contentElement) contentDOM = rule.contentElement this.findAround(dom, contentDOM, true) - this.addAll(contentDOM) + this.addAll(contentDOM, marks) } if (sync && this.sync(startIn)) this.open-- - if (mark) this.removePendingMark(mark, startIn) } // Add all child nodes between `startIndex` and `endIndex` (or the // whole node, if not given). If `sync` is passed, use it to // synchronize after every block element. - addAll(parent: DOMNode, startIndex?: number, endIndex?: number) { + addAll(parent: DOMNode, marks: readonly Mark[], startIndex?: number, endIndex?: number) { let index = startIndex || 0 for (let dom = startIndex ? parent.childNodes[startIndex] : parent.firstChild, end = endIndex == null ? null : parent.childNodes[endIndex]; dom != end; dom = dom!.nextSibling, ++index) { this.findAtPoint(parent, index) - this.addDOM(dom!) + this.addDOM(dom!, marks) } this.findAtPoint(parent, index) } @@ -604,7 +595,7 @@ class ParseContext { // Try to find a way to fit the given node type into the current // context. May add intermediate wrappers and/or leave non-solid // nodes that we're in. - findPlace(node: Node) { + findPlace(node: Node, marks: readonly Mark[]) { let route, sync: NodeContext | undefined for (let depth = this.open; depth >= 0; depth--) { let cx = this.nodes[depth] @@ -616,29 +607,29 @@ class ParseContext { } if (cx.solid) break } - if (!route) return false + if (!route) return null this.sync(sync!) for (let i = 0; i < route.length; i++) - this.enterInner(route[i], null, false) - return true + marks = this.enterInner(route[i], null, marks, false) + return marks } // Try to insert the given node, adjusting the context when needed. - insertNode(node: Node) { + insertNode(node: Node, marks: readonly Mark[]) { if (node.isInline && this.needsBlock && !this.top.type) { let block = this.textblockFromContext() - if (block) this.enterInner(block) + if (block) marks = this.enterInner(block, null, marks) } - if (this.findPlace(node)) { + let innerMarks = this.findPlace(node, marks) + if (innerMarks) { this.closeExtra() let top = this.top - top.applyPending(node.type) if (top.match) top.match = top.match.matchType(node.type) - let marks = top.activeMarks - for (let i = 0; i < node.marks.length; i++) - if (!top.type || top.type.allowsMarkType(node.marks[i].type)) - marks = node.marks[i].addToSet(marks) - top.content.push(node.mark(marks)) + let nodeMarks = Mark.none + for (let m of innerMarks.concat(node.marks)) + if (top.type ? top.type.allowsMarkType(m.type) : markMayApply(m.type, node.type)) + nodeMarks = m.addToSet(nodeMarks) + top.content.push(node.mark(nodeMarks)) return true } return false @@ -646,22 +637,31 @@ class ParseContext { // Try to start a node of the given type, adjusting the context when // necessary. - enter(type: NodeType, attrs: Attrs | null, preserveWS?: boolean | "full") { - let ok = this.findPlace(type.create(attrs)) - if (ok) this.enterInner(type, attrs, true, preserveWS) - return ok + enter(type: NodeType, attrs: Attrs | null, marks: readonly Mark[], preserveWS?: boolean | "full") { + let innerMarks = this.findPlace(type.create(attrs), marks) + if (innerMarks) innerMarks = this.enterInner(type, attrs, marks, true, preserveWS) + return innerMarks } // Open a node of the given type - enterInner(type: NodeType, attrs: Attrs | null = null, solid: boolean = false, preserveWS?: boolean | "full") { + enterInner(type: NodeType, attrs: Attrs | null, marks: readonly Mark[], + solid: boolean = false, preserveWS?: boolean | "full") { this.closeExtra() let top = this.top - top.applyPending(type) top.match = top.match && top.match.matchType(type) let options = wsOptionsFor(type, preserveWS, top.options) if ((top.options & OPT_OPEN_LEFT) && top.content.length == 0) options |= OPT_OPEN_LEFT - this.nodes.push(new NodeContext(type, attrs, top.activeMarks, top.pendingMarks, solid, null, options)) + let applyMarks = Mark.none + marks = marks.filter(m => { + if (top.type ? top.type.allowsMarkType(m.type) : markMayApply(m.type, type)) { + applyMarks = m.addToSet(applyMarks) + return false + } + return true + }) + this.nodes.push(new NodeContext(type, attrs, applyMarks, solid, null, options)) this.open++ + return marks } // Make sure all nodes above this.open are finished and added to @@ -773,35 +773,13 @@ class ParseContext { if (type.isTextblock && type.defaultAttrs) return type } } - - addPendingMark(mark: Mark) { - let found = findSameMarkInSet(mark, this.top.pendingMarks) - if (found) this.top.stashMarks.push(found) - this.top.pendingMarks = mark.addToSet(this.top.pendingMarks) - } - - removePendingMark(mark: Mark, upto: NodeContext) { - for (let depth = this.open; depth >= 0; depth--) { - let level = this.nodes[depth] - let found = level.pendingMarks.lastIndexOf(mark) - if (found > -1) { - level.pendingMarks = mark.removeFromSet(level.pendingMarks) - } else { - level.activeMarks = mark.removeFromSet(level.activeMarks) - let stashMark = level.popFromStashMark(mark) - if (stashMark && level.type && level.type.allowsMarkType(stashMark.type)) - level.activeMarks = stashMark.addToSet(level.activeMarks) - } - if (level == upto) break - } - } } // Kludge to work around directly nested list nodes produced by some // tools and allowed by browsers to mean that the nested list is // actually part of the list item above it. function normalizeList(dom: DOMNode) { - for (let child = dom.firstChild, prevItem = null; child; child = child.nextSibling) { + for (let child = dom.firstChild, prevItem: ChildNode | null = null; child; child = child.nextSibling) { let name = child.nodeType == 1 ? child.nodeName.toLowerCase() : null if (name && listTags.hasOwnProperty(name) && prevItem) { prevItem.appendChild(child) @@ -819,13 +797,6 @@ function matches(dom: any, selector: string): boolean { return (dom.matches || dom.msMatchesSelector || dom.webkitMatchesSelector || dom.mozMatchesSelector).call(dom, selector) } -// Tokenize a style attribute into property/value pairs. -function parseStyles(style: string): string[] { - let re = /\s*([\w-]+)\s*:\s*([^;]+)/g, m, result = [] - while (m = re.exec(style)) result.push(m[1], m[2].trim()) - return result -} - function copy(obj: {[prop: string]: any}) { let copy: {[prop: string]: any} = {} for (let prop in obj) copy[prop] = obj[prop] @@ -851,9 +822,3 @@ function markMayApply(markType: MarkType, nodeType: NodeType) { if (scan(parent.contentMatch)) return true } } - -function findSameMarkInSet(mark: Mark, set: readonly Mark[]) { - for (let i = 0; i < set.length; i++) { - if (mark.eq(set[i])) return set[i] - } -} diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/index.ts b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/index.ts similarity index 78% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/index.ts rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/index.ts index 4c449de..e131cae 100644 --- a/model/src/main/kotlin/com/atlassian/prosemirror/model/index.ts +++ b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/index.ts @@ -7,5 +7,5 @@ export {Mark} from "./mark" export {Schema, NodeType, Attrs, MarkType, NodeSpec, MarkSpec, AttributeSpec, SchemaSpec} from "./schema" export {ContentMatch} from "./content" -export {DOMParser, ParseRule, ParseOptions} from "./from_dom" +export {DOMParser, GenericParseRule, TagParseRule, StyleParseRule, ParseRule, ParseOptions} from "./from_dom" export {DOMSerializer, DOMOutputSpec} from "./to_dom" diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/mark.ts b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/mark.ts similarity index 97% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/mark.ts rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/mark.ts index 82d66ad..7ee3ca8 100644 --- a/model/src/main/kotlin/com/atlassian/prosemirror/model/mark.ts +++ b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/mark.ts @@ -82,7 +82,9 @@ export class Mark { if (!json) throw new RangeError("Invalid input for Mark.fromJSON") let type = schema.marks[json.type] if (!type) throw new RangeError(`There is no mark type ${json.type} in this schema`) - return type.create(json.attrs) + let mark = type.create(json.attrs) + type.checkAttrs(mark.attrs) + return mark } /// Test whether two sets of marks are identical. diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/node.ts b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/node.ts similarity index 95% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/node.ts rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/node.ts index aec9c19..26f49f8 100644 --- a/model/src/main/kotlin/com/atlassian/prosemirror/model/node.ts +++ b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/node.ts @@ -67,10 +67,11 @@ export class Node { /// Invoke a callback for all descendant nodes recursively between /// the given two positions that are relative to start of this /// node's content. The callback is invoked with the node, its - /// parent-relative position, its parent node, and its child index. - /// When the callback returns false for a given node, that node's - /// children will not be recursed over. The last parameter can be - /// used to specify a starting position to count from. + /// position relative to the original node (method receiver), + /// its parent node, and its child index. When the callback returns + /// false for a given node, that node's children will not be + /// recursed over. The last parameter can be used to specify a + /// starting position to count from. nodesBetween(from: number, to: number, f: (node: Node, pos: number, parent: Node | null, index: number) => void | boolean, startPos = 0) { @@ -295,12 +296,16 @@ export class Node { } /// Check whether this node and its descendants conform to the - /// schema, and raise error when they do not. + /// schema, and raise an exception when they do not. check() { - if (!this.type.validContent(this.content)) - throw new RangeError(`Invalid content for node ${this.type.name}: ${this.content.toString().slice(0, 50)}`) + this.type.checkContent(this.content) + this.type.checkAttrs(this.attrs) let copy = Mark.none - for (let i = 0; i < this.marks.length; i++) copy = this.marks[i].addToSet(copy) + for (let i = 0; i < this.marks.length; i++) { + let mark = this.marks[i] + mark.type.checkAttrs(mark.attrs) + copy = mark.addToSet(copy) + } if (!Mark.sameSet(copy, this.marks)) throw new RangeError(`Invalid collection of marks for node ${this.type.name}: ${this.marks.map(m => m.type.name)}`) this.content.forEach(node => node.check()) @@ -323,7 +328,7 @@ export class Node { /// Deserialize a node from its JSON representation. static fromJSON(schema: Schema, json: any): Node { if (!json) throw new RangeError("Invalid input for Node.fromJSON") - let marks = null + let marks: Mark[] | undefined = undefined if (json.marks) { if (!Array.isArray(json.marks)) throw new RangeError("Invalid mark data for Node.fromJSON") marks = json.marks.map(schema.markFromJSON) @@ -333,7 +338,9 @@ export class Node { return schema.text(json.text, marks) } let content = Fragment.fromJSON(schema, json.content) - return schema.nodeType(json.type).create(json.attrs, content, marks) + let node = schema.nodeType(json.type).create(json.attrs, content, marks) + node.type.checkAttrs(node.attrs) + return node } } diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/parser/AnySerialiser.kt b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/parser/AnySerialiser.kt similarity index 100% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/parser/AnySerialiser.kt rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/parser/AnySerialiser.kt diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/replace.ts b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/replace.ts similarity index 98% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/replace.ts rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/replace.ts index 519116f..9aa30a6 100644 --- a/model/src/main/kotlin/com/atlassian/prosemirror/model/replace.ts +++ b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/replace.ts @@ -180,8 +180,7 @@ function addRange($start: ResolvedPos | null, $end: ResolvedPos | null, depth: n } function close(node: Node, content: Fragment) { - if (!node.type.validContent(content)) - throw new ReplaceError("Invalid content for node " + node.type.name) + node.type.checkContent(content) return node.copy(content) } diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/resolvedpos.ts b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/resolvedpos.ts similarity index 95% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/resolvedpos.ts rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/resolvedpos.ts index 93635db..40e4c8e 100644 --- a/model/src/main/kotlin/com/atlassian/prosemirror/model/resolvedpos.ts +++ b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/resolvedpos.ts @@ -217,7 +217,7 @@ export class ResolvedPos { /// @internal static resolve(doc: Node, pos: number): ResolvedPos { if (!(pos >= 0 && pos <= doc.content.size)) throw new RangeError("Position " + pos + " out of range") - let path = [] + let path: Array = [] let start = 0, parentOffset = pos for (let node = doc;;) { let {index, offset} = node.content.findIndex(parentOffset) @@ -234,17 +234,27 @@ export class ResolvedPos { /// @internal static resolveCached(doc: Node, pos: number): ResolvedPos { - for (let i = 0; i < resolveCache.length; i++) { - let cached = resolveCache[i] - if (cached.pos == pos && cached.doc == doc) return cached + let cache = resolveCache.get(doc) + if (cache) { + for (let i = 0; i < cache.elts.length; i++) { + let elt = cache.elts[i] + if (elt.pos == pos) return elt + } + } else { + resolveCache.set(doc, cache = new ResolveCache) } - let result = resolveCache[resolveCachePos] = ResolvedPos.resolve(doc, pos) - resolveCachePos = (resolveCachePos + 1) % resolveCacheSize + let result = cache.elts[cache.i] = ResolvedPos.resolve(doc, pos) + cache.i = (cache.i + 1) % resolveCacheSize return result } } -let resolveCache: ResolvedPos[] = [], resolveCachePos = 0, resolveCacheSize = 12 +class ResolveCache { + elts: ResolvedPos[] = [] + i = 0 +} + +const resolveCacheSize = 12, resolveCache = new WeakMap() /// Represents a flat range of content, i.e. one that starts and /// ends in the same node. diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/schema.ts b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/schema.ts similarity index 86% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/schema.ts rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/schema.ts index 9a08eae..ea47d50 100644 --- a/model/src/main/kotlin/com/atlassian/prosemirror/model/schema.ts +++ b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/schema.ts @@ -5,7 +5,7 @@ import {Fragment} from "./fragment" import {Mark} from "./mark" import {ContentMatch} from "./content" import {DOMOutputSpec} from "./to_dom" -import {ParseRule} from "./from_dom" +import {ParseRule, TagParseRule} from "./from_dom" /// An object holding the attributes of a node. export type Attrs = {readonly [attr: string]: any} @@ -14,7 +14,7 @@ export type Attrs = {readonly [attr: string]: any} // have any attributes), build up a single reusable default attribute // object, and use it for all nodes that don't specify specific // attributes. -function defaultAttrs(attrs: Attrs) { +function defaultAttrs(attrs: {[name: string]: Attribute}) { let defaults = Object.create(null) for (let attrName in attrs) { let attr = attrs[attrName] @@ -24,7 +24,7 @@ function defaultAttrs(attrs: Attrs) { return defaults } -function computeAttrs(attrs: Attrs, value: Attrs | null) { +function computeAttrs(attrs: {[name: string]: Attribute}, value: Attrs | null) { let built = Object.create(null) for (let name in attrs) { let given = value && value[name] @@ -38,9 +38,18 @@ function computeAttrs(attrs: Attrs, value: Attrs | null) { return built } -function initAttrs(attrs?: {[name: string]: AttributeSpec}) { +export function checkAttrs(attrs: {[name: string]: Attribute}, values: Attrs, type: string, name: string) { + for (let name in values) + if (!(name in attrs)) throw new RangeError(`Unsupported attribute ${name} for ${type} of type ${name}`) + for (let name in attrs) { + let attr = attrs[name] + if (attr.validate) attr.validate(values[name]) + } +} + +function initAttrs(typeName: string, attrs?: {[name: string]: AttributeSpec}) { let result: {[name: string]: Attribute} = Object.create(null) - if (attrs) for (let name in attrs) result[name] = new Attribute(attrs[name]) + if (attrs) for (let name in attrs) result[name] = new Attribute(typeName, name, attrs[name]) return result } @@ -66,7 +75,7 @@ export class NodeType { readonly spec: NodeSpec ) { this.groups = spec.group ? spec.group.split(" ") : [] - this.attrs = initAttrs(spec.attrs) + this.attrs = initAttrs(name, spec.attrs) this.defaultAttrs = defaultAttrs(this.attrs) // Filled in later @@ -144,8 +153,7 @@ export class NodeType { /// if it doesn't match. createChecked(attrs: Attrs | null = null, content?: Fragment | Node | readonly Node[] | null, marks?: readonly Mark[]) { content = Fragment.from(content) - if (!this.validContent(content)) - throw new RangeError("Invalid content for node " + this.name) + this.checkContent(content) return new Node(this, this.computeAttrs(attrs), content, Mark.setFrom(marks)) } @@ -170,7 +178,7 @@ export class NodeType { } /// Returns true if the given fragment is valid content for this node - /// type with the given attributes. + /// type. validContent(content: Fragment) { let result = this.contentMatch.matchFragment(content) if (!result || !result.validEnd) return false @@ -179,6 +187,19 @@ export class NodeType { return true } + /// Throws a RangeError if the given fragment is not valid content for this + /// node type. + /// @internal + checkContent(content: Fragment) { + if (!this.validContent(content)) + throw new RangeError(`Invalid content for node ${this.name}: ${content.toString().slice(0, 50)}`) + } + + /// @internal + checkAttrs(attrs: Attrs) { + checkAttrs(this.attrs, attrs, "node", this.name) + } + /// Check whether the given mark type is allowed in this node. allowsMarkType(markType: MarkType) { return this.markSet == null || this.markSet.indexOf(markType) > -1 @@ -219,15 +240,25 @@ export class NodeType { } } +function validateType(typeName: string, attrName: string, type: string) { + let types = type.split("|") + return (value: any) => { + let name = value === null ? "null" : typeof value + if (types.indexOf(name) < 0) throw new RangeError(`Expected value of type ${types} for attribute ${attrName} on type ${typeName}, got ${name}`) + } +} + // Attribute descriptors class Attribute { hasDefault: boolean default: any + validate: undefined | ((value: any) => void) - constructor(options: AttributeSpec) { + constructor(typeName: string, attrName: string, options: AttributeSpec) { this.hasDefault = Object.prototype.hasOwnProperty.call(options, "default") this.default = options.default + this.validate = typeof options.validate == "string" ? validateType(typeName, attrName, options.validate) : options.validate } get isRequired() { @@ -260,7 +291,7 @@ export class MarkType { /// The spec on which the type is based. readonly spec: MarkSpec ) { - this.attrs = initAttrs(spec.attrs) + this.attrs = initAttrs(name, spec.attrs) ;(this as any).excluded = null let defaults = defaultAttrs(this.attrs) this.instance = defaults ? new Mark(this, defaults) : null @@ -297,6 +328,11 @@ export class MarkType { if (set[i].type == this) return set[i] } + /// @internal + checkAttrs(attrs: Attrs) { + checkAttrs(this.attrs, attrs, "mark", this.name) + } + /// Queries whether a given mark type is /// [excluded](#model.MarkSpec.excludes) by this one. excludes(other: MarkType) { @@ -422,7 +458,7 @@ export interface NodeSpec { /// implied (the name of this node will be filled in automatically). /// If you supply your own parser, you do not need to also specify /// parsing rules in your schema. - parseDOM?: readonly ParseRule[] + parseDOM?: readonly TagParseRule[] /// Defines the default way a node of this type should be serialized /// to a string representation for debugging (e.g. in error messages). @@ -434,6 +470,15 @@ export interface NodeSpec { /// [`Node.textContent`](#model.Node^textContent)). leafText?: (node: Node) => string + /// A single inline node in a schema can be set to be a linebreak + /// equivalent. When converting between block types that support the + /// node and block types that don't but have + /// [`whitespace`](#model.NodeSpec.whitespace) set to `"pre"`, + /// [`setBlockType`](#transform.Transform.setBlockType) will convert + /// between newline characters to or from linebreak nodes as + /// appropriate. + linebreakReplacement?: boolean + /// Node specs may include arbitrary properties that can be read by /// other code via [`NodeType.spec`](#model.NodeType.spec). [key: string]: any @@ -496,6 +541,15 @@ export interface AttributeSpec { /// provided whenever a node or mark of a type that has them is /// created. default?: any + /// A function or type name used to validate values of this + /// attribute. This will be used when deserializing the attribute + /// from JSON, and when running [`Node.check`](#model.Node.check). + /// When a function, it should raise an exception if the value isn't + /// of the expected type or shape. When a string, it should be a + /// `|`-separated string of primitive types (`"number"`, `"string"`, + /// `"boolean"`, `"null"`, and `"undefined"`), and the library will + /// raise an error when the value is not one of those types. + validate?: string | ((value: any) => void) } /// A document schema. Holds [node](#model.NodeType) and [mark @@ -523,13 +577,17 @@ export class Schema { /// A map from mark names to mark type objects. marks: {readonly [name in Marks]: MarkType} & {readonly [key: string]: MarkType} + /// The [linebreak + /// replacement](#model.NodeSpec.linebreakReplacement) node defined + /// in this schema, if any. + linebreakReplacement: NodeType | null = null + /// Construct a schema from a schema [specification](#model.SchemaSpec). constructor(spec: SchemaSpec) { - this.spec = { - nodes: OrderedMap.from(spec.nodes), - marks: OrderedMap.from(spec.marks || {}), - topNode: spec.topNode - } + let instanceSpec = this.spec = {} as any + for (let prop in spec) instanceSpec[prop] = (spec as any)[prop] + instanceSpec.nodes = OrderedMap.from(spec.nodes), + instanceSpec.marks = OrderedMap.from(spec.marks || {}), this.nodes = NodeType.compile(this.spec.nodes, this) this.marks = MarkType.compile(this.spec.marks, this) @@ -542,6 +600,11 @@ export class Schema { type.contentMatch = contentExprCache[contentExpr] || (contentExprCache[contentExpr] = ContentMatch.parse(contentExpr, this.nodes)) ;(type as any).inlineContent = type.contentMatch.inlineContent + if (type.spec.linebreakReplacement) { + if (this.linebreakReplacement) throw new RangeError("Multiple linebreak nodes defined") + if (!type.isInline || !type.isLeaf) throw new RangeError("Linebreak replacement nodes must be inline leaf nodes") + this.linebreakReplacement = type + } type.markSet = markExpr == "_" ? null : markExpr ? gatherMarks(this, markExpr.split(" ")) : markExpr == "" || !type.inlineContent ? [] : null @@ -618,7 +681,7 @@ export class Schema { } function gatherMarks(schema: Schema, marks: readonly string[]) { - let found = [] + let found: MarkType[] = [] for (let i = 0; i < marks.length; i++) { let name = marks[i], mark = schema.marks[name], ok = mark if (mark) { diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/to_dom.ts b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/to_dom.ts similarity index 63% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/to_dom.ts rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/to_dom.ts index a386888..b50be17 100644 --- a/model/src/main/kotlin/com/atlassian/prosemirror/model/to_dom.ts +++ b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/to_dom.ts @@ -20,7 +20,7 @@ import {DOMNode} from "./dom" /// where a node's child nodes should be inserted. If it occurs in an /// output spec, it should be the only child element in its parent /// node. -export type DOMOutputSpec = string | DOMNode | {dom: DOMNode, contentDOM?: HTMLElement} | [string, ...any] +export type DOMOutputSpec = string | DOMNode | {dom: DOMNode, contentDOM?: HTMLElement} | readonly [string, ...any[]] /// A DOM serializer knows how to convert ProseMirror nodes and /// marks of various types to DOM nodes. @@ -76,7 +76,7 @@ export class DOMSerializer { /// @internal serializeNodeInner(node: Node, options: {document?: Document}) { let {dom, contentDOM} = - DOMSerializer.renderSpec(doc(options), this.nodes[node.type.name](node)) + renderSpec(doc(options), this.nodes[node.type.name](node), null, node.attrs) if (contentDOM) { if (node.isLeaf) throw new RangeError("Content hole not allowed in a leaf node spec") @@ -105,54 +105,22 @@ export class DOMSerializer { /// @internal serializeMark(mark: Mark, inline: boolean, options: {document?: Document} = {}) { let toDOM = this.marks[mark.type.name] - return toDOM && DOMSerializer.renderSpec(doc(options), toDOM(mark, inline)) + return toDOM && renderSpec(doc(options), toDOM(mark, inline), null, mark.attrs) } /// Render an [output spec](#model.DOMOutputSpec) to a DOM node. If /// the spec has a hole (zero) in it, `contentDOM` will point at the /// node with the hole. - static renderSpec(doc: Document, structure: DOMOutputSpec, xmlNS: string | null = null): { + static renderSpec(doc: Document, structure: DOMOutputSpec, xmlNS?: string | null): { + dom: DOMNode, + contentDOM?: HTMLElement + } + static renderSpec(doc: Document, structure: DOMOutputSpec, xmlNS: string | null = null, + blockArraysIn?: {[name: string]: any}): { dom: DOMNode, contentDOM?: HTMLElement } { - if (typeof structure == "string") - return {dom: doc.createTextNode(structure)} - if ((structure as DOMNode).nodeType != null) - return {dom: structure as DOMNode} - if ((structure as any).dom && (structure as any).dom.nodeType != null) - return structure as {dom: DOMNode, contentDOM?: HTMLElement} - let tagName = (structure as [string])[0], space = tagName.indexOf(" ") - if (space > 0) { - xmlNS = tagName.slice(0, space) - tagName = tagName.slice(space + 1) - } - let contentDOM: HTMLElement | undefined - let dom = (xmlNS ? doc.createElementNS(xmlNS, tagName) : doc.createElement(tagName)) as HTMLElement - let attrs = (structure as any)[1], start = 1 - if (attrs && typeof attrs == "object" && attrs.nodeType == null && !Array.isArray(attrs)) { - start = 2 - for (let name in attrs) if (attrs[name] != null) { - let space = name.indexOf(" ") - if (space > 0) dom.setAttributeNS(name.slice(0, space), name.slice(space + 1), attrs[name]) - else dom.setAttribute(name, attrs[name]) - } - } - for (let i = start; i < (structure as any[]).length; i++) { - let child = (structure as any)[i] as DOMOutputSpec | 0 - if (child === 0) { - if (i < (structure as any[]).length - 1 || i > start) - throw new RangeError("Content hole must be the only child of its parent node") - return {dom, contentDOM: dom} - } else { - let {dom: inner, contentDOM: innerContent} = DOMSerializer.renderSpec(doc, child, xmlNS) - dom.appendChild(inner) - if (innerContent) { - if (contentDOM) throw new RangeError("Multiple content holes") - contentDOM = innerContent as HTMLElement - } - } - } - return {dom, contentDOM} + return renderSpec(doc, structure, xmlNS, blockArraysIn) } /// Build a serializer using the [`toDOM`](#model.NodeSpec.toDOM) @@ -188,3 +156,82 @@ function gatherToDOM(obj: {[node: string]: NodeType | MarkType}) { function doc(options: {document?: Document}) { return options.document || window.document } + +const suspiciousAttributeCache = new WeakMap() + +function suspiciousAttributes(attrs: {[name: string]: any}): readonly any[] | null { + let value = suspiciousAttributeCache.get(attrs) + if (value === undefined) + suspiciousAttributeCache.set(attrs, value = suspiciousAttributesInner(attrs)) + return value +} + +function suspiciousAttributesInner(attrs: {[name: string]: any}): readonly any[] | null { + let result: any[] | null = null + function scan(value: any) { + if (value && typeof value == "object") { + if (Array.isArray(value)) { + if (typeof value[0] == "string") { + if (!result) result = [] + result.push(value) + } else { + for (let i = 0; i < value.length; i++) scan(value[i]) + } + } else { + for (let prop in value) scan(value[prop]) + } + } + } + scan(attrs) + return result +} + +function renderSpec(doc: Document, structure: DOMOutputSpec, xmlNS: string | null, + blockArraysIn?: {[name: string]: any}): { + dom: DOMNode, + contentDOM?: HTMLElement +} { + if (typeof structure == "string") + return {dom: doc.createTextNode(structure)} + if ((structure as DOMNode).nodeType != null) + return {dom: structure as DOMNode} + if ((structure as any).dom && (structure as any).dom.nodeType != null) + return structure as {dom: DOMNode, contentDOM?: HTMLElement} + let tagName = (structure as [string])[0], suspicious + if (typeof tagName != "string") throw new RangeError("Invalid array passed to renderSpec") + if (blockArraysIn && (suspicious = suspiciousAttributes(blockArraysIn)) && + suspicious.indexOf(structure) > -1) + throw new RangeError("Using an array from an attribute object as a DOM spec. This may be an attempted cross site scripting attack.") + let space = tagName.indexOf(" ") + if (space > 0) { + xmlNS = tagName.slice(0, space) + tagName = tagName.slice(space + 1) + } + let contentDOM: HTMLElement | undefined + let dom = (xmlNS ? doc.createElementNS(xmlNS, tagName) : doc.createElement(tagName)) as HTMLElement + let attrs = (structure as any)[1], start = 1 + if (attrs && typeof attrs == "object" && attrs.nodeType == null && !Array.isArray(attrs)) { + start = 2 + for (let name in attrs) if (attrs[name] != null) { + let space = name.indexOf(" ") + if (space > 0) dom.setAttributeNS(name.slice(0, space), name.slice(space + 1), attrs[name]) + else dom.setAttribute(name, attrs[name]) + } + } + for (let i = start; i < (structure as readonly any[]).length; i++) { + let child = (structure as any)[i] as DOMOutputSpec | 0 + if (child === 0) { + if (i < (structure as readonly any[]).length - 1 || i > start) + throw new RangeError("Content hole must be the only child of its parent node") + return {dom, contentDOM: dom} + } else { + let {dom: inner, contentDOM: innerContent} = renderSpec(doc, child, xmlNS, blockArraysIn) + dom.appendChild(inner) + if (innerContent) { + if (contentDOM) throw new RangeError("Multiple content holes") + contentDOM = innerContent as HTMLElement + } + } + } + return {dom, contentDOM} +} diff --git a/model/src/main/kotlin/com/atlassian/prosemirror/model/util/Utils.kt b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/util/Utils.kt similarity index 64% rename from model/src/main/kotlin/com/atlassian/prosemirror/model/util/Utils.kt rename to model/src/commonMain/kotlin/com/atlassian/prosemirror/model/util/Utils.kt index c0e0ced..d897ed0 100644 --- a/model/src/main/kotlin/com/atlassian/prosemirror/model/util/Utils.kt +++ b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/util/Utils.kt @@ -4,6 +4,7 @@ import com.atlassian.prosemirror.model.Node import com.atlassian.prosemirror.model.RangeError import com.atlassian.prosemirror.model.ResolvedPos import com.atlassian.prosemirror.util.safeMode +import com.fleeksoft.ksoup.nodes.Node as DOMNode fun Node.resolveSafe(from: Int, to: Int): Pair? { try { @@ -24,3 +25,14 @@ fun Node.resolveSafe(pos: Int): ResolvedPos? { return null } } + +fun DOMNode.contains(node: DOMNode) = isInclusiveAncestor(this, node) + +private fun isInclusiveAncestor(parent: DOMNode, node: DOMNode): Boolean { + var current: com.fleeksoft.ksoup.nodes.Node? = node + while (current != null) { + if (current == parent) return true + current = current.parent() + } + return false +} diff --git a/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/util/WeakMap.kt b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/util/WeakMap.kt new file mode 100644 index 0000000..bce4e22 --- /dev/null +++ b/model/src/commonMain/kotlin/com/atlassian/prosemirror/model/util/WeakMap.kt @@ -0,0 +1,19 @@ +package com.atlassian.prosemirror.model.util + +// Implementation of WeakMap for JS, which is a map with weak keys +interface WeakMap { + fun getOrPut(key: K, defaultValue: () -> V): V { + val value = get(key) + return if (value == null) { + val answer = defaultValue() + put(key, answer) + answer + } else { + value + } + } + fun get(key: K): V? + fun put(key: K, value: V) +} + +expect fun mutableWeakMapOf(): WeakMap diff --git a/model/src/test/kotlin/com/atlassian/prosemirror/model/ContentTest.kt b/model/src/commonTest/kotlin/com/atlassian/prosemirror/model/ContentTest.kt similarity index 97% rename from model/src/test/kotlin/com/atlassian/prosemirror/model/ContentTest.kt rename to model/src/commonTest/kotlin/com/atlassian/prosemirror/model/ContentTest.kt index a239506..d96f160 100644 --- a/model/src/test/kotlin/com/atlassian/prosemirror/model/ContentTest.kt +++ b/model/src/commonTest/kotlin/com/atlassian/prosemirror/model/ContentTest.kt @@ -1,9 +1,13 @@ package com.atlassian.prosemirror.model +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isNull +import assertk.assertions.isTrue import com.atlassian.prosemirror.testbuilder.PMNodeBuilder.Companion.doc import com.atlassian.prosemirror.testbuilder.PMNodeBuilder.Companion.p import com.atlassian.prosemirror.testbuilder.schema -import org.assertj.core.api.Assertions.assertThat import kotlin.test.Test fun get(expr: String) = ContentMatch.parse(expr, schema.nodes) @@ -21,11 +25,11 @@ fun match(expr: String, types: String): Boolean { } fun valid(expr: String, types: String) { - assertThat(match(expr, types)).isTrue + assertThat(match(expr, types)).isTrue() } fun invalid(expr: String, types: String) { - assertThat(match(expr, types)).isFalse + assertThat(match(expr, types)).isFalse() } fun fill(expr: String, before: Node, after: Node, result: Node?) { diff --git a/model/src/commonTest/kotlin/com/atlassian/prosemirror/model/DomTest.kt b/model/src/commonTest/kotlin/com/atlassian/prosemirror/model/DomTest.kt new file mode 100644 index 0000000..afe705a --- /dev/null +++ b/model/src/commonTest/kotlin/com/atlassian/prosemirror/model/DomTest.kt @@ -0,0 +1,1281 @@ +package com.atlassian.prosemirror.model + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.atlassian.prosemirror.testbuilder.MarkSpecImpl +import com.atlassian.prosemirror.testbuilder.NodeBuildCompanion +import com.atlassian.prosemirror.testbuilder.NodeBuilder +import com.atlassian.prosemirror.testbuilder.NodeSpecImpl +import com.atlassian.prosemirror.testbuilder.PMNodeBuilder.Companion.doc +import kotlin.test.Test +import com.atlassian.prosemirror.testbuilder.schema as testSchema +import com.atlassian.prosemirror.testbuilder.AttributeSpecImpl +import com.atlassian.prosemirror.testbuilder.PMNodeBuilder +import com.atlassian.prosemirror.testbuilder.PMNodeBuilder.Companion.pos +import com.fleeksoft.ksoup.nodes.Element +import com.fleeksoft.ksoup.nodes.Node as DOMNode +import assertk.assertions.isTrue +import com.fleeksoft.ksoup.nodes.TextNode +import kotlin.test.assertFailsWith + +class CommentNodeBuilder( + pos: Int = 0, + marks: List = emptyList(), + override val schema: Schema = testSchema +) : NodeBuilder(pos, marks, schema) { + override val checked: Boolean + get() = false + + override fun create(pos: Int, marks: List, schema: Schema): NodeBuilder { + return CommentNodeBuilder(pos, marks, schema) + } +} + +class CustomNodeBuildCompanion(schema: Schema): NodeBuildCompanion(schema) { + override val checked: Boolean + get() = false + + override fun create(): CommentNodeBuilder { + return CommentNodeBuilder(schema = schema) + } +} + +class DomTest { + //region DOMParser + fun test(doc: Node, html: String) { + val schema = doc.type.schema + val innerHTML = DOMSerializer.fromSchema(schema).serializeFragmentToHtml(doc.content) + assertThat(innerHTML).isEqualTo(html) + val parsedDoc = DOMParser.fromSchema(schema).parseHtml(innerHTML) + assertThat(parsedDoc).isEqualTo(doc) + } + + @Test + fun `can represent simple node`() { + test(doc { p { +"hello" } }, "

hello

") + } + + @Test + fun `can represent a line break`() { + test(doc { p { +"hi" + br {} + "there" } }, "

hi
there

") + } + + @Test + fun `can represent an image`() { + test( + doc { p { +"hi" + img(mapOf("alt" to "x")) {} + "there" } }, + "

hi\"x\"there

" + ) + } + + @Test + fun `joins styles`() { + test( + doc { p { +"one" + strong { +"two" + em { +"three" } } + em { +"four" } + "five" } }, + "

onetwothreefourfive

" + ) + } + + @Test + fun `can represent links`() { + // custom link mark that has a title=null attribute + fun NodeBuilder.aWithTitle(href: String = "foo", func: NodeBuilder.() -> Unit) = + mark("link", func, attrs = mapOf("href" to href, "title" to null)) + + test( + // TypeScript code: doc(p("a ", a({href: "foo"}, "big ", a({href: "bar"}, "nested"), " link"))) + // converts to the code below because each node cannot have more than 1 Link mark + doc { + p { + +"a " + + aWithTitle(href = "foo") { +"big "} + + aWithTitle(href = "bar") { +"nested" } + + aWithTitle(href = "foo") { +" link" } + } + }, + "

a big nested link

" + ) + } + + @Test + fun `can represent and unordered list`() { + test( + doc { + ul { + li { p { +"one" } } + + li { p { +"two" } } + + li { p { +"three" + strong { +"!" } } } + } + + p { +"after" } + }, + "
  • one

  • two

  • three!

after

" + ) + } + + @Test + fun `can represent an ordered list`() { + test( + doc { + ol { + li { p { +"one" } } + + li { p { +"two" } } + + li { p { +"three" + strong { +"!" } } } + } + + p { +"after" } + }, + "
  1. one

  2. two

  3. three!

after

" + ) + } + + @Test + fun `can represent a blockquote`() { + test( + doc { blockquote { p { +"hello" } + p { +"bye" } } }, + "

hello

bye

" + ) + } + + @Test + fun `can represent a nested blockquote`() { + test( + doc { blockquote { blockquote { blockquote { p { +"he said" } } } + p { +"i said" } } }, + "

he said

i said

" + ) + } + + @Test + fun `can represent headings`() { + test( + doc { h1 { +"one" } + h2 { +"two" } + p { +"text" } }, + "

one

two

text

" + ) + } + + @Test + fun `can represent inline code`() { + test( + doc { p { +"text and " + code { +"code that is " + em { +"emphasized" } + "..." } } }, + "

text and code that is emphasized...

" + ) + } + + @Test + fun `can represent a code block`() { + test( + doc { blockquote { pre { +"some code" } } + p { +"and" } }, + "
some code

and

" + ) + } + + @Test + fun `supports leaf nodes in marks`() { + test( + doc { p { em { +"hi" + br {} + "x" } } }, + "

hi
x

" + ) + } + + @Test + fun `doesn't collapse non-breaking spaces`() { + test( + doc { p { +"\u00a0 \u00a0hello\u00a0" } }, + "

   hello 

" + ) + } + + @Test + fun `can parse marks on block nodes`() { + val schemaWithComment = Schema( + SchemaSpec( + nodes = testSchema.spec.nodes + mapOf( + "doc" to (testSchema.spec.nodes["doc"] as NodeSpecImpl).copy(marks = "comment") + ), + marks = testSchema.spec.marks + mapOf( + "comment" to MarkSpecImpl( + parseDOM = listOf(TagParseRuleImpl(tag = "div.comment")), + toDOM = { _, _ -> + DOMOutputSpec.ArrayDOMOutputSpec(listOf("div", mapOf("class" to "comment"), 0)) + } + ) + ) + ) + ) + + fun NodeBuilder.comment(func: NodeBuilder.() -> Unit) = + mark("comment", func) + + val doc = CustomNodeBuildCompanion(schemaWithComment).doc { + p { +"one" } + this.comment { p { +"two" } + p { strong { +"three" } } } + p { +"four" } + } + test( + doc, + "

one

two

three

four

" + ) + } + + @Test + fun `parses unique non-exclusive same-typed marks`() { + val commentSchema = Schema( + SchemaSpec( + nodes = testSchema.spec.nodes, + marks = testSchema.spec.marks + mapOf( + "comment" to MarkSpecImpl( + attrs = mapOf("id" to AttributeSpecImpl(default = null)), + parseDOM = listOf( + TagParseRuleImpl( + tag = "span.comment", + getNodeAttrs = { dom -> + val id = dom.attribute("data-id")?.int() ?: 10 + ParseRuleMatch(mapOf("id" to id)) + }, + ) + ), + excludes = "", + toDOM = { mark, _ -> + DOMOutputSpec.ArrayDOMOutputSpec( + listOf( + "span", + mapOf("class" to "comment", "data-id" to mark.attrs["id"]), + 0 + ) + ) + } + ) + ) + ) + ) + val doc = commentSchema.nodes["doc"]!!.createAndFill( + attrs = null, + content = listOf( + commentSchema.nodes["paragraph"]!!.createAndFill( + attrs = null, + content = listOf( + commentSchema.text( + text = "double comment", + marks = listOf( + commentSchema.marks["comment"]!!.create(mapOf("id" to 1)), + commentSchema.marks["comment"]!!.create(mapOf("id" to 2)) + ) + ) + ), + marks = null + )!! + ), + marks = null + )!! + test( + doc, + "

double comment

" + ) + } + + @Test + fun `serializes non-spanning marks correctly`() { + val markSchema = Schema( + SchemaSpec( + nodes = testSchema.spec.nodes, + marks = testSchema.spec.marks + mapOf( + "test" to MarkSpecImpl( + parseDOM = listOf(TagParseRuleImpl(tag = "test")), + toDOM = { _, _ -> DOMOutputSpec.ArrayDOMOutputSpec(listOf("test", 0)) }, + spanning = false + ) + ) + ) + ) + val b = CustomNodeBuildCompanion(markSchema) + + fun NodeBuilder.test(func: NodeBuilder.() -> Unit) = + mark("test", func) + + test( + b.doc { p { test { +"a" + img(mapOf("src" to "x")) {} + "b" } } }, + "

ab

" + ) + } + + // Skipping the following tests because we don't support them yet +// it("serializes an element and an attribute with XML namespace", () => { +// let xmlnsSchema = new Schema({ +// nodes: { +// doc: { content: "svg*" }, text: {}, +// "svg": { +// parseDOM: [{tag: "svg", namespace: 'http://www.w3.org/2000/svg'}], +// group: 'block', +// toDOM() { return ["http://www.w3.org/2000/svg svg", ["use", { "http://www.w3.org/1999/xlink href": "#svg-id" }]] }, +// }, +// }, +// }) +// +// let b = builders(xmlnsSchema) as any +// let d = b.doc(b.svg()) +// test(d, '', xmlDocument)() +// +// let dom = xmlDocument.createElement('div') +// dom.appendChild(DOMSerializer.fromSchema(xmlnsSchema).serializeFragment(d.content, {document: xmlDocument})) +// ist(dom.querySelector('svg').namespaceURI, 'http://www.w3.org/2000/svg') +// ist(dom.querySelector('use').namespaceURI, 'http://www.w3.org/2000/svg') +// ist(dom.querySelector('use').attributes[0].namespaceURI, 'http://www.w3.org/1999/xlink') +// }) + + fun recover(html: String, doc: Node, options: ParseOptions = ParseOptionsImpl()) { + val schema = doc.type.schema + val parsedDoc = DOMParser.fromSchema(schema).parseHtml(html, options) + assertThat(parsedDoc).isEqualTo(doc) + } + + @Test + fun `can recover a list item`() { + recover( + "

    Oh no

", + doc { ol { li { p { +"Oh no" } } } } + ) + } + + @Test + fun `wraps a list item in a list`() { + recover( + "
  • hey
  • ", + doc { ol { li { p { +"hey" } } } } + ) + } + + @Test + fun `can turn divs into paragraphs`() { + recover( + "
    hi
    bye
    ", + doc { p { +"hi" } + p { +"bye" } } + ) + } + + @Test + fun `interprets i and b as emphasis and strong`() { + recover( + "

    hello there

    ", + doc { p { em { +"hello " + strong { +"there" } } } } + ) + } + + @Test + fun `wraps stray text in a paragraph`() { + recover( + "hi", + doc { p { +"hi" } } + ) + } + + @Test + fun `ignores an extra wrapping _div_`() { + recover( + "

    one

    two

    ", + doc { p { +"one" } + p { +"two" } } + ) + } + + @Test + fun `ignores meaningless whitespace`() { + recover( + "

    woo \n hooo

    ", + doc { blockquote { p { +"woo " + em { +"hooo" } } } } + ) + } + + @Test + fun `removes whitespace after a hard break`() { + recover( + "

    hello
    \n world

    ", + doc { p { +"hello" + br {} + "world" } } + ) + } + + @Test + fun `converts br nodes to newlines when they would otherwise be ignored`() { + recover( + "
    foo
    bar
    ", + doc { pre { +"foo\nbar" } } + ) + } + + @Test + fun `finds a valid place for invalid content`() { + recover( + "
    • hi
    • whoah

    • again
    ", + doc { ul { li { p { +"hi" } } + li { p { +"whoah" } } + li { p { +"again" } } } } + ) + } + + @Test + fun `moves nodes up when they don't fit the current context`() { + recover( + "
    hello
    bye
    ", + doc { p { +"hello" } + hr {} + p { +"bye" } } + ) + } + + @Test + fun `doesn't ignore whitespace-only text nodes`() { + recover( + "

    one two

    ", + doc { p { em { +"one" } + " " + strong { +"two" } } } + ) + } + + @Test + fun `can handle stray tab characters`() { + recover( + "

    ", + doc { p { } } + ) + } + + @Test + fun `normalizes random spaces`() { + recover( + "

    1

    ", + doc { p { strong { +"1" } } } + ) + } + + @Test + fun `can parse an empty code block`() { + recover( + "
    ",
    +            doc { pre { } }
    +        )
    +    }
    +
    +    @Test
    +    fun `preserves trailing space in a code block`() {
    +        recover(
    +            "
    foo\n
    ", + doc { pre { +"foo\n" } } + ) + } + + @Test + fun `normalizes newlines when preserving whitespace`() { + recover( + "

    foo bar\nbaz

    ", + doc { p { +"foo bar baz" } }, + options = ParseOptionsImpl(preserveWhitespace = PreserveWhitespace.YES) + ) + } + + @Test + fun `ignores script tags`() { + recover( + "

    hello!

    ", + doc { p { +"hello!" } } + ) + } + + @Test + fun `can handle a head body input structure`() { + recover( + "Thi", + doc { p { +"hi" } } + ) + } + + @Test + fun `only applies a mark once`() { + recover( + "

    A big strong monster.

    ", + doc { p { +"A " + strong { +"big strong monster" } + "." } } + ) + } + + @Test + fun `interprets font-style italic as em`() { + recover( + "

    Hello!

    ", + doc { p { em { +"Hello" } + "!" } } + ) + } + + @Test + fun `interprets font-weight bold as strong`() { + recover( + "

    Hello

    ", + doc { p { strong { +"Hello" } } } + ) + } + + @Test + fun `allows clearing of pending marks`() { + recover( + "

    One

    Two

    ", + doc { blockquote { p { +"One" } + p { em { +"Two" } } } } + ) + } + + @Test + fun `allows clearing of active marks`() { + recover( + "
    • Foo" + + "Bar

    ", + doc { ul { li { p { em { +"Foo" } + "Bar" } } } } + ) + } + + @Test + fun `ignores unknown inline tags`() { + recover( + "

    abc

    ", + doc { p { +"abc" } } + ) + } + + @Test + fun `keeps applying a mark for the all of the node's content`() { + recover( + "

    xxbar

    ", + doc { p { strong { +"xxbar" } } } + ) + } + + @Test + fun `doesn't ignore whitespace-only nodes in preserveWhitespace full mode`() { + recover( + " x", + doc { p { +" x" } }, + options = ParseOptionsImpl(preserveWhitespace = PreserveWhitespace.FULL) + ) + } + + @Test + fun `closes block with inline content on seeing block-level children`() { + recover( + "

    CCC
    DDD

    ", + doc { p { br {} } + p { +"CCC" } + p { +"DDD" } + p { br {} } } + ) + } + + private fun parse(html: String, options: ParseOptions, doc: Node) { + val schema = doc.type.schema + val dom = doc().createElement("div") + dom.html(html) + val result = DOMParser.fromSchema(schema).parse(dom, options) + assertThat(result).isEqualTo(doc) + } + + @Test + fun `accepts the topNode option`() { + parse( + "
  • wow
  • such
  • ", + ParseOptionsImpl(topNode = testSchema.nodes["bullet_list"]!!.createAndFill()!!), + doc { ul { li { p { +"wow" } } + li { p { +"such" } } } }.firstChild!! + ) + } + + @Test + fun `accepts the topMatch option`() { + val item = testSchema.nodes["list_item"]!!.createAndFill()!! + parse( + "
    • x
    ", + ParseOptionsImpl(topNode = item, topMatch = item.contentMatchAt(1)!!), + doc { li { ul { li { p { +"x" } } } } }.firstChild!! + ) + } + + @Test + fun `accepts from and to options`() { + parse( + "

    foo

    bar

    ", + ParseOptionsImpl(from = 1, to = 3), + doc { p { +"foo" } + p { +"bar" } } + ) + } + + @Test + fun `accepts the preserveWhitespace option`() { + parse( + "foo bar", + ParseOptionsImpl(preserveWhitespace = PreserveWhitespace.YES), + doc { p { +"foo bar" } } + ) + } + + private fun open(html: String, nodes: List, openStart: Int, openEnd: Int, options: ParseOptions = ParseOptionsImpl()) { + val schema = testSchema + val dom = doc().createElement("div") + dom.html(html) + val result = DOMParser.fromSchema(schema).parseSlice(dom, options) + assertThat(result).isEqualTo( + Slice( + Fragment.from(nodes), + openStart, + openEnd + ) + ) + } + + @Test + fun `can parse an open slice`() { + open("foo", listOf(testSchema.text("foo")), 0, 0) + } + + @Test + fun `will accept weird siblings`() { + val doc = doc { p { +"bar" } } + open("foo

    bar

    ", listOf(testSchema.text("foo"), doc.firstChild!!), 0, 1) + } + + @Test + fun `will open all the way to the inner nodes`() { + val doc = doc { ul { li { p { +"foo" } } + li { p { +"bar" + br {} } } } } + open( + "
    • foo
    • bar
    ", + doc.content.content, + 3, + 3 + ) + } + + @Test + fun `accepts content open to the left`() { + val doc = doc { li { ul { li { p { +"a" } } } } } + open("
    • a
  • ", listOf(doc.firstChild!!), 4, 4) + } + + @Test + fun `accepts content open to the right`() { + val doc = doc { li { p { +"foo" } } + li {} } + open("
  • foo
  • ", doc.content.content, 2, 1) + } + + @Test + fun `will create textblocks for block nodes`() { + val doc = doc { p { +"foo" } + p { +"bar" } } + open("
    foo
    bar
    ", doc.content.content, 1, 1) + } + + @Test + fun `can parse marks at the start of defaulted textblocks`() { + val doc = doc { p { +"foo" } + p { em { +"bar" } } } + open("
    foo
    bar
    ", doc.content.content, 1, 1) + } + + @Test + fun `will not apply invalid marks to nodes`() { + val doc = doc { ul { li { p { strong { +"foo" } } } } } + open("
    • foo
    ", listOf(doc.firstChild!!), 3, 3) + } + + @Test + fun `will apply pending marks from parents to all children`() { + val doc = doc { ul { li { p { strong { +"foo" } } } + li { p { strong { +"bar" } } } } } + open("
    • foo
    • bar
    ", doc.content.content, 3, 3) + } + + @Test + fun `can parse nested mark with same type`() { + val doc = doc { p { strong { +"foobarbaz" } } } + open( + "

    foobarbaz

    ", + doc.content.content, + 1, + 1 + ) + } + + @Test + fun `drops block-level whitespace`() { + open("
    ", listOf(), 0, 0, ParseOptionsImpl(preserveWhitespace = PreserveWhitespace.YES)) + } + + @Test + fun `keeps whitespace in inline elements`() { + val doc = doc { p { strong { +" " } } } + open( + " ", + listOf(doc.firstChild!!.firstChild!!), + 0, + 0, + ParseOptionsImpl(preserveWhitespace = PreserveWhitespace.YES) + ) + } + + @Test + fun `can parse nested mark with same type but different attrs`() { + val markSchema = Schema( + SchemaSpec( + nodes = testSchema.spec.nodes, + marks = testSchema.spec.marks + mapOf( + "s" to MarkSpecImpl( + attrs = mapOf("data-s" to AttributeSpecImpl(default = "tag")), + excludes = "", + parseDOM = listOf( + TagParseRuleImpl(tag = "s"), + StyleParseRuleImpl( + style = "text-decoration", + getStyleAttrs = { + ParseRuleMatch(mapOf("data-s" to "style")) + } + ) + ) + ) + ) + ) + ) + val b = CustomNodeBuildCompanion(markSchema) + val dom = doc().createElement("div") + dom.html("

    ooo

    ") + var result = DOMParser.fromSchema(markSchema).parseSlice(dom) + assertThat(result).isEqualTo( + Slice( + Fragment.from( + b.schema.nodes["paragraph"]!!.createAndFill( + attrs = null, + content = Fragment.from( + listOf( + b.schema.text("o", listOf(b.schema.marks["s"]!!.create(mapOf("data-s" to "style")))), + b.schema.text( + "o", + listOf( + b.schema.marks["s"]!!.create(mapOf("data-s" to "style")), + b.schema.marks["s"]!!.create(mapOf("data-s" to "tag")) + ) + ), + b.schema.text("o", listOf(b.schema.marks["s"]!!.create(mapOf("data-s" to "style"))) + ) + ) + ) + )!! + ), + 1, + 1 + ) + ) + + dom.html("

    ooo

    ") + result = DOMParser.fromSchema(markSchema).parseSlice(dom) + assertThat(result).isEqualTo( + Slice( + Fragment.from( + b.schema.nodes["paragraph"]!!.createAndFill( + attrs = null, + content = Fragment.from( + listOf( + b.schema.text( + "o", + listOf( + b.schema.marks["s"]!!.create(mapOf("data-s" to "style")), + b.schema.marks["s"]!!.create(mapOf("data-s" to "tag")) + ) + ), + b.schema.text("o", listOf(b.schema.marks["s"]!!.create(mapOf("data-s" to "style")))), + b.schema.text("o") + ) + ) + )!! + ), + 1, + 1 + ) + ) + } + + @Test + fun `can temporary shadow a mark with another configuration of the same type`() { + val markSchema = Schema( + SchemaSpec( + nodes = testSchema.spec.nodes, + marks = mapOf( + "color" to MarkSpecImpl( + attrs = mapOf("color" to AttributeSpecImpl()), + parseDOM = listOf( + StyleParseRuleImpl( + style = "color", + getStyleAttrs = { ParseRuleMatch(mapOf("color" to it)) } + ) + ) + ) + ) + ) + ) + val b = CustomNodeBuildCompanion(markSchema) + val dom = doc().createElement("div") + dom.html("

    abcdefghi

    ") + val result = DOMParser.fromSchema(markSchema).parse(dom) + assertThat(result).isEqualTo( + b.schema.nodes["doc"]!!.create( + null, + listOf( + b.schema.nodes["paragraph"]!!.create( + attrs = null, + content = + listOf( + b.schema.text("abc", listOf(b.schema.marks["color"]!!.create(mapOf("color" to "red")))), + b.schema.text("def", listOf(b.schema.marks["color"]!!.create(mapOf("color" to "blue")))), + b.schema.text("ghi", listOf(b.schema.marks["color"]!!.create(mapOf("color" to "red"))) + ) + ) + ) + ) + ), + ) + } + + private fun find(html: String, doc: Node) { + val schema = doc.type.schema + val dom = doc().createElement("div") + dom.html(html) + val tag = dom.selectFirst("var")!! + val prev = tag.previousElementSibling() + val next = tag.nextElementSibling() + val pos = if (prev is TextNode && next is TextNode) { + val prevText = prev.text() + prev.text(prevText + next.text()) + next.remove() + ParseOptionPosition(prev, offset = prevText.length, pos = null) + } else { + ParseOptionPosition(tag.parent()!!, offset = tag.parent()!!.childNodes().indexOf(tag), pos = null) + } + tag.remove() + val result = DOMParser.fromSchema(schema).parse(dom, ParseOptionsImpl(findPositions = listOf(pos))) + assertThat(result).isEqualTo(doc) + assertThat(pos.pos).isEqualTo(pos(doc, "a")) + } + + @Test + fun `can find a position at the start of a paragraph`() { + find("

    hello

    ", doc { p { +"hello" } }) + } + + @Test + fun `can find a position at the end of a paragraph`() { + find("

    hello

    ", doc { p { +"hello
    " } }) + } + + @Test + fun `can find a position inside text`() { + find("

    hello

    ", doc { p { +"hel
    lo" } }) + } + + @Test + fun `can find a position inside an ignored node`() { + find("

    hi

    foo

    ok

    ", doc { p { +"hi" } +"
    " + p { +"ok" } }) + } + + @Test + fun `can find a position between nodes`() { + find("
    • foo
    • bar
    ", doc { ul { li { p { +"foo" } } + "
    " + li { p { +"bar" } } } }) + } + + @Test + fun `can find a position at the start of the document`() { + find("

    hi

    ", doc { + "
    " + p { +"hi" }}) + } + + @Test + fun `can find a position at the end of the document`() { + find("

    hi

    ", doc { p { +"hi" } + "
    " }) + } + + @Test + fun `uses a custom top node when parsing`() { + val quoteSchema = Schema( + SchemaSpec( + nodes = testSchema.spec.nodes, + marks = testSchema.spec.marks, + topNode = "blockquote" + ) + ) + val quote = quoteSchema.nodes["blockquote"]!!.create( + attrs = null, + content = listOf( + quoteSchema.nodes["paragraph"]!!.create( + attrs = null, + content = listOf(quoteSchema.text("hello")) + ) + ) + ) + test(quote, "

    hello

    ") + } + + private fun contextParser(context: String) = DOMParser( + testSchema, + listOf(TagParseRuleImpl(tag = "foo", node = "horizontal_rule", context = context)) + DOMParser.schemaRules(testSchema) + ) + + private fun domFrom(html: String) = doc().createElement("div").html(html) + + @Test + fun `recognizes context restrictions`() { + val result = contextParser("blockquote/").parse( + domFrom("

    ") + ) + val expected = doc { blockquote { hr {} + p {} } } + assertThat(result).isEqualTo(expected) + } + + @Test + fun `accepts group names in contexts`() { + val result = contextParser("block/").parse( + domFrom("

    ") + ) + val expected = doc { blockquote { hr {} + p {} } } + assertThat(result).isEqualTo(expected) + } + + @Test + fun `understands nested context restrictions`() { + val result = contextParser("blockquote/ordered_list//").parse( + domFrom("
    1. a

    ") + ) + val expected = doc { blockquote { ol { li { p { +"a" } + hr {} } } } } + assertThat(result).isEqualTo(expected) + } + + @Test + fun `understands double slashes in context restrictions`() { + val result = contextParser("blockquote//list_item/").parse( + domFrom("
    1. a

    ") + ) + val expected = doc { blockquote { ol { li { p { +"a"} + hr {} } } }} + assertThat(result).isEqualTo(expected) + } + + @Test + fun `understands pipes in context restrictions`() { + val result = contextParser("list_item/|blockquote/").parse( + domFrom("

    1. a

    ") + ) + val expected = doc { blockquote { p {} + hr {} } + ol { li { p { +"a" } + hr {} } } } + assertThat(result).isEqualTo(expected) + } + + @Test + fun `uses the passed context`() { + val cxDoc = doc { blockquote { +"
    " + hr {} } } + val result = contextParser("doc//blockquote/").parse( + domFrom("
    "), + ParseOptionsImpl( + topNode = testSchema.nodes["blockquote"]!!.createAndFill()!!, + context = cxDoc.resolve(pos(cxDoc, "a")!!) + ) + ) + val expected = doc { blockquote { blockquote { hr {} } } } + assertThat(result).isEqualTo(expected.firstChild!!) + } + + @Test + fun `uses the passed context when parsing a slice`() { + val cxDoc = doc { blockquote { +"
    " + hr {} } } + val result = contextParser("doc//blockquote/").parseSlice( + domFrom(""), + ParseOptionsImpl( + context = cxDoc.resolve(pos(cxDoc, "a")!!) + ) + ) + val expected = Slice( + Fragment.from(doc { blockquote { hr {} } }.firstChild!!.content), + 0, + 0 + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `can close parent nodes from a rule`() { + val closeParser = DOMParser( + testSchema, + listOf(TagParseRuleImpl(tag = "br", closeParent = true)) + DOMParser.schemaRules(testSchema) + ) + val result = closeParser.parse(domFrom("

    one
    two

    ")) + val expected = doc { p { +"one" } + p { +"two" } } + assertThat(result).isEqualTo(expected) + } + + @Test + fun `supports non-consuming node rules`() { + val parser = DOMParser( + testSchema, + listOf(TagParseRuleImpl(tag = "ol", consuming = false, node = "blockquote")) + DOMParser.schemaRules(testSchema) + ) + val result = parser.parse(domFrom("

      one

    ")) + val expected = doc { blockquote { ol { li { p { +"one" } } } } } + assertThat(result).isEqualTo(expected) + } + + @Test + fun `supports non-consuming style rules`() { + val parser = DOMParser( + testSchema, + listOf( + StyleParseRuleImpl(style = "font-weight", consuming = false, mark = "em") + ) + DOMParser.schemaRules(testSchema) + ) + val result = parser.parse(domFrom("

    one

    ")) + val expected = doc { p { em { strong { +"one" } } } } + assertThat(result).isEqualTo(expected) + } + + @Test + fun `doesn't get confused by nested mark tags`() { + recover( + "
    AB
    C", + doc { p { strong { +"A" } + "B" } + p { +"C" } } + ) + } + + @Test + fun `ignores styles on skipped nodes`() { + val dom = doc().createElement("div") + dom.html("

    abc def

    ") + val result = DOMParser.fromSchema(testSchema).parse( + dom, + ParseOptionsImpl( + ruleFromNode = { node -> + if (node is Element && node.nodeName() == "SPAN") { + ParseOptionsRuleImpl(skip = node) + } else { + null + } + } + ) + ) + val expected = doc { p { +"abc def" } } + assertThat(result).isEqualTo(expected) + } + //endregion + + //region schemaRules + @Test + fun `defaults to schema order`() { + val schema = Schema( + SchemaSpec( + marks = mapOf( + "em" to MarkSpecImpl( + parseDOM = listOf(TagParseRuleImpl(tag = "i"), TagParseRuleImpl(tag = "em")) + ) + ), + nodes = mapOf( + "doc" to NodeSpecImpl(content = "inline*"), + "text" to NodeSpecImpl(group = "inline"), + "foo" to NodeSpecImpl( + group = "inline", + inline = true, + parseDOM = listOf(TagParseRuleImpl(tag = "foo")) + ), + "bar" to NodeSpecImpl( + group = "inline", + inline = true, + parseDOM = listOf(TagParseRuleImpl(tag = "bar")) + ) + ) + ) + ) + val result = DOMParser.schemaRules(schema).mapNotNull { (it as? TagParseRule)?.tag }.joinToString(" ") + assertThat(result).isEqualTo("i em foo bar") + } + + @Test + fun `understands priority`() { + val schema = Schema( + SchemaSpec( + marks = mapOf( + "em" to MarkSpecImpl( + parseDOM = listOf(TagParseRuleImpl(tag = "i", priority = 40), TagParseRuleImpl(tag = "em", priority = 70)) + ) + ), + nodes = mapOf( + "doc" to NodeSpecImpl(content = "inline*"), + "text" to NodeSpecImpl(group = "inline"), + "foo" to NodeSpecImpl( + group = "inline", + inline = true, + parseDOM = listOf(TagParseRuleImpl(tag = "foo")) + ), + "bar" to NodeSpecImpl( + group = "inline", + inline = true, + parseDOM = listOf(TagParseRuleImpl(tag = "bar", priority = 60)) + ) + ) + ) + ) + val result = DOMParser.schemaRules(schema).mapNotNull { (it as? TagParseRule)?.tag }.joinToString(" ") + assertThat(result).isEqualTo("em bar foo i") + } + + private fun nsParse(doc: DOMNode, namespace: String? = null): Node { + val schema = Schema( + SchemaSpec( + nodes = mapOf( + "doc" to NodeSpecImpl(content = "h*"), + "text" to NodeSpecImpl(), + "h" to NodeSpecImpl( + parseDOM = listOf(TagParseRuleImpl(tag = "h", namespace = namespace)) + ) + ) + ) + ) + return DOMParser.fromSchema(schema).parse(doc) + } + + @Test + fun `includes nodes when namespace is correct`() { + val doc = doc().createElement("doc") + val h = doc().createElementNS("urn:ns", "h") + doc.appendChild(h) + assertThat(nsParse(doc, "urn:ns").childCount).isEqualTo(1) + } + + @Test + fun `excludes nodes when namespace is wrong`() { + val doc = doc().createElement("doc") + val h = doc().createElementNS("urn:nt", "h") + doc.appendChild(h) + assertThat(nsParse(doc, "urn:ns").childCount).isEqualTo(0) + } + + // Skipping this test because ksoup doesn't allow null namespace +// it("excludes nodes when namespace is absent", () => { +// let doc = xmlDocument.createElement("doc") +// // in HTML documents, createElement gives namespace +// // 'http://www.w3.org/1999/xhtml' so use createElementNS +// let h = xmlDocument.createElementNS(null, "h") +// doc.appendChild(h) +// ist(nsParse(doc, "urn:ns").childCount, 0) +// }) + + @Test + fun `exclude nodes when namespace is wrong and xhtml`() { + val doc = doc().createElement("doc") + val h = doc().createElementNS("urn:nt", "h") + doc.appendChild(h) + assertThat(nsParse(doc, "http://www.w3.org/1999/xhtml").childCount).isEqualTo(0) + } + + @Test + fun `exclude nodes when namespace is wrong and empty`() { + val doc = doc().createElement("doc") + val h = doc().createElementNS("urn:nt", "h") + doc.appendChild(h) + assertThat(nsParse(doc, "").childCount).isEqualTo(0) + } + + // Skipping this test because ksoup doesn't allow null namespace +// it("includes nodes when namespace is correct and empty", () => { +// let doc = xmlDocument.createElement("doc") +// let h = xmlDocument.createElementNS(null, "h") +// doc.appendChild(h) +// ist(nsParse(doc).childCount, 1) +// }) + //endregion + + //region DOMSerializer + @Test + fun `can omit a mark`() { + val node = noEm.serializeNode( + doc { p { +"foo" + em { +"bar" } + strong { +"baz" } } }.firstChild!!, + doc() + ) as Element + assertThat(node.html()).isEqualTo("foobarbaz") + } + + @Test + fun `doesn't split other marks for omitted marks`() { + val node = noEm.serializeNode( + doc { p { +"foo" + code { +"bar" } + em { code { +"baz" } + "quux" } + "xyz" } }.firstChild!!, + doc() + ) as Element + assertThat(node.html()).isEqualTo("foobarbazquuxxyz") + } + + @Test + fun `can render marks with complex structure`() { + val deepEm = DOMSerializer( + serializer.nodes, + serializer.marks + mapOf( + "em" to { _, _ -> + DOMOutputSpec.ArrayDOMOutputSpec( + listOf( + "em", + DOMOutputSpec.ArrayDOMOutputSpec( + listOf( + "i", + mapOf("data-emphasis" to true), + 0 + ) + ) + ) + ) + } + ) + ) + val node = deepEm.serializeNode( + doc { p { strong { +"foo" + code { +"bar" } + em { code { +"baz" } } } + em { +"quux" } + "xyz" } }.firstChild!!, + doc() + ) as Element + val expected = "foobarbazquuxxyz" + assertThat(node.html()).isEqualTo(expected) + } + + @Test + fun `refuses to use values from attributes as DOM specs`() { + val weird = DOMSerializer( + serializer.nodes + mapOf( + "image" to { node -> + DOMOutputSpec.ArrayDOMOutputSpec( + listOf( + "span", + DOMOutputSpec.ArrayDOMOutputSpec( + listOf( + "img", + mapOf("src" to node.attrs["src"]) + ) + ), + if ((node.attrs["alt"] as? List) != null) { + DOMOutputSpec.ArrayDOMOutputSpec( + node.attrs["alt"] as List + ) + } else { + node.attrs["alt"] ?: "" + } + ), + ) + } + ), + serializer.marks + ) + val ex = assertFailsWith { + weird.serializeNode( + doc { + p { + img( + mapOf( + "src" to "x.png", + "alt" to listOf( + "script", + mapOf("src" to "http://evil.com/inject.js") + ) + ) + ) { } + } + }.firstChild!!.firstChild!!, + doc() + ) + } + assertThat(ex.message?.contains("Using an array from an attribute object as a DOM spec") ?: false).isTrue() + } + + companion object { + private val serializer = DOMSerializer.fromSchema(testSchema) + private val noEm = DOMSerializer( + serializer.nodes, + serializer.marks.minus("em") + ) + } + //endregion +} + +fun com.fleeksoft.ksoup.nodes.Attribute?.int(default: Int? = null): Int? { + return try { + this?.value?.takeIf { it.isNotBlank() }?.toInt() + } catch (ex: NumberFormatException) { + default + } +} diff --git a/model/src/test/kotlin/com/atlassian/prosemirror/model/MarkTest.kt b/model/src/commonTest/kotlin/com/atlassian/prosemirror/model/MarkTest.kt similarity index 90% rename from model/src/test/kotlin/com/atlassian/prosemirror/model/MarkTest.kt rename to model/src/commonTest/kotlin/com/atlassian/prosemirror/model/MarkTest.kt index 585821d..a2f7714 100644 --- a/model/src/test/kotlin/com/atlassian/prosemirror/model/MarkTest.kt +++ b/model/src/commonTest/kotlin/com/atlassian/prosemirror/model/MarkTest.kt @@ -1,5 +1,9 @@ package com.atlassian.prosemirror.model +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotEqualTo +import assertk.assertions.isTrue import com.atlassian.prosemirror.testbuilder.AttributeSpecImpl import com.atlassian.prosemirror.testbuilder.MarkSpecImpl import com.atlassian.prosemirror.testbuilder.NodeSpecImpl @@ -7,7 +11,6 @@ import com.atlassian.prosemirror.testbuilder.PMNodeBuilder import com.atlassian.prosemirror.testbuilder.PMNodeBuilder.Companion.doc import com.atlassian.prosemirror.testbuilder.PMNodeBuilder.Companion.pos import com.atlassian.prosemirror.testbuilder.schema -import org.assertj.core.api.Assertions.assertThat import kotlin.test.BeforeTest import kotlin.test.Test @@ -65,32 +68,32 @@ class MarkTest { @Test fun `returns true for two empty sets`() { - assertThat(Mark.sameSet(emptyList(), emptyList())).isTrue + assertThat(Mark.sameSet(emptyList(), emptyList())).isTrue() } @Test fun `returns true for simple identical sets`() { - assertThat(Mark.sameSet(listOf(em_, strong), listOf(em_, strong))).isTrue + assertThat(Mark.sameSet(listOf(em_, strong), listOf(em_, strong))).isTrue() } @Test fun `returns false for different sets`() { - assertThat(!Mark.sameSet(listOf(em_, strong), listOf(em_, code))).isTrue + assertThat(!Mark.sameSet(listOf(em_, strong), listOf(em_, code))).isTrue() } @Test fun `returns false when set size differs`() { - assertThat(!Mark.sameSet(listOf(em_, strong), listOf(em_, strong, code))).isTrue + assertThat(!Mark.sameSet(listOf(em_, strong), listOf(em_, strong, code))).isTrue() } @Test fun `recognizes identical links in set`() { - assertThat(Mark.sameSet(listOf(link("http://foo"), code), listOf(link("http://foo"), code))).isTrue + assertThat(Mark.sameSet(listOf(link("http://foo"), code), listOf(link("http://foo"), code))).isTrue() } @Test fun `recognizes different links in set`() { - assertThat(!Mark.sameSet(listOf(link("http://foo"), code), listOf(link("http://bar"), code))).isTrue + assertThat(!Mark.sameSet(listOf(link("http://foo"), code), listOf(link("http://bar"), code))).isTrue() } @Test @@ -110,22 +113,22 @@ class MarkTest { @Test fun `can add to the empty set`() { - assertThat(Mark.sameSet(em_.addToSet(emptyList()), listOf(em_))).isTrue + assertThat(Mark.sameSet(em_.addToSet(emptyList()), listOf(em_))).isTrue() } @Test fun `is a no-op when the added thing is in set`() { - assertThat(Mark.sameSet(em_.addToSet(listOf(em_)), listOf(em_))).isTrue + assertThat(Mark.sameSet(em_.addToSet(listOf(em_)), listOf(em_))).isTrue() } @Test fun `adds marks with lower rank before others`() { - assertThat(Mark.sameSet(em_.addToSet(listOf(strong)), listOf(em_, strong))).isTrue + assertThat(Mark.sameSet(em_.addToSet(listOf(strong)), listOf(em_, strong))).isTrue() } @Test fun `adds marks with higher rank after others`() { - assertThat(Mark.sameSet(strong.addToSet(listOf(em_)), listOf(em_, strong))).isTrue + assertThat(Mark.sameSet(strong.addToSet(listOf(em_)), listOf(em_, strong))).isTrue() } @Test @@ -135,7 +138,7 @@ class MarkTest { link("http://bar").addToSet(listOf(link("http://foo"), em_)), listOf(link("http://bar"), em_) ) - ).isTrue + ).isTrue() } @Test @@ -145,7 +148,7 @@ class MarkTest { link("http://foo").addToSet(listOf(em_, link("http://foo"))), listOf(em_, link("http://foo")) ) - ).isTrue + ).isTrue() } @Test @@ -155,69 +158,69 @@ class MarkTest { code.addToSet(listOf(em_, strong, link("http://foo"))), listOf(em_, strong, link("http://foo"), code) ) - ).isTrue + ).isTrue() } @Test fun `puts marks with middle rank in the middle`() { - assertThat(Mark.sameSet(strong.addToSet(listOf(em_, code)), listOf(em_, strong, code))).isTrue + assertThat(Mark.sameSet(strong.addToSet(listOf(em_, code)), listOf(em_, strong, code))).isTrue() } @Test fun `allows nonexclusive instances of marks with the same type`() { - assertThat(Mark.sameSet(remark2.addToSet(listOf(remark1)), listOf(remark1, remark2))).isTrue + assertThat(Mark.sameSet(remark2.addToSet(listOf(remark1)), listOf(remark1, remark2))).isTrue() } @Test fun `doesn't duplicate identical instances of nonexclusive marks`() { - assertThat(Mark.sameSet(remark1.addToSet(listOf(remark1)), listOf(remark1))).isTrue + assertThat(Mark.sameSet(remark1.addToSet(listOf(remark1)), listOf(remark1))).isTrue() } @Test fun `clears all others when adding a globally-excluding mark`() { - assertThat(Mark.sameSet(user1.addToSet(listOf(remark1, customEm)), listOf(user1))).isTrue + assertThat(Mark.sameSet(user1.addToSet(listOf(remark1, customEm)), listOf(user1))).isTrue() } @Test fun `does not allow adding another mark to a globally-excluding mark`() { - assertThat(Mark.sameSet(customEm.addToSet(listOf(user1)), listOf(user1))).isTrue + assertThat(Mark.sameSet(customEm.addToSet(listOf(user1)), listOf(user1))).isTrue() } @Test fun `does overwrite a globally-excluding mark when adding another instance`() { - assertThat(Mark.sameSet(user2.addToSet(listOf(user1)), listOf(user2))).isTrue + assertThat(Mark.sameSet(user2.addToSet(listOf(user1)), listOf(user2))).isTrue() } @Test fun `doesn't add anything when another mark excludes the added mark`() { - assertThat(Mark.sameSet(customEm.addToSet(listOf(remark1, customStrong)), listOf(remark1, customStrong))).isTrue + assertThat(Mark.sameSet(customEm.addToSet(listOf(remark1, customStrong)), listOf(remark1, customStrong))).isTrue() } @Test fun `remove excluded marks when adding a mark`() { - assertThat(Mark.sameSet(customStrong.addToSet(listOf(remark1, customEm)), listOf(remark1, customStrong))).isTrue + assertThat(Mark.sameSet(customStrong.addToSet(listOf(remark1, customEm)), listOf(remark1, customStrong))).isTrue() } @Test fun `is a no-op for the empty set`() { - assertThat(Mark.sameSet(em_.removeFromSet(emptyList()), emptyList())).isTrue + assertThat(Mark.sameSet(em_.removeFromSet(emptyList()), emptyList())).isTrue() } @Test fun `can remove the last mark from a set`() { - assertThat(Mark.sameSet(em_.removeFromSet(listOf(em_)), emptyList())).isTrue + assertThat(Mark.sameSet(em_.removeFromSet(listOf(em_)), emptyList())).isTrue() } @Test fun `is a no-op when the mark isn't in the set`() { - assertThat(Mark.sameSet(strong.removeFromSet(listOf(em_)), listOf(em_))).isTrue + assertThat(Mark.sameSet(strong.removeFromSet(listOf(em_)), listOf(em_))).isTrue() } @Test fun `can remove a mark with attributes`() { assertThat( Mark.sameSet(link("http://foo").removeFromSet(listOf(link("http://foo"))), emptyList()) - ).isTrue + ).isTrue() } @Test @@ -227,7 +230,7 @@ class MarkTest { link("http://foo", "title").removeFromSet(listOf(link("http://foo"))), listOf(link("http://foo")) ) - ).isTrue + ).isTrue() } fun isAt(doc: Node, mark: Mark, result: Boolean) { @@ -286,26 +289,26 @@ class MarkTest { @Test fun `omits non-inclusive marks at end of mark`() { - assertThat(Mark.sameSet(customDoc.resolve(4).marks(), listOf(customStrong))).isTrue + assertThat(Mark.sameSet(customDoc.resolve(4).marks(), listOf(customStrong))).isTrue() } @Test fun `includes non-inclusive marks inside a text node`() { - assertThat(Mark.sameSet(customDoc.resolve(3).marks(), listOf(remark1, customStrong))).isTrue + assertThat(Mark.sameSet(customDoc.resolve(3).marks(), listOf(remark1, customStrong))).isTrue() } @Test fun `omits non-inclusive marks at the end of a line`() { - assertThat(Mark.sameSet(customDoc.resolve(20).marks(), emptyList())).isTrue + assertThat(Mark.sameSet(customDoc.resolve(20).marks(), emptyList())).isTrue() } @Test fun `includes non-inclusive marks between two marked nodes`() { - assertThat(Mark.sameSet(customDoc.resolve(15).marks(), listOf(remark1))).isTrue + assertThat(Mark.sameSet(customDoc.resolve(15).marks(), listOf(remark1))).isTrue() } @Test fun `excludes non-inclusive marks at a point where mark attrs change`() { - assertThat(Mark.sameSet(customDoc.resolve(25).marks(), emptyList())).isTrue + assertThat(Mark.sameSet(customDoc.resolve(25).marks(), emptyList())).isTrue() } } diff --git a/model/src/test/kotlin/com/atlassian/prosemirror/model/NodeSelectionTest.kt b/model/src/commonTest/kotlin/com/atlassian/prosemirror/model/NodeSelectionTest.kt similarity index 98% rename from model/src/test/kotlin/com/atlassian/prosemirror/model/NodeSelectionTest.kt rename to model/src/commonTest/kotlin/com/atlassian/prosemirror/model/NodeSelectionTest.kt index 1bc8782..4901791 100644 --- a/model/src/test/kotlin/com/atlassian/prosemirror/model/NodeSelectionTest.kt +++ b/model/src/commonTest/kotlin/com/atlassian/prosemirror/model/NodeSelectionTest.kt @@ -1,7 +1,8 @@ package com.atlassian.prosemirror.model import com.atlassian.prosemirror.testbuilder.PMNodeBuilder.Companion.doc -import org.assertj.core.api.Assertions.assertThat +import assertk.assertThat +import assertk.assertions.isEqualTo import kotlin.test.Test class NodeSelectionTest { diff --git a/model/src/test/kotlin/com/atlassian/prosemirror/model/NodeTest.kt b/model/src/commonTest/kotlin/com/atlassian/prosemirror/model/NodeTest.kt similarity index 70% rename from model/src/test/kotlin/com/atlassian/prosemirror/model/NodeTest.kt rename to model/src/commonTest/kotlin/com/atlassian/prosemirror/model/NodeTest.kt index 3b34fcb..a881eb9 100644 --- a/model/src/test/kotlin/com/atlassian/prosemirror/model/NodeTest.kt +++ b/model/src/commonTest/kotlin/com/atlassian/prosemirror/model/NodeTest.kt @@ -2,6 +2,10 @@ package com.atlassian.prosemirror.model +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNull import com.atlassian.prosemirror.testbuilder.AttributeSpecImpl import com.atlassian.prosemirror.testbuilder.NodeSpecImpl import com.atlassian.prosemirror.testbuilder.PMNodeBuilder @@ -13,10 +17,10 @@ import com.atlassian.prosemirror.testbuilder.schema import com.atlassian.prosemirror.util.verbose import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.fail import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertFails +import kotlin.test.fail val customSchemaSpec = SchemaSpec( nodes = mapOf( @@ -28,8 +32,8 @@ val customSchemaSpec = SchemaSpec( "contact" to NodeSpecImpl( inline = true, attrs = mapOf( - "name" to AttributeSpecImpl(), - "email" to AttributeSpecImpl() + "name" to AttributeSpecImpl("default"), + "email" to AttributeSpecImpl() // no default value intentional ), leafText = { node -> "${node.attr("name")} <${node.attr("email")}>" } ), @@ -218,6 +222,18 @@ class NodeTest { ) assertThat(d.textBetween(0, d.content.size, "", "")).isEqualTo("Hello ") } + // TODO: convert the following tests +// it("adds block separator around empty paragraphs", () => { +// ist(doc(p("one"), p(), p("two")).textBetween(0, 12, "\n"), "one\n\ntwo") +// }) +// +// it("adds block separator around leaf nodes", () => { +// ist(doc(p("one"), hr(), hr(), p("two")).textBetween(0, 12, "\n", "---"), "one\n---\n---\ntwo") +// }) +// +// it("doesn't add block separator around non-rendered leaf nodes", () => { +// ist(doc(p("one"), hr(), hr(), p("two")).textBetween(0, 12, "\n"), "one\ntwo") +// }) @Test fun `works on a whole doc`() { @@ -234,6 +250,29 @@ class NodeTest { assertThat(doc { ul { li { p { +"hi" } } + li { p { em { +"a" } + "b" } } } }.textContent).isEqualTo("hiab") } + // TODO: convert the following tests +// describe("check", () => { +// it("notices invalid content", () => { +// ist.throws(() => doc(li("x")).check(), +// /Invalid content for node doc/) +// }) +// +// it("notices marks in wrong places", () => { +// ist.throws(() => doc(schema.nodes.paragraph.create(null, [], [schema.marks.em.create()])).check(), +// /Invalid content for node doc/) +// }) +// +// it("notices incorrect sets of marks", () => { +// ist.throws(() => schema.text("a", [schema.marks.em.create(), schema.marks.em.create()]).check(), +// /Invalid collection of marks/) +// }) +// +// it("notices wrong attribute types", () => { +// ist.throws(() => schema.nodes.image.create({src: true}).check(), +// /Expected value of type string for attribute src on type image, got boolean/) +// }) +// }) + fun from(arg: Node, expect: Node) { assertThat(expect.copy(Fragment.from(arg))).isEqualTo(expect) } @@ -327,4 +366,69 @@ class NodeTest { assertThat(contact.textContent).isEqualTo("Bob ") assertThat(paragraph.textContent).isEqualTo("Hello Bob ") } + + @Test + fun `should use default if attr does not exist`() { + val d = createContactTestDoc(mapOf( + "email" to "alice@example.com" + )) + val contactNode = d.child(0).child(0) + // Use default regardless of being nullable + assertThat(contactNode.attr("name")).isEqualTo("default") + assertThat(contactNode.attr("name")).isEqualTo("default") + } + + @Test + fun `maybe use default if attr is null`() { + val d = createContactTestDoc(mapOf( + "name" to null, + "email" to "alice@example.com" + )) + val contactNode = d.child(0).child(0) + // If attr is null and default null, then only return null if we're asked for nullable - otherwise use + // nodeSpec's default + assertThat(contactNode.attr("name")).isEqualTo("default") + assertThat(contactNode.attr("name", "default2")).isEqualTo("default2") + assertThat(contactNode.attr("name")).isNull() + assertThat(contactNode.attr("name", "default2")).isEqualTo("default2") + } + + @Test + fun `should use default if attr is wrong type`() { + val d = createContactTestDoc(mapOf( + "name" to 123, + "email" to 123 + )) + val contactNode = d.child(0).child(0) + // Use default due to wrong type, or null if no default and is nullable + assertThat(contactNode.attr("name")).isEqualTo("default") + assertThat(contactNode.attr("email", "default")).isEqualTo("default") + assertThat(contactNode.attr("email")).isNull() + } + + @Suppress("UnusedPrivateMember") + @Test + fun `throw IllegalArgumentException if default cannot be resolved`() { + val d = createContactTestDoc(mapOf( + "email" to null + )) + val contactNode = d.child(0).child(0) + // If attr is null, default null, and nodeSpec default null, then we cannot return if is not nullable + val caughtException = assertFails("Expected a IllegalArgumentException") { + contactNode.attr("email") + } + assertThat(caughtException).isInstanceOf(IllegalArgumentException::class) + } + + private fun createContactTestDoc(attrs: Map) = customSchema.nodes["doc"]!!.createChecked( + emptyMap(), + listOf( + customSchema.nodes["paragraph"]!!.createChecked( + emptyMap(), + listOf( + customSchema.nodes["contact"]!!.createChecked(attrs) + ) + ) + ) + ) } diff --git a/model/src/test/kotlin/com/atlassian/prosemirror/model/test-content.ts b/model/src/commonTest/kotlin/com/atlassian/prosemirror/model/test-content.ts similarity index 100% rename from model/src/test/kotlin/com/atlassian/prosemirror/model/test-content.ts rename to model/src/commonTest/kotlin/com/atlassian/prosemirror/model/test-content.ts diff --git a/model/src/test/kotlin/com/atlassian/prosemirror/model/test-diff.ts b/model/src/commonTest/kotlin/com/atlassian/prosemirror/model/test-diff.ts similarity index 100% rename from model/src/test/kotlin/com/atlassian/prosemirror/model/test-diff.ts rename to model/src/commonTest/kotlin/com/atlassian/prosemirror/model/test-diff.ts diff --git a/model/src/test/kotlin/com/atlassian/prosemirror/model/test-dom.ts b/model/src/commonTest/kotlin/com/atlassian/prosemirror/model/test-dom.ts similarity index 94% rename from model/src/test/kotlin/com/atlassian/prosemirror/model/test-dom.ts rename to model/src/commonTest/kotlin/com/atlassian/prosemirror/model/test-dom.ts index 45cd1d8..e6c1916 100644 --- a/model/src/test/kotlin/com/atlassian/prosemirror/model/test-dom.ts +++ b/model/src/commonTest/kotlin/com/atlassian/prosemirror/model/test-dom.ts @@ -107,7 +107,7 @@ describe("DOMParser", () => { attrs: { id: { default: null }}, parseDOM: [{ tag: "span.comment", - getAttrs(dom) { return { id: parseInt((dom as HTMLElement).getAttribute('data-id')!, 10) } } + getAttrs(dom: HTMLElement) { return { id: parseInt(dom.getAttribute('data-id')!, 10) } } }], excludes: '', toDOM(mark: Mark) { return ["span", {class: "comment", "data-id": mark.attrs.id }, 0] } @@ -408,6 +408,21 @@ describe("DOMParser", () => { ), 1, 1), eq) }) + it("can temporary shadow a mark with another configuration of the same type", () => { + let s = new Schema({nodes: schema.spec.nodes, marks: {color: { + attrs: {color: {}}, + toDOM: m => ["span", {style: `color: ${m.attrs.color}`}], + parseDOM: [{style: "color", getAttrs: v => ({color: v})}] + }}}) + let d = DOMParser.fromSchema(s) + .parse(domFrom('

    abcdefghi

    ')) + ist(d, s.node("doc", null, [s.node("paragraph", null, [ + s.text("abc", [s.mark("color", {color: "red"})]), + s.text("def", [s.mark("color", {color: "blue"})]), + s.text("ghi", [s.mark("color", {color: "red"})]) + ])]), eq) + }) + function find(html: string, doc: PMNode) { return () => { let dom = document.createElement("div") @@ -554,7 +569,7 @@ describe("DOMParser", () => { foo: {group: "inline", inline: true, parseDOM: [{tag: "foo"}]}, bar: {group: "inline", inline: true, parseDOM: [{tag: "bar"}]}} }) - ist(DOMParser.schemaRules(schema).map(r => r.tag).join(" "), "i em foo bar") + ist(DOMParser.schemaRules(schema).map(r => (r as any).tag).join(" "), "i em foo bar") }) it("understands priority", () => { @@ -565,7 +580,7 @@ describe("DOMParser", () => { foo: {group: "inline", inline: true, parseDOM: [{tag: "foo"}]}, bar: {group: "inline", inline: true, parseDOM: [{tag: "bar", priority: 60}]}} }) - ist(DOMParser.schemaRules(schema).map(r => r.tag).join(" "), "em bar foo i") + ist(DOMParser.schemaRules(schema).map(r => (r as any).tag).join(" "), "em bar foo i") }) function nsParse(doc: Node, namespace?: string) { @@ -643,4 +658,13 @@ describe("DOMSerializer", () => { ist((node as HTMLElement).innerHTML, "foobarbazquuxxyz") }) + + it("refuses to use values from attributes as DOM specs", () => { + let weird = new DOMSerializer(Object.assign({}, serializer.nodes, { + image: (node: PMNode) => ["span", ["img", {src: node.attrs.src}], node.attrs.alt] + }), serializer.marks) + ist.throws(() => weird.serializeNode(img({src: "x.png", alt: ["script", {src: "http://evil.com/inject.js"}]}), + {document}), + /Using an array from an attribute object as a DOM spec/) + }) }) diff --git a/model/src/test/kotlin/com/atlassian/prosemirror/model/test-mark.ts b/model/src/commonTest/kotlin/com/atlassian/prosemirror/model/test-mark.ts similarity index 100% rename from model/src/test/kotlin/com/atlassian/prosemirror/model/test-mark.ts rename to model/src/commonTest/kotlin/com/atlassian/prosemirror/model/test-mark.ts diff --git a/model/src/test/kotlin/com/atlassian/prosemirror/model/test-node.ts b/model/src/commonTest/kotlin/com/atlassian/prosemirror/model/test-node.ts similarity index 85% rename from model/src/test/kotlin/com/atlassian/prosemirror/model/test-node.ts rename to model/src/commonTest/kotlin/com/atlassian/prosemirror/model/test-node.ts index 86ef863..3920cbd 100644 --- a/model/src/test/kotlin/com/atlassian/prosemirror/model/test-node.ts +++ b/model/src/commonTest/kotlin/com/atlassian/prosemirror/model/test-node.ts @@ -120,6 +120,18 @@ describe("Node", () => { ]) ist(d.textBetween(0, d.content.size, '', ''), 'Hello ') }) + + it("adds block separator around empty paragraphs", () => { + ist(doc(p("one"), p(), p("two")).textBetween(0, 12, "\n"), "one\n\ntwo") + }) + + it("adds block separator around leaf nodes", () => { + ist(doc(p("one"), hr(), hr(), p("two")).textBetween(0, 12, "\n", "---"), "one\n---\n---\ntwo") + }) + + it("doesn't add block separator around non-rendered leaf nodes", () => { + ist(doc(p("one"), hr(), hr(), p("two")).textBetween(0, 12, "\n"), "one\ntwo") + }) }) describe("textContent", () => { @@ -137,6 +149,28 @@ describe("Node", () => { }) }) + describe("check", () => { + it("notices invalid content", () => { + ist.throws(() => doc(li("x")).check(), + /Invalid content for node doc/) + }) + + it("notices marks in wrong places", () => { + ist.throws(() => doc(schema.nodes.paragraph.create(null, [], [schema.marks.em.create()])).check(), + /Invalid content for node doc/) + }) + + it("notices incorrect sets of marks", () => { + ist.throws(() => schema.text("a", [schema.marks.em.create(), schema.marks.em.create()]).check(), + /Invalid collection of marks/) + }) + + it("notices wrong attribute types", () => { + ist.throws(() => schema.nodes.image.create({src: true}).check(), + /Expected value of type string for attribute src on type image, got boolean/) + }) + }) + describe("from", () => { function from(arg: Node | Node[] | Fragment | null, expect: Node) { ist(expect.copy(Fragment.from(arg)), expect, eq) diff --git a/model/src/test/kotlin/com/atlassian/prosemirror/model/test-replace.ts b/model/src/commonTest/kotlin/com/atlassian/prosemirror/model/test-replace.ts similarity index 100% rename from model/src/test/kotlin/com/atlassian/prosemirror/model/test-replace.ts rename to model/src/commonTest/kotlin/com/atlassian/prosemirror/model/test-replace.ts diff --git a/model/src/test/kotlin/com/atlassian/prosemirror/model/test-resolve.ts b/model/src/commonTest/kotlin/com/atlassian/prosemirror/model/test-resolve.ts similarity index 100% rename from model/src/test/kotlin/com/atlassian/prosemirror/model/test-resolve.ts rename to model/src/commonTest/kotlin/com/atlassian/prosemirror/model/test-resolve.ts diff --git a/model/src/test/kotlin/com/atlassian/prosemirror/model/test-slice.ts b/model/src/commonTest/kotlin/com/atlassian/prosemirror/model/test-slice.ts similarity index 100% rename from model/src/test/kotlin/com/atlassian/prosemirror/model/test-slice.ts rename to model/src/commonTest/kotlin/com/atlassian/prosemirror/model/test-slice.ts diff --git a/model/src/iosMain/kotlin/com/atlassian/prosemirror/model/Platform.ios.kt b/model/src/iosMain/kotlin/com/atlassian/prosemirror/model/Platform.ios.kt new file mode 100644 index 0000000..65d2a60 --- /dev/null +++ b/model/src/iosMain/kotlin/com/atlassian/prosemirror/model/Platform.ios.kt @@ -0,0 +1,17 @@ +package com.atlassian.prosemirror.model + +import com.fleeksoft.ksoup.nodes.Element +import platform.UIKit.UIDevice + +class IOSPlatform : Platform { + override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion +} + +actual fun getPlatform(): Platform = IOSPlatform() + +actual fun evaluateXpathNode(s: String, dom: Element): Element { + // TODO implement properly +// val xPath = XPathFactory.newInstance().newXPath() +// xPath.evaluate(contentElement.s, dom, XPathConstants.NODE) as Element + return dom +} diff --git a/model/src/iosMain/kotlin/com/atlassian/prosemirror/model/util/WeakMap.ios.kt b/model/src/iosMain/kotlin/com/atlassian/prosemirror/model/util/WeakMap.ios.kt new file mode 100644 index 0000000..f53793c --- /dev/null +++ b/model/src/iosMain/kotlin/com/atlassian/prosemirror/model/util/WeakMap.ios.kt @@ -0,0 +1,16 @@ +package com.atlassian.prosemirror.model.util + +import platform.Foundation.NSMapTable + +class IOSWeakMap: WeakMap { + private val map = NSMapTable.weakToStrongObjectsMapTable() + + override fun get(key: K): V? = map.objectForKey(key) as? V + + override fun put(key: K, value: V) { + map.setObject(value, key) + } + +} + +actual fun mutableWeakMapOf(): WeakMap = IOSWeakMap() diff --git a/model/src/iosTest/kotlin/com/atlassian/prosemirror/model/IosPlatformTest.ios.kt b/model/src/iosTest/kotlin/com/atlassian/prosemirror/model/IosPlatformTest.ios.kt new file mode 100644 index 0000000..b9a33a4 --- /dev/null +++ b/model/src/iosTest/kotlin/com/atlassian/prosemirror/model/IosPlatformTest.ios.kt @@ -0,0 +1,11 @@ +package com.atlassian.prosemirror.model + +import kotlin.test.Test +import kotlin.test.assertTrue + +class IosPlatformTest { + @Test + fun testExample() { + assertTrue(getPlatform().name.contains("iOS"), "Check iOS is mentioned") + } +} diff --git a/model/src/jvmMain/kotlin/com/atlassian/prosemirror/model/Platform.jvm.kt b/model/src/jvmMain/kotlin/com/atlassian/prosemirror/model/Platform.jvm.kt new file mode 100644 index 0000000..abb420e --- /dev/null +++ b/model/src/jvmMain/kotlin/com/atlassian/prosemirror/model/Platform.jvm.kt @@ -0,0 +1,16 @@ +package com.atlassian.prosemirror.model + +import com.fleeksoft.ksoup.nodes.Element +import javax.xml.xpath.XPathConstants +import javax.xml.xpath.XPathFactory + +class AndroidPlatform : Platform { + override val name: String = "Jvm ${System.getProperty("java.version")}" +} + +actual fun getPlatform(): Platform = AndroidPlatform() + +actual fun evaluateXpathNode(s: String, dom: Element): Element { + val xPath = XPathFactory.newInstance().newXPath() + return xPath.evaluate(s, dom, XPathConstants.NODE) as Element +} diff --git a/model/src/jvmMain/kotlin/com/atlassian/prosemirror/model/util/WeakMap.jvm.kt b/model/src/jvmMain/kotlin/com/atlassian/prosemirror/model/util/WeakMap.jvm.kt new file mode 100644 index 0000000..f629d73 --- /dev/null +++ b/model/src/jvmMain/kotlin/com/atlassian/prosemirror/model/util/WeakMap.jvm.kt @@ -0,0 +1,16 @@ +package com.atlassian.prosemirror.model.util + +import java.util.WeakHashMap + +class AndroidWeakMap: WeakMap { + private val map = WeakHashMap() + + override fun get(key: K): V? = map[key] + + override fun put(key: K, value: V) { + map[key] = value + } + +} + +actual fun mutableWeakMapOf(): WeakMap = AndroidWeakMap() diff --git a/model/src/jvmTest/kotlin/com/atlassian/prosemirror/model/JvmPlatformTest.jvm.kt b/model/src/jvmTest/kotlin/com/atlassian/prosemirror/model/JvmPlatformTest.jvm.kt new file mode 100644 index 0000000..56206dc --- /dev/null +++ b/model/src/jvmTest/kotlin/com/atlassian/prosemirror/model/JvmPlatformTest.jvm.kt @@ -0,0 +1,11 @@ +package com.atlassian.prosemirror.model + +import kotlin.test.Test +import kotlin.test.assertTrue + +class JvmPlatformTest { + @Test + fun testExample() { + assertTrue(getPlatform().name.contains("Jvm"), "Check Jvm is mentioned") + } +} diff --git a/model/src/test/kotlin/com/atlassian/prosemirror/model/DomTest.kt b/model/src/test/kotlin/com/atlassian/prosemirror/model/DomTest.kt deleted file mode 100644 index 38534bd..0000000 --- a/model/src/test/kotlin/com/atlassian/prosemirror/model/DomTest.kt +++ /dev/null @@ -1,823 +0,0 @@ -package com.atlassian.prosemirror.model - -import com.atlassian.prosemirror.testbuilder.MarkSpecImpl -import com.atlassian.prosemirror.testbuilder.NodeBuildCompanion -import com.atlassian.prosemirror.testbuilder.NodeBuilder -import com.atlassian.prosemirror.testbuilder.NodeSpecImpl -import com.atlassian.prosemirror.testbuilder.PMNodeBuilder.Companion.doc -import org.assertj.core.api.Assertions.assertThat -import kotlin.test.Ignore -import kotlin.test.Test -import com.atlassian.prosemirror.testbuilder.schema as testSchema - -val schemaWithComment = Schema( - SchemaSpec( - nodes = testSchema.spec.nodes + mapOf( - "doc" to (testSchema.spec.nodes["doc"] as NodeSpecImpl).copy(marks = "comment") - ), - marks = testSchema.spec.marks + mapOf( - "comment" to MarkSpecImpl( - parseDOM = listOf(ParseRuleImpl(tag = "div.comment")), - toDOM = { _, _ -> - DOMOutputSpec.ArrayDOMOutputSpec(listOf("div", mapOf("class" to "comment"), 0)) - } - ) - ) - ) -) - -fun NodeBuilder.comment(func: NodeBuilder.() -> Unit) = mark("comment", func) - -class CommentNodeBuilder( - pos: Int = 0, - marks: List = emptyList(), - override val schema: Schema = schemaWithComment -) : NodeBuilder(pos, marks, schema) { - - override val checked: Boolean - get() = false - - override fun create(pos: Int, marks: List, schema: Schema): NodeBuilder { - return CommentNodeBuilder(pos, marks, schema) - } - - companion object : NodeBuildCompanion(schemaWithComment) { - override val checked: Boolean - get() = false - - override fun create(): CommentNodeBuilder { - return CommentNodeBuilder() - } - } -} - -class DomTest { - fun test(doc: Node, html: String) { - val schema = doc.type.schema - val innerHTML = DOMSerializer.fromSchema(schema).serializeFragmentToHtml(doc.content) - assertThat(innerHTML).isEqualTo(html) - val parsedDoc = DOMParser.fromSchema(schema).parseHtml(innerHTML) - assertThat(parsedDoc).isEqualTo(doc) - } - - @Test - fun `can represent simple node`() { - test(doc { p { +"hello" } }, "

    hello

    ") - } - - @Test - fun `can represent a line break`() { - test(doc { p { +"hi" + br {} + "there" } }, "

    hi
    there

    ") - } - - @Test - fun `can represent an image`() { - test( - doc { p { +"hi" + img(mapOf("alt" to "x")) {} + "there" } }, - "

    hi\"x\"there

    " - ) - } - - @Test - fun `joins styles`() { - test( - doc { p { +"one" + strong { +"two" + em { +"three" } } + em { +"four" } + "five" } }, - "

    onetwothreefourfive

    " - ) - } - - @Ignore("This test is failing - fix code") - @Test - fun `can represent links`() { - test( - doc { p { +"a " + a(href = "foo") { +"big " + a(href = "bar") { +"nested" } + " link" } } }, - "

    a big nested link

    " - ) - } - - @Test - fun `can represent and unordered list`() { - test( - doc { - ul { - li { p { +"one" } } + - li { p { +"two" } } + - li { p { +"three" + strong { +"!" } } } - } + - p { +"after" } - }, - "
    • one

    • two

    • three!

    after

    " - ) - } - - @Test - fun `can represent an ordered list`() { - test( - doc { - ol { - li { p { +"one" } } + - li { p { +"two" } } + - li { p { +"three" + strong { +"!" } } } - } + - p { +"after" } - }, - "
    1. one

    2. two

    3. three!

    after

    " - ) - } - - @Test - fun `can represent a blockquote`() { - test( - doc { blockquote { p { +"hello" } + p { +"bye" } } }, - "

    hello

    bye

    " - ) - } - - @Test - fun `can represent a nested blockquote`() { - test( - doc { blockquote { blockquote { blockquote { p { +"he said" } } } + p { +"i said" } } }, - "

    he said

    i said

    " - ) - } - - @Test - fun `can represent headings`() { - test( - doc { h1 { +"one" } + h2 { +"two" } + p { +"text" } }, - "

    one

    two

    text

    " - ) - } - - @Test - fun `can represent inline code`() { - test( - doc { p { +"text and " + code { +"code that is " + em { +"emphasized" } + "..." } } }, - "

    text and code that is emphasized...

    " - ) - } - - @Test - fun `can represent a code block`() { - test( - doc { blockquote { pre { +"some code" } } + p { +"and" } }, - "
    some code

    and

    " - ) - } - - @Test - fun `supports leaf nodes in marks`() { - test( - doc { p { em { +"hi" + br {} + "x" } } }, - "

    hi
    x

    " - ) - } - - @Test - fun `doesn't collapse non-breaking spaces`() { - test( - doc { p { +"\u00a0 \u00a0hello\u00a0" } }, - "

       hello 

    " - ) - } - - @Test - fun `can parse marks on block nodes`() { - val doc = CommentNodeBuilder.doc { - p { +"one" } + this.comment { p { +"two" } + p { strong { +"three" } } } + p { +"four" } - } - test( - doc, - "

    one

    two

    three

    four

    " - ) - } - - // TODO convert tests below -// it("parses unique, non-exclusive, same-typed marks", () => { -// let commentSchema = new Schema({ -// nodes: schema.spec.nodes, -// marks: schema.spec.marks.update("comment", { -// attrs: { id: { default: null }}, -// parseDOM: [{ -// tag: "span.comment", -// getAttrs(dom) { return { id: parseInt((dom as HTMLElement).getAttribute('data-id')!, 10) } } -// }], -// excludes: '', -// toDOM(mark: Mark) { return ["span", {class: "comment", "data-id": mark.attrs.id }, 0] } -// }) -// }) -// let b = builders(commentSchema) -// test(b.schema.nodes.doc.createAndFill(undefined, [ -// b.schema.nodes.paragraph.createAndFill(undefined, [ -// b.schema.text('double comment', [ -// b.schema.marks.comment.create({ id: 1 }), -// b.schema.marks.comment.create({ id: 2 }) -// ])! -// ])! -// ])!, -// "

    double comment

    ")() -// }) -// -// it("serializes non-spanning marks correctly", () => { -// let markSchema = new Schema({ -// nodes: schema.spec.nodes, -// marks: schema.spec.marks.update("test", { -// parseDOM: [{tag: "test"}], -// toDOM() { return ["test", 0] }, -// spanning: false -// }) -// }) -// let b = builders(markSchema) as any -// test(b.doc(b.paragraph(b.test("a", b.image({src: "x"}), "b"))), -// "

    ab

    ")() -// }) -// -// it("serializes an element and an attribute with XML namespace", () => { -// let xmlnsSchema = new Schema({ -// nodes: { -// doc: { content: "svg*" }, text: {}, -// "svg": { -// parseDOM: [{tag: "svg", namespace: 'http://www.w3.org/2000/svg'}], -// group: 'block', -// toDOM() { return ["http://www.w3.org/2000/svg svg", ["use", { "http://www.w3.org/1999/xlink href": "#svg-id" }]] }, -// }, -// }, -// }) -// -// let b = builders(xmlnsSchema) as any -// let d = b.doc(b.svg()) -// test(d, '', xmlDocument)() -// -// let dom = xmlDocument.createElement('div') -// dom.appendChild(DOMSerializer.fromSchema(xmlnsSchema).serializeFragment(d.content, {document: xmlDocument})) -// ist(dom.querySelector('svg').namespaceURI, 'http://www.w3.org/2000/svg') -// ist(dom.querySelector('use').namespaceURI, 'http://www.w3.org/2000/svg') -// ist(dom.querySelector('use').attributes[0].namespaceURI, 'http://www.w3.org/1999/xlink') -// }) - - fun recover(html: String, doc: Node, options: ParseOptions = ParseOptionsImpl()) { - val schema = doc.type.schema - val parsedDoc = DOMParser.fromSchema(schema).parseHtml(html, options) - assertThat(parsedDoc).isEqualTo(doc) - } - - @Test - fun `can recover a list item`() { - recover( - "

      Oh no

    ", - doc { ol { li { p { +"Oh no" } } } } - ) - } - - @Test - fun `wraps a list item in a list`() { - recover( - "
  • hey
  • ", - doc { ol { li { p { +"hey" } } } } - ) - } - - @Test - fun `can turn divs into paragraphs`() { - recover( - "
    hi
    bye
    ", - doc { p { +"hi" } + p { +"bye" } } - ) - } - - @Test - fun `interprets i and b as emphasis and strong`() { - recover( - "

    hello there

    ", - doc { p { em { +"hello " + strong { +"there" } } } } - ) - } - - @Test - fun `wraps stray text in a paragraph`() { - recover( - "hi", - doc { p { +"hi" } } - ) - } - - @Test - fun `ignores an extra wrapping _div_`() { - recover( - "

    one

    two

    ", - doc { p { +"one" } + p { +"two" } } - ) - } - - @Test - fun `ignores meaningless whitespace`() { - recover( - "

    woo \n hooo

    ", - doc { blockquote { p { +"woo " + em { +"hooo" } } } } - ) - } - - @Test - fun `removes whitespace after a hard break`() { - recover( - "

    hello
    \n world

    ", - doc { p { +"hello" + br {} + "world" } } - ) - } - - @Test - fun `converts br nodes to newlines when they would otherwise be ignored`() { - recover( - "
    foo
    bar
    ", - doc { pre { +"foo\nbar" } } - ) - } - - @Test - fun `finds a valid place for invalid content`() { - recover( - "
    • hi
    • whoah

    • again
    ", - doc { ul { li { p { +"hi" } } + li { p { +"whoah" } } + li { p { +"again" } } } } - ) - } - - @Test - fun `moves nodes up when they don't fit the current context`() { - recover( - "
    hello
    bye
    ", - doc { p { +"hello" } + hr {} + p { +"bye" } } - ) - } - - @Test - fun `doesn't ignore whitespace-only text nodes`() { - recover( - "

    one two

    ", - doc { p { em { +"one" } + " " + strong { +"two" } } } - ) - } - - @Test - fun `can handle stray tab characters`() { - recover( - "

    ", - doc { p { } } - ) - } - - @Test - fun `normalizes random spaces`() { - recover( - "

    1

    ", - doc { p { strong { +"1" } } } - ) - } - - @Test - fun `can parse an empty code block`() { - recover( - "
    ",
    -            doc { pre { } }
    -        )
    -    }
    -
    -    @Test
    -    fun `preserves trailing space in a code block`() {
    -        recover(
    -            "
    foo\n
    ", - doc { pre { +"foo\n" } } - ) - } - - @Test - fun `normalizes newlines when preserving whitespace`() { - recover( - "

    foo bar\nbaz

    ", - doc { p { +"foo bar baz" } }, - options = ParseOptionsImpl(preserveWhitespace = PreserveWhitespace.YES) - ) - } - - @Test - fun `ignores script tags`() { - recover( - "

    hello!

    ", - doc { p { +"hello!" } } - ) - } - - @Test - fun `can handle a head body input structure`() { - recover( - "Thi", - doc { p { +"hi" } } - ) - } - - @Test - fun `only applies a mark once`() { - recover( - "

    A big strong monster.

    ", - doc { p { +"A " + strong { +"big strong monster" } + "." } } - ) - } - - @Test - fun `interprets font-style italic as em`() { - recover( - "

    Hello!

    ", - doc { p { em { +"Hello" } + "!" } } - ) - } -} - -// it("interprets font-weight: bold as strong", -// recover("

    Hello

    ", -// doc(p(strong("Hello"))))) -// -// it("allows clearing of pending marks", -// recover("

    One

    Two

  • Foo" + -// "Bar

  • ", -// doc(ul(li(p(em("Foo"), "Bar")))))) -// -// it("ignores unknown inline tags", -// recover("

    abc

    ", -// doc(p("abc")))) -// -// it("can add marks specified before their parent node is opened", -// recover("hi you", -// doc(p(em("hi"), " you")))) -// -// it("keeps applying a mark for the all of the node's content", -// recover("

    xxbar

    ", -// doc(p(strong("xxbar"))))) -// -// it("doesn't ignore whitespace-only nodes in preserveWhitespace full mode", -// recover(" x", doc(p(" x")), {preserveWhitespace: "full"})) -// -// it("closes block with inline content on seeing block-level children", -// recover("

    CCC
    DDD

    ", -// doc(p(br()), p("CCC"), p("DDD"), p(br())))) -// -// function parse(html: string, options: ParseOptions, doc: PMNode) { -// return () => { -// let dom = document.createElement("div") -// dom.innerHTML = html -// let result = parser.parse(dom, options) -// ist(result, doc, eq) -// } -// } -// -// it("accepts the topNode option", -// parse("
  • wow
  • such
  • ", {topNode: schema.nodes.bullet_list.createAndFill()!}, -// ul(li(p("wow")), li(p("such"))))) -// -// let item = schema.nodes.list_item.createAndFill()! -// it("accepts the topMatch option", -// parse("
    • x
    ", {topNode: item, topMatch: item.contentMatchAt(1)!}, -// li(ul(li(p("x")))))) -// -// it("accepts from and to options", -// parse("

    foo

    bar

    ", {from: 1, to: 3}, -// doc(p("foo"), p("bar")))) -// -// it("accepts the preserveWhitespace option", -// parse("foo bar", {preserveWhitespace: true}, -// doc(p("foo bar")))) -// -// function open(html: string, nodes: (string | PMNode)[], openStart: number, openEnd: number, options?: ParseOptions) { -// return () => { -// let dom = document.createElement("div") -// dom.innerHTML = html -// let result = parser.parseSlice(dom, options) -// ist(result, new Slice(Fragment.from(nodes.map(n => typeof n == "string" ? schema.text(n) : n)), openStart, openEnd), eq) -// } -// } -// -// it("can parse an open slice", -// open("foo", ["foo"], 0, 0)) -// -// it("will accept weird siblings", -// open("foo

    bar

    ", ["foo", p("bar")], 0, 1)) -// -// it("will open all the way to the inner nodes", -// open("
    • foo
    • bar
    ", [ul(li(p("foo")), li(p("bar", br())))], 3, 3)) -// -// it("accepts content open to the left", -// open("
    • a
  • ", [li(ul(li(p("a"))))], 4, 4)) -// -// it("accepts content open to the right", -// open("
  • foo
  • ", [li(p("foo")), li()], 2, 1)) -// -// it("will create textblocks for block nodes", -// open("
    foo
    bar
    ", [p("foo"), p("bar")], 1, 1)) -// -// it("can parse marks at the start of defaulted textblocks", -// open("
    foo
    bar
    ", -// [p("foo"), p(em("bar"))], 1, 1)) -// -// it("will not apply invalid marks to nodes", -// open("
    • foo
    ", [ul(li(p(strong("foo"))))], 3, 3)) -// -// it("will apply pending marks from parents to all children", -// open("
    • foo
    • bar
    ", [ul(li(p(strong("foo"))), li(p(strong("bar"))))], 3, 3)) -// -// it("can parse nested mark with same type", -// open("

    foobarbaz

    ", -// [p(strong("foobarbaz"))], 1, 1)) -// -// it("drops block-level whitespace", -// open("
    ", [], 0, 0, {preserveWhitespace: true})) -// -// it("keeps whitespace in inline elements", -// open(" ", [p(strong(" ")).child(0)], 0, 0, {preserveWhitespace: true})) -// -// it("can parse nested mark with same type but different attrs", () => { -// let markSchema = new Schema({ -// nodes: schema.spec.nodes, -// marks: schema.spec.marks.update("s", { -// attrs: { -// 'data-s': { default: 'tag' } -// }, -// excludes: '', -// parseDOM: [{ -// tag: "s", -// }, { -// style: "text-decoration", -// getAttrs() { -// return { -// 'data-s': 'style' -// } -// } -// }] -// }) -// }) -// let b = builders(markSchema) -// let dom = document.createElement("div") -// dom.innerHTML = "

    ooo

    " -// let result = DOMParser.fromSchema(markSchema).parseSlice(dom) -// ist(result, new Slice(Fragment.from( -// b.schema.nodes.paragraph.create( -// undefined, -// [ -// b.schema.text('o', [b.schema.marks.s.create({ 'data-s': 'style' })]), -// b.schema.text('o', [b.schema.marks.s.create({ 'data-s': 'style' }), b.schema.marks.s.create({ 'data-s': 'tag' })]), -// b.schema.text('o', [b.schema.marks.s.create({ 'data-s': 'style' })]) -// ] -// ) -// ), 1, 1), eq) -// -// dom.innerHTML = "

    ooo

    " -// result = DOMParser.fromSchema(markSchema).parseSlice(dom) -// ist(result, new Slice(Fragment.from( -// b.schema.nodes.paragraph.create( -// undefined, -// [ -// b.schema.text('o', [b.schema.marks.s.create({ 'data-s': 'style' }), b.schema.marks.s.create({ 'data-s': 'tag' })]), -// b.schema.text('o', [b.schema.marks.s.create({ 'data-s': 'style' })]), -// b.schema.text('o') -// ] -// ) -// ), 1, 1), eq) -// }) -// -// function find(html: string, doc: PMNode) { -// return () => { -// let dom = document.createElement("div") -// dom.innerHTML = html -// let tag = dom.querySelector("var"), prev = tag.previousSibling!, next = tag.nextSibling, pos -// if (prev && next && prev.nodeType == 3 && next.nodeType == 3) { -// pos = {node: prev, offset: prev.nodeValue.length} -// prev.nodeValue += next.nodeValue -// next.parentNode.removeChild(next) -// } else { -// pos = {node: tag.parentNode, offset: Array.prototype.indexOf.call(tag.parentNode.childNodes, tag)} -// } -// tag.parentNode.removeChild(tag) -// let result = parser.parse(dom, { -// findPositions: [pos] -// }) -// ist(result, doc, eq) -// ist((pos as any).pos, (doc as any).tag.a) -// } -// } -// -// it("can find a position at the start of a paragraph", -// find("

    hello

    ", -// doc(p("hello")))) -// -// it("can find a position at the end of a paragraph", -// find("

    hello

    ", -// doc(p("hello
    ")))) -// -// it("can find a position inside text", -// find("

    hello

    ", -// doc(p("hel
    lo")))) -// -// it("can find a position inside an ignored node", -// find("

    hi

    foo

    ok

    ", -// doc(p("hi"), "
    ", p("ok")))) -// -// it("can find a position between nodes", -// find("
    • foo
    • bar
    ", -// doc(ul(li(p("foo")), "
    ", li(p("bar")))))) -// -// it("can find a position at the start of the document", -// find("

    hi

    ", -// doc("
    ", p("hi")))) -// -// it("can find a position at the end of the document", -// find("

    hi

    ", -// doc(p("hi"), "
    "))) -// -// let quoteSchema = new Schema({nodes: schema.spec.nodes, marks: schema.spec.marks, topNode: "blockquote"}) -// -// it("uses a custom top node when parsing", -// test(quoteSchema.node("blockquote", null, quoteSchema.node("paragraph", null, quoteSchema.text("hello"))), -// "

    hello

    ")) -// -// function contextParser(context: string) { -// return new DOMParser(schema, [{tag: "foo", node: "horizontal_rule", context} as ParseRule] -// .concat(DOMParser.schemaRules(schema) as ParseRule[])) -// } -// -// it("recognizes context restrictions", () => { -// ist(contextParser("blockquote/").parse(domFrom("

    ")), -// doc(blockquote(hr(), p())), eq) -// }) -// -// it("accepts group names in contexts", () => { -// ist(contextParser("block/").parse(domFrom("

    ")), -// doc(blockquote(hr(), p())), eq) -// }) -// -// it("understands nested context restrictions", () => { -// ist(contextParser("blockquote/ordered_list//") -// .parse(domFrom("
    1. a

    ")), -// doc(blockquote(ol(li(p("a"), hr())))), eq) -// }) -// -// it("understands double slashes in context restrictions", () => { -// ist(contextParser("blockquote//list_item/") -// .parse(domFrom("
    1. a

    ")), -// doc(blockquote(ol(li(p("a"), hr())))), eq) -// }) -// -// it("understands pipes in context restrictions", () => { -// ist(contextParser("list_item/|blockquote/") -// .parse(domFrom("

    1. a

    ")), -// doc(blockquote(p(), hr()), ol(li(p("a"), hr()))), eq) -// }) -// -// it("uses the passed context", () => { -// let cxDoc = doc(blockquote("
    ", hr())) -// ist(contextParser("doc//blockquote/").parse(domFrom("
    "), { -// topNode: blockquote(), -// context: cxDoc.resolve((cxDoc as any).tag.a) -// }), blockquote(blockquote(hr())), eq) -// }) -// -// it("uses the passed context when parsing a slice", () => { -// let cxDoc = doc(blockquote("
    ", hr())) -// ist(contextParser("doc//blockquote/").parseSlice(domFrom(""), { -// context: cxDoc.resolve((cxDoc as any).tag.a) -// }), new Slice(blockquote(hr()).content, 0, 0), eq) -// }) -// -// it("can close parent nodes from a rule", () => { -// let closeParser = new DOMParser(schema, [{tag: "br", closeParent: true} as ParseRule] -// .concat(DOMParser.schemaRules(schema))) -// ist(closeParser.parse(domFrom("

    one
    two

    ")), doc(p("one"), p("two")), eq) -// }) -// -// it("supports non-consuming node rules", () => { -// let parser = new DOMParser(schema, [{tag: "ol", consuming: false, node: "blockquote"} as ParseRule] -// .concat(DOMParser.schemaRules(schema))) -// ist(parser.parse(domFrom("

      one

    ")), doc(blockquote(ol(li(p("one"))))), eq) -// }) -// -// it("supports non-consuming style rules", () => { -// let parser = new DOMParser(schema, [{style: "font-weight", consuming: false, mark: "em"} as ParseRule] -// .concat(DOMParser.schemaRules(schema))) -// ist(parser.parse(domFrom("

    one

    ")), doc(p(em(strong("one")))), eq) -// }) -// -// it("doesn't get confused by nested mark tags", -// recover("
    AB
    C", -// doc(p(strong("A"), "B"), p("C")))) -// -// it("ignores styles on skipped nodes", () => { -// let dom = document.createElement("div") -// dom.innerHTML = "

    abc def

    " -// ist(parser.parse(dom, { -// ruleFromNode: node => { -// return node.nodeType == 1 && (node as HTMLElement).tagName == "SPAN" ? {skip: node as any} : null -// } -// }), doc(p("abc def")), eq) -// -// }) -// }) -// -// describe("schemaRules", () => { -// it("defaults to schema order", () => { -// let schema = new Schema({ -// marks: {em: {parseDOM: [{tag: "i"}, {tag: "em"}]}}, -// nodes: {doc: {content: "inline*"}, -// text: {group: "inline"}, -// foo: {group: "inline", inline: true, parseDOM: [{tag: "foo"}]}, -// bar: {group: "inline", inline: true, parseDOM: [{tag: "bar"}]}} -// }) -// ist(DOMParser.schemaRules(schema).map(r => r.tag).join(" "), "i em foo bar") -// }) -// -// it("understands priority", () => { -// let schema = new Schema({ -// marks: {em: {parseDOM: [{tag: "i", priority: 40}, {tag: "em", priority: 70}]}}, -// nodes: {doc: {content: "inline*"}, -// text: {group: "inline"}, -// foo: {group: "inline", inline: true, parseDOM: [{tag: "foo"}]}, -// bar: {group: "inline", inline: true, parseDOM: [{tag: "bar", priority: 60}]}} -// }) -// ist(DOMParser.schemaRules(schema).map(r => r.tag).join(" "), "em bar foo i") -// }) -// -// function nsParse(doc: Node, namespace?: string) { -// let schema = new Schema({ -// nodes: {doc: {content: "h*"}, text: {}, -// h: {parseDOM: [{tag: "h", namespace}]}} -// }) -// return DOMParser.fromSchema(schema).parse(doc) -// } -// -// it("includes nodes when namespace is correct", () => { -// let doc = xmlDocument.createElement("doc") -// let h = xmlDocument.createElementNS("urn:ns", "h") -// doc.appendChild(h) -// ist(nsParse(doc, "urn:ns").childCount, 1) -// }) -// -// it("excludes nodes when namespace is wrong", () => { -// let doc = xmlDocument.createElement("doc") -// let h = xmlDocument.createElementNS("urn:nt", "h") -// doc.appendChild(h) -// ist(nsParse(doc, "urn:ns").childCount, 0) -// }) -// -// it("excludes nodes when namespace is absent", () => { -// let doc = xmlDocument.createElement("doc") -// // in HTML documents, createElement gives namespace -// // 'http://www.w3.org/1999/xhtml' so use createElementNS -// let h = xmlDocument.createElementNS(null, "h") -// doc.appendChild(h) -// ist(nsParse(doc, "urn:ns").childCount, 0) -// }) -// -// it("excludes nodes when namespace is wrong and xhtml", () => { -// let doc = xmlDocument.createElement("doc") -// let h = xmlDocument.createElementNS("urn:nt", "h") -// doc.appendChild(h) -// ist(nsParse(doc, "http://www.w3.org/1999/xhtml").childCount, 0) -// }) -// -// it("excludes nodes when namespace is wrong and empty", () => { -// let doc = xmlDocument.createElement("doc") -// let h = xmlDocument.createElementNS("urn:nt", "h") -// doc.appendChild(h) -// ist(nsParse(doc, "").childCount, 0) -// }) -// -// it("includes nodes when namespace is correct and empty", () => { -// let doc = xmlDocument.createElement("doc") -// let h = xmlDocument.createElementNS(null, "h") -// doc.appendChild(h) -// ist(nsParse(doc).childCount, 1) -// }) -// }) -// }) -// -// describe("DOMSerializer", () => { -// let noEm = new DOMSerializer(serializer.nodes, Object.assign({}, serializer.marks, {em: null})) -// -// it("can omit a mark", () => { -// ist((noEm.serializeNode(p("foo", em("bar"), strong("baz")), {document}) as HTMLElement).innerHTML, -// "foobarbaz") -// }) -// -// it("doesn't split other marks for omitted marks", () => { -// ist((noEm.serializeNode(p("foo", code("bar"), em(code("baz"), "quux"), "xyz"), {document}) as HTMLElement).innerHTML, -// "foobarbazquuxxyz") -// }) -// -// it("can render marks with complex structure", () => { -// let deepEm = new DOMSerializer(serializer.nodes, Object.assign({}, serializer.marks, { -// em() { return ["em", ["i", {"data-emphasis": true}, 0]] } -// })) -// let node = deepEm.serializeNode(p(strong("foo", code("bar"), em(code("baz"))), em("quux"), "xyz"), {document}) -// ist((node as HTMLElement).innerHTML, -// "foobarbazquuxxyz") -// }) -// }) diff --git a/state/README.md b/state/README.md index bfdf207..8ed97fd 100644 --- a/state/README.md +++ b/state/README.md @@ -40,21 +40,3 @@ ProseMirror has a plugin system. @PluginView @Plugin @PluginKey - -## Maven / Gradle dependency - -Check the latest package at Maven central on: https://packages.atlassian.com/maven-central/com/atlassian/prosemirror/state. - -### Maven: -```xml - - com.atlassian.prosemirror - state - 1.0.2 - -``` - -### Gradle: -```kotlin -implementation("com.atlassian.prosemirror:state:1.0.2") -``` diff --git a/state/build.gradle.kts b/state/build.gradle.kts index fbe2547..e3d71e7 100644 --- a/state/build.gradle.kts +++ b/state/build.gradle.kts @@ -1,103 +1,150 @@ +import java.net.URL +import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework + plugins { - alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.ktlint) alias(libs.plugins.dokka) id("maven-publish") id("signing") } -repositories { - mavenCentral() -} +kotlin { + // Java + jvm { + withJava() + testRuns["test"].executionTask.configure { + useJUnitPlatform() + } + } -java { - withSourcesJar() - withJavadocJar() -} + // iOS + val xcframeworkName = "state" + val xcf = XCFramework(xcframeworkName) + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ).forEach { + it.binaries.framework { + baseName = xcframeworkName + binaryOption("bundleId", "com.atlassian.prosemirror.$xcframeworkName") + xcf.add(this) + isStatic = true + } + } -dependencies { - implementation(libs.kotlin.stdlib) - implementation(libs.kotlinx.serialization.json) - implementation(project(":model")) - implementation(project(":transform")) - implementation(project(":util")) - testImplementation(project(":test-builder")) - testImplementation(kotlin("test")) - testImplementation(libs.test.assertj) -} + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlin.datetime) + implementation(project(":model")) + implementation(project(":transform")) + implementation(project(":util")) + } + commonTest.dependencies { + implementation(project(":test-builder")) + implementation(libs.kotlin.test) + implementation(libs.test.assertk) + } + } -description = "prosemirror-state" + tasks.dokkaHtml { + dokkaSourceSets { + val commonMain by getting { + sourceLink { + // Unix based directory relative path to the root of the project (where you execute gradle respectively). + localDirectory.set(file("src/commonMain/kotlin")) -val javaVersion = JavaVersion.VERSION_17 + // URL showing where the source code can be accessed through the web browser + remoteUrl.set(URL("https://github.com/atlassian-labs/prosemirror-kotlin/state/src/main/src/commonMain/kotlin")) -tasks.withType { - options.encoding = "UTF-8" - sourceCompatibility = javaVersion.toString() - targetCompatibility = javaVersion.toString() -} + // Suffix which is used to append the line number to the URL. Use #L for GitHub + remoteLineSuffix.set("#lines-") + } + } -tasks { + val jvmMain by getting { + sourceLink { + // Unix based directory relative path to the root of the project (where you execute gradle respectively). + localDirectory.set(file("src/jvmMain/kotlin")) - jar { - archiveBaseName.set("prosemirror-state") - } + // URL showing where the source code can be accessed through the web browser + remoteUrl.set(URL("https://github.com/atlassian-labs/prosemirror-kotlin/state/src/main/src/jvmMain/kotlin")) - // This task is added by Gradle when we use java.withJavadocJar() - named("javadocJar") { - from(dokkaJavadoc) - } + // Suffix which is used to append the line number to the URL. Use #L for GitHub + remoteLineSuffix.set("#lines-") + } + } + + val nativeMain by getting { + sourceLink { + // Unix based directory relative path to the root of the project (where you execute gradle respectively). + localDirectory.set(file("src/nativeMain/kotlin")) - test { - useJUnitPlatform() + // URL showing where the source code can be accessed through the web browser + remoteUrl.set(URL("https://github.com/atlassian-labs/prosemirror-kotlin/state/src/main/src/nativeMain/kotlin/")) + + // Suffix which is used to append the line number to the URL. Use #L for GitHub + remoteLineSuffix.set("#lines-") + } + } + } } +} - publishing { - publications { - create("release") { - from(project.components["java"]) - pom { - packaging = "jar" - name.set(project.name) - description.set("ProseMirror editor state") - url.set("https://github.com/atlassian-labs/prosemirror-kotlin/tree/state/") - scm { - connection.set("git@github.com:atlassian-labs/prosemirror-kotlin.git") - url.set("https://github.com/atlassian-labs/prosemirror-kotlin.git") +description = "prosemirror-state" + +publishing { + publications { + publications.withType { + pom { + name.set(project.name) + description.set("ProseMirror editor state") + url.set("https://github.com/atlassian-labs/prosemirror-kotlin/tree/transform/") + + scm { + connection.set("git@github.com:atlassian-labs/prosemirror-kotlin.git") + url.set("https://github.com/atlassian-labs/prosemirror-kotlin.git") + } + developers { + developer { + id.set("dmarques") + name.set("Douglas Marques") + email.set("dmarques@atlassian.com") } - developers { - developer { - id.set("dmarques") - name.set("Douglas Marques") - email.set("dmarques@atlassian.com") - } + developer { + id.set("achernykh") + name.set("Aleksei Chernykh") + email.set("achernykh@atlassian.com") } - licenses { - license { - name.set("Apache License 2.0") - url.set("https://www.apache.org/licenses/LICENSE-2.0") - distribution.set("repo") - } + } + licenses { + license { + name.set("Apache License 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0") + distribution.set("repo") } } } } + } - repositories { - maven { - url = uri("https://packages.atlassian.com/maven-central") - credentials { - username = System.getenv("ARTIFACTORY_USERNAME") - password = System.getenv("ARTIFACTORY_API_KEY") - } + repositories { + maven { + url = uri("https://packages.atlassian.com/maven-central") + credentials { + username = System.getenv("ARTIFACTORY_USERNAME") + password = System.getenv("ARTIFACTORY_API_KEY") } } } +} - signing { - useInMemoryPgpKeys( - System.getenv("SIGNING_KEY"), - System.getenv("SIGNING_PASSWORD"), - ) - sign(publishing.publications["release"]) - } +signing { + useInMemoryPgpKeys( + System.getenv("SIGNING_KEY"), + System.getenv("SIGNING_PASSWORD"), + ) + sign(publishing.publications) } diff --git a/state/config/ktlint/baseline.xml b/state/config/ktlint/baseline.xml index 8a3a27f..868e9c9 100644 --- a/state/config/ktlint/baseline.xml +++ b/state/config/ktlint/baseline.xml @@ -1,9 +1,8 @@ - + - @@ -19,19 +18,17 @@ - + - - @@ -40,8 +37,6 @@ - - @@ -51,9 +46,7 @@ - - @@ -83,9 +76,7 @@ - - @@ -100,7 +91,6 @@ - @@ -114,130 +104,98 @@ - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + - + - - - - - - - - - - + + + + diff --git a/state/src/main/kotlin/com/atlassian/prosemirror/state/Plugin.kt b/state/src/commonMain/kotlin/com/atlassian/prosemirror/state/Plugin.kt similarity index 100% rename from state/src/main/kotlin/com/atlassian/prosemirror/state/Plugin.kt rename to state/src/commonMain/kotlin/com/atlassian/prosemirror/state/Plugin.kt diff --git a/state/src/main/kotlin/com/atlassian/prosemirror/state/Selection.kt b/state/src/commonMain/kotlin/com/atlassian/prosemirror/state/Selection.kt similarity index 100% rename from state/src/main/kotlin/com/atlassian/prosemirror/state/Selection.kt rename to state/src/commonMain/kotlin/com/atlassian/prosemirror/state/Selection.kt diff --git a/state/src/main/kotlin/com/atlassian/prosemirror/state/State.kt b/state/src/commonMain/kotlin/com/atlassian/prosemirror/state/State.kt similarity index 100% rename from state/src/main/kotlin/com/atlassian/prosemirror/state/State.kt rename to state/src/commonMain/kotlin/com/atlassian/prosemirror/state/State.kt diff --git a/state/src/main/kotlin/com/atlassian/prosemirror/state/Transaction.kt b/state/src/commonMain/kotlin/com/atlassian/prosemirror/state/Transaction.kt similarity index 98% rename from state/src/main/kotlin/com/atlassian/prosemirror/state/Transaction.kt rename to state/src/commonMain/kotlin/com/atlassian/prosemirror/state/Transaction.kt index fe0254f..d052a29 100644 --- a/state/src/main/kotlin/com/atlassian/prosemirror/state/Transaction.kt +++ b/state/src/commonMain/kotlin/com/atlassian/prosemirror/state/Transaction.kt @@ -7,6 +7,7 @@ import com.atlassian.prosemirror.model.RangeError import com.atlassian.prosemirror.model.Slice import com.atlassian.prosemirror.transform.Step import com.atlassian.prosemirror.transform.Transform +import kotlinx.datetime.Clock // Commands are functions that take a state and a an optional transaction dispatch function and... // @@ -58,7 +59,7 @@ class Transaction : Transform { var storedMarks: List? internal constructor(state: PMEditorState) : super(state.doc) { - this.time = System.currentTimeMillis() + this.time = Clock.System.now().toEpochMilliseconds() this.curSelection = state.selection this.storedMarks = state.storedMarks } diff --git a/state/src/main/kotlin/com/atlassian/prosemirror/state/index.ts b/state/src/commonMain/kotlin/com/atlassian/prosemirror/state/index.ts similarity index 100% rename from state/src/main/kotlin/com/atlassian/prosemirror/state/index.ts rename to state/src/commonMain/kotlin/com/atlassian/prosemirror/state/index.ts diff --git a/state/src/main/kotlin/com/atlassian/prosemirror/state/plugin.ts b/state/src/commonMain/kotlin/com/atlassian/prosemirror/state/plugin.ts similarity index 100% rename from state/src/main/kotlin/com/atlassian/prosemirror/state/plugin.ts rename to state/src/commonMain/kotlin/com/atlassian/prosemirror/state/plugin.ts diff --git a/state/src/main/kotlin/com/atlassian/prosemirror/state/selection.ts b/state/src/commonMain/kotlin/com/atlassian/prosemirror/state/selection.ts similarity index 100% rename from state/src/main/kotlin/com/atlassian/prosemirror/state/selection.ts rename to state/src/commonMain/kotlin/com/atlassian/prosemirror/state/selection.ts diff --git a/state/src/main/kotlin/com/atlassian/prosemirror/state/state.ts b/state/src/commonMain/kotlin/com/atlassian/prosemirror/state/state.ts similarity index 100% rename from state/src/main/kotlin/com/atlassian/prosemirror/state/state.ts rename to state/src/commonMain/kotlin/com/atlassian/prosemirror/state/state.ts diff --git a/state/src/main/kotlin/com/atlassian/prosemirror/state/transaction.ts b/state/src/commonMain/kotlin/com/atlassian/prosemirror/state/transaction.ts similarity index 100% rename from state/src/main/kotlin/com/atlassian/prosemirror/state/transaction.ts rename to state/src/commonMain/kotlin/com/atlassian/prosemirror/state/transaction.ts diff --git a/state/src/test/kotlin/com/atlassian/prosemirror/state/SelectionTest.kt b/state/src/commonTest/kotlin/com/atlassian/prosemirror/state/SelectionTest.kt similarity index 97% rename from state/src/test/kotlin/com/atlassian/prosemirror/state/SelectionTest.kt rename to state/src/commonTest/kotlin/com/atlassian/prosemirror/state/SelectionTest.kt index 0f0071b..5123113 100644 --- a/state/src/test/kotlin/com/atlassian/prosemirror/state/SelectionTest.kt +++ b/state/src/commonTest/kotlin/com/atlassian/prosemirror/state/SelectionTest.kt @@ -1,10 +1,13 @@ package com.atlassian.prosemirror.state +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import assertk.assertions.isTrue import com.atlassian.prosemirror.testbuilder.PMNodeBuilder import com.atlassian.prosemirror.testbuilder.PMNodeBuilder.Companion.doc import com.atlassian.prosemirror.testbuilder.PMNodeBuilder.Companion.pos import com.atlassian.prosemirror.testbuilder.schema -import org.assertj.core.api.Assertions.assertThat import kotlin.test.BeforeTest import kotlin.test.Test @@ -18,7 +21,7 @@ class SelectionTest { fun `should follow changes`() { val state = TestState(EditorStateConfigImpl(doc = doc { p { +"hi" } }, schema = schema)) state.apply(state.tr.insertText("xy", 1)) - assertThat(state.selection.head) + assertThat(state.selection.head).isEqualTo(3) assertThat(state.selection.anchor).isEqualTo(3) state.apply(state.tr.insertText("zq", 1)) assertThat(state.selection.head).isEqualTo(5) @@ -179,12 +182,12 @@ class SelectionTest { state.apply(state.tr.replaceSelectionWith(schema.node("hard_break"))) assertThat(state.doc).isEqualTo(doc { p { +"foo" + br {} + "bar" + img {} + "baz" } }) assertThat(state.selection.head).isEqualTo(5) - assertThat(state.selection.empty).isTrue + assertThat(state.selection.empty).isTrue() state.nodeSel(8) state.apply(state.tr.insertText("abc")) assertThat(state.doc).isEqualTo(doc { p { +"foo" + br {} + "barabcbaz" } }) assertThat(state.selection.head).isEqualTo(11) - assertThat(state.selection.empty).isTrue + assertThat(state.selection.empty).isTrue() state.nodeSel(0) state.apply(state.tr.insertText("xyz")) assertThat(state.doc).isEqualTo(doc { p { +"xyz" } }) diff --git a/state/src/test/kotlin/com/atlassian/prosemirror/state/State.kt b/state/src/commonTest/kotlin/com/atlassian/prosemirror/state/State.kt similarity index 100% rename from state/src/test/kotlin/com/atlassian/prosemirror/state/State.kt rename to state/src/commonTest/kotlin/com/atlassian/prosemirror/state/State.kt diff --git a/state/src/test/kotlin/com/atlassian/prosemirror/state/StateTest.kt b/state/src/commonTest/kotlin/com/atlassian/prosemirror/state/StateTest.kt similarity index 97% rename from state/src/test/kotlin/com/atlassian/prosemirror/state/StateTest.kt rename to state/src/commonTest/kotlin/com/atlassian/prosemirror/state/StateTest.kt index 3d6cb32..92458b0 100644 --- a/state/src/test/kotlin/com/atlassian/prosemirror/state/StateTest.kt +++ b/state/src/commonTest/kotlin/com/atlassian/prosemirror/state/StateTest.kt @@ -1,9 +1,10 @@ package com.atlassian.prosemirror.state +import assertk.assertThat +import assertk.assertions.isEqualTo import com.atlassian.prosemirror.testbuilder.PMNodeBuilder import com.atlassian.prosemirror.testbuilder.PMNodeBuilder.Companion.doc import com.atlassian.prosemirror.testbuilder.schema -import org.assertj.core.api.Assertions.assertThat import kotlin.test.BeforeTest import kotlin.test.Test diff --git a/state/src/test/kotlin/com/atlassian/prosemirror/state/state.ts b/state/src/commonTest/kotlin/com/atlassian/prosemirror/state/state.ts similarity index 100% rename from state/src/test/kotlin/com/atlassian/prosemirror/state/state.ts rename to state/src/commonTest/kotlin/com/atlassian/prosemirror/state/state.ts diff --git a/state/src/test/kotlin/com/atlassian/prosemirror/state/test-selection.ts b/state/src/commonTest/kotlin/com/atlassian/prosemirror/state/test-selection.ts similarity index 100% rename from state/src/test/kotlin/com/atlassian/prosemirror/state/test-selection.ts rename to state/src/commonTest/kotlin/com/atlassian/prosemirror/state/test-selection.ts diff --git a/state/src/test/kotlin/com/atlassian/prosemirror/state/test-state.ts b/state/src/commonTest/kotlin/com/atlassian/prosemirror/state/test-state.ts similarity index 100% rename from state/src/test/kotlin/com/atlassian/prosemirror/state/test-state.ts rename to state/src/commonTest/kotlin/com/atlassian/prosemirror/state/test-state.ts diff --git a/test-builder/README.md b/test-builder/README.md index 8144581..52ce523 100644 --- a/test-builder/README.md +++ b/test-builder/README.md @@ -72,20 +72,11 @@ node or mark the builder by this name should create. Calls `a.eq(b)`. Can be useful to pass as comparison predicate when comparing ProseMirror nodes or slices. -## Maven / Gradle dependency - -Check the latest package at Maven central on: https://packages.atlassian.com/maven-central/com/atlassian/prosemirror/test-builder. - -### Maven: -```xml - - com.atlassian.prosemirror - test-builder - 1.0.2 - -``` - -### Gradle: -```kotlin -implementation("com.atlassian.prosemirror:test-builder:1.0.2") -``` +## Versioning +This module is a port of version: + - [1.1.1](https://github.com/ProseMirror/prosemirror-test-builder/releases/tag/1.1.1) + of prosemirror-test-builder + - [1.2.3](https://github.com/ProseMirror/prosemirror-schema-basic/releases/tag/1.2.3) + of prosemirror-schema-basic + - [1.4.1](https://github.com/ProseMirror/prosemirror-schema-list/releases/tag/1.4.1) + of prosemirror-schema-list diff --git a/test-builder/build.gradle.kts b/test-builder/build.gradle.kts index a18bea1..aee5298 100644 --- a/test-builder/build.gradle.kts +++ b/test-builder/build.gradle.kts @@ -1,102 +1,146 @@ +import java.net.URL +import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework + plugins { - alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.ktlint) alias(libs.plugins.dokka) id("maven-publish") id("signing") } -repositories { - mavenCentral() -} +kotlin { + // Java + jvm { + withJava() + testRuns["test"].executionTask.configure { + useJUnitPlatform() + } + } -java { - withSourcesJar() - withJavadocJar() -} + // iOS + val xcframeworkName = "test-builder" + val xcf = XCFramework(xcframeworkName) + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ).forEach { + it.binaries.framework { + baseName = xcframeworkName + binaryOption("bundleId", "com.atlassian.prosemirror.$xcframeworkName") + xcf.add(this) + isStatic = true + } + } -dependencies { - implementation(libs.kotlin.stdlib) - implementation(libs.kotlinx.serialization.json) - implementation(project(":model")) - implementation(project(":transform")) - implementation(project(":util")) - testImplementation(kotlin("test")) - testImplementation(libs.test.assertj) -} + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.json) + implementation(project(":model")) + implementation(project(":transform")) + implementation(project(":util")) + } + commonTest.dependencies { + } + } -description = "prosemirror-test-builder" + tasks.dokkaHtml { + dokkaSourceSets { + val commonMain by getting { + sourceLink { + // Unix based directory relative path to the root of the project (where you execute gradle respectively). + localDirectory.set(file("src/commonMain/kotlin")) -val javaVersion = JavaVersion.VERSION_17 + // URL showing where the source code can be accessed through the web browser + remoteUrl.set(URL("https://github.com/atlassian-labs/prosemirror-kotlin/test-builder/src/main/src/commonMain/kotlin")) -tasks.withType { - options.encoding = "UTF-8" - sourceCompatibility = javaVersion.toString() - targetCompatibility = javaVersion.toString() -} + // Suffix which is used to append the line number to the URL. Use #L for GitHub + remoteLineSuffix.set("#lines-") + } + } -tasks { + val jvmMain by getting { + sourceLink { + // Unix based directory relative path to the root of the project (where you execute gradle respectively). + localDirectory.set(file("src/jvmMain/kotlin")) - jar { - archiveBaseName.set("prosemirror-test-builder") - } + // URL showing where the source code can be accessed through the web browser + remoteUrl.set(URL("https://github.com/atlassian-labs/prosemirror-kotlin/test-builder/src/main/src/jvmMain/kotlin")) - // This task is added by Gradle when we use java.withJavadocJar() - named("javadocJar") { - from(dokkaJavadoc) - } + // Suffix which is used to append the line number to the URL. Use #L for GitHub + remoteLineSuffix.set("#lines-") + } + } + + val nativeMain by getting { + sourceLink { + // Unix based directory relative path to the root of the project (where you execute gradle respectively). + localDirectory.set(file("src/nativeMain/kotlin")) - test { - useJUnitPlatform() + // URL showing where the source code can be accessed through the web browser + remoteUrl.set(URL("https://github.com/atlassian-labs/prosemirror-kotlin/test-builder/src/main/src/nativeMain/kotlin/")) + + // Suffix which is used to append the line number to the URL. Use #L for GitHub + remoteLineSuffix.set("#lines-") + } + } + } } +} - publishing { - publications { - create("release") { - from(project.components["java"]) - pom { - packaging = "jar" - name.set(project.name) - description.set("Document building utilities for writing tests") - url.set("https://github.com/atlassian-labs/prosemirror-kotlin/tree/test-builder/") - scm { - connection.set("git@github.com:atlassian-labs/prosemirror-kotlin.git") - url.set("https://github.com/atlassian-labs/prosemirror-kotlin.git") +description = "prosemirror-test-builder" + +publishing { + publications { + publications.withType { + pom { + name.set(project.name) + description.set("Document building utilities for writing tests") + url.set("https://github.com/atlassian-labs/prosemirror-kotlin/tree/test-builder/") + + scm { + connection.set("git@github.com:atlassian-labs/prosemirror-kotlin.git") + url.set("https://github.com/atlassian-labs/prosemirror-kotlin.git") + } + developers { + developer { + id.set("dmarques") + name.set("Douglas Marques") + email.set("dmarques@atlassian.com") } - developers { - developer { - id.set("dmarques") - name.set("Douglas Marques") - email.set("dmarques@atlassian.com") - } + developer { + id.set("achernykh") + name.set("Aleksei Chernykh") + email.set("achernykh@atlassian.com") } - licenses { - license { - name.set("Apache License 2.0") - url.set("https://www.apache.org/licenses/LICENSE-2.0") - distribution.set("repo") - } + } + licenses { + license { + name.set("Apache License 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0") + distribution.set("repo") } } } } + } - repositories { - maven { - url = uri("https://packages.atlassian.com/maven-central") - credentials { - username = System.getenv("ARTIFACTORY_USERNAME") - password = System.getenv("ARTIFACTORY_API_KEY") - } + repositories { + maven { + url = uri("https://packages.atlassian.com/maven-central") + credentials { + username = System.getenv("ARTIFACTORY_USERNAME") + password = System.getenv("ARTIFACTORY_API_KEY") } } } +} - signing { - useInMemoryPgpKeys( - System.getenv("SIGNING_KEY"), - System.getenv("SIGNING_PASSWORD"), - ) - sign(publishing.publications["release"]) - } +signing { + useInMemoryPgpKeys( + System.getenv("SIGNING_KEY"), + System.getenv("SIGNING_PASSWORD"), + ) + sign(publishing.publications) } diff --git a/test-builder/config/ktlint/baseline.xml b/test-builder/config/ktlint/baseline.xml index e192b30..97bcb5e 100644 --- a/test-builder/config/ktlint/baseline.xml +++ b/test-builder/config/ktlint/baseline.xml @@ -1,15 +1,7 @@ - - - - - - + - - - @@ -21,7 +13,6 @@ - @@ -83,99 +74,59 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - diff --git a/test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/AdfSchema.kt b/test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/AdfSchema.kt similarity index 83% rename from test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/AdfSchema.kt rename to test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/AdfSchema.kt index 5be0810..f49bd39 100644 --- a/test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/AdfSchema.kt +++ b/test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/AdfSchema.kt @@ -7,6 +7,7 @@ import com.atlassian.prosemirror.model.MarkSpec import com.atlassian.prosemirror.model.Node import com.atlassian.prosemirror.model.NodeSpec import com.atlassian.prosemirror.model.ParseRule +import com.atlassian.prosemirror.model.TagParseRule import com.atlassian.prosemirror.model.Whitespace data class NodeSpecImpl( @@ -27,8 +28,9 @@ data class NodeSpecImpl( override val toDebugString: ((node: Node) -> String)? = null, override val leafText: ((node: Node) -> String)? = null, override val toDOM: ((node: Node) -> DOMOutputSpec)? = null, - override val parseDOM: List? = null, - override val autoFocusable: Boolean? = null + override val parseDOM: List? = null, + override val autoFocusable: Boolean? = null, + override val linebreakReplacement: Boolean? = null ) : NodeSpec data class MarkSpecImpl( @@ -43,8 +45,8 @@ data class MarkSpecImpl( data class AttributeSpecImpl( override val default: Any?, - override val hasDefault: Boolean + override val validateString: String? = null, + override val validateFunction: ((value: Any?) -> Unit)? = null ) : AttributeSpec { - constructor(default: Any?) : this(default, true) - constructor() : this(null, false) + constructor() : this(null) } diff --git a/test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/Build.kt b/test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/Build.kt similarity index 100% rename from test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/Build.kt rename to test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/Build.kt diff --git a/test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/NodeBuilder.kt b/test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/NodeBuilder.kt similarity index 100% rename from test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/NodeBuilder.kt rename to test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/NodeBuilder.kt diff --git a/test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/SchemaBasic.kt b/test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/SchemaBasic.kt similarity index 73% rename from test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/SchemaBasic.kt rename to test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/SchemaBasic.kt index 37cd0dc..c5c03f7 100644 --- a/test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/SchemaBasic.kt +++ b/test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/SchemaBasic.kt @@ -8,11 +8,13 @@ import com.atlassian.prosemirror.model.Mark import com.atlassian.prosemirror.model.MarkSpec import com.atlassian.prosemirror.model.Node import com.atlassian.prosemirror.model.NodeSpec -import com.atlassian.prosemirror.model.ParseRuleImpl import com.atlassian.prosemirror.model.ParseRuleMatch import com.atlassian.prosemirror.model.PreserveWhitespace import com.atlassian.prosemirror.model.Schema import com.atlassian.prosemirror.model.SchemaSpec +import com.atlassian.prosemirror.model.StyleParseRuleImpl +import com.atlassian.prosemirror.model.TagParseRuleImpl +import com.atlassian.prosemirror.model.styles val pDOM: DOMOutputSpec = DOMOutputSpec.ArrayDOMOutputSpec(listOf("p", 0)) val blockquoteDOM: DOMOutputSpec = DOMOutputSpec.ArrayDOMOutputSpec(listOf("blockquote", 0)) @@ -39,7 +41,7 @@ val nodes = mapOf( "paragraph" to NodeSpecImpl( content = "inline*", group = "block", - parseDOM = listOf(ParseRuleImpl(tag = "p")), + parseDOM = listOf(TagParseRuleImpl(tag = "p")), toDOM = { _ -> pDOM } ), @@ -48,31 +50,31 @@ val nodes = mapOf( content = "block+", group = "block", defining = true, - parseDOM = listOf(ParseRuleImpl(tag = "blockquote")), + parseDOM = listOf(TagParseRuleImpl(tag = "blockquote")), toDOM = { _ -> blockquoteDOM } ), // A horizontal rule (`
    `). "horizontal_rule" to NodeSpecImpl( group = "block", - parseDOM = listOf(ParseRuleImpl(tag = "hr")), + parseDOM = listOf(TagParseRuleImpl(tag = "hr")), toDOM = { _ -> hrDOM } ), // A heading textblock, with a `level` attribute that should hold the number 1 to 6. Parsed and // serialized as `

    ` to `

    ` elements. "heading" to NodeSpecImpl( - attrs = mutableMapOf("level" to AttributeSpecImpl(default = 1)), + attrs = mutableMapOf("level" to AttributeSpecImpl(default = 1, validateString = "Int")), content = "inline*", group = "block", defining = true, parseDOM = listOf( - ParseRuleImpl(tag = "h1", attrs = mapOf("level" to 1)), - ParseRuleImpl(tag = "h2", attrs = mapOf("level" to 2)), - ParseRuleImpl(tag = "h3", attrs = mapOf("level" to 3)), - ParseRuleImpl(tag = "h4", attrs = mapOf("level" to 4)), - ParseRuleImpl(tag = "h5", attrs = mapOf("level" to 5)), - ParseRuleImpl(tag = "h6", attrs = mapOf("level" to 6)) + TagParseRuleImpl(tag = "h1", attrs = mapOf("level" to 1)), + TagParseRuleImpl(tag = "h2", attrs = mapOf("level" to 2)), + TagParseRuleImpl(tag = "h3", attrs = mapOf("level" to 3)), + TagParseRuleImpl(tag = "h4", attrs = mapOf("level" to 4)), + TagParseRuleImpl(tag = "h5", attrs = mapOf("level" to 5)), + TagParseRuleImpl(tag = "h6", attrs = mapOf("level" to 6)) ), toDOM = { node: Node -> DOMOutputSpec.ArrayDOMOutputSpec(listOf("h" + node.attrs["level"], 0)) @@ -87,7 +89,7 @@ val nodes = mapOf( group = "block", code = true, defining = true, - parseDOM = listOf(ParseRuleImpl(tag = "pre", preserveWhitespace = PreserveWhitespace.FULL)), + parseDOM = listOf(TagParseRuleImpl(tag = "pre", preserveWhitespace = PreserveWhitespace.FULL)), toDOM = { _ -> preDOM } ), @@ -101,14 +103,14 @@ val nodes = mapOf( "image" to NodeSpecImpl( inline = true, attrs = mutableMapOf( - "src" to AttributeSpecImpl(), - "alt" to AttributeSpecImpl(default = null), - "title" to AttributeSpecImpl(default = null) + "src" to AttributeSpecImpl(default = "", validateString = "String"), + "alt" to AttributeSpecImpl(default = null, validateString = "String|null"), + "title" to AttributeSpecImpl(default = null, validateString = "String|null") ), group = "inline", draggable = true, parseDOM = listOf( - ParseRuleImpl(tag = "img[src]", getNodeAttrs = { dom -> + TagParseRuleImpl(tag = "img[src]", getNodeAttrs = { dom -> ParseRuleMatch( mapOf( "src" to dom.attribute("src")?.value, @@ -142,7 +144,7 @@ val nodes = mapOf( group = "inline", selectable = false, leafText = { "\n" }, - parseDOM = listOf(ParseRuleImpl(tag = "br")), + parseDOM = listOf(TagParseRuleImpl(tag = "br")), toDOM = { _ -> brDOM } ) ) @@ -153,12 +155,12 @@ val marks = mapOf( // parsed as an `` element. "link" to MarkSpecImpl( attrs = mutableMapOf( - "href" to AttributeSpecImpl(), - "title" to AttributeSpecImpl(default = null) + "href" to AttributeSpecImpl(default = "", validateString = "String"), + "title" to AttributeSpecImpl(default = null, validateString = "String|null") ), inclusive = false, parseDOM = listOf( - ParseRuleImpl(tag = "a[href]", getNodeAttrs = { dom -> + TagParseRuleImpl(tag = "a[href]", getNodeAttrs = { dom -> ParseRuleMatch( mapOf( "href" to dom.attribute("href")?.value, @@ -185,9 +187,12 @@ val marks = mapOf( // `font-style: italic`. "em" to MarkSpecImpl( parseDOM = listOf( - ParseRuleImpl(tag = "i"), - ParseRuleImpl(tag = "em"), - ParseRuleImpl(style = "font-style=italic") + TagParseRuleImpl(tag = "i"), + TagParseRuleImpl(tag = "em"), + StyleParseRuleImpl(style = "font-style=italic"), + StyleParseRuleImpl(style = "font-style=normal", clearMark = { m -> + m.type.name == "em" + }) ), toDOM = { _, _ -> emDOM } ), @@ -195,32 +200,36 @@ val marks = mapOf( // A strong mark. Rendered as ``, parse rules also match `` and `font-weight: bold`. "strong" to MarkSpecImpl( parseDOM = listOf( - ParseRuleImpl(tag = "strong"), + TagParseRuleImpl(tag = "strong"), // This works around a Google Docs misbehavior where // pasted content will be inexplicably wrapped in `` // tags with a font-weight normal. - ParseRuleImpl(tag = "b", getNodeAttrs = { node -> - // TODO find a way to parse css - // node.style.fontWeight != "normal" && null - ParseRuleMatch(null) + TagParseRuleImpl(tag = "b", getNodeAttrs = { node -> + ParseRuleMatch(null, node.styles()?.get("font-weight") != "normal") + }), + StyleParseRuleImpl(style = "font-weight=400", clearMark = { m -> + m.type.name == "strong" }), -// {style: "font-weight", getAttrs: (value: string) => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null} + StyleParseRuleImpl(style = "font-weight", getStyleAttrs = { value -> + val regex = "^bold(er)?|[5-9]\\d{2,}".toRegex() + ParseRuleMatch(null, regex.matches(value)) + }) ), toDOM = { _, _ -> strongDOM } ), // Code font mark. Represented as a `` element. "code" to MarkSpecImpl( - parseDOM = listOf(ParseRuleImpl(tag = "code")), + parseDOM = listOf(TagParseRuleImpl(tag = "code")), toDOM = { _, _ -> codeDOM } ), "strike" to MarkSpecImpl( - parseDOM = listOf(ParseRuleImpl(tag = "strike")), + parseDOM = listOf(TagParseRuleImpl(tag = "strike")), toDOM = { _, _ -> strikeDOM } ), "underline" to MarkSpecImpl( - parseDOM = listOf(ParseRuleImpl(tag = "underline")), + parseDOM = listOf(TagParseRuleImpl(tag = "underline")), toDOM = { _, _ -> underlineDOM } ) ) diff --git a/test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/SchemaLists.kt b/test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/SchemaLists.kt similarity index 91% rename from test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/SchemaLists.kt rename to test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/SchemaLists.kt index 0039eb6..7ad475d 100644 --- a/test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/SchemaLists.kt +++ b/test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/SchemaLists.kt @@ -4,8 +4,8 @@ package com.atlassian.prosemirror.testbuilder import com.atlassian.prosemirror.model.DOMOutputSpec import com.atlassian.prosemirror.model.NodeSpec -import com.atlassian.prosemirror.model.ParseRuleImpl import com.atlassian.prosemirror.model.ParseRuleMatch +import com.atlassian.prosemirror.model.TagParseRuleImpl val olDOM: DOMOutputSpec = DOMOutputSpec.ArrayDOMOutputSpec(listOf("ol", 0)) val ulDOM: DOMOutputSpec = DOMOutputSpec.ArrayDOMOutputSpec(listOf("ul", 0)) @@ -15,9 +15,9 @@ val liDOM: DOMOutputSpec = DOMOutputSpec.ArrayDOMOutputSpec(listOf("li", 0)) // the number at which the list starts counting, and defaults to 1. Represented as an `
      ` // element. val orderedList = NodeSpecImpl( - attrs = mapOf("order" to AttributeSpecImpl(default = 1)), + attrs = mapOf("order" to AttributeSpecImpl(default = 1, validateString = "Int|null")), parseDOM = listOf( - ParseRuleImpl(tag = "ol", getNodeAttrs = { dom -> + TagParseRuleImpl(tag = "ol", getNodeAttrs = { dom -> val start = if (dom.hasAttr("start")) { dom.attr("start").toInt() } else { @@ -37,13 +37,13 @@ val orderedList = NodeSpecImpl( // A bullet list node spec, represented in the DOM as `
        `. val bulletList = NodeSpecImpl( - parseDOM = listOf(ParseRuleImpl(tag = "ul")), + parseDOM = listOf(TagParseRuleImpl(tag = "ul")), toDOM = { _ -> ulDOM } ) // A list item (`
      • `) spec. val listItem = NodeSpecImpl( - parseDOM = listOf(ParseRuleImpl(tag = "li")), + parseDOM = listOf(TagParseRuleImpl(tag = "li")), toDOM = { _ -> liDOM }, defining = true ) diff --git a/test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/build.ts b/test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/build.ts similarity index 87% rename from test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/build.ts rename to test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/build.ts index aaf986e..5257e9b 100644 --- a/test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/build.ts +++ b/test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/build.ts @@ -65,12 +65,22 @@ function takeAttrs(attrs: Attrs | null, args: [a?: Attrs | ChildSpec, ...b: Chil return result } -export type NodeBuilder = (attrsOrFirstChild?: Attrs | ChildSpec, ...children: ChildSpec[]) => Node +export type NodeBuilder = (attrsOrFirstChild?: Attrs | ChildSpec, ...children: ChildSpec[]) => Node & {tag: Tags} export type MarkBuilder = (attrsOrFirstChild?: Attrs | ChildSpec, ...children: ChildSpec[]) => ChildSpec +type Builders = { + schema: S; +} & { + [key in keyof S['nodes']]: NodeBuilder +} & { + [key in keyof S['marks']]: MarkBuilder +} & { + [name: string]: NodeBuilder | MarkBuilder +} + /// Create a builder function for nodes with content. function block(type: NodeType, attrs: Attrs | null = null): NodeBuilder { - let result: NodeBuilder = function(...args) { + let result = function(...args: any[]) { let myAttrs = takeAttrs(attrs, args) let {nodes, tag} = flatten(type.schema, args as ChildSpec[], id) let node = type.create(myAttrs, nodes) @@ -78,7 +88,7 @@ function block(type: NodeType, attrs: Attrs | null = null): NodeBuilder { return node } if (type.isLeaf) try { (result as any).flat = [type.create(attrs)] } catch(_) {} - return result + return result as NodeBuilder } // Create a builder function for marks. @@ -93,7 +103,7 @@ function mark(type: MarkType, attrs: Attrs | null): MarkBuilder { } } -export function builders(schema: Schema, names?: {[name: string]: Attrs}) { +export function builders(schema: Schema, names?: {[name: string]: Attrs}) { let result = {schema} for (let name in schema.nodes) (result as any)[name] = block(schema.nodes[name], {}) for (let name in schema.marks) (result as any)[name] = mark(schema.marks[name], {}) @@ -103,5 +113,5 @@ export function builders(schema: Schema, names?: {[name: string]: Attrs}) { if (type = schema.nodes[typeName]) (result as any)[name] = block(type, value) else if (type = schema.marks[typeName]) (result as any)[name] = mark(type, value) } - return result + return result as Builders> } diff --git a/test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/index.ts b/test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/index.ts similarity index 100% rename from test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/index.ts rename to test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/index.ts diff --git a/test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/schema-basic.ts b/test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/schema-basic.ts similarity index 82% rename from test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/schema-basic.ts rename to test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/schema-basic.ts index 9d35dc8..fc53817 100644 --- a/test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/schema-basic.ts +++ b/test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/schema-basic.ts @@ -40,7 +40,7 @@ export const nodes = { /// should hold the number 1 to 6. Parsed and serialized as `

        ` to /// `

        ` elements. heading: { - attrs: {level: {default: 1}}, + attrs: {level: {default: 1, validate: "number"}}, content: "inline*", group: "block", defining: true, @@ -77,9 +77,9 @@ export const nodes = { image: { inline: true, attrs: { - src: {}, - alt: {default: null}, - title: {default: null} + src: {validate: "string"}, + alt: {default: null, validate: "string|null"}, + title: {default: null, validate: "string|null"} }, group: "inline", draggable: true, @@ -112,8 +112,8 @@ export const marks = { /// element. link: { attrs: { - href: {}, - title: {default: null} + href: {validate: "string"}, + title: {default: null, validate: "string|null"} }, inclusive: false, parseDOM: [{tag: "a[href]", getAttrs(dom: HTMLElement) { @@ -125,19 +125,26 @@ export const marks = { /// An emphasis mark. Rendered as an `` element. Has parse rules /// that also match `` and `font-style: italic`. em: { - parseDOM: [{tag: "i"}, {tag: "em"}, {style: "font-style=italic"}], + parseDOM: [ + {tag: "i"}, {tag: "em"}, + {style: "font-style=italic"}, + {style: "font-style=normal", clearMark: m => m.type.name == "em"} + ], toDOM() { return emDOM } } as MarkSpec, /// A strong mark. Rendered as ``, parse rules also match /// `` and `font-weight: bold`. strong: { - parseDOM: [{tag: "strong"}, - // This works around a Google Docs misbehavior where - // pasted content will be inexplicably wrapped in `` - // tags with a font-weight normal. - {tag: "b", getAttrs: (node: HTMLElement) => node.style.fontWeight != "normal" && null}, - {style: "font-weight", getAttrs: (value: string) => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null}], + parseDOM: [ + {tag: "strong"}, + // This works around a Google Docs misbehavior where + // pasted content will be inexplicably wrapped in `` + // tags with a font-weight normal. + {tag: "b", getAttrs: (node: HTMLElement) => node.style.fontWeight != "normal" && null}, + {style: "font-weight=400", clearMark: m => m.type.name == "strong"}, + {style: "font-weight", getAttrs: (value: string) => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null}, + ], toDOM() { return strongDOM } } as MarkSpec, diff --git a/test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/schema-list.ts b/test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/schema-list.ts similarity index 95% rename from test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/schema-list.ts rename to test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/schema-list.ts index 22a7b23..b054d13 100644 --- a/test-builder/src/main/kotlin/com/atlassian/prosemirror/testbuilder/schema-list.ts +++ b/test-builder/src/commonMain/kotlin/com/atlassian/prosemirror/testbuilder/schema-list.ts @@ -10,7 +10,7 @@ const olDOM: DOMOutputSpec = ["ol", 0], ulDOM: DOMOutputSpec = ["ul", 0], liDOM: /// starts counting, and defaults to 1. Represented as an `
          ` /// element. export const orderedList = { - attrs: {order: {default: 1}}, + attrs: {order: {default: 1, validate: "number"}}, parseDOM: [{tag: "ol", getAttrs(dom: HTMLElement) { return {order: dom.hasAttribute("start") ? +dom.getAttribute("start")! : 1} }}], @@ -155,6 +155,19 @@ export function splitListItem(itemType: NodeType, itemAttrs?: Attrs): Command { } } +/// Acts like [`splitListItem`](#schema-list.splitListItem), but +/// without resetting the set of active marks at the cursor. +export function splitListItemKeepMarks(itemType: NodeType, itemAttrs?: Attrs): Command { + let split = splitListItem(itemType, itemAttrs) + return (state, dispatch) => { + return split(state, dispatch && (tr => { + let marks = state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks()) + if (marks) tr.ensureMarks(marks) + dispatch(tr) + })) + } +} + /// Create a command to lift the list item around the selection up into /// a wrapping list. export function liftListItem(itemType: NodeType): Command { diff --git a/transform/README.md b/transform/README.md index ff01e75..757498c 100644 --- a/transform/README.md +++ b/transform/README.md @@ -53,21 +53,3 @@ transformations or determining whether they are even possible. @joinPoint @insertPoint @dropPoint - -## Maven / Gradle dependency - -Check the latest package at Maven central on: https://packages.atlassian.com/maven-central/com/atlassian/prosemirror/transform. - -### Maven: -```xml - - com.atlassian.prosemirror - transform - 1.0.2 - -``` - -### Gradle: -```kotlin -implementation("com.atlassian.prosemirror:transform:1.0.2") -``` diff --git a/transform/build.gradle.kts b/transform/build.gradle.kts index 03a359b..f8c3546 100644 --- a/transform/build.gradle.kts +++ b/transform/build.gradle.kts @@ -1,102 +1,148 @@ +import java.net.URL +import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework + plugins { - alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.ktlint) alias(libs.plugins.dokka) id("maven-publish") id("signing") } -repositories { - mavenCentral() -} +kotlin { + // Java + jvm { + withJava() + testRuns["test"].executionTask.configure { + useJUnitPlatform() + } + } -java { - withSourcesJar() - withJavadocJar() -} + // iOS + val xcframeworkName = "transform" + val xcf = XCFramework(xcframeworkName) + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ).forEach { + it.binaries.framework { + baseName = xcframeworkName + binaryOption("bundleId", "com.atlassian.prosemirror.$xcframeworkName") + xcf.add(this) + isStatic = true + } + } -dependencies { - implementation(libs.kotlin.stdlib) - implementation(libs.kotlinx.serialization.json) - implementation(project(":model")) - implementation(project(":util")) - testImplementation(project(":test-builder")) - testImplementation(kotlin("test")) - testImplementation(libs.test.assertj) -} + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.json) + implementation(project(":model")) + implementation(project(":util")) + } + commonTest.dependencies { + implementation(project(":test-builder")) + implementation(libs.kotlin.test) + implementation(libs.test.assertk) + } + } -description = "prosemirror-transform" + tasks.dokkaHtml { + dokkaSourceSets { + val commonMain by getting { + sourceLink { + // Unix based directory relative path to the root of the project (where you execute gradle respectively). + localDirectory.set(file("src/commonMain/kotlin")) -val javaVersion = JavaVersion.VERSION_17 + // URL showing where the source code can be accessed through the web browser + remoteUrl.set(URL("https://github.com/atlassian-labs/prosemirror-kotlin/transform/src/main/src/commonMain/kotlin")) -tasks.withType { - options.encoding = "UTF-8" - sourceCompatibility = javaVersion.toString() - targetCompatibility = javaVersion.toString() -} + // Suffix which is used to append the line number to the URL. Use #L for GitHub + remoteLineSuffix.set("#lines-") + } + } -tasks { + val jvmMain by getting { + sourceLink { + // Unix based directory relative path to the root of the project (where you execute gradle respectively). + localDirectory.set(file("src/jvmMain/kotlin")) - jar { - archiveBaseName.set("prosemirror-transform") - } + // URL showing where the source code can be accessed through the web browser + remoteUrl.set(URL("https://github.com/atlassian-labs/prosemirror-kotlin/transform/src/main/src/jvmMain/kotlin")) - // This task is added by Gradle when we use java.withJavadocJar() - named("javadocJar") { - from(dokkaJavadoc) - } + // Suffix which is used to append the line number to the URL. Use #L for GitHub + remoteLineSuffix.set("#lines-") + } + } + + val nativeMain by getting { + sourceLink { + // Unix based directory relative path to the root of the project (where you execute gradle respectively). + localDirectory.set(file("src/nativeMain/kotlin")) - test { - useJUnitPlatform() + // URL showing where the source code can be accessed through the web browser + remoteUrl.set(URL("https://github.com/atlassian-labs/prosemirror-kotlin/transform/src/main/src/nativeMain/kotlin/")) + + // Suffix which is used to append the line number to the URL. Use #L for GitHub + remoteLineSuffix.set("#lines-") + } + } + } } +} - publishing { - publications { - create("release") { - from(project.components["java"]) - pom { - packaging = "jar" - name.set(project.name) - description.set("ProseMirror document transformations") - url.set("https://github.com/atlassian-labs/prosemirror-kotlin/tree/transform/") - scm { - connection.set("git@github.com:atlassian-labs/prosemirror-kotlin.git") - url.set("https://github.com/atlassian-labs/prosemirror-kotlin.git") +description = "prosemirror-transform" + +publishing { + publications { + publications.withType { + pom { + name.set(project.name) + description.set("ProseMirror document transformations") + url.set("https://github.com/atlassian-labs/prosemirror-kotlin/tree/transform/") + + scm { + connection.set("git@github.com:atlassian-labs/prosemirror-kotlin.git") + url.set("https://github.com/atlassian-labs/prosemirror-kotlin.git") + } + developers { + developer { + id.set("dmarques") + name.set("Douglas Marques") + email.set("dmarques@atlassian.com") } - developers { - developer { - id.set("dmarques") - name.set("Douglas Marques") - email.set("dmarques@atlassian.com") - } + developer { + id.set("achernykh") + name.set("Aleksei Chernykh") + email.set("achernykh@atlassian.com") } - licenses { - license { - name.set("Apache License 2.0") - url.set("https://www.apache.org/licenses/LICENSE-2.0") - distribution.set("repo") - } + } + licenses { + license { + name.set("Apache License 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0") + distribution.set("repo") } } } } + } - repositories { - maven { - url = uri("https://packages.atlassian.com/maven-central") - credentials { - username = System.getenv("ARTIFACTORY_USERNAME") - password = System.getenv("ARTIFACTORY_API_KEY") - } + repositories { + maven { + url = uri("https://packages.atlassian.com/maven-central") + credentials { + username = System.getenv("ARTIFACTORY_USERNAME") + password = System.getenv("ARTIFACTORY_API_KEY") } } } +} - signing { - useInMemoryPgpKeys( - System.getenv("SIGNING_KEY"), - System.getenv("SIGNING_PASSWORD"), - ) - sign(publishing.publications["release"]) - } +signing { + useInMemoryPgpKeys( + System.getenv("SIGNING_KEY"), + System.getenv("SIGNING_PASSWORD"), + ) + sign(publishing.publications) } diff --git a/transform/config/ktlint/baseline.xml b/transform/config/ktlint/baseline.xml index de77f1a..cd3efab 100644 --- a/transform/config/ktlint/baseline.xml +++ b/transform/config/ktlint/baseline.xml @@ -1,6 +1,6 @@ - + @@ -12,9 +12,7 @@ - - @@ -37,7 +35,6 @@ - @@ -47,7 +44,6 @@ - @@ -60,7 +56,7 @@ - + @@ -83,31 +79,26 @@ - + - - - - - - + @@ -117,10 +108,6 @@ - - - - @@ -129,14 +116,8 @@ - - - - - - @@ -146,12 +127,10 @@ - - @@ -160,7 +139,6 @@ - @@ -176,8 +154,6 @@ - - @@ -185,7 +161,6 @@ - @@ -202,36 +177,30 @@ - + - - - - - - - + @@ -242,7 +211,6 @@ - @@ -250,7 +218,7 @@ - + @@ -260,9 +228,6 @@ - - - @@ -273,15 +238,12 @@ - - - @@ -292,15 +254,11 @@ - - - - @@ -335,8 +293,7 @@ - - + @@ -447,328 +404,196 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/transform/src/main/kotlin/com/atlassian/prosemirror/transform/Map.kt b/transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/Map.kt similarity index 100% rename from transform/src/main/kotlin/com/atlassian/prosemirror/transform/Map.kt rename to transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/Map.kt diff --git a/transform/src/main/kotlin/com/atlassian/prosemirror/transform/Mark.kt b/transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/Mark.kt similarity index 100% rename from transform/src/main/kotlin/com/atlassian/prosemirror/transform/Mark.kt rename to transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/Mark.kt diff --git a/transform/src/main/kotlin/com/atlassian/prosemirror/transform/MarkStep.kt b/transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/MarkStep.kt similarity index 100% rename from transform/src/main/kotlin/com/atlassian/prosemirror/transform/MarkStep.kt rename to transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/MarkStep.kt diff --git a/transform/src/main/kotlin/com/atlassian/prosemirror/transform/Replace.kt b/transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/Replace.kt similarity index 100% rename from transform/src/main/kotlin/com/atlassian/prosemirror/transform/Replace.kt rename to transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/Replace.kt diff --git a/transform/src/main/kotlin/com/atlassian/prosemirror/transform/ReplaceStep.kt b/transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/ReplaceStep.kt similarity index 100% rename from transform/src/main/kotlin/com/atlassian/prosemirror/transform/ReplaceStep.kt rename to transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/ReplaceStep.kt diff --git a/transform/src/main/kotlin/com/atlassian/prosemirror/transform/Step.kt b/transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/Step.kt similarity index 96% rename from transform/src/main/kotlin/com/atlassian/prosemirror/transform/Step.kt rename to transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/Step.kt index 6cf2fd7..0c4f59d 100644 --- a/transform/src/main/kotlin/com/atlassian/prosemirror/transform/Step.kt +++ b/transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/Step.kt @@ -105,6 +105,9 @@ class StepResult internal constructor( ok(doc.replace(from, to, slice)) } catch (e: ReplaceError) { fail(e.message) + } catch (e: RangeError) { + // TODO: check if still need this catch after updating to latest prosemirror-transform + fail(e.message ?: "RangeError") } } } diff --git a/transform/src/main/kotlin/com/atlassian/prosemirror/transform/Structure.kt b/transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/Structure.kt similarity index 100% rename from transform/src/main/kotlin/com/atlassian/prosemirror/transform/Structure.kt rename to transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/Structure.kt diff --git a/transform/src/main/kotlin/com/atlassian/prosemirror/transform/Transform.kt b/transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/Transform.kt similarity index 100% rename from transform/src/main/kotlin/com/atlassian/prosemirror/transform/Transform.kt rename to transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/Transform.kt diff --git a/transform/src/main/kotlin/com/atlassian/prosemirror/transform/index.ts b/transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/index.ts similarity index 100% rename from transform/src/main/kotlin/com/atlassian/prosemirror/transform/index.ts rename to transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/index.ts diff --git a/transform/src/main/kotlin/com/atlassian/prosemirror/transform/map.ts b/transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/map.ts similarity index 100% rename from transform/src/main/kotlin/com/atlassian/prosemirror/transform/map.ts rename to transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/map.ts diff --git a/transform/src/main/kotlin/com/atlassian/prosemirror/transform/mark.ts b/transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/mark.ts similarity index 100% rename from transform/src/main/kotlin/com/atlassian/prosemirror/transform/mark.ts rename to transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/mark.ts diff --git a/transform/src/main/kotlin/com/atlassian/prosemirror/transform/mark_step.ts b/transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/mark_step.ts similarity index 100% rename from transform/src/main/kotlin/com/atlassian/prosemirror/transform/mark_step.ts rename to transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/mark_step.ts diff --git a/transform/src/main/kotlin/com/atlassian/prosemirror/transform/replace.ts b/transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/replace.ts similarity index 100% rename from transform/src/main/kotlin/com/atlassian/prosemirror/transform/replace.ts rename to transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/replace.ts diff --git a/transform/src/main/kotlin/com/atlassian/prosemirror/transform/replace_step.ts b/transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/replace_step.ts similarity index 100% rename from transform/src/main/kotlin/com/atlassian/prosemirror/transform/replace_step.ts rename to transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/replace_step.ts diff --git a/transform/src/main/kotlin/com/atlassian/prosemirror/transform/step.ts b/transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/step.ts similarity index 100% rename from transform/src/main/kotlin/com/atlassian/prosemirror/transform/step.ts rename to transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/step.ts diff --git a/transform/src/main/kotlin/com/atlassian/prosemirror/transform/structure.ts b/transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/structure.ts similarity index 100% rename from transform/src/main/kotlin/com/atlassian/prosemirror/transform/structure.ts rename to transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/structure.ts diff --git a/transform/src/main/kotlin/com/atlassian/prosemirror/transform/transform.ts b/transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/transform.ts similarity index 100% rename from transform/src/main/kotlin/com/atlassian/prosemirror/transform/transform.ts rename to transform/src/commonMain/kotlin/com/atlassian/prosemirror/transform/transform.ts diff --git a/transform/src/test/kotlin/com/atlassian/prosemirror/transform/MappingTest.kt b/transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/MappingTest.kt similarity index 98% rename from transform/src/test/kotlin/com/atlassian/prosemirror/transform/MappingTest.kt rename to transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/MappingTest.kt index 562d1f1..8a8ba13 100644 --- a/transform/src/test/kotlin/com/atlassian/prosemirror/transform/MappingTest.kt +++ b/transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/MappingTest.kt @@ -1,7 +1,8 @@ package com.atlassian.prosemirror.transform +import assertk.assertThat +import assertk.assertions.isEqualTo import kotlin.test.Test -import org.assertj.core.api.Assertions.assertThat data class Case(val from: Int, val to: Int, val bias: Int = 0, val lossy: Boolean = false) diff --git a/transform/src/test/kotlin/com/atlassian/prosemirror/transform/StepTest.kt b/transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/StepTest.kt similarity index 94% rename from transform/src/test/kotlin/com/atlassian/prosemirror/transform/StepTest.kt rename to transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/StepTest.kt index 942f583..9f2d0ef 100644 --- a/transform/src/test/kotlin/com/atlassian/prosemirror/transform/StepTest.kt +++ b/transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/StepTest.kt @@ -1,10 +1,13 @@ package com.atlassian.prosemirror.transform +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isNull import com.atlassian.prosemirror.model.Fragment import com.atlassian.prosemirror.model.Slice import com.atlassian.prosemirror.testbuilder.PMNodeBuilder.Companion.doc import com.atlassian.prosemirror.testbuilder.schema -import org.assertj.core.api.Assertions.assertThat import kotlin.test.Test val testDoc = doc { p { +"foobar" } } @@ -26,7 +29,7 @@ class StepTest { val step1 = mkStep(from1, to1, val1) val step2 = mkStep(from2, to2, val2) val merged = step1.merge(step2) - assertThat(merged).isNotNull + assertThat(merged).isNotNull() assertThat(merged!!.apply(testDoc).doc).isEqualTo(step2.apply(step1.apply(testDoc).doc!!).doc) } diff --git a/transform/src/test/kotlin/com/atlassian/prosemirror/transform/StructureTest.kt b/transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/StructureTest.kt similarity index 97% rename from transform/src/test/kotlin/com/atlassian/prosemirror/transform/StructureTest.kt rename to transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/StructureTest.kt index 83b54d6..12f30b8 100644 --- a/transform/src/test/kotlin/com/atlassian/prosemirror/transform/StructureTest.kt +++ b/transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/StructureTest.kt @@ -1,5 +1,9 @@ package com.atlassian.prosemirror.transform +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue import com.atlassian.prosemirror.model.Node import com.atlassian.prosemirror.model.NodeBase import com.atlassian.prosemirror.model.NodeRange @@ -9,7 +13,6 @@ import com.atlassian.prosemirror.model.Slice import com.atlassian.prosemirror.testbuilder.MarkSpecImpl import com.atlassian.prosemirror.testbuilder.NodeSpecImpl import com.atlassian.prosemirror.testbuilder.schemaBasic -import org.assertj.core.api.Assertions.assertThat import kotlin.test.Test val schema = Schema( @@ -87,7 +90,7 @@ class CanSplitTest { fun no(pos: Int, depth: Int = 1, after: String? = null) { assertThat( canSplit(doc, pos, depth, if (after == null) null else listOf(NodeBase(type = schema.nodes[after]!!))) - ).isFalse + ).isFalse() } @Test @@ -171,14 +174,14 @@ class CanSplitTest { 1, listOf(NodeBase(type = s.nodes["scene"]!!)) ) - ).isFalse + ).isFalse() } } class LiftTargetTest { fun yes(pos: Int) { val r = range(pos) - assertThat(r != null && liftTarget(r).let { it != null && it != 0 }).isTrue + assertThat(r != null && liftTarget(r).let { it != null && it != 0 }).isTrue() } fun no(pos: Int) { @@ -208,12 +211,12 @@ class LiftTargetTest { class FindWrappingsTest { fun yes(pos: Int, end: Int, type: String) { val r = range(pos, end) - assertThat(r != null && findWrapping(r, schema.nodes[type]!!) != null).isTrue + assertThat(r != null && findWrapping(r, schema.nodes[type]!!) != null).isTrue() } fun no(pos: Int, end: Int, type: String) { val r = range(pos, end) - assertThat(r == null || findWrapping(r, schema.nodes[type]!!) == null).isTrue + assertThat(r == null || findWrapping(r, schema.nodes[type]!!) == null).isTrue() } @Test diff --git a/transform/src/test/kotlin/com/atlassian/prosemirror/transform/Trans.kt b/transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/Trans.kt similarity index 95% rename from transform/src/test/kotlin/com/atlassian/prosemirror/transform/Trans.kt rename to transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/Trans.kt index b808e63..8571beb 100644 --- a/transform/src/test/kotlin/com/atlassian/prosemirror/transform/Trans.kt +++ b/transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/Trans.kt @@ -1,9 +1,10 @@ package com.atlassian.prosemirror.transform +import assertk.assertThat +import assertk.assertions.isEqualTo import com.atlassian.prosemirror.model.Node import com.atlassian.prosemirror.testbuilder.PMNodeBuilder.Companion.pos import com.atlassian.prosemirror.testbuilder.PMNodeBuilder.Companion.tags -import org.assertj.core.api.Assertions.assertThat fun invert(transform: Transform): Transform { val out = Transform(transform.doc) diff --git a/transform/src/test/kotlin/com/atlassian/prosemirror/transform/TransformTest.kt b/transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/TransformTest.kt similarity index 99% rename from transform/src/test/kotlin/com/atlassian/prosemirror/transform/TransformTest.kt rename to transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/TransformTest.kt index 9c27e2a..07ddadc 100644 --- a/transform/src/test/kotlin/com/atlassian/prosemirror/transform/TransformTest.kt +++ b/transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/TransformTest.kt @@ -1,5 +1,7 @@ package com.atlassian.prosemirror.transform +import assertk.assertThat +import assertk.assertions.isEqualTo import com.atlassian.prosemirror.model.Attrs import com.atlassian.prosemirror.model.Mark import com.atlassian.prosemirror.model.Node @@ -15,9 +17,8 @@ import com.atlassian.prosemirror.testbuilder.PMNodeBuilder.Companion.pos import com.atlassian.prosemirror.testbuilder.schema import com.atlassian.prosemirror.util.safeMode import kotlin.test.BeforeTest -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.failBecauseExceptionWasNotThrown import kotlin.test.Test +import kotlin.test.assertFailsWith @Suppress("LargeClass") class TransformTest { @@ -429,11 +430,8 @@ class TransformTest { @Suppress("SwallowedException") fun splitFail(doc: Node, depth: Int = 1, typesAfter: List? = null) { - try { + assertFailsWith(TransformError::class) { Transform(doc).split(pos(doc, "a")!!, depth, typesAfter) - failBecauseExceptionWasNotThrown(TransformError::class.java) - } catch (ex: TransformError) { - // noice } } diff --git a/transform/src/test/kotlin/com/atlassian/prosemirror/transform/test-mapping.ts b/transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/test-mapping.ts similarity index 100% rename from transform/src/test/kotlin/com/atlassian/prosemirror/transform/test-mapping.ts rename to transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/test-mapping.ts diff --git a/transform/src/test/kotlin/com/atlassian/prosemirror/transform/test-step.ts b/transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/test-step.ts similarity index 100% rename from transform/src/test/kotlin/com/atlassian/prosemirror/transform/test-step.ts rename to transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/test-step.ts diff --git a/transform/src/test/kotlin/com/atlassian/prosemirror/transform/test-structure.ts b/transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/test-structure.ts similarity index 100% rename from transform/src/test/kotlin/com/atlassian/prosemirror/transform/test-structure.ts rename to transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/test-structure.ts diff --git a/transform/src/test/kotlin/com/atlassian/prosemirror/transform/test-trans.ts b/transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/test-trans.ts similarity index 100% rename from transform/src/test/kotlin/com/atlassian/prosemirror/transform/test-trans.ts rename to transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/test-trans.ts diff --git a/transform/src/test/kotlin/com/atlassian/prosemirror/transform/trans.ts b/transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/trans.ts similarity index 100% rename from transform/src/test/kotlin/com/atlassian/prosemirror/transform/trans.ts rename to transform/src/commonTest/kotlin/com/atlassian/prosemirror/transform/trans.ts diff --git a/util/build.gradle.kts b/util/build.gradle.kts index 0ea37b7..db3178d 100644 --- a/util/build.gradle.kts +++ b/util/build.gradle.kts @@ -29,6 +29,7 @@ kotlin { it.binaries.framework { baseName = xcframeworkName binaryOption("bundleId", "com.atlassian.prosemirror.$xcframeworkName") + binaryOption("bundleVersion", "${project.version}") xcf.add(this) isStatic = true } diff --git a/util/config/ktlint/baseline.xml b/util/config/ktlint/baseline.xml index a67e424..9814207 100644 --- a/util/config/ktlint/baseline.xml +++ b/util/config/ktlint/baseline.xml @@ -1,8 +1,3 @@ - - - - -