├── .github └── workflows │ └── build_and_deploy_site.yml ├── .gitignore ├── .idea ├── .gitignore ├── misc.xml └── vcs.xml ├── LICENSE ├── README.md ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── parsing ├── build.gradle.kts └── src │ ├── commonMain │ └── kotlin │ │ └── io │ │ └── github │ │ └── opletter │ │ └── css2kobweb │ │ ├── Arg.kt │ │ ├── CSSParser.kt │ │ ├── ColoredCode.kt │ │ ├── Css2Kobweb.kt │ │ ├── ParseResults.kt │ │ ├── PostProcessing.kt │ │ ├── Properties.kt │ │ ├── StringUtils.kt │ │ ├── StyleModifier.kt │ │ ├── constants │ │ ├── Colors.kt │ │ ├── CssRules.kt │ │ ├── ShorthandProperties.kt │ │ └── Units.kt │ │ └── functions │ │ ├── Color.kt │ │ ├── ConicGradient.kt │ │ ├── Gradient.kt │ │ ├── LinearGradient.kt │ │ ├── Position.kt │ │ ├── RadialGradient.kt │ │ └── Transition.kt │ └── jvmMain │ ├── kotlin │ └── io │ │ └── github │ │ └── opletter │ │ └── css2kobweb │ │ ├── DataCreation.kt │ │ └── Main.kt │ └── resources │ ├── colors.txt │ └── units.txt ├── settings.gradle.kts └── site ├── .gitignore ├── .kobweb └── conf.yaml ├── build.gradle.kts └── src └── jsMain ├── kotlin └── io │ └── github │ └── opletter │ └── css2kobweb │ ├── AppEntry.kt │ ├── components │ ├── layouts │ │ └── PageLayout.kt │ ├── sections │ │ └── Footer.kt │ ├── styles │ │ └── Background.kt │ └── widgets │ │ └── KotlinCode.kt │ └── pages │ └── Index.kt └── resources └── public └── favicon.ico /.github/workflows/build_and_deploy_site.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Kobweb site to Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | workflow_dispatch: 9 | 10 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 11 | permissions: 12 | contents: read 13 | pages: write 14 | id-token: write 15 | 16 | # Allow one concurrent deployment 17 | concurrency: 18 | group: "pages" 19 | cancel-in-progress: true 20 | 21 | jobs: 22 | export: 23 | runs-on: ubuntu-latest 24 | defaults: 25 | run: 26 | shell: bash 27 | 28 | env: 29 | KOBWEB_CLI_VERSION: 0.9.18 30 | 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | 35 | - name: Set up Java 36 | uses: actions/setup-java@v4 37 | with: 38 | distribution: temurin 39 | java-version: 17 40 | 41 | - name: Setup Gradle 42 | uses: gradle/actions/setup-gradle@v4 43 | with: 44 | build-scan-publish: true 45 | build-scan-terms-of-use-url: "https://gradle.com/help/legal-terms-of-use" 46 | build-scan-terms-of-use-agree: "yes" 47 | 48 | - name: Query Browser Cache ID 49 | id: browser-cache-id 50 | run: echo "value=$(./gradlew -q :site:kobwebBrowserCacheId)" >> $GITHUB_OUTPUT 51 | 52 | - name: Cache Browser Dependencies 53 | uses: actions/cache@v4 54 | id: playwright-cache 55 | with: 56 | path: ~/.cache/ms-playwright 57 | key: ${{ runner.os }}-playwright-${{ steps.browser-cache-id.outputs.value }} 58 | 59 | - name: Fetch kobweb 60 | uses: robinraju/release-downloader@v1.10 61 | with: 62 | repository: "varabyte/kobweb-cli" 63 | tag: "v${{ env.KOBWEB_CLI_VERSION }}" 64 | fileName: "kobweb-${{ env.KOBWEB_CLI_VERSION }}.zip" 65 | tarBall: false 66 | zipBall: false 67 | 68 | - name: Unzip kobweb 69 | run: unzip kobweb-${{ env.KOBWEB_CLI_VERSION }}.zip 70 | 71 | - name: Run export 72 | run: | 73 | cd site 74 | ../kobweb-${{ env.KOBWEB_CLI_VERSION }}/bin/kobweb export --notty --layout static 75 | 76 | - name: Upload artifact 77 | uses: actions/upload-pages-artifact@v3 78 | with: 79 | path: ./site/.kobweb/site 80 | 81 | deploy: 82 | environment: 83 | name: github-pages 84 | url: ${{ steps.deployment.outputs.page_url }} 85 | runs-on: ubuntu-latest 86 | needs: export 87 | steps: 88 | - name: Deploy to GitHub Pages 89 | id: deployment 90 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # General ignores 2 | .DS_Store 3 | build 4 | out 5 | kotlin-js-store 6 | 7 | # IntelliJ ignores 8 | *.iml 9 | /*.ipr 10 | 11 | /.idea/caches 12 | /.idea/libraries 13 | /.idea/modules.xml 14 | /.idea/workspace.xml 15 | /.idea/gradle.xml 16 | /.idea/navEditor.xml 17 | /.idea/assetWizardSettings.xml 18 | /.idea/artifacts 19 | /.idea/compiler.xml 20 | /.idea/jarRepositories.xml 21 | /.idea/*.iml 22 | /.idea/modules 23 | /.idea/libraries-with-intellij-classes.xml 24 | 25 | # Gradle ignores 26 | .gradle 27 | 28 | # Kotlin ignores 29 | .kotlin -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 opLetter 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Kobweb](https://github.com/varabyte/kobweb) project bootstrapped with the `app/empty` template. 2 | 3 | This template is useful if you already know what you're doing and just want a clean slate. By default, it 4 | just creates a blank home page (which prints to the console so you can confirm it's working) 5 | 6 | If you are still learning, consider instantiating the `app` template (or one of the examples) to see actual, 7 | working projects. 8 | 9 | ## Getting Started 10 | 11 | First, run the development server by typing the following command in a terminal under the `site` folder: 12 | 13 | ```bash 14 | $ cd site 15 | $ kobweb run 16 | ``` 17 | 18 | Open [http://localhost:8080](http://localhost:8080) with your browser to see the result. 19 | 20 | You can use any editor you want for the project, but we recommend using **IntelliJ IDEA Community Edition** downloaded 21 | using the [Toolbox App](https://www.jetbrains.com/toolbox-app/). 22 | 23 | Press `Q` in the terminal to gracefully stop the server. 24 | 25 | ### Live Reload 26 | 27 | Feel free to edit / add / delete new components, pages, and API endpoints! When you make any changes, the site will 28 | indicate the status of the build and automatically reload when ready. 29 | 30 | ## Exporting the Project 31 | 32 | When you are ready to ship, you should shutdown the development server and then export the project using: 33 | 34 | ```bash 35 | kobweb export 36 | ``` 37 | 38 | When finished, you can run a Kobweb server in production mode: 39 | 40 | ```bash 41 | kobweb run --env prod 42 | ``` 43 | 44 | If you want to run this command in the Cloud provider of your choice, consider disabling interactive mode since nobody 45 | is sitting around watching the console in that case anyway. To do that, use: 46 | 47 | ```bash 48 | kobweb run --env prod --notty 49 | ``` 50 | 51 | Kobweb also supports exporting to a static layout which is compatible with static hosting providers, such as GitHub 52 | Pages, Netlify, Firebase, any presumably all the others. You can read more about that approach here: 53 | https://bitspittle.dev/blog/2022/staticdeploy -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlin.multiplatform) apply false 3 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | org.gradle.caching=true 3 | org.gradle.configuration-cache=true 4 | #kotlin.js.ir.output.granularity=per-file -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | jetbrains-compose = "1.7.1" 3 | kobweb = "0.21.1" 4 | kotlin = "2.1.20" 5 | 6 | [libraries] 7 | compose-html-core = { module = "org.jetbrains.compose.html:html-core", version.ref = "jetbrains-compose" } 8 | compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "jetbrains-compose" } 9 | kobweb-core = { module = "com.varabyte.kobweb:kobweb-core ", version.ref = "kobweb" } 10 | kobweb-silk = { module = "com.varabyte.kobweb:kobweb-silk", version.ref = "kobweb" } 11 | silk-icons-fa = { module = "com.varabyte.kobwebx:silk-icons-fa", version.ref = "kobweb" } 12 | 13 | [plugins] 14 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 15 | kobweb-application = { id = "com.varabyte.kobweb.application", version.ref = "kobweb" } 16 | kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opLetter/css2kobweb/17386befdb5c76df2308fd484ba6a2dd3a006a40/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s 90 | ' "$PWD" ) || exit 91 | 92 | # Use the maximum available, or set MAX_FD != -1 to use that value. 93 | MAX_FD=maximum 94 | 95 | warn () { 96 | echo "$*" 97 | } >&2 98 | 99 | die () { 100 | echo 101 | echo "$*" 102 | echo 103 | exit 1 104 | } >&2 105 | 106 | # OS specific support (must be 'true' or 'false'). 107 | cygwin=false 108 | msys=false 109 | darwin=false 110 | nonstop=false 111 | case "$( uname )" in #( 112 | CYGWIN* ) cygwin=true ;; #( 113 | Darwin* ) darwin=true ;; #( 114 | MSYS* | MINGW* ) msys=true ;; #( 115 | NONSTOP* ) nonstop=true ;; 116 | esac 117 | 118 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 119 | 120 | 121 | # Determine the Java command to use to start the JVM. 122 | if [ -n "$JAVA_HOME" ] ; then 123 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 124 | # IBM's JDK on AIX uses strange locations for the executables 125 | JAVACMD=$JAVA_HOME/jre/sh/java 126 | else 127 | JAVACMD=$JAVA_HOME/bin/java 128 | fi 129 | if [ ! -x "$JAVACMD" ] ; then 130 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 131 | 132 | Please set the JAVA_HOME variable in your environment to match the 133 | location of your Java installation." 134 | fi 135 | else 136 | JAVACMD=java 137 | if ! command -v java >/dev/null 2>&1 138 | then 139 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 140 | 141 | Please set the JAVA_HOME variable in your environment to match the 142 | location of your Java installation." 143 | fi 144 | fi 145 | 146 | # Increase the maximum file descriptors if we can. 147 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 148 | case $MAX_FD in #( 149 | max*) 150 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 151 | # shellcheck disable=SC2039,SC3045 152 | MAX_FD=$( ulimit -H -n ) || 153 | warn "Could not query maximum file descriptor limit" 154 | esac 155 | case $MAX_FD in #( 156 | '' | soft) :;; #( 157 | *) 158 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 159 | # shellcheck disable=SC2039,SC3045 160 | ulimit -n "$MAX_FD" || 161 | warn "Could not set maximum file descriptor limit to $MAX_FD" 162 | esac 163 | fi 164 | 165 | # Collect all arguments for the java command, stacking in reverse order: 166 | # * args from the command line 167 | # * the main class name 168 | # * -classpath 169 | # * -D...appname settings 170 | # * --module-path (only if needed) 171 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 172 | 173 | # For Cygwin or MSYS, switch paths to Windows format before running java 174 | if "$cygwin" || "$msys" ; then 175 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 176 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 177 | 178 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 179 | 180 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 181 | for arg do 182 | if 183 | case $arg in #( 184 | -*) false ;; # don't mess with options #( 185 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 186 | [ -e "$t" ] ;; #( 187 | *) false ;; 188 | esac 189 | then 190 | arg=$( cygpath --path --ignore --mixed "$arg" ) 191 | fi 192 | # Roll the args list around exactly as many times as the number of 193 | # args, so each arg winds up back in the position where it started, but 194 | # possibly modified. 195 | # 196 | # NB: a `for` loop captures its iteration list before it begins, so 197 | # changing the positional parameters here affects neither the number of 198 | # iterations, nor the values presented in `arg`. 199 | shift # remove old arg 200 | set -- "$@" "$arg" # push replacement arg 201 | done 202 | fi 203 | 204 | 205 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 206 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 207 | 208 | # Collect all arguments for the java command: 209 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 210 | # and any embedded shellness will be escaped. 211 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 212 | # treated as '${Hostname}' itself on the command line. 213 | 214 | set -- \ 215 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 216 | -classpath "$CLASSPATH" \ 217 | org.gradle.wrapper.GradleWrapperMain \ 218 | "$@" 219 | 220 | # Stop when "xargs" is not available. 221 | if ! command -v xargs >/dev/null 2>&1 222 | then 223 | die "xargs is not available" 224 | fi 225 | 226 | # Use "xargs" to parse quoted args. 227 | # 228 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 229 | # 230 | # In Bash we could simply go: 231 | # 232 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 233 | # set -- "${ARGS[@]}" "$@" 234 | # 235 | # but POSIX shell has neither arrays nor command substitution, so instead we 236 | # post-process each arg (as a line of input to sed) to backslash-escape any 237 | # character that might be a shell metacharacter, then use eval to reverse 238 | # that process (while maintaining the separation between arguments), and wrap 239 | # the whole thing up as a single "set" statement. 240 | # 241 | # This will of course break if any of these variables contains a newline or 242 | # an unmatched quote. 243 | # 244 | 245 | eval "set -- $( 246 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 247 | xargs -n1 | 248 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 249 | tr '\n' ' ' 250 | )" '"$@"' 251 | 252 | exec "$JAVACMD" "$@" -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega -------------------------------------------------------------------------------- /parsing/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi 2 | 3 | plugins { 4 | alias(libs.plugins.kotlin.multiplatform) 5 | } 6 | 7 | group = "io.github.opletter.css2kobweb" 8 | version = "1.0-SNAPSHOT" 9 | 10 | kotlin { 11 | jvm { 12 | @OptIn(ExperimentalKotlinGradlePluginApi::class) 13 | mainRun { 14 | mainClass = "io.github.opletter.css2kobweb.MainKt" 15 | } 16 | } 17 | js { 18 | browser() 19 | } 20 | } -------------------------------------------------------------------------------- /parsing/src/commonMain/kotlin/io/github/opletter/css2kobweb/Arg.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb 2 | 3 | import io.github.opletter.css2kobweb.constants.units 4 | 5 | sealed class Arg(private val value: String) { 6 | class Literal(value: String) : Arg(value) { 7 | companion object { 8 | fun withQuotesIfNecessary(value: String): Literal { 9 | val str = if (value.firstOrNull() == '"') value else "\"$value\"" 10 | return Literal(str) 11 | } 12 | } 13 | } 14 | 15 | sealed class FancyNumber(value: String) : Arg(value) 16 | class Hex(value: String) : FancyNumber("0x$value") 17 | class Float(value: Number) : FancyNumber("${value}f") 18 | 19 | /** A number that can be used in a calculation */ 20 | sealed class CalcNumber(value: String) : Arg(value) 21 | 22 | class RawNumber(value: Number) : CalcNumber(value.toString()) 23 | 24 | sealed class UnitNum(value: String) : CalcNumber(value) { 25 | class Normal(val value: Number, val type: String) : 26 | UnitNum("${if (value.toDouble() < 0.0) "($value)" else "$value"}.$type") 27 | 28 | class Calc(val arg1: CalcNumber, val arg2: CalcNumber, val operation: Char) : UnitNum(run { 29 | val arg1Str = arg1.let { if (it is Calc) "($it)" else "$it" } 30 | val arg2Str = arg2.let { if (it is Calc) "($it)" else "$it" } 31 | "$arg1Str $operation $arg2Str" 32 | }) 33 | 34 | object Auto : UnitNum("autoLength") 35 | 36 | companion object { 37 | fun ofOrNull(str: String, zeroUnit: String = "px"): UnitNum? = 38 | parseCalcNum(str.prependCalcToParens(), zeroUnit) as? UnitNum 39 | 40 | fun of(str: String, zeroUnit: String = "px"): UnitNum { 41 | val unitNum = if (str == "auto") Auto else ofOrNull(str, zeroUnit) 42 | return requireNotNull(unitNum) { "Not a unit number: $str" } 43 | } 44 | 45 | private fun String.prependCalcToParens(): String = fold("") { result, c -> 46 | result + if (c == '(' && result.takeLast(4) != "calc") "calc$c" else c 47 | } 48 | 49 | private fun parseCalcNum(str: String, zeroUnit: String): CalcNumber? { 50 | if (str == "0") return Normal(0, zeroUnit) 51 | 52 | if (str.startsWith("calc(")) { 53 | // whitespace isn't required for / & *, so we add it for parsing (extra space gets trimmed anyway) 54 | val expr = parenContents(str) 55 | .replace("/", " / ") 56 | .replace("*", " * ") 57 | val parts = expr.splitNotInParens(' ') 58 | 59 | return when (parts.size) { 60 | 0, 2 -> null 61 | 1 -> parseCalcNum(parts.single(), zeroUnit) 62 | 3 -> { 63 | val (arg1, operation, arg2) = parts 64 | Calc(parseCalcNum(arg1, zeroUnit)!!, parseCalcNum(arg2, zeroUnit)!!, operation.single()) 65 | } 66 | 67 | else -> { 68 | // For chained operations (e.g. "1px + 2px + 3px..."), we recursively add "calc(..)" 69 | // wrappings so that the rest of the parsing logic can handle it. 70 | val newCalc = parts.take(3).joinToString(" ", prefix = "calc(", postfix = ") ") + 71 | parts.drop(3).joinToString(" ") 72 | parseCalcNum("calc($newCalc)", zeroUnit) 73 | } 74 | } 75 | } 76 | 77 | val potentialUnit = str.dropWhile { it.isDigit() || it == '.' || it == '-' || it == '+' }.lowercase() 78 | val unit = units[potentialUnit] 79 | if (unit != null) { 80 | val num = str.dropLast(potentialUnit.length) 81 | return Normal(num.toIntOrNull() ?: num.toDouble(), unit) 82 | } 83 | return (str.toIntOrNull() ?: str.toDoubleOrNull())?.let { RawNumber(it) } 84 | } 85 | } 86 | } 87 | 88 | class Property(val className: String?, val value: String) : Arg(className?.let { "$it." }.orEmpty() + value) { 89 | companion object { 90 | fun fromKebabValue(className: String?, value: String) = Property(className, kebabToPascalCase(value)) 91 | } 92 | } 93 | 94 | class NamedArg(val name: String, val value: Arg) : Arg("$name = $value") 95 | 96 | class Function( 97 | val name: String, 98 | val args: List = emptyList(), 99 | val lambdaStatements: List = emptyList(), 100 | ) : CssParseResult, Arg( 101 | if (lambdaStatements.isEmpty()) "$name(${args.joinToString(", ")})" 102 | else { 103 | val argsStr = if (args.isEmpty()) "" else args.joinToString(", ", prefix = "(", postfix = ")") 104 | val lambdaStr = lambdaStatements.joinToString("\n\t\t", prefix = " {\n\t\t", postfix = "\n\t}") 105 | name + argsStr + lambdaStr 106 | } 107 | ) { 108 | constructor(name: String, arg: Arg) : this(name, listOf(arg)) 109 | 110 | override fun asCodeBlocks(indentLevel: Int): List = (this as Arg).asCodeBlocks(indentLevel) 111 | 112 | internal companion object // for extensions 113 | } 114 | 115 | class ExtensionCall(val property: Arg, val function: Function) : Arg("$property.$function") 116 | 117 | override fun toString(): String = value 118 | override fun hashCode(): Int = value.hashCode() 119 | override fun equals(other: Any?): Boolean = other is Arg && other.value == value 120 | 121 | internal companion object // for extensions 122 | } 123 | 124 | 125 | fun Arg.asCodeBlocks( 126 | indentLevel: Int, 127 | functionType: CodeElement = CodeElement.Plain, 128 | nestedCalc: Boolean = false, 129 | ): List { 130 | return when (this) { 131 | is Arg.Literal -> listOf(CodeBlock(toString(), CodeElement.String)) 132 | is Arg.FancyNumber, is Arg.RawNumber -> listOf(CodeBlock(toString(), CodeElement.Number)) 133 | is Arg.Property -> listOfNotNull( 134 | className?.let { CodeBlock("$it.", CodeElement.Plain) }, 135 | CodeBlock(value, CodeElement.Property), 136 | ) 137 | 138 | is Arg.UnitNum.Normal -> buildList { 139 | add(CodeBlock(value.toString(), CodeElement.Number)) 140 | if (value.toDouble() < 0.0) { 141 | add(0, CodeBlock("(", CodeElement.Plain)) 142 | add(CodeBlock(")", CodeElement.Plain)) 143 | } 144 | add(CodeBlock(".", CodeElement.Plain)) 145 | add(CodeBlock(type, CodeElement.Property)) 146 | } 147 | 148 | is Arg.UnitNum.Calc -> buildList { 149 | addAll(arg1.asCodeBlocks(indentLevel, nestedCalc = true)) 150 | add(CodeBlock(" $operation ", CodeElement.Plain)) 151 | addAll(arg2.asCodeBlocks(indentLevel, nestedCalc = true)) 152 | 153 | if (nestedCalc) { 154 | add(0, CodeBlock("(", CodeElement.Plain)) 155 | add(CodeBlock(")", CodeElement.Plain)) 156 | } 157 | } 158 | 159 | is Arg.UnitNum.Auto -> listOf(CodeBlock(toString(), CodeElement.Property)) 160 | 161 | is Arg.NamedArg -> listOf(CodeBlock("$name = ", CodeElement.NamedArg)) + value.asCodeBlocks(indentLevel) 162 | 163 | is Arg.Function -> buildList { 164 | val indents = "\t".repeat(indentLevel) 165 | add(CodeBlock(name, functionType)) 166 | if (args.isNotEmpty() || lambdaStatements.isEmpty()) { 167 | val longArgs = args.toString().length > 100 // number chosen arbitrarily 168 | val separator = if (longArgs) ",\n\t$indents" else ", " 169 | val start = if (longArgs) "(\n\t$indents" else "(" 170 | val end = if (longArgs) "\n$indents)" else ")" 171 | 172 | add(CodeBlock(start, CodeElement.Plain)) 173 | args.forEachIndexed { index, arg -> 174 | addAll(arg.asCodeBlocks(indentLevel + if (longArgs) 1 else 0)) 175 | if (index < args.size - 1) 176 | add(CodeBlock(separator, CodeElement.Plain)) 177 | } 178 | add(CodeBlock(end, CodeElement.Plain)) 179 | } 180 | if (lambdaStatements.isNotEmpty()) { 181 | add(CodeBlock(" {", CodeElement.Plain)) 182 | // todo: consider making this a setting 183 | val sameLine = lambdaStatements.size == 1 && lambdaStatements.single().toString().length < 50 184 | 185 | lambdaStatements.forEach { 186 | add(CodeBlock(if (sameLine) " " else "\n\t$indents", CodeElement.Plain)) 187 | addAll(it.asCodeBlocks(indentLevel + 1)) 188 | } 189 | add(CodeBlock(if (sameLine) " }" else "\n$indents}", CodeElement.Plain)) 190 | } 191 | } 192 | 193 | is Arg.ExtensionCall -> { 194 | property.asCodeBlocks(indentLevel) + CodeBlock(".", CodeElement.Plain) + 195 | function.asCodeBlocks(indentLevel, functionType = CodeElement.ExtensionFun) 196 | } 197 | } 198 | } -------------------------------------------------------------------------------- /parsing/src/commonMain/kotlin/io/github/opletter/css2kobweb/CSSParser.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb 2 | 3 | internal fun parseCss(css: String): List { 4 | return css.splitIntoCssBlocks().mapNotNull { (selector, properties) -> 5 | val subBlocks = properties.splitIntoCssBlocks() 6 | 7 | if (subBlocks.isEmpty()) { 8 | ParsedStyleBlock(getProperties(properties), selector) 9 | } else if (selector.startsWith("@keyframes")) { 10 | val modifiers = subBlocks.map { (subSelector, subProperties) -> 11 | ParsedStyleBlock(getProperties(subProperties), subSelector) 12 | } 13 | ParsedKeyframes(selector.substringAfter("@keyframes").trim(), modifiers) 14 | } else null // TODO: handle @media and maybe some other nested blocks? 15 | } 16 | } 17 | 18 | internal fun getProperties(str: String): List { 19 | return str.splitNotInParens(';').mapNotNull { prop -> 20 | val (name, value) = prop.split(':', limit = 2).map { it.trim() } + "" // use empty if not present 21 | 22 | if (name.startsWith("--")) return@mapNotNull null // ignore css variables 23 | 24 | val parsedProperty = if (name.startsWith("-")) { 25 | val propertyArgs = listOf(name, value).map { Arg.Literal.withQuotesIfNecessary(it) } 26 | Arg.Function("styleModifier", lambdaStatements = listOf(Arg.Function("property", propertyArgs))) 27 | } else { 28 | parseCssProperty( 29 | propertyName = kebabToCamelCase(name), 30 | value = value 31 | .replace("!important", "") 32 | .lines() 33 | .joinToString(" ") { it.trim() } 34 | .replace(" ", " "), 35 | ) 36 | } 37 | 38 | parsedProperty.name to parsedProperty 39 | }.postProcessProperties() 40 | } 41 | 42 | /** 43 | * Returns a list of pairs of the form (selector, block content). 44 | * Note that this only gets the first level of selectors, so nested selectors will be kept within their parent. 45 | */ 46 | private fun String.splitIntoCssBlocks(): List> { 47 | return splitNotBetween(setOf('{' to '}'), setOf('{')) 48 | .filter { it.isNotBlank() } 49 | .fold(listOf>()) { acc, str -> 50 | val prev = acc.lastOrNull() ?: ("" to "") 51 | val properties = str.substringBeforeLast("}").trim() 52 | val nextSelector = str.substringAfterLast("}").trim() 53 | 54 | acc.dropLast(1) + (prev.first to properties) + (nextSelector to "") 55 | }.drop(1).dropLast(1) 56 | } -------------------------------------------------------------------------------- /parsing/src/commonMain/kotlin/io/github/opletter/css2kobweb/ColoredCode.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb 2 | 3 | enum class CodeElement { 4 | Plain, Keyword, Property, ExtensionFun, String, Number, NamedArg 5 | } 6 | 7 | class CodeBlock(val text: String, val type: CodeElement) { 8 | override fun toString(): String = text 9 | } 10 | 11 | fun css2kobwebAsCode(rawCSS: String, extractOutCommonModifiers: Boolean = true): List { 12 | // fold adjacent code blocks of the same type into one to hopefully improve rendering performance 13 | // we use a mutable list as otherwise this can become a performance bottleneck 14 | return css2kobweb(rawCSS, extractOutCommonModifiers).asCodeBlocks().toMutableList().apply { 15 | var i = 0 16 | while (i < size - 1) { 17 | if (this[i].type == this[i + 1].type) { 18 | this[i] = CodeBlock(this[i].text + this[i + 1].text, CodeElement.Plain) 19 | removeAt(i + 1) 20 | } else { 21 | i++ 22 | } 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /parsing/src/commonMain/kotlin/io/github/opletter/css2kobweb/Css2Kobweb.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb 2 | 3 | import io.github.opletter.css2kobweb.constants.cssRules 4 | 5 | fun css2kobweb(rawCSS: String, extractOutCommonModifiers: Boolean = true): CssParseResult { 6 | val cleanedCss = inlineCssVariables(rawCSS) 7 | .lines() 8 | .filterNot { it.startsWith("@import") || it.startsWith("@charset") || it.startsWith("@namespace") } 9 | .joinToString("\n") 10 | .replace("/\\*[\\s\\S]*?\\*/".toRegex(), "") // remove comments 11 | .replace('\'', '"') // to simplify parsing 12 | 13 | val cssBySelector = parseCss(cleanedCss).ifEmpty { 14 | return if (":" in cleanedCss) { 15 | ParsedStyleBlock(getProperties(cleanedCss)) 16 | } else { 17 | val parsedProperty = parseCssProperty("", cleanedCss) 18 | val singleArg = parsedProperty.args.singleOrNull() 19 | // Only return non-trivial parsed properties so that we don't show garbage during the initial input. 20 | // However, to show responsiveness, we do want to show some output ("Modifier") when the user start typing 21 | if (singleArg != null && (singleArg !is Arg.Property || singleArg.className != "")) { 22 | parsedProperty 23 | } else if (cleanedCss.trimEnd().last() == '{') { 24 | // display an empty CssStyle block if it looks like the css will have a selector 25 | ParsedCssStyles(listOf(ParsedCssStyle("", emptyMap()))) 26 | } else { 27 | ParsedStyleBlock(emptyList()) 28 | } 29 | } 30 | } 31 | 32 | val parsedModifiers = cssBySelector.filterIsInstance().run { 33 | // If there are only empty blocks, it's likely the user is still typing, so we show them 34 | // However if there are non-empty blocks, then we hide any empty blocks since they're not needed 35 | // Note that empty blocks may arise if the original block only contained css vars (which are inlined) 36 | filter { it.properties.isNotEmpty() }.ifEmpty { this } 37 | } 38 | 39 | val modifiersBySelector = parsedModifiers.flatMapIndexed { index, modifier -> 40 | val allSelectors = modifier.label.splitNotInParens(',') 41 | 42 | allSelectors.associateWith { _ -> 43 | if (extractOutCommonModifiers && allSelectors.distinctBy { it.baseName() }.size != 1) { 44 | StyleModifier.Global("sharedModifier$index", modifier) 45 | } else if (extractOutCommonModifiers && allSelectors.size != 1) { 46 | StyleModifier.Local("sharedModifier$index", modifier) 47 | } else { 48 | StyleModifier.Inline(modifier) 49 | } 50 | }.toList() 51 | }.sortedBy { it.first }.fold(emptyList>()) { acc, (selector, modifier) -> 52 | val prev = acc.lastOrNull() 53 | if (prev?.first == selector) { 54 | acc.dropLast(1) + (selector to (prev.second + modifier)) 55 | } else { 56 | acc + (selector to modifier) 57 | } 58 | }.toMap() 59 | 60 | val styles = parsedModifiers.flatMap { it.label.splitNotInParens(',') }.groupBy { it.baseName() } 61 | val parsedStyles = styles.map { (baseName, selectors) -> 62 | val modifiers = selectors.associate { selector -> 63 | val cleanedUpName = if (selector == baseName) { 64 | "base" 65 | } else { 66 | selector.substringAfter(baseName).let { 67 | cssRules[it] ?: "cssRule(\"${it.replace("\"", "\\\"")}\")" 68 | } 69 | } 70 | 71 | cleanedUpName to modifiersBySelector[selector]!! 72 | } 73 | val styleName = kebabToPascalCase(baseName.substringAfter(".").substringAfter("#")) 74 | .replace("*", "All") 75 | ParsedCssStyle(styleName, modifiers) 76 | }.let { ParsedCssStyles(it) } 77 | 78 | val keyframes = cssBySelector.filterIsInstance() 79 | 80 | return if (keyframes.isEmpty()) parsedStyles else ParsedBlocks(parsedStyles, keyframes) 81 | } 82 | 83 | private fun inlineCssVariables(css: String): String { 84 | val cssVarPattern = Regex("--([\\w-]+):\\s*([^;]+);") 85 | var newCss = css 86 | 87 | // Extract and replace CSS variables in reverse order so that nested variables are replaced first 88 | cssVarPattern.findAll(css).toList().reversed().forEach { matchResult -> 89 | val varName = matchResult.groupValues[1].trim() 90 | val varValue = matchResult.groupValues[2].trim() 91 | newCss = newCss.replace("var(--$varName)", varValue) 92 | } 93 | return newCss 94 | } 95 | 96 | private fun String.baseName() = substringBefore(":").substringBefore(" ") -------------------------------------------------------------------------------- /parsing/src/commonMain/kotlin/io/github/opletter/css2kobweb/ParseResults.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb 2 | 3 | sealed interface CssParseResult { 4 | fun asCodeBlocks(indentLevel: Int = 0): List 5 | } 6 | 7 | class ParsedStyleBlock(val properties: List, val label: String = "") : CssParseResult { 8 | // this purposely excludes [label] as that is just metadata used by the code itself 9 | override fun asCodeBlocks(indentLevel: Int): List { 10 | val indents = "\t".repeat(indentLevel) 11 | val coloredModifiers = properties.flatMap { 12 | listOf(CodeBlock("\n\t$indents.", CodeElement.Plain)) + 13 | it.asCodeBlocks(indentLevel + 1, functionType = CodeElement.ExtensionFun) 14 | } 15 | return listOf(CodeBlock("${indents}Modifier", CodeElement.Plain)) + coloredModifiers 16 | } 17 | 18 | override fun toString(): String = asCodeBlocks().joinToString("") { it.text } 19 | } 20 | 21 | class ParsedKeyframes(private val name: String, val modifiers: List) : CssParseResult { 22 | override fun asCodeBlocks(indentLevel: Int): List { 23 | return buildList { 24 | add(CodeBlock("val ", CodeElement.Keyword)) 25 | add(CodeBlock(kebabToPascalCase(name), CodeElement.Property)) 26 | add(CodeBlock(" = Keyframes {\n", CodeElement.Plain)) 27 | modifiers.forEach { block -> 28 | add(CodeBlock("\t", CodeElement.Plain)) 29 | 30 | val labelParts = block.label.split(',').mapNotNull { Arg.UnitNum.ofOrNull(it.trim()) } 31 | if (block.label == "from" || block.label == "to") { 32 | add(CodeBlock(block.label, CodeElement.Plain)) 33 | } else if (labelParts.size == 1) { 34 | addAll(labelParts.single().asCodeBlocks(1)) 35 | } else { 36 | addAll(Arg.Function("each", labelParts).asCodeBlocks(1)) 37 | } 38 | 39 | add(CodeBlock(" {\n", CodeElement.Plain)) 40 | addAll(block.asCodeBlocks(2)) 41 | add(CodeBlock("\n\t}\n", CodeElement.Plain)) 42 | } 43 | add(CodeBlock("}\n", CodeElement.Plain)) 44 | } 45 | } 46 | 47 | override fun toString(): String = asCodeBlocks().joinToString("") { it.text } 48 | } 49 | 50 | class ParsedCssStyles(private val styles: List) : CssParseResult { 51 | override fun asCodeBlocks(indentLevel: Int): List { 52 | val globalModifierCode = styles.flatMap { style -> 53 | style.modifiers.values.flatMap { it.filterModifiers() } 54 | }.distinctBy { it.value }.flatMap { modifier -> 55 | listOf( 56 | CodeBlock("private val ", CodeElement.Keyword), 57 | CodeBlock("${modifier.value} = ", CodeElement.Plain), 58 | ) + modifier.modifier.asCodeBlocks() + CodeBlock("\n", CodeElement.Plain) 59 | } 60 | val stylesCode = styles.flatMap { it.asCodeBlocks() } 61 | return globalModifierCode + stylesCode 62 | } 63 | 64 | override fun toString(): String = asCodeBlocks().joinToString("") { it.text } 65 | } 66 | 67 | class ParsedBlocks( 68 | private val styles: ParsedCssStyles, 69 | private val keyframes: List, 70 | ) : CssParseResult { 71 | override fun asCodeBlocks(indentLevel: Int): List { 72 | return styles.asCodeBlocks() + keyframes.flatMap { it.asCodeBlocks() } 73 | } 74 | 75 | override fun toString(): String = asCodeBlocks().joinToString("") { it.text } 76 | } 77 | 78 | // convenient to reuse the same type for both 79 | typealias ParsedProperty = Arg.Function 80 | 81 | class ParsedCssStyle(private val name: String, val modifiers: Map) { 82 | fun asCodeBlocks(): List { 83 | val onlyBaseStyle = modifiers.size == 1 && modifiers.keys.first() == "base" 84 | 85 | val styleText = listOfNotNull( 86 | CodeBlock("val ", CodeElement.Keyword), 87 | CodeBlock("${name}Style", CodeElement.Property), 88 | CodeBlock(" = CssStyle${if (onlyBaseStyle) "." else ""}", CodeElement.Plain), 89 | if (onlyBaseStyle) CodeBlock("base", CodeElement.ExtensionFun) else null, 90 | CodeBlock(" {\n", CodeElement.Plain) 91 | ) 92 | 93 | val localModifierCode = modifiers.values 94 | .flatMap { it.filterModifiers() } 95 | .distinctBy { it.value } 96 | .flatMap { modifier -> 97 | val modifierText = modifier.modifier.asCodeBlocks(indentLevel = 1) 98 | .let { listOf(CodeBlock("Modifier", CodeElement.Plain)) + it.drop(1) } 99 | val selectorText = listOf( 100 | CodeBlock("\tval ", CodeElement.Keyword), 101 | CodeBlock("${modifier.value} = ", CodeElement.Plain), 102 | ) 103 | selectorText + modifierText + CodeBlock("\n", CodeElement.Plain) 104 | } 105 | 106 | val modifierText = if (onlyBaseStyle) { 107 | modifiers["base"]!!.asCodeBlocks(indentLevel = 1) + CodeBlock("\n", CodeElement.Plain) 108 | } else { 109 | modifiers.flatMap { (selectorName, modifier) -> 110 | val selector = if (selectorName.startsWith("cssRule(")) { 111 | listOf( 112 | CodeBlock("\tcssRule(", CodeElement.Plain), 113 | CodeBlock(parenContents(selectorName), CodeElement.String), 114 | CodeBlock(") {\n", CodeElement.Plain), 115 | ) 116 | } else listOf(CodeBlock("\t$selectorName {\n", CodeElement.Plain)) 117 | 118 | selector + modifier.asCodeBlocks(indentLevel = 2) + CodeBlock("\n\t}\n", CodeElement.Plain) 119 | } 120 | } 121 | return styleText + localModifierCode + modifierText + CodeBlock("}\n", CodeElement.Plain) 122 | } 123 | 124 | override fun toString(): String = asCodeBlocks().joinToString("") { it.text } 125 | } -------------------------------------------------------------------------------- /parsing/src/commonMain/kotlin/io/github/opletter/css2kobweb/PostProcessing.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb 2 | 3 | import io.github.opletter.css2kobweb.constants.intoShorthandLambdaProperty 4 | import io.github.opletter.css2kobweb.functions.position 5 | import io.github.opletter.css2kobweb.functions.transition 6 | 7 | internal fun List>.postProcessProperties(): List { 8 | return map { 9 | val newProp = it.second.intoShorthandLambdaProperty() 10 | newProp.name to newProp 11 | } 12 | .combineLambdaModifiers() 13 | .replaceKeysIfEqual(setOf("width", "height"), "size") 14 | .replaceKeysIfEqual(setOf("minWidth", "minHeight"), "minSize") 15 | .replaceKeysIfEqual(setOf("maxWidth", "maxHeight"), "maxSize") 16 | .combineDirectionalModifiers("margin") 17 | .combineDirectionalModifiers("padding") 18 | .combineTransitionModifiers() 19 | .combineBackgroundPosition() // must be before combineBackgroundModifiers 20 | .combineBackgroundModifiers() 21 | .combineAnimationModifiers() 22 | .values.map { property -> 23 | val matchedFunction = setOf("width", "height", "size").find { it == property.name } 24 | if (matchedFunction != null && property.args.singleOrNull() == Arg.UnitNum.of("100%")) 25 | ParsedProperty("fillMax${matchedFunction.replaceFirstChar(Char::uppercase)}") 26 | else property 27 | } 28 | } 29 | 30 | private fun List>.combineLambdaModifiers(): Map { 31 | return groupBy({ it.first }, { it.second }).mapValues { (name, properties) -> 32 | if (properties.all { it.args.isEmpty() }) { 33 | Arg.Function(name, lambdaStatements = properties.flatMap { it.lambdaStatements }) 34 | } else { // should only be one but if not just take last 35 | properties.last() 36 | } 37 | } 38 | } 39 | 40 | private fun Map.combineBackgroundPosition(): Map { 41 | // necessary for the combined x & y value to be valid, since the 3-value syntax is limited 42 | fun String.toTwoValue(direction: String) = 43 | if (Arg.UnitNum.ofOrNull(this) == null) this else "$direction $this" 44 | 45 | val posKey = "backgroundPosition" 46 | 47 | val x = this["${posKey}X"]?.args?.single()?.toString()?.splitNotInParens(',').orEmpty() 48 | val y = this["${posKey}Y"]?.args?.single()?.toString()?.splitNotInParens(',').orEmpty() 49 | 50 | if (x.isEmpty() && y.isEmpty()) return this 51 | 52 | val backgroundPositions = if (x.size >= y.size) { 53 | x.mapIndexed { index, xValue -> 54 | val yValue = y.getOrNull(index)?.toTwoValue("top").orEmpty() 55 | Arg.Function.position(xValue.toTwoValue("left") + " " + yValue) 56 | } 57 | } else { 58 | y.mapIndexed { index, yValue -> 59 | // technically if the x value isn't provided, the browser is supposed to figure it out, 60 | // but kobweb requires it, so we just use a dummy value 61 | val xValue = (x.getOrNull(index) ?: x.lastOrNull() ?: "left 50%").toTwoValue("left") 62 | Arg.Function.position("$xValue ${yValue.toTwoValue("top")}") 63 | } 64 | }.map { Arg.Function("BackgroundPosition.of", it) } 65 | 66 | val newProperty = ParsedProperty(posKey, backgroundPositions) 67 | return (this - "${posKey}X" - "${posKey}Y").plus(newProperty.name to newProperty) 68 | } 69 | 70 | 71 | private fun Map.combineBackgroundModifiers(): Map { 72 | fun String.getArgName() = this.substringAfter("background").substringBefore("Mode").lowercase() 73 | 74 | val existingBackground = this["background"] 75 | val existingBackgroundArgs = existingBackground?.args 76 | ?.dropWhile { !it.toString().startsWith("CSS") } // filter color arg 77 | ?.ifEmpty { null } 78 | // Un-reverse the initial parsing back to line up with order of other properties, 79 | // after which we will reverse again to match the order in kobweb. 80 | // Note that it's important to do parsing in declaration order, as if "background" specifies two images 81 | // but "backgroundImage" only specifies one, we want to use the latter's value for the first image 82 | ?.reversed() 83 | 84 | val propertyKeys = setOf( 85 | "backgroundImage", "backgroundRepeat", "backgroundSize", "backgroundPosition", 86 | "backgroundBlendMode", "backgroundOrigin", "backgroundClip", "backgroundAttachment" 87 | ) 88 | val argNames = propertyKeys.map { it.getArgName() } 89 | 90 | val propertyValues = propertyKeys.mapNotNull { prop -> 91 | this[prop]?.let { prop to it.args } 92 | }.toMap() 93 | 94 | if (propertyValues.isEmpty() || (existingBackgroundArgs == null && propertyValues.values.all { it.size == 1 })) 95 | return this 96 | 97 | // According to the CSS spec, the number of layers is determined by the # of background-image values, 98 | // which can come from either the "backgroundImage" or "background" 99 | val layerIndices = propertyValues["backgroundImage"]?.indices ?: existingBackgroundArgs?.indices 100 | val backgroundProperties = layerIndices?.map { index -> 101 | val args = propertyValues.mapNotNull { (prop, args) -> 102 | val originalArg = args.getOrNull(index) ?: return@mapNotNull null 103 | val adjustedArg = if (prop == "backgroundImage" && originalArg is Arg.Function) { 104 | if (originalArg.name == "url") 105 | Arg.Function("BackgroundImage.of", originalArg) 106 | else Arg.ExtensionCall(originalArg, Arg.Function("toImage")) 107 | } else originalArg 108 | 109 | Arg.NamedArg(prop.getArgName(), adjustedArg) 110 | } 111 | val existingArgs = (existingBackgroundArgs?.get(index) as Arg.Function?)?.args.orEmpty() 112 | val combinedArgs = (existingArgs + args).sortedBy { argNames.indexOf(it.toString().substringBefore(" ")) } 113 | 114 | Arg.Function("Background.of", combinedArgs) 115 | }.orEmpty().reversed() // args order reversed as in kobweb 116 | 117 | val color = this["backgroundColor"]?.args 118 | ?: listOfNotNull(existingBackground?.args?.firstOrNull().takeIf { !it.toString().startsWith("CSS") }) 119 | 120 | val newProperty = ParsedProperty("background", color + backgroundProperties) 121 | 122 | return (this - propertyKeys - "backgroundColor") + (newProperty.name to newProperty) 123 | } 124 | 125 | private fun Map.combineAnimationModifiers(): Map { 126 | fun String.getArgName() = this.substringAfter("animation").replaceFirstChar { it.lowercase() } 127 | 128 | val existingAnimation = this["animation"] 129 | val existingAnimationArgs = existingAnimation?.args?.ifEmpty { null } 130 | 131 | val propertyKeys = setOf( 132 | "animationName", 133 | "animationDuration", 134 | "animationTimingFunction", 135 | "animationDelay", 136 | "animationIterationCount", 137 | "animationDirection", 138 | "animationFillMode", 139 | "animationPlayState", 140 | ) 141 | val argNames = propertyKeys.map { it.getArgName() } 142 | 143 | val propertyValues = propertyKeys.mapNotNull { prop -> 144 | this[prop]?.let { prop to it.args } 145 | }.toMap() 146 | 147 | // kobweb currently only supports specifying the whole animation as a single property, so we have to handle 148 | // all cases where one of these properties is individually specified in css. If this changes, 149 | // then this return condition can be expanded to match the one for [background] 150 | 151 | // IMPORTANT: we currently take advantage of the above fact by using combineAnimationModifiers 152 | // as the sole place where `Animation.of(...)` gets transformed into 'Name.toAnimation(...)' 153 | // If the `animation-...` properties are ever supported individually, this would need to be accounted for 154 | if (propertyValues.isEmpty() && existingAnimation == null) 155 | return this 156 | 157 | val animationProperties = (propertyValues.values.firstOrNull()?.indices ?: (0..0)).map { index -> 158 | val args = propertyValues.map { (prop, args) -> 159 | Arg.NamedArg(prop.getArgName(), args[index]) 160 | } 161 | val existingArgs = (existingAnimationArgs?.get(index) as Arg.Function?)?.args.orEmpty() 162 | val combinedArgs = (existingArgs + args).sortedBy { argNames.indexOf(it.toString().substringBefore(" ")) } 163 | 164 | val (name, otherArgs) = combinedArgs.partition { it is Arg.NamedArg && it.name == "name" } 165 | 166 | name.singleOrNull()?.let { arg -> 167 | check(arg is Arg.NamedArg) 168 | Arg.ExtensionCall( 169 | Arg.Property.fromKebabValue(null, arg.value.toString().removeSurrounding("\"")), 170 | Arg.Function("toAnimation", otherArgs) 171 | ) 172 | } ?: Arg.Function("Animation.of", combinedArgs) 173 | } 174 | val newProperty = ParsedProperty("animation", animationProperties) 175 | 176 | return (this - propertyKeys).plus(newProperty.name to newProperty) 177 | } 178 | 179 | private fun Map.combineTransitionModifiers(): Map { 180 | val transitionProperties = this["transitionProperty"]?.args 181 | // treat "transition" as "transitionProperty" if all it contains are properties 182 | ?: this["transition"]?.args?.mapNotNull { (it as? Arg.Function)?.args?.singleOrNull() } 183 | ?: return this 184 | 185 | val propertyKeys = setOf( 186 | "transitionProperty", 187 | "transitionDuration", 188 | "transitionTimingFunction", 189 | "transitionDelay", 190 | ) 191 | val propertyValues = propertyKeys.map { this[it]?.args } 192 | 193 | val transitionGroup = transitionProperties.size > 1 && 194 | propertyValues.drop(1).all { it == null || it.size == 1 } 195 | 196 | val combinedProperties = if (transitionGroup) { 197 | Arg.Function.transition( 198 | property = Arg.Function("setOf", transitionProperties), 199 | duration = propertyValues.getOrNull(1)?.getOrNull(0), 200 | remainingArgs = propertyValues.drop(2).mapNotNull { it?.getOrNull(0) } 201 | ).let { listOf(it) } 202 | } else { 203 | val otherProperties = propertyValues.drop(1).filterNotNull() 204 | if (otherProperties.isEmpty() || otherProperties.any { it.size != transitionProperties.size }) 205 | return this 206 | 207 | transitionProperties.indices.map { index -> 208 | Arg.Function.transition( 209 | property = transitionProperties[index], 210 | duration = propertyValues[1]?.getOrNull(index), 211 | remainingArgs = propertyValues.drop(2).mapNotNull { it?.getOrNull(index) } 212 | ) 213 | } 214 | } 215 | return (this - propertyKeys) + ("transition" to ParsedProperty("transition", combinedProperties)) 216 | } 217 | 218 | private fun Map.replaceKeysIfEqual( 219 | keysToReplace: Set, 220 | newKey: String, 221 | ): Map { 222 | val values = keysToReplace.mapNotNull { get(it)?.args } 223 | return if (values.size == keysToReplace.size && values.toSet().size == 1) { 224 | minus(keysToReplace) + (newKey to ParsedProperty(newKey, values.first())) 225 | } else this 226 | } 227 | 228 | private fun Map.combineDirectionalModifiers(property: String): Map { 229 | // consider whether this should be combined with the above replaceKeysIfEqual function 230 | fun MutableMap.replaceIfEqual(keysToReplace: Set, newKey: String) { 231 | val values = keysToReplace.mapNotNull { get(it)?.value } 232 | if (values.size == keysToReplace.size && values.toSet().size == 1) { 233 | keysToReplace.forEach { remove(it) } 234 | put(newKey, Arg.NamedArg(newKey, values.first())) 235 | } 236 | } 237 | 238 | val directions = setOf("Top", "Right", "Bottom", "Left") // capitalized for camelCase 239 | val processedArgs = buildMap { 240 | directions.forEach { direction -> 241 | this@combineDirectionalModifiers[property + direction] 242 | ?.let { put(direction.lowercase(), Arg.NamedArg(direction.lowercase(), it.args.single())) } 243 | } 244 | replaceIfEqual(setOf("left", "right"), "leftRight") 245 | if ("leftRight" in this || ("left" !in this && "right" !in this)) 246 | replaceIfEqual(setOf("top", "bottom"), "topBottom") 247 | replaceIfEqual(setOf("leftRight", "topBottom"), "all") 248 | }.ifEmpty { return this } 249 | 250 | val finalArgs = listOfNotNull( 251 | processedArgs["top"], 252 | processedArgs["topBottom"], 253 | processedArgs["leftRight"], 254 | processedArgs["right"], 255 | processedArgs["bottom"], 256 | processedArgs["left"], 257 | processedArgs["all"]?.value, // ignore name for "all" 258 | ) 259 | 260 | return minus(directions.map { property + it }.toSet()) + (property to ParsedProperty(property, finalArgs)) 261 | } -------------------------------------------------------------------------------- /parsing/src/commonMain/kotlin/io/github/opletter/css2kobweb/Properties.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb 2 | 3 | import io.github.opletter.css2kobweb.functions.* 4 | import kotlin.math.min 5 | 6 | private val GlobalValues = setOf("initial", "inherit", "unset", "revert") 7 | 8 | internal fun parseCssProperty(propertyName: String, value: String): ParsedProperty { 9 | if (propertyName == "transition") { 10 | val transitions = value.splitNotInParens(',').map { transition -> 11 | val params = transition.splitNotInParens(' ') 12 | .let { if (Arg.UnitNum.ofOrNull(it.first()) != null) listOf("all") + it else it } 13 | val thirdArg = params.getOrNull(2)?.let { 14 | Arg.UnitNum.ofOrNull(it) ?: parseCssProperty("transitionTimingFunction", it).args.singleOrNull() 15 | } 16 | val fourthArg = params.getOrNull(3)?.let { Arg.UnitNum.of(it) } 17 | 18 | Arg.Function.transition( 19 | property = Arg.Literal("\"${params[0]}\""), 20 | duration = params.getOrNull(1)?.let { Arg.UnitNum.of(it) }, 21 | remainingArgs = listOfNotNull(thirdArg, fourthArg), 22 | ) 23 | } 24 | return ParsedProperty(propertyName, transitions) 25 | } 26 | if (propertyName == "transform") { 27 | val statements = value.splitNotInParens(' ').map { func -> 28 | val args = parenContents(func).splitNotInParens(',').map { 29 | if (it.toDoubleOrNull() == 0.0 && (func.startsWith("matrix") || func.startsWith("scale"))) 30 | Arg.RawNumber(0) 31 | else 32 | Arg.UnitNum.ofOrNull(it) ?: Arg.RawNumber(it.toIntOrNull() ?: it.toDouble()) 33 | } 34 | Arg.Function(func.substringBefore('('), args) 35 | } 36 | return ParsedProperty(propertyName, lambdaStatements = statements) 37 | } 38 | if (propertyName == "aspectRatio" && '/' in value) { 39 | return ParsedProperty( 40 | propertyName, 41 | value.split('/').map { Arg.RawNumber(it.toIntOrNull() ?: it.toDouble()) } 42 | ) 43 | } 44 | if (propertyName == "fontFamily") { 45 | return ParsedProperty( 46 | propertyName, 47 | value.splitNotInParens(',').map { Arg.Literal.withQuotesIfNecessary(it) } 48 | ) 49 | } 50 | if (propertyName == "background") { 51 | return ParsedProperty(propertyName, parseBackground(value)) 52 | } 53 | if (propertyName == "backgroundPosition" && value !in GlobalValues) { 54 | val args = value.splitNotInParens(',').map { 55 | if (it in GlobalValues) parseCssProperty(propertyName, it).args.single() 56 | else Arg.Function("BackgroundPosition.of", Arg.Function.position(it)) 57 | } 58 | return ParsedProperty(propertyName, args) 59 | } 60 | if (propertyName == "backgroundPositionX" || propertyName == "backgroundPositionY") { 61 | // will be handled in postProcessing, preserve values for now 62 | return ParsedProperty(propertyName, Arg.Literal(value)) 63 | } 64 | if (propertyName == "backgroundSize") { 65 | val args = value.splitNotInParens(',').map { subValue -> 66 | if (subValue in GlobalValues || subValue in setOf("cover", "contain")) { 67 | Arg.Property.fromKebabValue("BackgroundSize", subValue) 68 | } else { 69 | Arg.Function("BackgroundSize.of", subValue.splitNotInParens(' ').map { Arg.UnitNum.of(it) }) 70 | } 71 | } 72 | return ParsedProperty(propertyName, args) 73 | } 74 | if (propertyName == "backgroundRepeat") { 75 | val args = value.splitNotInParens(',').map { subValue -> 76 | val values = subValue.splitNotInParens(' ') 77 | .map { Arg.Property.fromKebabValue(kebabToPascalCase(propertyName), it) } 78 | 79 | values.singleOrNull() ?: Arg.Function("BackgroundRepeat.of", values) 80 | } 81 | return ParsedProperty(propertyName, args) 82 | } 83 | if (propertyName == "animation") { 84 | return ParsedProperty(propertyName, parseAnimation(value)) 85 | } 86 | if (propertyName == "animationName") { 87 | return ParsedProperty(propertyName, Arg.Literal("\"$value\"")) 88 | } 89 | if (propertyName == "animationIterationCount") { 90 | val num = value.toIntOrNull() ?: value.toDoubleOrNull() 91 | val arg = if (num != null) { 92 | Arg.Function("AnimationIterationCount.of", Arg.RawNumber(num)) 93 | } else { 94 | Arg.Property.fromKebabValue("AnimationIterationCount", value) 95 | } 96 | return ParsedProperty(propertyName, arg) 97 | } 98 | if ( 99 | value !in GlobalValues && value != "none" 100 | && propertyName in setOf("gridAutoRows", "gridAutoColumns", "gridTemplateRows", "gridTemplateColumns") 101 | ) { 102 | return ParsedProperty(propertyName, lambdaStatements = parseGridRowCol(value)) 103 | } 104 | if (value !in GlobalValues && propertyName == "flexFlow") { 105 | val subValues = value.splitNotInParens(' ') 106 | val indexOfWrap = subValues.indexOfFirst { "wrap" in it } 107 | 108 | return if (subValues.size == 2) { 109 | ParsedProperty( 110 | propertyName, 111 | parseCssProperty("flexDirection", subValues[1 - indexOfWrap]).args + 112 | parseCssProperty("flexWrap", subValues[indexOfWrap]).args 113 | ) 114 | } else { 115 | val property = if (indexOfWrap != -1) "flexWrap" else "flexDirection" 116 | ParsedProperty(property, parseCssProperty(property, value).args) 117 | } 118 | } 119 | // kobweb treats "nowrap" as if it was "no-wrap", so we need to handle it separately 120 | if (propertyName == "whiteSpace" && value == "nowrap") { 121 | return ParsedProperty(propertyName, Arg.Property("WhiteSpace", "NoWrap")) 122 | } 123 | 124 | return value.splitNotBetween(setOf('(' to ')'), setOf(' ', ',', '/')).map { prop -> 125 | if (prop in GlobalValues) { 126 | return@map Arg.Property.fromKebabValue(classNamesFromProperty(propertyName), prop) 127 | } 128 | 129 | val unit = Arg.UnitNum.ofOrNull(prop) 130 | if (unit != null) { 131 | val takeRawZero = setOf( 132 | "zIndex", "opacity", "lineHeight", "flexGrow", "flexShrink", "flex", "order", 133 | "gridColumnEnd", "gridColumnStart", "gridRowEnd", "gridRowStart", 134 | ) 135 | 136 | return@map if (unit.toString().substringBeforeLast('.') == "0" && propertyName in takeRawZero) 137 | Arg.RawNumber(0) 138 | else unit 139 | } 140 | 141 | val rawNum = prop.toIntOrNull() ?: prop.toDoubleOrNull() 142 | if (rawNum != null) { 143 | return@map Arg.RawNumber(rawNum) 144 | } 145 | 146 | Arg.asColorOrNull(prop)?.let { return@map it } 147 | 148 | if (prop.startsWith("linear-gradient(")) { 149 | return@map Arg.Function.linearGradient(parenContents(prop)) 150 | } 151 | if (prop.startsWith("radial-gradient(")) { 152 | return@map Arg.Function.radialGradient(parenContents(prop)) 153 | } 154 | if (prop.startsWith("conic-gradient(")) { 155 | return@map Arg.Function.conicGradient(parenContents(prop)) 156 | } 157 | 158 | if (prop.startsWith("url(")) { 159 | val contents = parenContents(prop) 160 | return@map Arg.Function("url", Arg.Literal.withQuotesIfNecessary(contents)) 161 | } 162 | 163 | if (prop.startsWith('"')) { 164 | return@map Arg.Literal(prop) 165 | } 166 | if (propertyName == "transitionProperty") { 167 | return@map Arg.Literal("\"$prop\"") 168 | } 169 | 170 | val className = classNamesFromProperty(propertyName) 171 | 172 | if (prop.endsWith(")")) { 173 | val functionPropertyName = if (propertyName.endsWith("TimingFunction") && prop.startsWith("steps(")) { 174 | "StepPosition" 175 | } else propertyName 176 | 177 | val filterFunctions = setOf( 178 | "blur", "brightness", "contrast", "dropShadow", "grayscale", "hueRotate", "invert", "saturate", "sepia", 179 | ) 180 | val mathFunctions = setOf("clamp", "min", "max") 181 | val simpleGlobalFunctions = filterFunctions + mathFunctions 182 | 183 | val functionName = kebabToCamelCase(prop.substringBefore("(")) 184 | val prefix = if (functionName in simpleGlobalFunctions) "" else "$className." 185 | 186 | val adjustedArgs = parenContents(prop).let { args -> 187 | // math function can contain expressions, so wrap them in calc() for parsing purposes 188 | if (functionName in mathFunctions) 189 | args.splitNotInParens(',').joinToString { "calc($it)" } 190 | else args 191 | } 192 | 193 | return@map Arg.Function("$prefix$functionName", parseCssProperty(functionPropertyName, adjustedArgs).args) 194 | } 195 | 196 | Arg.Property.fromKebabValue(className, prop).let { 197 | if (it.value == "Auto" && it.className != null && takesAutoLength(it.className)) { 198 | Arg.UnitNum.Auto 199 | } else it 200 | } 201 | }.let { ParsedProperty(propertyName, it) } 202 | } 203 | 204 | // Loose check for properties that often use "auto" as a length 205 | private fun takesAutoLength(className: String): Boolean { 206 | return className.startsWith("Padding") || className.startsWith("Margin") 207 | || className.endsWith("Width") || className.endsWith("Height") 208 | } 209 | 210 | private fun classNamesFromProperty(propertyName: String): String { 211 | return when (propertyName) { 212 | "display" -> "DisplayStyle" 213 | "overflowY", "overflowX" -> "Overflow" 214 | "float" -> "CSSFloat" 215 | "gridTemplateRows", "gridTemplateColumns" -> "GridTemplate" 216 | "gridAutoRows", "gridAutoColumns" -> "GridAuto" 217 | "border", "borderStyle", "borderTop", "borderBottom", "borderLeft", "borderRight", 218 | "borderTopStyle", "borderBottomStyle", "borderLeftStyle", "borderRightStyle", 219 | "outline", "outlineStyle", 220 | -> "LineStyle" 221 | 222 | else -> propertyName.replaceFirstChar { it.uppercase() } 223 | } 224 | } 225 | 226 | private fun parseBackground(value: String): List { 227 | // kobweb reverses order of backgrounds 228 | val backgrounds = value.splitNotInParens(',').reversed() 229 | .map { it.splitNotInParens('/').joinToString(" / ") } 230 | 231 | val backgroundObjects = backgrounds.map { background -> 232 | val repeatRegex = """(repeat-x|repeat-y|repeat|space|round|no-repeat)\b""".toRegex() 233 | val attachmentRegex = """(scroll|fixed|local)\b""".toRegex() 234 | val boxRegex = """(border-box|padding-box|content-box)\b""".toRegex() 235 | 236 | val backgroundArgs = buildList { 237 | val image = background.splitNotInParens(' ').firstOrNull { 238 | it.startsWith("url(") || it.startsWith("linear-gradient(") 239 | || it.startsWith("radial-gradient(") || it.startsWith("conic-gradient(") 240 | } 241 | if (image != null) { 242 | val imageArg = parseCssProperty("backgroundImage", image).args.single().let { 243 | if (it is Arg.Function) { 244 | if (it.name == "url") Arg.Function("BackgroundImage.of", it) 245 | else Arg.ExtensionCall(it, Arg.Function("toImage")) 246 | } else it 247 | } 248 | add(Arg.NamedArg("image", imageArg)) 249 | } 250 | 251 | val repeat = repeatRegex.find(background)?.value 252 | if (repeat != null) { 253 | val repeatArg = parseCssProperty("backgroundRepeat", repeat).args.single() 254 | add(Arg.NamedArg("repeat", repeatArg)) 255 | } 256 | 257 | val attachment = attachmentRegex.find(background)?.value 258 | if (attachment != null) { 259 | val attachmentArg = parseCssProperty("backgroundAttachment", attachment).args.single() 260 | add(Arg.NamedArg("attachment", attachmentArg)) 261 | } 262 | 263 | val boxMatches = boxRegex.findAll(background).toList() 264 | if (boxMatches.isNotEmpty()) { 265 | val (origin, clip) = if (boxMatches.size == 2) boxMatches else List(2) { boxMatches.single() } 266 | val originArg = parseCssProperty("backgroundOrigin", origin.value).args.single() 267 | val clipArg = parseCssProperty("backgroundClip", clip.value).args.single() 268 | add(Arg.NamedArg("origin", originArg)) 269 | add(Arg.NamedArg("clip", clipArg)) 270 | } 271 | 272 | val otherProps = background.splitNotInParens(' ') - setOfNotNull(image, repeat, attachment) - 273 | boxMatches.map { it.value }.toSet() 274 | 275 | val slashIndex = otherProps.indexOf("/") 276 | if (slashIndex != -1) { 277 | val position = ((slashIndex - 4).coerceAtLeast(0).. { 309 | val animations = value.splitNotInParens(',') 310 | val animationObjects = animations.map { animation -> 311 | val timingRegex = 312 | """((ease-in-out|ease-in|ease-out|ease|linear|step-start|step-end)|cubic-bezier\([^)]*\)|steps\([^)]*\))""".toRegex() 313 | val directionRegex = """(normal|reverse|alternate|alternate-reverse)\b""".toRegex() 314 | val fillModeRegex = """(none|forwards|backwards|both)\b""".toRegex() 315 | val playStateRegex = """(running|paused)\b""".toRegex() 316 | 317 | val parts = animation.splitNotInParens(' ') 318 | 319 | val units = parts.mapNotNull { Arg.UnitNum.ofOrNull(it) } 320 | 321 | val animationArgs = buildList { 322 | if (units.isNotEmpty()) { 323 | add(Arg.NamedArg("duration", units.first())) 324 | } 325 | 326 | val timing = timingRegex.find(animation)?.value 327 | if (timing != null) { 328 | val repeatArg = parseCssProperty("animationTimingFunction", timing).args.single() 329 | add(Arg.NamedArg("timingFunction", repeatArg)) 330 | } 331 | 332 | if (units.size > 1) { 333 | add(Arg.NamedArg("delay", units[1])) 334 | } 335 | 336 | val iterationCount = parts.firstNotNullOfOrNull { it.toIntOrNull() ?: it.toDoubleOrNull() } 337 | ?: "infinite".takeIf { it in parts } 338 | 339 | if (iterationCount != null) { 340 | val iterationCountArg = 341 | parseCssProperty("animationIterationCount", iterationCount.toString()).args.single() 342 | add(Arg.NamedArg("iterationCount", iterationCountArg)) 343 | } 344 | 345 | val direction = directionRegex.find(animation)?.value 346 | if (direction != null) { 347 | val directionArg = parseCssProperty("animationDirection", direction).args.single() 348 | add(Arg.NamedArg("direction", directionArg)) 349 | } 350 | 351 | val fillMode = fillModeRegex.find(animation)?.value 352 | if (fillMode != null) { 353 | val fillModeArg = parseCssProperty("animationFillMode", fillMode).args.single() 354 | add(Arg.NamedArg("fillMode", fillModeArg)) 355 | } 356 | 357 | val playState = playStateRegex.find(animation)?.value 358 | if (playState != null) { 359 | val playStateArg = parseCssProperty("animationPlayState", playState).args.single() 360 | add(Arg.NamedArg("playState", playStateArg)) 361 | } 362 | 363 | val otherProps = parts.filter { Arg.UnitNum.ofOrNull(it) == null } - 364 | setOfNotNull(timing, iterationCount.toString(), direction, fillMode, playState) 365 | 366 | val name = otherProps.lastOrNull { it.isNotBlank() } // search from end per css best practice 367 | if (name != null) { 368 | add(0, Arg.NamedArg("name", parseCssProperty("animationName", name).args.single())) 369 | } 370 | } 371 | Arg.Function("Animation.of", animationArgs) 372 | }.filter { it.args.isNotEmpty() } 373 | 374 | return animationObjects 375 | } 376 | 377 | private fun parseGridRowCol(value: String): List { 378 | return value.splitNotBetween(setOf('(' to ')', '[' to ']'), setOf(' ')).map { subValue -> 379 | if (subValue.startsWith("[")) { 380 | Arg.Function( 381 | "lineNames", 382 | subValue.drop(1).dropLast(1).splitNotInParens(' ').map { Arg.Literal.withQuotesIfNecessary(it) } 383 | ) 384 | } else if (subValue.startsWith("minmax") || subValue.startsWith("fit-content")) { 385 | Arg.Function( 386 | kebabToCamelCase(subValue.substringBefore("(")), 387 | parenContents(subValue).splitNotInParens(',').map { 388 | Arg.UnitNum.ofOrNull(it.trim()) ?: Arg.Property(null, kebabToCamelCase(it.trim())) 389 | } 390 | ) 391 | } else if (subValue.startsWith("repeat")) { 392 | val repeatArgs = parenContents(subValue).splitNotInParens(',') 393 | val repeatCount = repeatArgs[0].toIntOrNull()?.let { Arg.RawNumber(it) } 394 | ?: Arg.Property(null, kebabToCamelCase(repeatArgs[0])) 395 | Arg.Function("repeat", listOf(repeatCount), parseGridRowCol(repeatArgs[1])) 396 | } else { 397 | Arg.Function( 398 | "size", 399 | Arg.UnitNum.ofOrNull(subValue) ?: Arg.Property(null, kebabToCamelCase(subValue)) 400 | ) 401 | } 402 | } 403 | } -------------------------------------------------------------------------------- /parsing/src/commonMain/kotlin/io/github/opletter/css2kobweb/StringUtils.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb 2 | 3 | internal fun kebabToPascalCase(str: String): String { 4 | return str.split('-').joinToString("") { part -> 5 | part.replaceFirstChar { it.titlecase() } 6 | } 7 | } 8 | 9 | internal fun kebabToCamelCase(str: String): String = kebabToPascalCase(str).replaceFirstChar { it.lowercase() } 10 | 11 | internal fun parenContents(str: String): String = str.substringAfter('(').substringBeforeLast(')').trim() 12 | 13 | /** 14 | * Splits a string based on delimiters, except those present between sets of [groups] chars, 15 | * and except those between quotes. 16 | * 17 | * @param groups a set of char pairs, where the string will not be split between the first and second char of each pair 18 | */ 19 | internal fun String.splitNotBetween( 20 | groups: Set>, 21 | splitOn: Set, 22 | ): List = splitNotBetween(groups, splitOn, ParseState()) 23 | 24 | private data class ParseState( 25 | val quotesCount: Int = 0, 26 | val groupCounts: Map = emptyMap(), 27 | val buffer: String = "", 28 | val result: List = emptyList(), 29 | ) 30 | 31 | /** 32 | * Splits a string based on delimiters, except those present between sets of [groups] chars, 33 | * and except those between quotes. 34 | * 35 | * @param groups a set of char pairs, where the string will not be split between the first and second char of each pair 36 | */ 37 | private tailrec fun String.splitNotBetween( 38 | groups: Set>, 39 | splitOn: Set, 40 | state: ParseState, 41 | ): List { 42 | if (isEmpty()) { 43 | return (state.result + state.buffer).filter { it.isNotBlank() } 44 | } 45 | val nextState = when (val ch = first()) { 46 | in groups.flatMap { listOf(it.first, it.second) } -> { 47 | val openChar = groups.first { ch == it.first || ch == it.second }.first 48 | val isGroupStart = openChar == ch 49 | val newGroupCount = (state.groupCounts[openChar] ?: 0) + if (isGroupStart) 1 else -1 50 | val restartBuffer = isGroupStart && newGroupCount == 1 && splitOn.contains(ch) 51 | 52 | state.copy( 53 | buffer = if (restartBuffer) "" else state.buffer + ch, 54 | groupCounts = state.groupCounts + (openChar to newGroupCount), 55 | result = if (restartBuffer) state.result + state.buffer else state.result 56 | ) 57 | } 58 | 59 | '"' -> state.copy(buffer = state.buffer + ch, quotesCount = state.quotesCount + 1) 60 | in splitOn -> { 61 | if (state.groupCounts.values.any { it > 0 } || state.quotesCount % 2 == 1) { 62 | state.copy(buffer = state.buffer + ch) 63 | } else { 64 | state.copy(buffer = "", result = state.result + state.buffer) 65 | } 66 | } 67 | 68 | else -> state.copy(buffer = state.buffer + ch) 69 | } 70 | return drop(1).splitNotBetween(groups, splitOn, nextState) 71 | } 72 | 73 | internal fun String.splitNotInParens(split: Char): List { 74 | return splitNotBetween(setOf('(' to ')'), splitOn = setOf(split)) 75 | .map { it.trim() }.filter { it.isNotEmpty() } 76 | } -------------------------------------------------------------------------------- /parsing/src/commonMain/kotlin/io/github/opletter/css2kobweb/StyleModifier.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb 2 | 3 | /** Represents the different ways a modifier can be used inside a CssStyle */ 4 | sealed class StyleModifier(val value: String) { 5 | sealed class Normal(value: String) : StyleModifier(value) 6 | 7 | class Inline(val parsedModifier: ParsedStyleBlock) : Normal(parsedModifier.toString()) 8 | class Global(key: String, val modifier: ParsedStyleBlock) : Normal(key) 9 | class Local(key: String, val modifier: ParsedStyleBlock) : Normal(key) 10 | 11 | class Composite(val modifiers: List) : StyleModifier(run { 12 | val (inlineModifiers, sharedModifiers) = modifiers.partition { it is Inline } 13 | val start = sharedModifiers.firstOrNull()?.let { first -> 14 | first.toString() + sharedModifiers.drop(1).joinToString("") { "\n\t.then($it)" } 15 | } ?: "Modifier" 16 | val end = inlineModifiers.joinToString("") { "\n" + it.toString().substringAfter("\n") } 17 | 18 | start + end 19 | }) 20 | 21 | override fun toString(): String = value 22 | 23 | operator fun plus(other: Normal): Composite { 24 | return when (this) { 25 | is Composite -> Composite(modifiers + other) 26 | is Normal -> Composite(listOf(this, other)) 27 | } 28 | } 29 | 30 | fun asCodeBlocks(indentLevel: Int = 0): List { 31 | val indents = "\t".repeat(indentLevel) 32 | return when (this) { 33 | is Global, is Local -> listOf(CodeBlock(indents + value, CodeElement.Plain)) 34 | is Inline -> parsedModifier.asCodeBlocks(indentLevel) 35 | is Composite -> { 36 | val (inlineModifiers, sharedModifiers) = modifiers.partition { it is Inline } 37 | val start = sharedModifiers.firstOrNull()?.let { style -> 38 | indents + style.toString() + 39 | sharedModifiers.drop(1).joinToString("") { "\n\t$indents.then($it)" } 40 | } ?: "${indents}Modifier" 41 | val end = inlineModifiers.flatMap { style -> 42 | style.asCodeBlocks(indentLevel).let { if (style is Inline) it.drop(1) else it } 43 | } 44 | listOf(CodeBlock(start, CodeElement.Plain)) + end 45 | } 46 | } 47 | } 48 | } 49 | 50 | inline fun StyleModifier.filterModifiers(): List { 51 | return when (this) { 52 | is StyleModifier.Composite -> modifiers.filterIsInstance() 53 | is T -> listOf(this) 54 | else -> emptyList() 55 | } 56 | } -------------------------------------------------------------------------------- /parsing/src/commonMain/kotlin/io/github/opletter/css2kobweb/constants/Colors.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb.constants 2 | 3 | internal val colors = listOf( 4 | "Transparent", 5 | "AliceBlue", 6 | "AntiqueWhite", 7 | "Aqua", 8 | "Aquamarine", 9 | "Azure", 10 | "Beige", 11 | "Bisque", 12 | "Black", 13 | "BlanchedAlmond", 14 | "Blue", 15 | "BlueViolet", 16 | "Brown", 17 | "BurlyWood", 18 | "CadetBlue", 19 | "Chartreuse", 20 | "Chocolate", 21 | "Coral", 22 | "CornflowerBlue", 23 | "Cornsilk", 24 | "Crimson", 25 | "Cyan", 26 | "DarkBlue", 27 | "DarkCyan", 28 | "DarkGoldenRod", 29 | "DarkGray", 30 | "DarkGrey", 31 | "DarkGreen", 32 | "DarkKhaki", 33 | "DarkMagenta", 34 | "DarkOliveGreen", 35 | "DarkOrange", 36 | "DarkOrchid", 37 | "DarkRed", 38 | "DarkSalmon", 39 | "DarkSeaGreen", 40 | "DarkSlateBlue", 41 | "DarkSlateGray", 42 | "DarkSlateGrey", 43 | "DarkTurquoise", 44 | "DarkViolet", 45 | "DeepPink", 46 | "DeepSkyBlue", 47 | "DimGray", 48 | "DimGrey", 49 | "DodgerBlue", 50 | "FireBrick", 51 | "FloralWhite", 52 | "ForestGreen", 53 | "Fuchsia", 54 | "Gainsboro", 55 | "GhostWhite", 56 | "Gold", 57 | "GoldenRod", 58 | "Gray", 59 | "Grey", 60 | "Green", 61 | "GreenYellow", 62 | "HoneyDew", 63 | "HotPink", 64 | "IndianRed", 65 | "Indigo", 66 | "Ivory", 67 | "Khaki", 68 | "Lavender", 69 | "LavenderBlush", 70 | "LawnGreen", 71 | "LemonChiffon", 72 | "LightBlue", 73 | "LightCoral", 74 | "LightCyan", 75 | "LightGoldenRodYellow", 76 | "LightGray", 77 | "LightGrey", 78 | "LightGreen", 79 | "LightPink", 80 | "LightSalmon", 81 | "LightSeaGreen", 82 | "LightSkyBlue", 83 | "LightSlateGray", 84 | "LightSlateGrey", 85 | "LightSteelBlue", 86 | "LightYellow", 87 | "Lime", 88 | "LimeGreen", 89 | "Linen", 90 | "Magenta", 91 | "Maroon", 92 | "MediumAquaMarine", 93 | "MediumBlue", 94 | "MediumOrchid", 95 | "MediumPurple", 96 | "MediumSeaGreen", 97 | "MediumSlateBlue", 98 | "MediumSpringGreen", 99 | "MediumTurquoise", 100 | "MediumVioletRed", 101 | "MidnightBlue", 102 | "MintCream", 103 | "MistyRose", 104 | "Moccasin", 105 | "NavajoWhite", 106 | "Navy", 107 | "OldLace", 108 | "Olive", 109 | "OliveDrab", 110 | "Orange", 111 | "OrangeRed", 112 | "Orchid", 113 | "PaleGoldenRod", 114 | "PaleGreen", 115 | "PaleTurquoise", 116 | "PaleVioletRed", 117 | "PapayaWhip", 118 | "PeachPuff", 119 | "Peru", 120 | "Pink", 121 | "Plum", 122 | "PowderBlue", 123 | "Purple", 124 | "RebeccaPurple", 125 | "Red", 126 | "RosyBrown", 127 | "RoyalBlue", 128 | "SaddleBrown", 129 | "Salmon", 130 | "SandyBrown", 131 | "SeaGreen", 132 | "SeaShell", 133 | "Sienna", 134 | "Silver", 135 | "SkyBlue", 136 | "SlateBlue", 137 | "SlateGray", 138 | "SlateGrey", 139 | "Snow", 140 | "SpringGreen", 141 | "SteelBlue", 142 | "Tan", 143 | "Teal", 144 | "Thistle", 145 | "Tomato", 146 | "Turquoise", 147 | "Violet", 148 | "Wheat", 149 | "White", 150 | "WhiteSmoke", 151 | "Yellow", 152 | "YellowGreen" 153 | ) -------------------------------------------------------------------------------- /parsing/src/commonMain/kotlin/io/github/opletter/css2kobweb/constants/CssRules.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb.constants 2 | 3 | internal val cssRules = mapOf( 4 | ":any-link" to "anyLink", 5 | ":link" to "link", 6 | ":target" to "target", 7 | ":visited" to "visited", 8 | ":hover" to "hover", 9 | ":active" to "active", 10 | ":focus" to "focus", 11 | ":focus-visible" to "focusVisible", 12 | ":focus-within" to "focusWithin", 13 | ":autofill" to "autofill", 14 | ":enabled" to "enabled", 15 | ":disabled" to "disabled", 16 | ":read-only" to "readOnly", 17 | ":read-write" to "readWrite", 18 | ":placeholder-shown" to "placeholderShown", 19 | ":default" to "default", 20 | ":checked" to "checked", 21 | ":indeterminate" to "indeterminate", 22 | ":valid" to "valid", 23 | ":invalid" to "invalid", 24 | ":in-range" to "inRange", 25 | ":out-of-range" to "outOfRange", 26 | ":required" to "required", 27 | ":optional" to "optional", 28 | ":user-valid" to "userValid", 29 | ":user-invalid" to "userInvalid", 30 | ":root" to "root", 31 | ":empty" to "empty", 32 | ":first-child" to "firstChild", 33 | ":last-child" to "lastChild", 34 | ":only-child" to "onlyChild", 35 | ":first-of-type" to "firstOfType", 36 | ":last-of-type" to "lastOfType", 37 | ":only-of-type" to "onlyOfType", 38 | // support both single and double colon 39 | "::before" to "before", 40 | ":before" to "before", 41 | "::after" to "after", 42 | ":after" to "after", 43 | "::selection" to "selection", 44 | ":selection" to "selection", 45 | "::first-letter" to "firstLetter", 46 | ":first-letter" to "firstLetter", 47 | "::first-line" to "firstLine", 48 | ":first-line" to "firstLine", 49 | "::placeholder" to "placeholder", 50 | ":placeholder" to "placeholder", 51 | ) -------------------------------------------------------------------------------- /parsing/src/commonMain/kotlin/io/github/opletter/css2kobweb/constants/ShorthandProperties.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb.constants 2 | 3 | import io.github.opletter.css2kobweb.Arg 4 | import io.github.opletter.css2kobweb.ParsedProperty 5 | 6 | private class ShorthandProperty(val property: String, val subProperties: List) 7 | 8 | // TODO: In the future we should have an option for "strict" matching the original CSS (using the scope functions) 9 | // vs. a mode that uses named args instead. Note that even in strict mode, if all shorthands are specified, we 10 | // should probably still use named args if available. 11 | private val shorthandProperties = listOf( 12 | ShorthandProperty("border", listOf("Width", "Style", "Color")), 13 | ShorthandProperty("borderTop", listOf("Width", "Style", "Color")), 14 | ShorthandProperty("borderBottom", listOf("Width", "Style", "Color")), 15 | ShorthandProperty("borderRight", listOf("Width", "Style", "Color")), 16 | ShorthandProperty("borderLeft", listOf("Width", "Style", "Color")), 17 | ShorthandProperty("overflow", listOf("X", "Y")), 18 | ShorthandProperty("paddingInline", listOf("Start", "End")), 19 | ShorthandProperty("paddingBlock", listOf("Start", "End")), 20 | // Currently don't use scope for these as it's usually unnecessary, and instead we can provide smart reduced 21 | // named properties (like turning equal "top" and "bottom" into "topBottom"). 22 | // These should be re-enabled after the to-do above is addressed. 23 | // ShorthandProperty("padding", listOf("Top", "Right", "Bottom", "Left")), 24 | // ShorthandProperty("margin", listOf("Top", "Right", "Bottom", "Left")), 25 | ShorthandProperty("font", listOf("Alternates", "Caps", "EastAsian", "Emoji", "Ligatures", "Numeric", "Settings")), 26 | ).flatMap { shortHand -> 27 | shortHand.subProperties.map { "${shortHand.property}$it" to it } 28 | }.toMap() 29 | 30 | fun ParsedProperty.intoShorthandLambdaProperty(): ParsedProperty { 31 | return shorthandProperties[name]?.let { shorthandFun -> 32 | ParsedProperty( 33 | name.substringBefore(shorthandFun), 34 | lambdaStatements = listOf( 35 | Arg.Function(shorthandFun.replaceFirstChar { it.lowercase() }, args, lambdaStatements) 36 | ) 37 | ) 38 | } ?: this 39 | } -------------------------------------------------------------------------------- /parsing/src/commonMain/kotlin/io/github/opletter/css2kobweb/constants/Units.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb.constants 2 | 3 | internal val units = mapOf("%" to "percent", "rem" to "cssRem") + listOf( 4 | // "%", 5 | "em", 6 | "ex", 7 | "ch", 8 | "ic", 9 | // "rem", 10 | "lh", 11 | "rlh", 12 | "vw", 13 | "vh", 14 | "vi", 15 | "vb", 16 | "vmin", 17 | "vmax", 18 | "svb", 19 | "svh", 20 | "svi", 21 | "svmax", 22 | "svmin", 23 | "svw", 24 | "lvb", 25 | "lvh", 26 | "lvi", 27 | "lvmax", 28 | "lvmin", 29 | "lvw", 30 | "dvb", 31 | "dvh", 32 | "dvi", 33 | "dvmax", 34 | "dvmin", 35 | "dvw", 36 | "vi", 37 | "vb", 38 | "cm", 39 | "mm", 40 | "Q", 41 | "pt", 42 | "pc", 43 | "px", 44 | "deg", 45 | "grad", 46 | "rad", 47 | "turn", 48 | "s", 49 | "ms", 50 | "Hz", 51 | "kHz", 52 | "dpi", 53 | "dpcm", 54 | "dppx", 55 | "fr", 56 | "number", 57 | ).associateWith { it } -------------------------------------------------------------------------------- /parsing/src/commonMain/kotlin/io/github/opletter/css2kobweb/functions/Color.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb.functions 2 | 3 | import io.github.opletter.css2kobweb.Arg 4 | import io.github.opletter.css2kobweb.constants.colors 5 | import io.github.opletter.css2kobweb.parenContents 6 | import kotlin.math.roundToInt 7 | 8 | internal fun Arg.Companion.asColorOrNull(value: String): Arg? { 9 | if (value.startsWith("#") && ' ' !in value.trim()) { 10 | val hexValue = if (value.length <= 5) { 11 | value.drop(1).toList().joinToString("") { "$it$it" } // #RGBA = #RRGGBBAA = 0xRRGGBBAA 12 | } else { 13 | value.drop(1) 14 | } 15 | val function = if (hexValue.length == 8) "rgba" else "rgb" 16 | return Arg.Function("Color.$function", Arg.Hex(hexValue)) 17 | } 18 | val color = colors.firstOrNull { it.lowercase() == value } 19 | if (color != null) { 20 | return Arg.Property("Colors", color) 21 | } 22 | if (value.startsWith("rgb") && value.endsWith(")")) { 23 | return rgbOrNull(value) 24 | } 25 | if (value.startsWith("hsl") && value.endsWith(")")) { 26 | return hslOrNull(value) 27 | } 28 | return null 29 | } 30 | 31 | private fun rgbOrNull(prop: String): Arg.Function? { 32 | val nums = parenContents(prop).split(' ', ',', '/').filter { it.isNotBlank() } 33 | 34 | val params = nums.take(3).map { 35 | if (it.endsWith("%")) Arg.Float(it.dropLast(1).toFloat() / 100) 36 | else Arg.RawNumber(it.toDouble().roundToInt()) 37 | } 38 | if (nums.size == 3) { 39 | return Arg.Function("Color.rgb", params) 40 | } 41 | if (nums.size == 4) { 42 | val alpha = nums.last().let { 43 | if (it.endsWith("%")) 44 | Arg.Float(it.dropLast(1).toFloat() / 100) 45 | else Arg.Float(it.toFloat()) 46 | } 47 | return Arg.Function("Color.rgba", params.take(3) + alpha) 48 | } 49 | return null 50 | } 51 | 52 | private fun hslOrNull(prop: String): Arg.Function? { 53 | val nums = parenContents(prop).split(' ', ',', '/').filter { it.isNotBlank() } 54 | 55 | val params = nums.take(3).mapIndexed { index, s -> 56 | if (index == 0) { 57 | Arg.UnitNum.ofOrNull(s, "deg") ?: Arg.UnitNum.of(s + "deg") 58 | } else { 59 | Arg.UnitNum.ofOrNull(s, "percent") ?: Arg.Float(s.toFloat()) 60 | } 61 | } 62 | if (nums.size == 3) { 63 | return Arg.Function("Color.hsl", params) 64 | } 65 | if (nums.size == 4) { 66 | val alpha = nums.last().let { 67 | Arg.UnitNum.ofOrNull(it, "percent") ?: Arg.Float(it.toFloat()) 68 | } 69 | return Arg.Function("Color.hsla", params.take(3) + alpha) 70 | } 71 | return null 72 | } -------------------------------------------------------------------------------- /parsing/src/commonMain/kotlin/io/github/opletter/css2kobweb/functions/ConicGradient.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb.functions 2 | 3 | import io.github.opletter.css2kobweb.Arg 4 | import io.github.opletter.css2kobweb.splitNotInParens 5 | 6 | internal fun Arg.Function.Companion.conicGradient(value: String): Arg.Function { 7 | val parts = value.splitNotInParens(',') 8 | val argsAsColors = parts.mapNotNull { Arg.asColorOrNull(it) } 9 | 10 | val angle = Arg.UnitNum.ofOrNull(parts[0].substringBefore(" at ").substringAfter("from "), zeroUnit = "deg") 11 | val position = parts[0].substringAfter("at ", "") 12 | .takeIf { it.isNotEmpty() } 13 | ?.let { Arg.Function.position(it) } 14 | 15 | if (argsAsColors.size == 2) { 16 | return conicGradientOf(listOfNotNull(argsAsColors[0], argsAsColors[1], angle, position)) 17 | } 18 | 19 | val mainArgs = listOfNotNull(angle, position) 20 | val lambdaFunctions = gradientColorStopList(parts.drop(if (mainArgs.isEmpty()) 0 else 1)) 21 | 22 | return conicGradientOf(args = mainArgs, lambdaFunctions = lambdaFunctions) 23 | } 24 | 25 | private fun conicGradientOf(args: List, lambdaFunctions: List = emptyList()): Arg.Function = 26 | Arg.Function("conicGradient", args, lambdaFunctions) -------------------------------------------------------------------------------- /parsing/src/commonMain/kotlin/io/github/opletter/css2kobweb/functions/Gradient.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb.functions 2 | 3 | import io.github.opletter.css2kobweb.Arg 4 | import io.github.opletter.css2kobweb.splitNotInParens 5 | 6 | internal fun gradientColorStopList(values: List): List { 7 | return values.map { colorStopList -> 8 | val subParts = colorStopList.splitNotInParens(' ') 9 | val unitParts = subParts.mapNotNull { Arg.UnitNum.ofOrNull(it, zeroUnit = "percent") } 10 | 11 | if (subParts.size == 1 && unitParts.size == 1) Arg.Function("setMidpoint", unitParts) 12 | else Arg.Function("add", listOf(Arg.asColorOrNull(subParts[0])!!) + unitParts) 13 | } 14 | } -------------------------------------------------------------------------------- /parsing/src/commonMain/kotlin/io/github/opletter/css2kobweb/functions/LinearGradient.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb.functions 2 | 3 | import io.github.opletter.css2kobweb.Arg 4 | import io.github.opletter.css2kobweb.splitNotInParens 5 | 6 | internal fun Arg.Function.Companion.linearGradient(value: String): Arg.Function { 7 | val parts = value.splitNotInParens(',') 8 | val argsAsColors = parts.mapNotNull { Arg.asColorOrNull(it) } 9 | val firstAsUnitNum = Arg.UnitNum.ofOrNull(parts[0], zeroUnit = "deg") 10 | // check for color value, keeping in mind that there may be a percentage value in the arg 11 | val firstHasColor = Arg.asColorOrNull(parts[0].splitNotInParens(' ').first()) != null 12 | 13 | val (x, y) = parts[0].split(' ').partition { it == "left" || it == "right" } 14 | val direction = Arg.Property( 15 | "LinearGradient.Direction", 16 | (y + x).joinToString("") { it.replaceFirstChar(Char::uppercase) }, 17 | ) 18 | 19 | if (parts.size == 2 && argsAsColors.size == 2) { 20 | return linearGradientOf(argsAsColors) 21 | } 22 | if (parts.size == 3 && argsAsColors.size == 2) { 23 | if (firstAsUnitNum != null) { 24 | return linearGradientOf(argsAsColors + firstAsUnitNum) 25 | } 26 | if (!firstHasColor) { 27 | return linearGradientOf(argsAsColors + direction) 28 | } 29 | } 30 | 31 | val mainArg = if (!firstHasColor) firstAsUnitNum ?: direction else null 32 | val lambdaFunctions = gradientColorStopList(parts.drop(if (mainArg == null) 0 else 1)) 33 | 34 | return linearGradientOf(args = listOfNotNull(mainArg), lambdaFunctions = lambdaFunctions) 35 | } 36 | 37 | private fun linearGradientOf(args: List, lambdaFunctions: List = emptyList()): Arg.Function = 38 | Arg.Function("linearGradient", args, lambdaFunctions) -------------------------------------------------------------------------------- /parsing/src/commonMain/kotlin/io/github/opletter/css2kobweb/functions/Position.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb.functions 2 | 3 | import io.github.opletter.css2kobweb.Arg 4 | import io.github.opletter.css2kobweb.kebabToPascalCase 5 | import io.github.opletter.css2kobweb.splitNotInParens 6 | 7 | internal fun Arg.Function.Companion.positionOrNull(value: String): Arg? { 8 | val position = value.splitNotInParens(' ') 9 | val xEdges = setOf("left", "right") 10 | val yEdges = setOf("top", "bottom") 11 | 12 | return when (position.size) { 13 | 1 -> { 14 | val arg = position.single() 15 | Arg.UnitNum.ofOrNull(arg)?.let { Arg.Function("CSSPosition", it) } 16 | ?: Arg.Property.fromKebabValue("CSSPosition", arg) 17 | } 18 | 19 | 2 -> { 20 | val units = position.mapNotNull { Arg.UnitNum.ofOrNull(it) } 21 | when (units.size) { 22 | 0 -> { 23 | val notCenter = position.filter { it != "center" } 24 | when (notCenter.size) { 25 | 0 -> Arg.Property("CSSPosition", "Center") 26 | 1 -> Arg.Property.fromKebabValue("CSSPosition", notCenter.single()) 27 | 2 -> { 28 | val (x, y) = notCenter.partition { it in xEdges } 29 | // nulls will be filtered out in validation step 30 | Arg.Property.fromKebabValue("CSSPosition", "${y.singleOrNull()}-${x.singleOrNull()}") 31 | } 32 | 33 | else -> error("Unexpected notCenter size: ${notCenter.size}") 34 | } 35 | } 36 | 37 | 1 -> { 38 | val centerIndex = position.indexOf("center") 39 | val xEdge = xEdges.singleOrNull { it in position } 40 | if (centerIndex != -1) { 41 | val x = if (centerIndex == 0) edge("center-x") else units.single() 42 | val y = if (centerIndex == 1) edge("center-y") else units.single() 43 | Arg.Function("CSSPosition", listOf(x, y)) 44 | } else if (xEdge != null) { 45 | val yFun = Arg.Function("Edge.Top", units) 46 | Arg.Function("CSSPosition", listOf(edge(xEdge), yFun)) 47 | } else { 48 | val yEdge = yEdges.singleOrNull { it in position } ?: return null 49 | val xFun = Arg.Function("Edge.Left", units) 50 | Arg.Function("CSSPosition", listOf(xFun, edge(yEdge))) 51 | } 52 | } 53 | 54 | 2 -> Arg.Function("CSSPosition", units) 55 | 56 | else -> error("Unexpected units size: ${units.size}") 57 | } 58 | } 59 | 60 | 3 -> { // could also delegate to 4-value logic but this lets us generate nicer code 61 | val xIndex = position.indexOfFirst { it in xEdges } 62 | .let { if (it == -1) position.indexOf("center") else it } 63 | val yIndex = position.indexOfFirst { it in yEdges } 64 | .let { if (it == -1) position.indexOf("center") else it } 65 | 66 | val unitIndex = ((0..2) - setOf(xIndex, yIndex)).singleOrNull() ?: return null 67 | val unit = Arg.UnitNum.ofOrNull(position[unitIndex]) ?: return null 68 | 69 | val xArg = if (xIndex + 1 == unitIndex) edge(position[xIndex], unit) 70 | else edge(position[xIndex].let { if (it == "center") "center-x" else it }) 71 | val yArg = if (yIndex + 1 == unitIndex) edge(position[yIndex], unit) 72 | else edge(position[yIndex].let { if (it == "center") "center-y" else it }) 73 | 74 | Arg.Function("CSSPosition", listOf(xArg, yArg)) 75 | } 76 | 77 | 4 -> { 78 | val xIndex = if (position[0] in xEdges) 0 else 2 79 | val xUnit = Arg.UnitNum.ofOrNull(position[xIndex + 1]) ?: return null 80 | val yUnit = Arg.UnitNum.ofOrNull(position[3 - xIndex]) ?: return null 81 | 82 | Arg.Function("CSSPosition", listOf(edge(position[xIndex], xUnit), edge(position[2 - xIndex], yUnit))) 83 | } 84 | 85 | else -> null 86 | }?.takeIf { 87 | val validPositions = setOf( 88 | "Top", "TopRight", "Right", "BottomRight", "Bottom", "BottomLeft", 89 | "Left", "TopLeft", "Center" 90 | ) 91 | !it.toString().startsWith("CSSPosition.") 92 | || it.toString().substringAfter(".") in validPositions 93 | } 94 | } 95 | 96 | internal fun Arg.Function.Companion.position(value: String): Arg = 97 | positionOrNull(value) ?: error("Invalid position: $value") 98 | 99 | private fun edge(name: String) = Arg.Property.fromKebabValue("Edge", name) 100 | private fun edge(name: String, unit: Arg.UnitNum) = Arg.Function("Edge.${kebabToPascalCase(name)}", unit) -------------------------------------------------------------------------------- /parsing/src/commonMain/kotlin/io/github/opletter/css2kobweb/functions/RadialGradient.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb.functions 2 | 3 | import io.github.opletter.css2kobweb.Arg 4 | import io.github.opletter.css2kobweb.splitNotInParens 5 | 6 | internal fun Arg.Function.Companion.radialGradient(value: String): Arg.Function { 7 | val parts = value.splitNotInParens(',') 8 | 9 | val shape = parts[0].substringBefore(" at ") 10 | // check for color value, keeping in mind that there may be a percentage value in the arg 11 | .takeIf { Arg.asColorOrNull(it.splitNotInParens(' ')[0]) == null } 12 | ?.let { shapeStr -> 13 | val shapeParts = shapeStr.splitNotInParens(' ') 14 | 15 | val shape = if (shapeParts.any { it == "circle" }) "Circle" else "Ellipse" 16 | val shapeSize = (shapeParts - setOf("circle", "ellipse")).map { 17 | Arg.UnitNum.ofOrNull(it) ?: Arg.Property.fromKebabValue("RadialGradient.Extent", it) 18 | } 19 | 20 | if (shapeSize.isEmpty()) Arg.Property("RadialGradient.Shape", shape) 21 | else Arg.Function("RadialGradient.Shape.$shape", shapeSize) 22 | } 23 | 24 | val position = parts[0].substringAfter("at ", "").takeIf { it.isNotEmpty() } 25 | ?.let { Arg.Function.position(it) } 26 | 27 | val argsAsColors = parts.mapNotNull { Arg.asColorOrNull(it) } 28 | if (argsAsColors.size == 2) { 29 | return radialGradientOf(listOfNotNull(argsAsColors[0], argsAsColors[1], shape, position)) 30 | } 31 | 32 | val mainArgs = listOfNotNull(shape, position) 33 | val lambdaFunctions = gradientColorStopList(parts.drop(if (mainArgs.isEmpty()) 0 else 1)) 34 | 35 | return radialGradientOf(args = mainArgs, lambdaFunctions = lambdaFunctions) 36 | } 37 | 38 | private fun radialGradientOf(args: List, lambdaFunctions: List = emptyList()): Arg.Function = 39 | Arg.Function("radialGradient", args, lambdaFunctions) -------------------------------------------------------------------------------- /parsing/src/commonMain/kotlin/io/github/opletter/css2kobweb/functions/Transition.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb.functions 2 | 3 | import io.github.opletter.css2kobweb.Arg 4 | 5 | internal fun Arg.Function.Companion.transition( 6 | property: Arg, 7 | duration: Arg? = null, 8 | remainingArgs: List = emptyList(), 9 | ): Arg.Function { 10 | val firstParams = listOfNotNull(property, duration) 11 | 12 | return when (remainingArgs.size) { 13 | 0, 2 -> transitionOf(firstParams + remainingArgs) 14 | 1 -> { 15 | val thirdArg = remainingArgs.single() 16 | .let { if (it is Arg.UnitNum) Arg.NamedArg("delay", it) else it } 17 | transitionOf(firstParams + thirdArg) 18 | } 19 | 20 | else -> error("Invalid transition") 21 | } 22 | } 23 | 24 | private fun transitionOf(args: List): Arg.Function { 25 | val function = if (args.first() is Arg.Function) "group" else "of" 26 | return Arg.Function("Transition.$function", args) 27 | } -------------------------------------------------------------------------------- /parsing/src/jvmMain/kotlin/io/github/opletter/css2kobweb/DataCreation.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb 2 | 3 | import java.io.File 4 | import java.nio.file.Paths 5 | import kotlin.io.path.absolutePathString 6 | 7 | fun createUnitMappings() { 8 | println(Paths.get("").absolutePathString()) 9 | val mappings = File("parsing/src/jvmMain/resources/units.txt").readText() 10 | .split("\n") 11 | .filter { it.isNotBlank() } 12 | .associate { 13 | val key = it.substringAfter("inline val ").substringBefore(' ') 14 | val rawStr = it.substringAfter('"').substringBefore('"') 15 | 16 | rawStr to key 17 | } 18 | 19 | mappings.forEach { (k, v) -> 20 | println("\"$k\" to \"$v\",") 21 | } 22 | } 23 | 24 | fun getColors() { 25 | File("parsing/src/jvmMain/resources/colors.txt").readLines() 26 | .joinToString("\", \"", "\"", "\"") { 27 | it.substringAfter("val ").substringBefore(" get()") 28 | }.also { println(it) } 29 | } -------------------------------------------------------------------------------- /parsing/src/jvmMain/kotlin/io/github/opletter/css2kobweb/Main.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb 2 | 3 | 4 | fun main() { 5 | println(css2kobwebAsCode(rawCss3).joinToString("")) 6 | // println(css2kobweb(rawCSS2)) 7 | } 8 | 9 | val rawCSS = """ 10 | a { 11 | color: red; 12 | text-decoration: none; 13 | } 14 | 15 | .button, .test { 16 | background-color: #007bff; 17 | color: #ffffff; 18 | padding: 10px 20px; 19 | border-radius: 0; 20 | text-transform: uppercase; 21 | font-weight: bold; 22 | text-align: center; 23 | display: inline-block; 24 | margin: 10px; 25 | cursor: pointer; 26 | transition: margin-right 2s, color 1s; 27 | } 28 | 29 | .button:focus, .button:hover, .test:focus { 30 | background-color: #0056b3; 31 | } 32 | .button:focus-visible, .button > { 33 | background-color: rgb(0% 20% 50%); 34 | } 35 | .also-like-items > button > img { 36 | background-color: rgb(100% 0% 0%); 37 | } 38 | 39 | .screen:after, 40 | .screen:before { 41 | content: ""; 42 | height: 5px; 43 | position: absolute; 44 | z-index: 4; 45 | left: 50%; 46 | translate: -50% 0%; 47 | background-color: white; 48 | } 49 | 50 | .screen:before, .screen:x { 51 | width: 15%; 52 | top: 0rem; 53 | border-bottom-left-radius: 1rem; 54 | border-bottom-right-radius: 1rem; 55 | } 56 | 57 | .screen:before, .screen:hover { 58 | width: 19%; 59 | } 60 | 61 | .screen:before { 62 | width: 19%; 63 | } 64 | 65 | .screen:z { 66 | height: 100%; 67 | } 68 | .screen:z { 69 | width: 200%; 70 | } 71 | .screen:z { 72 | border-radius: 0; 73 | } 74 | """.trimIndent() 75 | 76 | val rawCSS2 = """ 77 | background-color: #007bff; 78 | color: #ffffff; 79 | padding: 10px 20px; 80 | border-radius: 0; 81 | text-transform: uppercase; 82 | font-weight: bold; 83 | text-align: center; 84 | display: inline-block; 85 | margin: 10px; 86 | cursor: pointer; 87 | transition: margin-right 2s, color 1s; 88 | """.trimIndent() 89 | 90 | val rawCss3 = """ 91 | body { 92 | background-color: rgb(0,0,0); 93 | margin: 0px; 94 | } 95 | 96 | body::-webkit-scrollbar { 97 | width: 4px; 98 | } 99 | 100 | body::-webkit-scrollbar-track { 101 | background-color: rgb(1,1,1); 102 | } 103 | 104 | body::-webkit-scrollbar-thumb { 105 | background: rgba(255, 255, 255, 0.15); 106 | } 107 | 108 | * { 109 | box-sizing: border-box; 110 | margin: 0; 111 | padding: 0; 112 | } 113 | 114 | button { 115 | all: unset; 116 | cursor: pointer; 117 | } 118 | 119 | h1, h2, h3, h4, input, select, button, span, a, p { 120 | color: rgb(2,2,2); 121 | font-family: "Noto Sans", sans-serif; 122 | font-size: 1rem; 123 | } 124 | 125 | button, a, input { 126 | outline: none; 127 | } 128 | 129 | .highlight { 130 | color: rgb(3,3,3); 131 | } 132 | 133 | .gradient { 134 | background-image: rgb(4,4,4); 135 | -webkit-background-clip: text; 136 | -webkit-text-fill-color: transparent; 137 | } 138 | 139 | .fancy-scrollbar::-webkit-scrollbar { 140 | height: 4px; 141 | width: 4px; 142 | } 143 | 144 | .fancy-scrollbar::-webkit-scrollbar-track { 145 | background-color: transparent; 146 | } 147 | 148 | .fancy-scrollbar::-webkit-scrollbar-thumb { 149 | background: rgba(255, 255, 255, 0.15); 150 | } 151 | 152 | .no-scrollbar::-webkit-scrollbar { 153 | height: 0px; 154 | width: 0px; 155 | } 156 | 157 | #phone { 158 | box-shadow: rgba(0, 0, 0, 0.2) 0px 8px 24px; 159 | height: 851px; 160 | width: 393px; 161 | margin: 100px auto; 162 | position: relative; 163 | overflow: hidden; 164 | } 165 | 166 | #main-wrapper { 167 | height: 100%; 168 | overflow: auto; 169 | } 170 | 171 | #main { 172 | height: 100%; 173 | } 174 | 175 | #nav { 176 | width: 100%; 177 | display: flex; 178 | justify-content: space-around; 179 | position: absolute; 180 | left: 0px; 181 | bottom: 0px; 182 | z-index: 3; 183 | padding: 0.5rem 1rem; 184 | border-top: 1px solid rgb(255 255 255 / 10%); 185 | } 186 | 187 | #nav > button { 188 | padding: 0.5rem 1rem; 189 | border-radius: 0.25rem; 190 | position: relative; 191 | } 192 | 193 | #nav > button.active:after { 194 | content: ""; 195 | height: 0.25rem; 196 | width: 1.5rem; 197 | position: absolute; 198 | top: -0.5rem; 199 | left: 50%; 200 | translate: -50%; 201 | border-bottom-left-radius: 0.25rem; 202 | border-bottom-right-radius: 0.25rem; 203 | } 204 | 205 | #nav > button:hover, 206 | #nav > button:focus-visible { 207 | background-color: rgb(255 255 255 / 10%); 208 | } 209 | 210 | #nav > button > i { 211 | width: 1.5rem; 212 | font-size: 1.1rem; 213 | text-align: center; 214 | } 215 | 216 | #header { 217 | display: flex; 218 | flex-direction: column; 219 | width: 100%; 220 | overflow: hidden; 221 | position: relative; 222 | } 223 | 224 | #header-background-image { 225 | width: 100%; 226 | display: flex; 227 | z-index: 1; 228 | left: 0px; 229 | top: 0px; 230 | position: relative; 231 | } 232 | 233 | #header-background-image > img { 234 | height: 100%; 235 | width: 100%; 236 | object-fit: cover; 237 | object-position: center; 238 | } 239 | 240 | #header-items { 241 | display: flex; 242 | gap: 1rem; 243 | position: relative; 244 | z-index: 3; 245 | padding: 0.5rem 1rem; 246 | overflow: auto; 247 | background: linear-gradient(to bottom, rgb(34, 123, 66) 0%, rgb(10 10 10) 40%, transparent 40%); 248 | } 249 | 250 | .header-item-image { 251 | position: relative; 252 | } 253 | 254 | .header-item-image:after { 255 | content: ""; 256 | height: calc(100% - 0.5rem); 257 | width: calc(100% - 0.5rem); 258 | position: absolute; 259 | left: 0px; 260 | top: 0px; 261 | z-index: -1; 262 | background-color: white; 263 | margin: -0.25rem; 264 | border-radius: 0.5rem; 265 | box-shadow: rgba(99, 99, 99, 0.2) 0px 2px 8px 0px; 266 | } 267 | 268 | .header-item-image > img { 269 | width: 200px; 270 | aspect-ratio: 16 / 9; 271 | object-fit: cover; 272 | object-position: center; 273 | border-radius: 0.4rem; 274 | } 275 | 276 | .header-item-content > .label { 277 | display: flex; 278 | } 279 | 280 | .header-item-content > .label > p { 281 | color: white; 282 | font-size: 0.75rem; 283 | font-weight: 500; 284 | margin-left: 0.25rem; 285 | } 286 | 287 | #mainBody { 288 | display: flex; 289 | flex-direction: column; 290 | gap: 1rem; 291 | } 292 | 293 | #body-content { 294 | display: flex; 295 | flex-direction: column; 296 | gap: 1rem; 297 | position: relative; 298 | z-index: 2; 299 | margin-top: 1rem; 300 | padding-bottom: 4rem; 301 | } 302 | 303 | #background-gradient { 304 | height: 300px; 305 | width: 100%; 306 | position: absolute; 307 | z-index: 1; 308 | background: linear-gradient( 309 | -15deg, 310 | transparent 30%, 311 | ); 312 | opacity: 0.15; 313 | filter: blur(3rem); 314 | } 315 | 316 | #search-wrapper { 317 | display: flex; 318 | flex-direction: column; 319 | gap: 0.25rem; 320 | margin: 0rem 1rem; 321 | } 322 | 323 | #search { 324 | height: 3.5rem; 325 | position: relative; 326 | border-radius: 0.4rem; 327 | } 328 | 329 | #search-input { 330 | display: flex; 331 | align-items: center; 332 | gap: 0.5rem; 333 | position: absolute; 334 | inset: 2px; 335 | padding: 0.5rem; 336 | border-radius: 0.3rem; 337 | backdrop-filter: blur(0.75rem); 338 | } 339 | 340 | #search-input > i { 341 | width: 1.5rem; 342 | padding: 0rem 0.25rem; 343 | color: rgb(255 255 255 / 85%); 344 | text-align: center; 345 | } 346 | 347 | #search-input > input { 348 | width: 100%; 349 | flex-grow: 1; 350 | color: white; 351 | background-color: transparent; 352 | border: none; 353 | outline: none; 354 | font-weight: 500; 355 | } 356 | 357 | #search-input > button { 358 | height: 2rem; 359 | width: 2rem; 360 | display: grid; 361 | place-items: center; 362 | flex-shrink: 0; 363 | cursor: pointer; 364 | } 365 | 366 | #search-input > button > i { 367 | color: rgb(255 255 255 / 85%); 368 | } 369 | 370 | #search-input > button:is(:hover, :focus-visible) { 371 | background: rgb(255 255 255 / 10%); 372 | border-radius: 0.25rem; 373 | } 374 | 375 | #search-input > input::placeholder { 376 | color: rgb(255 255 255 / 25%); 377 | } 378 | 379 | #search-categories { 380 | display: flex; 381 | gap: 0.25rem; 382 | margin-bottom: 0.25rem; 383 | overflow: auto; 384 | } 385 | 386 | #search-categories > button { 387 | flex-shrink: 0; 388 | background-color: rgb(255 255 255 / 5%); 389 | padding: 0.5rem 0.75rem; 390 | border-radius: 0.25rem; 391 | color: white; 392 | font-size: 0.75rem; 393 | font-weight: 500; 394 | } 395 | 396 | #location > button { 397 | height: 2rem; 398 | display: flex; 399 | align-items: center; 400 | gap: 0.4rem; 401 | margin-left: 2.25rem; 402 | position: relative; 403 | } 404 | 405 | #location > button:after { 406 | content: ""; 407 | height: 0.75rem; 408 | width: 0.5rem; 409 | position: absolute; 410 | left: 0px; 411 | top: 0px; 412 | margin-left: -1.25rem; 413 | margin-top: 0.25rem; 414 | border-left: 2px solid rgb(255 255 255 / 40%); 415 | border-bottom: 2px solid rgb(255 255 255 / 40%); 416 | border-bottom-left-radius: 0.3rem; 417 | } 418 | 419 | #location > button > :is(i, p) { 420 | display: flex; 421 | align-items: center; 422 | height: 100%; 423 | color: white; 424 | font-size: 0.75rem; 425 | } 426 | 427 | #location > button > i { 428 | height: 100%; 429 | } 430 | 431 | #location > button > p { 432 | color: rgb(5,5,5 / 75%); 433 | font-weight: 500; 434 | } 435 | 436 | #ad { 437 | display: flex; 438 | border: 1px solid rgb(255 255 255 / 10%); 439 | padding: 0.25rem; 440 | margin: 0rem 1rem; 441 | border-radius: 0.25rem; 442 | } 443 | 444 | #ad > img { 445 | width: 100%; 446 | border-radius: inherit; 447 | } 448 | 449 | #also-like { 450 | display: flex; 451 | flex-direction: column; 452 | gap: 0.5rem; 453 | margin: 0rem 1rem; 454 | } 455 | 456 | #also-like > h3 { 457 | font-size: 0.9rem; 458 | } 459 | 460 | #also-like-items { 461 | display: grid; 462 | gap: 0.5rem; 463 | grid-template-columns: repeat(3, auto); 464 | grid-template-rows: repeat(3, 1fr); 465 | } 466 | 467 | #also-like-items > button { 468 | display: flex; 469 | aspect-ratio: 1; 470 | } 471 | 472 | #also-like-items > button > img { 473 | height: 100%; 474 | width: 100%; 475 | object-fit: cover; 476 | border-radius: 0.25rem; 477 | } 478 | 479 | @media(max-width: 500px) { 480 | body { 481 | overflow: auto; 482 | } 483 | 484 | #phone { 485 | height: 100vh; 486 | display: flex; 487 | width: 100%; 488 | margin: 0px auto; 489 | } 490 | 491 | #main-wrapper { 492 | width: 100%; 493 | flex-grow: 1; 494 | } 495 | } 496 | """.trimIndent() 497 | 498 | 499 | val rawCss4 = """ 500 | #search-input { 501 | display: flex; 502 | align-items: center; 503 | gap: 0.5rem; 504 | position: absolute; 505 | inset: 2px; 506 | padding: 0.5rem; 507 | border-radius: 0.3rem; 508 | backdrop-filter: blur(0.75rem); 509 | } 510 | 511 | #search-input > i { 512 | width: 1.5rem; 513 | padding: 0rem 0.25rem; 514 | color: rgb(255 255 255 / 85%); 515 | text-align: center; 516 | } 517 | 518 | #search-input > input { 519 | width: 100%; 520 | flex-grow: 1; 521 | color: white; 522 | background-color: transparent; 523 | border: none; 524 | outline: none; 525 | font-weight: 500; 526 | } 527 | 528 | #search-input > button, #search-input > custom { 529 | height: 2rem; 530 | width: 2rem; 531 | display: grid; 532 | place-items: center; 533 | flex-shrink: 0; 534 | cursor: pointer; 535 | } 536 | 537 | #search-input > button > i { 538 | color: rgb(255 255 255 / 85%); 539 | } 540 | 541 | #search-input > button:is(:hover, :focus-visible) { 542 | background: rgb(255 255 255 / 10%); 543 | border-radius: 0.25rem; 544 | } 545 | 546 | #search-input > input::placeholder { 547 | color: rgb(255 255 255 / 25%); 548 | } 549 | """.trimIndent() -------------------------------------------------------------------------------- /parsing/src/jvmMain/resources/colors.txt: -------------------------------------------------------------------------------- 1 | val AliceBlue get() = Color.rgb(0xF0, 0xF8, 0xFF) 2 | val AntiqueWhite get() = Color.rgb(0xFA, 0xEB, 0xD7) 3 | val Aqua get() = Color.rgb(0x00, 0xFF, 0xFF) 4 | val Aquamarine get() = Color.rgb(0x7F, 0xFF, 0xD4) 5 | val Azure get() = Color.rgb(0xF0, 0xFF, 0xFF) 6 | val Beige get() = Color.rgb(0xF5, 0xF5, 0xDC) 7 | val Bisque get() = Color.rgb(0xFF, 0xE4, 0xC4) 8 | val Black get() = Color.rgb(0x00, 0x00, 0x00) 9 | val BlanchedAlmond get() = Color.rgb(0xFF, 0xEB, 0xCD) 10 | val Blue get() = Color.rgb(0x00, 0x00, 0xFF) 11 | val BlueViolet get() = Color.rgb(0x8A, 0x2B, 0xE2) 12 | val Brown get() = Color.rgb(0xA5, 0x2A, 0x2A) 13 | val BurlyWood get() = Color.rgb(0xDE, 0xB8, 0x87) 14 | val CadetBlue get() = Color.rgb(0x5F, 0x9E, 0xA0) 15 | val Chartreuse get() = Color.rgb(0x7F, 0xFF, 0x00) 16 | val Chocolate get() = Color.rgb(0xD2, 0x69, 0x1E) 17 | val Coral get() = Color.rgb(0xFF, 0x7F, 0x50) 18 | val CornflowerBlue get() = Color.rgb(0x64, 0x95, 0xED) 19 | val Cornsilk get() = Color.rgb(0xFF, 0xF8, 0xDC) 20 | val Crimson get() = Color.rgb(0xDC, 0x14, 0x3C) 21 | val Cyan get() = Color.rgb(0x00, 0xFF, 0xFF) 22 | val DarkBlue get() = Color.rgb(0x00, 0x00, 0x8B) 23 | val DarkCyan get() = Color.rgb(0x00, 0x8B, 0x8B) 24 | val DarkGoldenRod get() = Color.rgb(0xB8, 0x86, 0x0B) 25 | val DarkGray get() = Color.rgb(0xA9, 0xA9, 0xA9) 26 | val DarkGrey get() = Color.rgb(0xA9, 0xA9, 0xA9) 27 | val DarkGreen get() = Color.rgb(0x00, 0x64, 0x00) 28 | val DarkKhaki get() = Color.rgb(0xBD, 0xB7, 0x6B) 29 | val DarkMagenta get() = Color.rgb(0x8B, 0x00, 0x8B) 30 | val DarkOliveGreen get() = Color.rgb(0x55, 0x6B, 0x2F) 31 | val DarkOrange get() = Color.rgb(0xFF, 0x8C, 0x00) 32 | val DarkOrchid get() = Color.rgb(0x99, 0x32, 0xCC) 33 | val DarkRed get() = Color.rgb(0x8B, 0x00, 0x00) 34 | val DarkSalmon get() = Color.rgb(0xE9, 0x96, 0x7A) 35 | val DarkSeaGreen get() = Color.rgb(0x8F, 0xBC, 0x8F) 36 | val DarkSlateBlue get() = Color.rgb(0x48, 0x3D, 0x8B) 37 | val DarkSlateGray get() = Color.rgb(0x2F, 0x4F, 0x4F) 38 | val DarkSlateGrey get() = Color.rgb(0x2F, 0x4F, 0x4F) 39 | val DarkTurquoise get() = Color.rgb(0x00, 0xCE, 0xD1) 40 | val DarkViolet get() = Color.rgb(0x94, 0x00, 0xD3) 41 | val DeepPink get() = Color.rgb(0xFF, 0x14, 0x93) 42 | val DeepSkyBlue get() = Color.rgb(0x00, 0xBF, 0xFF) 43 | val DimGray get() = Color.rgb(0x69, 0x69, 0x69) 44 | val DimGrey get() = Color.rgb(0x69, 0x69, 0x69) 45 | val DodgerBlue get() = Color.rgb(0x1E, 0x90, 0xFF) 46 | val FireBrick get() = Color.rgb(0xB2, 0x22, 0x22) 47 | val FloralWhite get() = Color.rgb(0xFF, 0xFA, 0xF0) 48 | val ForestGreen get() = Color.rgb(0x22, 0x8B, 0x22) 49 | val Fuchsia get() = Color.rgb(0xFF, 0x00, 0xFF) 50 | val Gainsboro get() = Color.rgb(0xDC, 0xDC, 0xDC) 51 | val GhostWhite get() = Color.rgb(0xF8, 0xF8, 0xFF) 52 | val Gold get() = Color.rgb(0xFF, 0xD7, 0x00) 53 | val GoldenRod get() = Color.rgb(0xDA, 0xA5, 0x20) 54 | val Gray get() = Color.rgb(0x80, 0x80, 0x80) 55 | val Grey get() = Color.rgb(0x80, 0x80, 0x80) 56 | val Green get() = Color.rgb(0x00, 0x80, 0x00) 57 | val GreenYellow get() = Color.rgb(0xAD, 0xFF, 0x2F) 58 | val HoneyDew get() = Color.rgb(0xF0, 0xFF, 0xF0) 59 | val HotPink get() = Color.rgb(0xFF, 0x69, 0xB4) 60 | val IndianRed get() = Color.rgb(0xCD, 0x5C, 0x5C) 61 | val Indigo get() = Color.rgb(0x4B, 0x00, 0x82) 62 | val Ivory get() = Color.rgb(0xFF, 0xFF, 0xF0) 63 | val Khaki get() = Color.rgb(0xF0, 0xE6, 0x8C) 64 | val Lavender get() = Color.rgb(0xE6, 0xE6, 0xFA) 65 | val LavenderBlush get() = Color.rgb(0xFF, 0xF0, 0xF5) 66 | val LawnGreen get() = Color.rgb(0x7C, 0xFC, 0x00) 67 | val LemonChiffon get() = Color.rgb(0xFF, 0xFA, 0xCD) 68 | val LightBlue get() = Color.rgb(0xAD, 0xD8, 0xE6) 69 | val LightCoral get() = Color.rgb(0xF0, 0x80, 0x80) 70 | val LightCyan get() = Color.rgb(0xE0, 0xFF, 0xFF) 71 | val LightGoldenRodYellow get() = Color.rgb(0xFA, 0xFA, 0xD2) 72 | val LightGray get() = Color.rgb(0xD3, 0xD3, 0xD3) 73 | val LightGrey get() = Color.rgb(0xD3, 0xD3, 0xD3) 74 | val LightGreen get() = Color.rgb(0x90, 0xEE, 0x90) 75 | val LightPink get() = Color.rgb(0xFF, 0xB6, 0xC1) 76 | val LightSalmon get() = Color.rgb(0xFF, 0xA0, 0x7A) 77 | val LightSeaGreen get() = Color.rgb(0x20, 0xB2, 0xAA) 78 | val LightSkyBlue get() = Color.rgb(0x87, 0xCE, 0xFA) 79 | val LightSlateGray get() = Color.rgb(0x77, 0x88, 0x99) 80 | val LightSlateGrey get() = Color.rgb(0x77, 0x88, 0x99) 81 | val LightSteelBlue get() = Color.rgb(0xB0, 0xC4, 0xDE) 82 | val LightYellow get() = Color.rgb(0xFF, 0xFF, 0xE0) 83 | val Lime get() = Color.rgb(0x00, 0xFF, 0x00) 84 | val LimeGreen get() = Color.rgb(0x32, 0xCD, 0x32) 85 | val Linen get() = Color.rgb(0xFA, 0xF0, 0xE6) 86 | val Magenta get() = Color.rgb(0xFF, 0x00, 0xFF) 87 | val Maroon get() = Color.rgb(0x80, 0x00, 0x00) 88 | val MediumAquaMarine get() = Color.rgb(0x66, 0xCD, 0xAA) 89 | val MediumBlue get() = Color.rgb(0x00, 0x00, 0xCD) 90 | val MediumOrchid get() = Color.rgb(0xBA, 0x55, 0xD3) 91 | val MediumPurple get() = Color.rgb(0x93, 0x70, 0xDB) 92 | val MediumSeaGreen get() = Color.rgb(0x3C, 0xB3, 0x71) 93 | val MediumSlateBlue get() = Color.rgb(0x7B, 0x68, 0xEE) 94 | val MediumSpringGreen get() = Color.rgb(0x00, 0xFA, 0x9A) 95 | val MediumTurquoise get() = Color.rgb(0x48, 0xD1, 0xCC) 96 | val MediumVioletRed get() = Color.rgb(0xC7, 0x15, 0x85) 97 | val MidnightBlue get() = Color.rgb(0x19, 0x19, 0x70) 98 | val MintCream get() = Color.rgb(0xF5, 0xFF, 0xFA) 99 | val MistyRose get() = Color.rgb(0xFF, 0xE4, 0xE1) 100 | val Moccasin get() = Color.rgb(0xFF, 0xE4, 0xB5) 101 | val NavajoWhite get() = Color.rgb(0xFF, 0xDE, 0xAD) 102 | val Navy get() = Color.rgb(0x00, 0x00, 0x80) 103 | val OldLace get() = Color.rgb(0xFD, 0xF5, 0xE6) 104 | val Olive get() = Color.rgb(0x80, 0x80, 0x00) 105 | val OliveDrab get() = Color.rgb(0x6B, 0x8E, 0x23) 106 | val Orange get() = Color.rgb(0xFF, 0xA5, 0x00) 107 | val OrangeRed get() = Color.rgb(0xFF, 0x45, 0x00) 108 | val Orchid get() = Color.rgb(0xDA, 0x70, 0xD6) 109 | val PaleGoldenRod get() = Color.rgb(0xEE, 0xE8, 0xAA) 110 | val PaleGreen get() = Color.rgb(0x98, 0xFB, 0x98) 111 | val PaleTurquoise get() = Color.rgb(0xAF, 0xEE, 0xEE) 112 | val PaleVioletRed get() = Color.rgb(0xDB, 0x70, 0x93) 113 | val PapayaWhip get() = Color.rgb(0xFF, 0xEF, 0xD5) 114 | val PeachPuff get() = Color.rgb(0xFF, 0xDA, 0xB9) 115 | val Peru get() = Color.rgb(0xCD, 0x85, 0x3F) 116 | val Pink get() = Color.rgb(0xFF, 0xC0, 0xCB) 117 | val Plum get() = Color.rgb(0xDD, 0xA0, 0xDD) 118 | val PowderBlue get() = Color.rgb(0xB0, 0xE0, 0xE6) 119 | val Purple get() = Color.rgb(0x80, 0x00, 0x80) 120 | val RebeccaPurple get() = Color.rgb(0x66, 0x33, 0x99) 121 | val Red get() = Color.rgb(0xFF, 0x00, 0x00) 122 | val RosyBrown get() = Color.rgb(0xBC, 0x8F, 0x8F) 123 | val RoyalBlue get() = Color.rgb(0x41, 0x69, 0xE1) 124 | val SaddleBrown get() = Color.rgb(0x8B, 0x45, 0x13) 125 | val Salmon get() = Color.rgb(0xFA, 0x80, 0x72) 126 | val SandyBrown get() = Color.rgb(0xF4, 0xA4, 0x60) 127 | val SeaGreen get() = Color.rgb(0x2E, 0x8B, 0x57) 128 | val SeaShell get() = Color.rgb(0xFF, 0xF5, 0xEE) 129 | val Sienna get() = Color.rgb(0xA0, 0x52, 0x2D) 130 | val Silver get() = Color.rgb(0xC0, 0xC0, 0xC0) 131 | val SkyBlue get() = Color.rgb(0x87, 0xCE, 0xEB) 132 | val SlateBlue get() = Color.rgb(0x6A, 0x5A, 0xCD) 133 | val SlateGray get() = Color.rgb(0x70, 0x80, 0x90) 134 | val SlateGrey get() = Color.rgb(0x70, 0x80, 0x90) 135 | val Snow get() = Color.rgb(0xFF, 0xFA, 0xFA) 136 | val SpringGreen get() = Color.rgb(0x00, 0xFF, 0x7F) 137 | val SteelBlue get() = Color.rgb(0x46, 0x82, 0xB4) 138 | val Tan get() = Color.rgb(0xD2, 0xB4, 0x8C) 139 | val Teal get() = Color.rgb(0x00, 0x80, 0x80) 140 | val Thistle get() = Color.rgb(0xD8, 0xBF, 0xD8) 141 | val Tomato get() = Color.rgb(0xFF, 0x63, 0x47) 142 | val Turquoise get() = Color.rgb(0x40, 0xE0, 0xD0) 143 | val Violet get() = Color.rgb(0xEE, 0x82, 0xEE) 144 | val Wheat get() = Color.rgb(0xF5, 0xDE, 0xB3) 145 | val White get() = Color.rgb(0xFF, 0xFF, 0xFF) 146 | val WhiteSmoke get() = Color.rgb(0xF5, 0xF5, 0xF5) 147 | val Yellow get() = Color.rgb(0xFF, 0xFF, 0x00) 148 | val YellowGreen get() = Color.rgb(0x9A, 0xCD, 0x32) -------------------------------------------------------------------------------- /parsing/src/jvmMain/resources/units.txt: -------------------------------------------------------------------------------- 1 | inline val percent get() = "%".unsafeCast() 2 | 3 | inline val em get() = "em".unsafeCast() 4 | 5 | inline val ex get() = "ex".unsafeCast() 6 | 7 | inline val ch get() = "ch".unsafeCast() 8 | 9 | inline val ic get() = "ic".unsafeCast() 10 | 11 | inline val cssRem get() = "rem".unsafeCast() // manually edited 12 | 13 | inline val lh get() = "lh".unsafeCast() 14 | 15 | inline val rlh get() = "rlh".unsafeCast() 16 | 17 | inline val vw get() = "vw".unsafeCast() 18 | 19 | inline val vh get() = "vh".unsafeCast() 20 | 21 | inline val vi get() = "vi".unsafeCast() 22 | 23 | inline val vb get() = "vb".unsafeCast() 24 | 25 | inline val vmin get() = "vmin".unsafeCast() 26 | 27 | inline val vmax get() = "vmax".unsafeCast() 28 | 29 | inline val cm get() = "cm".unsafeCast() 30 | 31 | inline val mm get() = "mm".unsafeCast() 32 | 33 | inline val Q get() = "Q".unsafeCast() 34 | 35 | inline val pt get() = "pt".unsafeCast() 36 | 37 | inline val pc get() = "pc".unsafeCast() 38 | 39 | inline val px get() = "px".unsafeCast() 40 | 41 | inline val deg get() = "deg".unsafeCast() 42 | 43 | inline val grad get() = "grad".unsafeCast() 44 | 45 | inline val rad get() = "rad".unsafeCast() 46 | 47 | inline val turn get() = "turn".unsafeCast() 48 | 49 | inline val s get() = "s".unsafeCast() 50 | 51 | inline val ms get() = "ms".unsafeCast() 52 | 53 | inline val Hz get() = "Hz".unsafeCast() 54 | 55 | inline val kHz get() = "kHz".unsafeCast() 56 | 57 | inline val dpi get() = "dpi".unsafeCast() 58 | 59 | inline val dpcm get() = "dpcm".unsafeCast() 60 | 61 | inline val dppx get() = "dppx".unsafeCast() 62 | 63 | inline val fr get() = "fr".unsafeCast() 64 | 65 | inline val number get() = "number".unsafeCast() -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | } 5 | } 6 | 7 | dependencyResolutionManagement { 8 | @Suppress("UnstableApiUsage") 9 | repositories { 10 | mavenCentral() 11 | google() 12 | } 13 | } 14 | 15 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 16 | 17 | rootProject.name = "css2kobweb" 18 | 19 | include(":site") 20 | include(":parsing") -------------------------------------------------------------------------------- /site/.gitignore: -------------------------------------------------------------------------------- 1 | # Kobweb ignores 2 | .kobweb/* 3 | !.kobweb/conf.yaml 4 | -------------------------------------------------------------------------------- /site/.kobweb/conf.yaml: -------------------------------------------------------------------------------- 1 | site: 2 | title: "css2kobweb" 3 | basePath: "css2kobweb" # repo name for gh-pages 4 | 5 | server: 6 | files: 7 | dev: 8 | contentRoot: "build/processedResources/js/main/public" 9 | script: "build/kotlin-webpack/js/developmentExecutable/css2kobweb.js" 10 | api: "build/libs/css2kobweb.jar" 11 | prod: 12 | script: "build/kotlin-webpack/js/productionExecutable/css2kobweb.js" 13 | siteRoot: ".kobweb/site" 14 | 15 | port: 8080 -------------------------------------------------------------------------------- /site/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.varabyte.kobweb.gradle.application.util.configAsKobwebApplication 2 | 3 | plugins { 4 | alias(libs.plugins.kotlin.multiplatform) 5 | alias(libs.plugins.compose.compiler) 6 | alias(libs.plugins.kobweb.application) 7 | } 8 | 9 | group = "io.github.opletter.css2kobweb" 10 | version = "1.0-SNAPSHOT" 11 | 12 | kobweb { 13 | app { 14 | index { 15 | description = "Convert CSS to Kobweb modifiers & styles" 16 | } 17 | } 18 | } 19 | 20 | kotlin { 21 | configAsKobwebApplication("css2kobweb") 22 | js().compilerOptions { 23 | target = "es2015" 24 | freeCompilerArgs.add("-Xir-generate-inline-anonymous-functions") 25 | } 26 | 27 | sourceSets { 28 | jsMain.dependencies { 29 | implementation(libs.compose.runtime) 30 | implementation(libs.compose.html.core) 31 | implementation(libs.kobweb.core) 32 | implementation(libs.kobweb.silk) 33 | implementation(libs.silk.icons.fa) 34 | implementation(projects.parsing) 35 | } 36 | } 37 | } 38 | 39 | composeCompiler { 40 | includeTraceMarkers = false 41 | } -------------------------------------------------------------------------------- /site/src/jsMain/kotlin/io/github/opletter/css2kobweb/AppEntry.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb 2 | 3 | import androidx.compose.runtime.Composable 4 | import com.varabyte.kobweb.compose.ui.Modifier 5 | import com.varabyte.kobweb.compose.ui.graphics.Color 6 | import com.varabyte.kobweb.compose.ui.modifiers.fontFamily 7 | import com.varabyte.kobweb.compose.ui.modifiers.fontSize 8 | import com.varabyte.kobweb.compose.ui.modifiers.marginBlock 9 | import com.varabyte.kobweb.compose.ui.modifiers.minHeight 10 | import com.varabyte.kobweb.core.App 11 | import com.varabyte.kobweb.silk.SilkApp 12 | import com.varabyte.kobweb.silk.components.layout.Surface 13 | import com.varabyte.kobweb.silk.init.InitSilk 14 | import com.varabyte.kobweb.silk.init.InitSilkContext 15 | import com.varabyte.kobweb.silk.init.registerStyleBase 16 | import com.varabyte.kobweb.silk.style.breakpoint.Breakpoint 17 | import com.varabyte.kobweb.silk.style.common.SmoothColorStyle 18 | import com.varabyte.kobweb.silk.style.toModifier 19 | import com.varabyte.kobweb.silk.theme.colors.palette.background 20 | import com.varabyte.kobweb.silk.theme.colors.palette.button 21 | import com.varabyte.kobweb.silk.theme.colors.palette.color 22 | import org.jetbrains.compose.web.css.cssRem 23 | import org.jetbrains.compose.web.css.vh 24 | 25 | @InitSilk 26 | fun updateTheme(ctx: InitSilkContext) { 27 | with(ctx.stylesheet) { 28 | registerStyleBase("body") { 29 | Modifier.fontFamily("system-ui", "Segoe UI", "Tahoma", "Helvetica", "sans-serif") 30 | } 31 | registerStyle("h1") { 32 | base { 33 | Modifier 34 | .fontSize(2.5.cssRem) 35 | .marginBlock(0.5.cssRem, 0.5.cssRem) 36 | } 37 | Breakpoint.MD { 38 | Modifier.fontSize(2.75.cssRem) 39 | } 40 | } 41 | registerStyleBase("h2") { 42 | Modifier.marginBlock(0.cssRem, 0.cssRem) 43 | } 44 | } 45 | 46 | // https://coolors.co/1e1f22-3f334d-8bdbe2-f97068-f7e733 47 | // todo: unique color modes 48 | ctx.theme.palettes.light.apply { 49 | background = Color.rgb(188, 190, 196) 50 | color = Color.rgb(30, 31, 34) 51 | button.set( 52 | default = Color.rgb(0xF97068), 53 | hover = Color.rgb(0xF97068).darkened(0.1f), 54 | focus = ctx.theme.palettes.light.button.focus, 55 | pressed = Color.rgb(0xF97068).darkened(0.25f), 56 | ) 57 | } 58 | } 59 | 60 | @App 61 | @Composable 62 | fun AppEntry(content: @Composable () -> Unit) { 63 | SilkApp { 64 | Surface(SmoothColorStyle.toModifier().minHeight(100.vh)) { 65 | content() 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /site/src/jsMain/kotlin/io/github/opletter/css2kobweb/components/layouts/PageLayout.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb.components.layouts 2 | 3 | import androidx.compose.runtime.Composable 4 | import com.varabyte.kobweb.compose.foundation.layout.Box 5 | import com.varabyte.kobweb.compose.foundation.layout.Column 6 | import com.varabyte.kobweb.compose.ui.Alignment 7 | import com.varabyte.kobweb.compose.ui.Modifier 8 | import com.varabyte.kobweb.compose.ui.modifiers.* 9 | import com.varabyte.kobweb.silk.style.toModifier 10 | import io.github.opletter.css2kobweb.components.sections.Footer 11 | import io.github.opletter.css2kobweb.components.styles.BackgroundGradientStyle 12 | import org.jetbrains.compose.web.css.fr 13 | import org.jetbrains.compose.web.css.percent 14 | import org.jetbrains.compose.web.dom.H1 15 | import org.jetbrains.compose.web.dom.Text 16 | 17 | @Composable 18 | fun PageLayout(title: String, content: @Composable () -> Unit) { 19 | // Create a box with two rows: the main content (fills as much space as it can) and the footer (which reserves 20 | // space at the bottom). "auto" means the use the height of the row. "1fr" means give the rest of the space to 21 | // that row. Since this box is set to *at least* 100%, the footer will always appear at least on the bottom but 22 | // can be pushed further down if the first row grows beyond the page. 23 | Box( 24 | BackgroundGradientStyle.toModifier() 25 | .fillMaxSize() 26 | .gridTemplateRows { size(1.fr); size(auto) }, 27 | contentAlignment = Alignment.TopCenter 28 | ) { 29 | Column( 30 | modifier = Modifier 31 | .fillMaxHeight() 32 | .width(90.percent), 33 | horizontalAlignment = Alignment.CenterHorizontally, 34 | ) { 35 | H1 { Text(title) } 36 | content() 37 | } 38 | // Associate the footer with the row that will get pushed off the bottom of the page if it can't fit. 39 | Footer(Modifier.gridRowStart(2).gridRowEnd(3)) 40 | } 41 | } -------------------------------------------------------------------------------- /site/src/jsMain/kotlin/io/github/opletter/css2kobweb/components/sections/Footer.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb.components.sections 2 | 3 | import androidx.compose.runtime.Composable 4 | import com.varabyte.kobweb.compose.css.AlignSelf 5 | import com.varabyte.kobweb.compose.css.TextAlign 6 | import com.varabyte.kobweb.compose.foundation.layout.Column 7 | import com.varabyte.kobweb.compose.foundation.layout.Row 8 | import com.varabyte.kobweb.compose.ui.Alignment 9 | import com.varabyte.kobweb.compose.ui.Modifier 10 | import com.varabyte.kobweb.compose.ui.modifiers.alignSelf 11 | import com.varabyte.kobweb.compose.ui.modifiers.margin 12 | import com.varabyte.kobweb.compose.ui.modifiers.rowGap 13 | import com.varabyte.kobweb.compose.ui.modifiers.textAlign 14 | import com.varabyte.kobweb.silk.components.icons.fa.FaGithub 15 | import com.varabyte.kobweb.silk.components.navigation.Link 16 | import com.varabyte.kobweb.silk.components.text.SpanText 17 | import com.varabyte.kobweb.silk.style.CssStyle 18 | import com.varabyte.kobweb.silk.style.base 19 | import com.varabyte.kobweb.silk.style.toModifier 20 | import org.jetbrains.compose.web.css.cssRem 21 | 22 | val FooterStyle = CssStyle.base { 23 | Modifier 24 | .margin(topBottom = 1.cssRem) 25 | .alignSelf(AlignSelf.Center) 26 | .textAlign(TextAlign.Center) 27 | .rowGap(0.1.cssRem) 28 | } 29 | 30 | @Composable 31 | fun Footer(modifier: Modifier = Modifier) { 32 | Column( 33 | FooterStyle.toModifier().then(modifier), 34 | horizontalAlignment = Alignment.CenterHorizontally, 35 | ) { 36 | Row(verticalAlignment = Alignment.CenterVertically) { 37 | FaGithub() 38 | SpanText(" This site is ") 39 | Link(path = "https://github.com/opLetter/css2kobweb", text = "open source") 40 | } 41 | Row { 42 | SpanText("Made with ") 43 | Link(path = "https://github.com/varabyte/kobweb", text = "Kobweb") 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /site/src/jsMain/kotlin/io/github/opletter/css2kobweb/components/styles/Background.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb.components.styles 2 | 3 | import com.varabyte.kobweb.compose.css.Background 4 | import com.varabyte.kobweb.compose.css.CSSPosition 5 | import com.varabyte.kobweb.compose.css.functions.RadialGradient 6 | import com.varabyte.kobweb.compose.css.functions.linearGradient 7 | import com.varabyte.kobweb.compose.css.functions.radialGradient 8 | import com.varabyte.kobweb.compose.css.functions.toImage 9 | import com.varabyte.kobweb.compose.ui.Modifier 10 | import com.varabyte.kobweb.compose.ui.graphics.Color 11 | import com.varabyte.kobweb.compose.ui.modifiers.background 12 | import com.varabyte.kobweb.silk.style.CssStyle 13 | import com.varabyte.kobweb.silk.style.base 14 | import org.jetbrains.compose.web.css.deg 15 | import org.jetbrains.compose.web.css.percent 16 | 17 | // Image courtesy of gradientmagic.com 18 | // https://www.gradientmagic.com/collection/popular/gradient/1583693118025 19 | // translated with css2kobweb :) 20 | val BackgroundGradientStyle = CssStyle.base { 21 | Modifier 22 | .background( 23 | Background.of(image = linearGradient(Color.rgb(34, 222, 237), Color.rgb(135, 89, 215), 90.deg).toImage()), 24 | Background.of( 25 | image = radialGradient(RadialGradient.Shape.Circle, CSSPosition(75.percent, 99.percent)) { 26 | add(Color.rgba(243, 243, 243, 0.04f), 0.percent) 27 | add(Color.rgba(243, 243, 243, 0.04f), 50.percent) 28 | add(Color.rgba(37, 37, 37, 0.04f), 50.percent) 29 | add(Color.rgba(37, 37, 37, 0.04f), 100.percent) 30 | }.toImage() 31 | ), 32 | Background.of( 33 | image = radialGradient(RadialGradient.Shape.Circle, CSSPosition(15.percent, 16.percent)) { 34 | add(Color.rgba(99, 99, 99, 0.04f), 0.percent) 35 | add(Color.rgba(99, 99, 99, 0.04f), 50.percent) 36 | add(Color.rgba(45, 45, 45, 0.04f), 50.percent) 37 | add(Color.rgba(45, 45, 45, 0.04f), 100.percent) 38 | }.toImage() 39 | ), 40 | Background.of( 41 | image = radialGradient(RadialGradient.Shape.Circle, CSSPosition(86.percent, 7.percent)) { 42 | add(Color.rgba(40, 40, 40, 0.04f), 0.percent) 43 | add(Color.rgba(40, 40, 40, 0.04f), 50.percent) 44 | add(Color.rgba(200, 200, 200, 0.04f), 50.percent) 45 | add(Color.rgba(200, 200, 200, 0.04f), 100.percent) 46 | }.toImage() 47 | ), 48 | Background.of( 49 | image = radialGradient(RadialGradient.Shape.Circle, CSSPosition(66.percent, 97.percent)) { 50 | add(Color.rgba(36, 36, 36, 0.04f), 0.percent) 51 | add(Color.rgba(36, 36, 36, 0.04f), 50.percent) 52 | add(Color.rgba(46, 46, 46, 0.04f), 50.percent) 53 | add(Color.rgba(46, 46, 46, 0.04f), 100.percent) 54 | }.toImage() 55 | ), 56 | Background.of( 57 | image = radialGradient(RadialGradient.Shape.Circle, CSSPosition(40.percent, 91.percent)) { 58 | add(Color.rgba(251, 251, 251, 0.04f), 0.percent) 59 | add(Color.rgba(251, 251, 251, 0.04f), 50.percent) 60 | add(Color.rgba(229, 229, 229, 0.04f), 50.percent) 61 | add(Color.rgba(229, 229, 229, 0.04f), 100.percent) 62 | }.toImage() 63 | ) 64 | ) 65 | } -------------------------------------------------------------------------------- /site/src/jsMain/kotlin/io/github/opletter/css2kobweb/components/widgets/KotlinCode.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb.components.widgets 2 | 3 | import androidx.compose.runtime.Composable 4 | import com.varabyte.kobweb.compose.dom.ref 5 | import com.varabyte.kobweb.compose.dom.registerRefScope 6 | import com.varabyte.kobweb.compose.ui.Modifier 7 | import com.varabyte.kobweb.compose.ui.graphics.Color 8 | import com.varabyte.kobweb.compose.ui.modifiers.margin 9 | import com.varabyte.kobweb.compose.ui.toAttrs 10 | import io.github.opletter.css2kobweb.CodeBlock 11 | import io.github.opletter.css2kobweb.CodeElement 12 | import org.jetbrains.compose.web.css.px 13 | import org.jetbrains.compose.web.dom.Pre 14 | 15 | @Composable 16 | fun KotlinCode(code: List, modifier: Modifier = Modifier) { 17 | Pre(Modifier.margin(0.px).then(modifier).toAttrs()) { 18 | registerRefScope(ref(code) { 19 | it.innerHTML = code.convertToHtml() 20 | }) 21 | } 22 | } 23 | 24 | private object ColorScheme { 25 | val keyword = Color.rgb(0xcF8E6D) 26 | val property = Color.rgb(0xC77DBB) 27 | val extensionFun = Color.rgb(0x56A8F5) 28 | val string = Color.rgb(0x6AAB73) 29 | val number = Color.rgb(0x2AACB8) 30 | val namedArg = Color.rgb(0x56C1D6) 31 | } 32 | 33 | private fun colorForType(type: CodeElement): Color = when (type) { 34 | CodeElement.Keyword -> ColorScheme.keyword 35 | CodeElement.Property -> ColorScheme.property 36 | CodeElement.ExtensionFun -> ColorScheme.extensionFun 37 | CodeElement.String -> ColorScheme.string 38 | CodeElement.Number -> ColorScheme.number 39 | CodeElement.NamedArg -> ColorScheme.namedArg 40 | CodeElement.Plain -> error("Should not be plain") 41 | } 42 | 43 | private fun List.convertToHtml(): String = joinToString("") { block -> 44 | if (block.type == CodeElement.Plain) { 45 | block.text.htmlEscape() 46 | } else { 47 | """${block.text.htmlEscape()}""" 48 | } 49 | } 50 | 51 | private fun String.htmlEscape(): String = this.replace("&", "&").replace("<", "<") -------------------------------------------------------------------------------- /site/src/jsMain/kotlin/io/github/opletter/css2kobweb/pages/Index.kt: -------------------------------------------------------------------------------- 1 | package io.github.opletter.css2kobweb.pages 2 | 3 | import androidx.compose.runtime.* 4 | import com.varabyte.kobweb.compose.css.Overflow 5 | import com.varabyte.kobweb.compose.css.OverflowWrap 6 | import com.varabyte.kobweb.compose.css.Resize 7 | import com.varabyte.kobweb.compose.foundation.layout.Column 8 | import com.varabyte.kobweb.compose.foundation.layout.Row 9 | import com.varabyte.kobweb.compose.ui.Alignment 10 | import com.varabyte.kobweb.compose.ui.Modifier 11 | import com.varabyte.kobweb.compose.ui.graphics.Color 12 | import com.varabyte.kobweb.compose.ui.graphics.Colors 13 | import com.varabyte.kobweb.compose.ui.modifiers.* 14 | import com.varabyte.kobweb.compose.ui.styleModifier 15 | import com.varabyte.kobweb.compose.ui.toAttrs 16 | import com.varabyte.kobweb.core.Page 17 | import com.varabyte.kobweb.silk.components.forms.Button 18 | import com.varabyte.kobweb.silk.components.forms.ButtonSize 19 | import com.varabyte.kobweb.silk.components.icons.fa.FaTrashCan 20 | import com.varabyte.kobweb.silk.components.layout.SimpleGrid 21 | import com.varabyte.kobweb.silk.components.layout.numColumns 22 | import com.varabyte.kobweb.silk.style.CssStyle 23 | import com.varabyte.kobweb.silk.style.base 24 | import com.varabyte.kobweb.silk.style.toModifier 25 | import com.varabyte.kobweb.silk.theme.colors.palette.background 26 | import com.varabyte.kobweb.silk.theme.colors.palette.color 27 | import com.varabyte.kobweb.silk.theme.colors.palette.toPalette 28 | import io.github.opletter.css2kobweb.CodeBlock 29 | import io.github.opletter.css2kobweb.components.layouts.PageLayout 30 | import io.github.opletter.css2kobweb.components.widgets.KotlinCode 31 | import io.github.opletter.css2kobweb.css2kobwebAsCode 32 | import kotlinx.browser.window 33 | import kotlinx.coroutines.delay 34 | import org.jetbrains.compose.web.css.LineStyle 35 | import org.jetbrains.compose.web.css.cssRem 36 | import org.jetbrains.compose.web.css.fr 37 | import org.jetbrains.compose.web.css.px 38 | import org.jetbrains.compose.web.dom.H2 39 | import org.jetbrains.compose.web.dom.Text 40 | import org.jetbrains.compose.web.dom.TextArea 41 | import kotlin.time.Duration.Companion.milliseconds 42 | 43 | val TextAreaStyle = CssStyle.base { 44 | Modifier 45 | .fillMaxSize() 46 | .padding(topBottom = 0.5.cssRem, leftRight = 1.cssRem) 47 | .borderRadius(bottomLeft = 8.px, bottomRight = 8.px) 48 | .resize(Resize.None) 49 | .overflow { y(Overflow.Auto) } 50 | .overflowWrap(OverflowWrap.Normal) 51 | .styleModifier { property("tab-size", 4) } 52 | .backgroundColor(colorMode.toPalette().color) 53 | .color(colorMode.toPalette().background) 54 | } 55 | 56 | val TextAreaLabelBarStyle = CssStyle.base { 57 | Modifier 58 | .fillMaxWidth() 59 | .backgroundColor(Colors.Black) 60 | .padding(topBottom = 0.5.cssRem, leftRight = 1.cssRem) 61 | .color(Color.rgb(0x8bdbe2)) 62 | .borderRadius(topLeft = 8.px, topRight = 8.px) 63 | } 64 | 65 | @Page 66 | @Composable 67 | fun HomePage() { 68 | var cssInput by remember { mutableStateOf("") } 69 | var outputCode: List by remember { mutableStateOf(emptyList()) } 70 | 71 | // get code here to avoid lagging onInput 72 | LaunchedEffect(cssInput) { 73 | try { 74 | if (cssInput.length > 5000) // debounce after semi-arbitrarily chosen number of characters 75 | delay(50.milliseconds) 76 | outputCode = if (cssInput.isNotBlank()) css2kobwebAsCode(cssInput) else emptyList() 77 | } catch (e: Exception) { 78 | // exceptions are expected while css is being typed / not invalid so only log them to verbose 79 | @Suppress("UNUSED_VARIABLE") val debug = e.stackTraceToString() 80 | js("console.debug(debug);") // why is console.debug not a thing? 81 | Unit 82 | } 83 | } 84 | 85 | PageLayout("CSS 2 Kobweb") { 86 | SimpleGrid( 87 | numColumns(1, md = 2), 88 | Modifier 89 | .fillMaxWidth() 90 | .flex(1) 91 | .gap(1.cssRem) 92 | .gridAutoRows { size(1.fr) } 93 | ) { 94 | Column(Modifier.fillMaxHeight()) { 95 | TextAreaHeader("CSS Input") { 96 | HeaderButton({ cssInput = "" }, Modifier.ariaLabel("clear text")) { 97 | FaTrashCan() 98 | } 99 | } 100 | TextArea( 101 | cssInput, 102 | TextAreaStyle.toModifier() 103 | .outlineStyle(LineStyle.None) 104 | .border { style(LineStyle.None) } 105 | .ariaLabel("CSS Input") 106 | .toAttrs { 107 | spellCheck(false) 108 | attr("data-enable-grammarly", "false") 109 | ref { 110 | it.focus() 111 | onDispose { } 112 | } 113 | onInput { 114 | cssInput = it.value 115 | } 116 | } 117 | ) 118 | } 119 | // outer column needed for child's flex-grow (while keeping overflowY) 120 | Column(Modifier.overflow { x(Overflow.Auto) }) { 121 | Column( 122 | Modifier 123 | .fillMaxWidth() 124 | .height(0.px) // set to make overflow work 125 | .flexGrow(1) 126 | ) { 127 | TextAreaHeader("Kobweb Code") { 128 | CopyTextButton(outputCode.joinToString("")) 129 | } 130 | KotlinCode(outputCode, TextAreaStyle.toModifier()) 131 | } 132 | } 133 | } 134 | } 135 | } 136 | 137 | @Composable 138 | fun TextAreaHeader(label: String, rightSideContent: @Composable () -> Unit) { 139 | Row( 140 | TextAreaLabelBarStyle.toModifier().columnGap(1.cssRem), 141 | verticalAlignment = Alignment.CenterVertically 142 | ) { 143 | H2(Modifier.fillMaxWidth().toAttrs()) { 144 | Text(label) 145 | } 146 | 147 | rightSideContent() 148 | } 149 | } 150 | 151 | @Composable 152 | fun HeaderButton(onClick: () -> Unit, modifier: Modifier = Modifier, content: @Composable () -> Unit) { 153 | Button({ onClick() }, modifier.fontSize(1.cssRem), size = ButtonSize.SM) { 154 | content() 155 | } 156 | } 157 | 158 | @Composable 159 | fun CopyTextButton(textToCopy: String) { 160 | var buttonText by remember { mutableStateOf("Copy") } 161 | HeaderButton( 162 | { 163 | window.navigator.clipboard.writeText(textToCopy) 164 | buttonText = "Copied!" 165 | window.setTimeout({ buttonText = "Copy" }, 2000) 166 | } 167 | ) { Text(buttonText) } 168 | } -------------------------------------------------------------------------------- /site/src/jsMain/resources/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opLetter/css2kobweb/17386befdb5c76df2308fd484ba6a2dd3a006a40/site/src/jsMain/resources/public/favicon.ico --------------------------------------------------------------------------------