├── .github └── workflows │ ├── ci.yml │ └── clean.yml ├── .gitignore ├── .mergify.yml ├── README.md ├── build.sbt ├── ci.sbt ├── generateInstructions.sbt └── project ├── CssExtractor.scala ├── Generator.scala ├── GeneratorPlugin.scala ├── TargetImpl.scala ├── build.properties ├── build.sbt └── plugins.sbt /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Continuous Integration 9 | 10 | on: 11 | pull_request: 12 | branches: ['**'] 13 | push: 14 | branches: ['**'] 15 | tags: [v*] 16 | 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | 20 | jobs: 21 | build: 22 | name: Build and Test 23 | strategy: 24 | matrix: 25 | os: [ubuntu-latest] 26 | scala: [2.13.x, 3.3.x] 27 | java: [temurin@11] 28 | runs-on: ${{ matrix.os }} 29 | steps: 30 | - name: Checkout current branch (full) 31 | uses: actions/checkout@v4 32 | with: 33 | fetch-depth: 0 34 | 35 | - name: Setup Java (temurin@11) 36 | if: matrix.java == 'temurin@11' 37 | uses: actions/setup-java@v4 38 | with: 39 | distribution: temurin 40 | java-version: 11 41 | cache: sbt 42 | 43 | - name: Setup sbt 44 | uses: sbt/setup-sbt@v1 45 | 46 | - name: Check that workflows are up to date 47 | run: sbt '++ ${{ matrix.scala }}' githubWorkflowCheck 48 | 49 | - name: Build project 50 | run: sbt '++ ${{ matrix.scala }}' test 51 | 52 | - name: Compress target directories 53 | run: tar cf targets.tar bootstrap3_scalatags/target fomanticui_scalatags/target bootstrap4_scalatags/target bulma_scalatags/target bootstrap3_scalajsreact/target fontawesome_scalatags/target bootstrap5_scalajsreact/target target bulma_scalajsreact/target bootstrap4_scalajsreact/target fontawesome_scalajsreact/target semanticui_scalajsreact/target semanticui_scalatags/target fomanticui_scalajsreact/target bootstrap5_scalatags/target project/target 54 | 55 | - name: Upload target directories 56 | uses: actions/upload-artifact@v4 57 | with: 58 | name: target-${{ matrix.os }}-${{ matrix.scala }}-${{ matrix.java }} 59 | path: targets.tar 60 | 61 | publish: 62 | name: Publish Artifacts 63 | needs: [build] 64 | if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) 65 | strategy: 66 | matrix: 67 | os: [ubuntu-latest] 68 | scala: [3.3.6] 69 | java: [temurin@11] 70 | runs-on: ${{ matrix.os }} 71 | steps: 72 | - name: Checkout current branch (full) 73 | uses: actions/checkout@v4 74 | with: 75 | fetch-depth: 0 76 | 77 | - name: Setup Java (temurin@11) 78 | if: matrix.java == 'temurin@11' 79 | uses: actions/setup-java@v4 80 | with: 81 | distribution: temurin 82 | java-version: 11 83 | cache: sbt 84 | 85 | - name: Setup sbt 86 | uses: sbt/setup-sbt@v1 87 | 88 | - name: Download target directories (2.13.x) 89 | uses: actions/download-artifact@v4 90 | with: 91 | name: target-${{ matrix.os }}-2.13.x-${{ matrix.java }} 92 | 93 | - name: Inflate target directories (2.13.x) 94 | run: | 95 | tar xf targets.tar 96 | rm targets.tar 97 | 98 | - name: Download target directories (3.3.x) 99 | uses: actions/download-artifact@v4 100 | with: 101 | name: target-${{ matrix.os }}-3.3.x-${{ matrix.java }} 102 | 103 | - name: Inflate target directories (3.3.x) 104 | run: | 105 | tar xf targets.tar 106 | rm targets.tar 107 | 108 | - env: 109 | PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} 110 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 111 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 112 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 113 | run: sbt ci-release 114 | -------------------------------------------------------------------------------- /.github/workflows/clean.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Clean 9 | 10 | on: push 11 | 12 | jobs: 13 | delete-artifacts: 14 | name: Delete Artifacts 15 | runs-on: ubuntu-latest 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | steps: 19 | - name: Delete artifacts 20 | shell: bash {0} 21 | run: | 22 | # Customize those three lines with your repository and credentials: 23 | REPO=${GITHUB_API_URL}/repos/${{ github.repository }} 24 | 25 | # A shortcut to call GitHub API. 26 | ghapi() { curl --silent --location --user _:$GITHUB_TOKEN "$@"; } 27 | 28 | # A temporary file which receives HTTP response headers. 29 | TMPFILE=$(mktemp) 30 | 31 | # An associative array, key: artifact name, value: number of artifacts of that name. 32 | declare -A ARTCOUNT 33 | 34 | # Process all artifacts on this repository, loop on returned "pages". 35 | URL=$REPO/actions/artifacts 36 | while [[ -n "$URL" ]]; do 37 | 38 | # Get current page, get response headers in a temporary file. 39 | JSON=$(ghapi --dump-header $TMPFILE "$URL") 40 | 41 | # Get URL of next page. Will be empty if we are at the last page. 42 | URL=$(grep '^Link:' "$TMPFILE" | tr ',' '\n' | grep 'rel="next"' | head -1 | sed -e 's/.*.*//') 43 | rm -f $TMPFILE 44 | 45 | # Number of artifacts on this page: 46 | COUNT=$(( $(jq <<<$JSON -r '.artifacts | length') )) 47 | 48 | # Loop on all artifacts on this page. 49 | for ((i=0; $i < $COUNT; i++)); do 50 | 51 | # Get name of artifact and count instances of this name. 52 | name=$(jq <<<$JSON -r ".artifacts[$i].name?") 53 | ARTCOUNT[$name]=$(( $(( ${ARTCOUNT[$name]} )) + 1)) 54 | 55 | id=$(jq <<<$JSON -r ".artifacts[$i].id?") 56 | size=$(( $(jq <<<$JSON -r ".artifacts[$i].size_in_bytes?") )) 57 | printf "Deleting '%s' #%d, %'d bytes\n" $name ${ARTCOUNT[$name]} $size 58 | ghapi -X DELETE $REPO/actions/artifacts/$id 59 | done 60 | done 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | target/ 3 | /.bsp/ 4 | /.metals/ 5 | /.bloop/ 6 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | defaults: {} 2 | queue_rules: 3 | - name: default 4 | merge_conditions: [] 5 | pull_request_rules: 6 | - name: Automatically merge successful Scala Steward PRs 7 | conditions: 8 | - or: 9 | - author=scala-steward 10 | - author=nafg-scala-steward[bot] 11 | - check-success=Build and Test (ubuntu-latest, 2.13.x, temurin@11) 12 | - check-success=Build and Test (ubuntu-latest, 3.3.x, temurin@11) 13 | actions: 14 | queue: 15 | name: default 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `css-dsl`: A DSL for CSS Frameworks 2 | Instead of stringly typed, noisy code like this, 3 | 4 | ```scala 5 | <.div( 6 | ^.cls := ("panel hidden-xs panel-" + (if (success) "success" else "default")), 7 | <.div( 8 | ^.cls := "panel-heading", 9 | <.h3(^.cls := "panel-title", "Panel title") 10 | ) 11 | ) 12 | ``` 13 | 14 | write code like this: 15 | 16 | ```scala 17 | <.div.panel.hiddenXs( 18 | if (success) C.panelSuccess else C.panelDefault, 19 | <.div.panelHeading( 20 | <.h3.panelTitle("Panel title") 21 | ) 22 | ) 23 | ``` 24 | 25 | ## Variants 26 | ### CSS libraries 27 | * Bootstrap 3 28 | * Bootstrap 4 29 | * Bulma 30 | * Semantic UI 31 | * Fomantic UI 32 | * Font Awesome 33 | 34 | ### Targeted Libraries 35 | * Scalajs-react 36 | * Scalatags (currently only the Text bundle for JVM) 37 | 38 | ### DSL Flavors 39 | * As chainable extension methods on tags 40 | * As methods on the `C` object 41 | 42 | Additionally, most frameworks are available with prefixed and unprefixed methods 43 | 44 | 45 | ## Usage 46 | 47 | ### Dependency Coordinates 48 | #### Resolver 49 | Artifacts are published to Bintray and synced to Bintray JCenter. For SBT use `resolvers += Resolver.jcenterRepo` or `useJCenter := true` (prefixed with `ThisBuild / ` if needed). For other build tools add https://jcenter.bintray.com as a maven repository. 50 | 51 | 52 | 53 | #### Artifact 54 | 55 | | CSS library | Scala DOM library | SBT Module ID | 56 | |--------------|--------------------------|---------------------------------------------------------------------| 57 | | Bootstrap 3 | `scalatags.Text` (JVM) | `"io.github.nafg.css-dsl" %% "bootstrap3_scalatags" % "0.9.0"` | 58 | | Bootstrap 3 | scalajs-react (scala.js) | `"io.github.nafg.css-dsl" %%% "bootstrap3_scalajsreact" % "0.9.0"` | 59 | | Bootstrap 4 | `scalatags.Text` (JVM) | `"io.github.nafg.css-dsl" %% "bootstrap4_scalatags" % "0.9.0"` | 60 | | Bootstrap 4 | scalajs-react (scala.js) | `"io.github.nafg.css-dsl" %%% "bootstrap4_scalajsreact" % "0.9.0"` | 61 | | Bootstrap 5 | `scalatags.Text` (JVM) | `"io.github.nafg.css-dsl" %% "bootstrap5_scalatags" % "0.9.0"` | 62 | | Bootstrap 5 | scalajs-react (scala.js) | `"io.github.nafg.css-dsl" %%% "bootstrap5_scalajsreact" % "0.9.0"` | 63 | | Bulma | `scalatags.Text` (JVM) | `"io.github.nafg.css-dsl" %% "bulma_scalatags" % "0.9.0"` | 64 | | Bulma | scalajs-react (scala.js) | `"io.github.nafg.css-dsl" %%% "bulma_scalajsreact" % "0.9.0"` | 65 | | Fomantic UI | `scalatags.Text` (JVM) | `"io.github.nafg.css-dsl" %% "fomanticui_scalatags" % "0.9.0"` | 66 | | Fomantic UI | scalajs-react (scala.js) | `"io.github.nafg.css-dsl" %%% "fomanticui_scalajsreact" % "0.9.0"` | 67 | | Font Awesome | `scalatags.Text` (JVM) | `"io.github.nafg.css-dsl" %% "fontawesome_scalatags" % "0.9.0"` | 68 | | Font Awesome | scalajs-react (scala.js) | `"io.github.nafg.css-dsl" %%% "fontawesome_scalajsreact" % "0.9.0"` | 69 | | Semantic UI | `scalatags.Text` (JVM) | `"io.github.nafg.css-dsl" %% "semanticui_scalatags" % "0.9.0"` | 70 | | Semantic UI | scalajs-react (scala.js) | `"io.github.nafg.css-dsl" %%% "semanticui_scalajsreact" % "0.9.0"` | 71 | 72 | 73 | ### Import 74 | 75 | | CSS library | Prefix | Import | 76 | |--------------|--------|-------------------------------------| 77 | | Bootstrap 3 | None | `import cssdsl.bootstrap3.Dsl._` | 78 | | Bootstrap 3 | `bs` | `import cssdsl.bootstrap3.BsDsl._` | 79 | | Bootstrap 3 | `bs3` | `import cssdsl.bootstrap3.Bs3Dsl._` | 80 | | Bootstrap 4 | None | `import cssdsl.bootstrap4.Dsl._` | 81 | | Bootstrap 4 | `bs` | `import cssdsl.bootstrap4.BsDsl._` | 82 | | Bootstrap 4 | `bs4` | `import cssdsl.bootstrap4.Bs4Dsl._` | 83 | | Bootstrap 5 | None | `import cssdsl.bootstrap5.Dsl._` | 84 | | Bootstrap 5 | `bs` | `import cssdsl.bootstrap5.BsDsl._` | 85 | | Bootstrap 5 | `bs5` | `import cssdsl.bootstrap5.Bs5Dsl._` | 86 | | Bulma | None | `import cssdsl.bulma.Dsl._` | 87 | | Bulma | `b` | `import cssdsl.bulma.BDsl._` | 88 | | Fomantic UI | `f` | `import cssdsl.fomanticui.FDsl._` | 89 | | Font Awesome | None | `import cssdsl.fontawesome.Dsl._` | 90 | | Font Awesome | `fa` | `import cssdsl.fontawesome.FaDsl._` | 91 | | Semantic UI | `s` | `import cssdsl.semanticui.SDsl._` | 92 | 93 | 94 | 95 | 96 | 97 | ### Code 98 | 99 | The import gives you two things: 100 | 101 | 1. Chainable extension methods on the target library's tag type (scalatags `ConcreteHtmlTag[String]` or scalajs-react's `TagOf[Node]`). These methods return a modified version of the tag which allows you to chain them, and then continue as usual (for instance `apply`ing modifiers and content to it). 102 | 2. The `C` object, which methods with the same name but that return a class modifier directly (scalatags `Modifier` or scalajs-react `TagMod`). This allows you to use classes conditionally. 103 | 104 | For an example illustrating both see the second snippet at the top of this file. 105 | 106 | If you use a prefixed flavor the method names are the same except they start with the chosen prefix, and the first letter after the prefix is capitalized. So for example `bootstrap4.Dsl` will use `tableHover` while `bootstrap4.BsDsl` will use `bsTableHover`. 107 | 108 | ## Contributing 109 | 110 | The DSLs are generated using [ph-css](https://github.com/phax/ph-css) and [Scalameta](https://scalameta.org/). 111 | The CSS is read from a CDN and parsed, class selectors are extracted, and their names are converted to camel case. 112 | 113 | If you want to add or update a CSS framework you just have to update `build.sbt`. 114 | 115 | To add a new target library you first have to implement it in [project/TargetImpl.scala](project/TargetImpl.scala). 116 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import scala.sys.process.stringToProcess 2 | import _root_.io.github.nafg.mergify.dsl._ 3 | import _root_.io.github.nafg.scalacoptions._ 4 | 5 | 6 | def myScalacOptions(version: String) = 7 | ScalacOptions.all(version)( 8 | (opts: options.Common) => opts.feature ++ opts.deprecation, 9 | ) 10 | 11 | inThisBuild( 12 | List( 13 | organization := "io.github.nafg.css-dsl", 14 | scalaVersion := "3.3.6", 15 | crossScalaVersions := Seq("2.13.16", scalaVersion.value), 16 | scalacOptions ++= myScalacOptions(scalaVersion.value), 17 | versionScheme := Some("early-semver") 18 | ) 19 | ) 20 | 21 | name := "css-dsl" 22 | publish / skip := true 23 | 24 | mergifyExtraConditions := Seq( 25 | (Attr.Author :== "scala-steward") || 26 | (Attr.Author :== "nafg-scala-steward[bot]") 27 | ) 28 | 29 | def npmView(pkg: String, field: String)(parse: Stream[String] => String) = 30 | parse(s"npm view $pkg $field".lineStream) 31 | 32 | def latestTag(pkg: String) = npmView(pkg, "dist-tags.latest")(_.head) 33 | 34 | val npmViewVersionRegex = ".*'(.*)'".r 35 | 36 | def latestIn(pkg: String, versionMajor: Int) = 37 | npmView(s"$pkg@$versionMajor", "version")(_.last match { case npmViewVersionRegex(v) => v }) 38 | 39 | def scalaJsReactSettings(config: CssDslConfig) = Seq( 40 | libraryDependencies += "com.github.japgolly.scalajs-react" %%% "core" % "2.1.2", 41 | cssVariant := TargetImpl.ScalaJsReact, 42 | cssDslConfig := config 43 | ) 44 | 45 | def scalatagsSettings(config: CssDslConfig) = Seq( 46 | libraryDependencies += "com.lihaoyi" %%% "scalatags" % "0.13.1", 47 | cssVariant := TargetImpl.Scalatags, 48 | cssDslConfig := config 49 | ) 50 | 51 | val bootstrap3Config = 52 | CssDslConfig( 53 | "Bootstrap 3", 54 | Set(None, Some("bs"), Some("bs3")), 55 | latestIn("bootstrap", 3), 56 | "https://stackpath.bootstrapcdn.com/bootstrap/" + _ + "/css/bootstrap.min.css" 57 | ) 58 | 59 | val bootstrap4Config = 60 | CssDslConfig( 61 | "Bootstrap 4", 62 | Set(None, Some("bs"), Some("bs4")), 63 | latestIn("bootstrap", 4), 64 | "https://cdn.jsdelivr.net/npm/bootstrap@" + _ + "/dist/css/bootstrap.min.css" 65 | ) 66 | 67 | val bootstrap5Config = 68 | CssDslConfig( 69 | "Bootstrap 5", 70 | Set(None, Some("bs"), Some("bs5")), 71 | latestIn("bootstrap", 5), 72 | "https://cdn.jsdelivr.net/npm/bootstrap@" + _ + "/dist/css/bootstrap.min.css" 73 | ) 74 | 75 | val bulmaConfig = 76 | CssDslConfig( 77 | "Bulma", 78 | Set(None, Some("b")), 79 | latestTag("bulma"), 80 | "https://cdn.jsdelivr.net/npm/bulma@" + _ + "/css/bulma.min.css" 81 | ) 82 | 83 | val semanticUiConfig = 84 | CssDslConfig( 85 | "Semantic UI", 86 | Set(Some("s")), 87 | latestTag("semantic-ui"), 88 | "https://cdn.jsdelivr.net/npm/semantic-ui@" + _ + "/dist/semantic.min.css" 89 | ) 90 | 91 | val fomanticUiConfig = 92 | CssDslConfig( 93 | "Fomantic UI", 94 | Set(Some("f")), 95 | "2.9.0" /*latestTag("fomantic-ui")*/, // pin at 2.9.0 until https://github.com/phax/ph-css/issues/90 is fixed 96 | "https://cdn.jsdelivr.net/npm/fomantic-ui@" + _ + "/dist/semantic.min.css" 97 | ) 98 | 99 | val fontawesomeUiConfig = 100 | CssDslConfig( 101 | "Font Awesome", 102 | Set(None, Some("fa")), 103 | latestTag("@fortawesome/fontawesome-free"), 104 | "https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@" + _ + "/css/all.min.css" 105 | ) 106 | 107 | lazy val bootstrap3_scalajsreact = 108 | project.enablePlugins(ScalaJSPlugin, GeneratorPlugin).settings(scalaJsReactSettings(bootstrap3Config)) 109 | lazy val bootstrap3_scalatags = 110 | project.enablePlugins(GeneratorPlugin).settings(scalatagsSettings(bootstrap3Config)) 111 | 112 | lazy val bootstrap4_scalajsreact = 113 | project.enablePlugins(ScalaJSPlugin, GeneratorPlugin).settings(scalaJsReactSettings(bootstrap4Config)) 114 | lazy val bootstrap4_scalatags = 115 | project.enablePlugins(GeneratorPlugin).settings(scalatagsSettings(bootstrap4Config)) 116 | 117 | lazy val bootstrap5_scalajsreact = 118 | project.enablePlugins(ScalaJSPlugin, GeneratorPlugin).settings(scalaJsReactSettings(bootstrap5Config)) 119 | lazy val bootstrap5_scalatags = 120 | project.enablePlugins(GeneratorPlugin).settings(scalatagsSettings(bootstrap5Config)) 121 | 122 | lazy val bulma_scalajsreact = 123 | project.enablePlugins(ScalaJSPlugin, GeneratorPlugin).settings(scalaJsReactSettings(bulmaConfig)) 124 | lazy val bulma_scalatags = 125 | project.enablePlugins(GeneratorPlugin).settings(scalatagsSettings(bulmaConfig)) 126 | 127 | lazy val semanticui_scalajsreact = 128 | project.enablePlugins(ScalaJSPlugin, GeneratorPlugin).settings(scalaJsReactSettings(semanticUiConfig)) 129 | lazy val semanticui_scalatags = 130 | project.enablePlugins(GeneratorPlugin).settings(scalatagsSettings(semanticUiConfig)) 131 | 132 | lazy val fomanticui_scalajsreact = 133 | project.enablePlugins(ScalaJSPlugin, GeneratorPlugin).settings(scalaJsReactSettings(fomanticUiConfig)) 134 | lazy val fomanticui_scalatags = 135 | project.enablePlugins(GeneratorPlugin).settings(scalatagsSettings(fomanticUiConfig)) 136 | 137 | lazy val fontawesome_scalajsreact = 138 | project.enablePlugins(ScalaJSPlugin, GeneratorPlugin).settings(scalaJsReactSettings(fontawesomeUiConfig)) 139 | lazy val fontawesome_scalatags = 140 | project.enablePlugins(GeneratorPlugin).settings(scalatagsSettings(fontawesomeUiConfig)) 141 | -------------------------------------------------------------------------------- /ci.sbt: -------------------------------------------------------------------------------- 1 | inThisBuild(List( 2 | homepage := Some(url("https://github.com/nafg/css-dsl")), 3 | licenses := List("Apache-2.0" -> url("https://www.apache.org/licenses/LICENSE-2.0")), 4 | developers := List( 5 | Developer("nafg", "Naftoli Gugenheim", "98384+nafg@users.noreply.github.com", url("https://github.com/nafg")) 6 | ), 7 | dynverGitDescribeOutput ~= (_.map(o => o.copy(dirtySuffix = sbtdynver.GitDirtySuffix("")))), 8 | dynverSonatypeSnapshots := true, 9 | githubWorkflowScalaVersions := githubWorkflowScalaVersions.value.map(_.replaceFirst("\\d+$", "x")), 10 | githubWorkflowTargetTags ++= Seq("v*"), 11 | githubWorkflowPublishTargetBranches := Seq(RefPredicate.StartsWith(Ref.Tag("v"))), 12 | githubWorkflowJavaVersions := Seq(JavaSpec.temurin("11")), 13 | githubWorkflowPublish := Seq( 14 | WorkflowStep.Sbt( 15 | List("ci-release"), 16 | env = Map( 17 | "PGP_PASSPHRASE" -> "${{ secrets.PGP_PASSPHRASE }}", 18 | "PGP_SECRET" -> "${{ secrets.PGP_SECRET }}", 19 | "SONATYPE_PASSWORD" -> "${{ secrets.SONATYPE_PASSWORD }}", 20 | "SONATYPE_USERNAME" -> "${{ secrets.SONATYPE_USERNAME }}" 21 | ) 22 | ) 23 | ) 24 | )) 25 | -------------------------------------------------------------------------------- /generateInstructions.sbt: -------------------------------------------------------------------------------- 1 | val generateInstallInstructions = taskKey[Unit]("Generate instructions in README.md") 2 | 3 | generateInstallInstructions := { 4 | val info = 5 | Def.task((projectID.value, cssDslConfig.value, (publish / skip).value)) 6 | .all(ScopeFilter(inAnyProject -- inProjects(ThisProject))).value 7 | .collect { case (id, cfg, false) => 8 | (id, 9 | id.name.split('_') match { 10 | case Array(p, s) => (p, s) 11 | }, 12 | cfg) 13 | } 14 | .sortBy(_._2)(Ordering.Tuple2(Ordering.String, Ordering.String.reverse)) 15 | 16 | def mdTable(head: Seq[String], rows: Seq[Seq[String]]) = { 17 | val widths = 18 | (head.map(_.length) +: rows.map(_.map(_.length))).transpose.map(_.max) 19 | .zipWithIndex.map(_.swap).toMap.withDefaultValue(0) 20 | def row(xs: Seq[String]) = 21 | xs.zipWithIndex.map { case (s, i) => s.padTo(widths(i), ' ') }.mkString("| ", " | ", " |\n") 22 | row(head) + 23 | widths.values.map("-" * _).mkString("|-", "-|-", "-|\n") + 24 | rows.map(row).mkString 25 | } 26 | 27 | def markerText(side: String) = s"" 28 | 29 | val artifactsTable = mdTable(List("CSS library", "Scala DOM library", "SBT Module ID"), info.map { 30 | case (moduleId, (_, moduleNameSuffix), config) => 31 | val (op, library) = moduleNameSuffix match { 32 | case "scalatags" => "%%" -> "`scalatags.Text` (JVM)" 33 | case "scalajsreact" => "%%%" -> "scalajs-react (scala.js)" 34 | } 35 | List( 36 | config.name, 37 | library, 38 | s"""`"${moduleId.organization}" $op "${moduleId.name}" % "${moduleId.revision}"`""" 39 | ) 40 | }) 41 | 42 | val importsTable = 43 | mdTable( 44 | List("CSS library", "Prefix", "Import"), 45 | info.flatMap { case (_, _, cfg) => 46 | cfg.prefixes.map { p => 47 | List( 48 | cfg.name, 49 | p.fold("None")("`" + _ + "`"), 50 | "`import cssdsl." + cfg.scalaPackage + "." + p.fold("")(_.capitalize) + "Dsl._`" 51 | ) 52 | } 53 | }.distinct 54 | ) 55 | 56 | val block = 57 | s""">${markerText("Begin")} 58 | > 59 | >#### Artifact 60 | > 61 | >$artifactsTable 62 | > 63 | >### Import 64 | > 65 | >$importsTable 66 | > 67 | >${markerText("End")} 68 | >""".stripMargin('>') 69 | 70 | val readmeFile = baseDirectory.value / "README.md" 71 | val currentReadme = IO.readLines(readmeFile) 72 | val (before, rest) = currentReadme.span(_.trim != markerText("Begin")) 73 | val after = rest.reverse.takeWhile(_.trim != markerText("End")).reverse 74 | val newReadme = before.map(_ + "\n").mkString + block + after.map(_ + "\n").mkString 75 | IO.write(readmeFile, newReadme) 76 | } 77 | -------------------------------------------------------------------------------- /project/CssExtractor.scala: -------------------------------------------------------------------------------- 1 | import java.net.URL 2 | 3 | import scala.collection.JavaConverters.* 4 | import scala.collection.immutable.SortedSet 5 | 6 | import com.helger.commons.collection.impl.ICommonsList 7 | import com.helger.commons.io.IHasReader 8 | import com.helger.css.decl.* 9 | import com.helger.css.reader.{CSSReader, CSSReaderSettings} 10 | 11 | 12 | object CssExtractor { 13 | private val hex = (('0' to '9') ++ ('a' to 'f') ++ ('A' to 'F')).toSet 14 | 15 | private object Unicode { 16 | def unapply(chars: List[Char]): Option[(Char, List[Char])] = { 17 | val (first6, further) = chars.splitAt(6) 18 | val (digits, beyond) = first6.span(hex) 19 | if (digits.isEmpty) 20 | None 21 | else { 22 | val c = Integer.parseInt(digits.mkString, 16).toChar 23 | val rest = beyond ++ further 24 | val more = 25 | rest match { 26 | case '\r' :: '\n' :: more => more 27 | case w :: more if w.isWhitespace => more 28 | case more => more 29 | } 30 | Some((c, more)) 31 | } 32 | } 33 | } 34 | //\\[^\r\n\f0-9a-f] 35 | val unesapable = Set('\r', '\n', '\f') ++ ((0 to 9).map(_.toChar) ++ ('a' to 'f') ++ ('A' to 'F')) 36 | 37 | def unescape(s: String) = { 38 | def loop(ch: List[Char]): List[Char] = ch match { 39 | case Nil => Nil 40 | case '\\' :: Unicode(c, beyond) => c :: loop(beyond) 41 | case '\\' :: c :: rest if !unesapable(c) => c :: loop(rest) 42 | case c :: rest => c :: loop(rest) 43 | } 44 | 45 | loop(s.toList).mkString 46 | } 47 | 48 | def unquote(s: String) = s.stripPrefix("\"").stripSuffix("\"") 49 | 50 | def getClassesFromSelectors(sels: ICommonsList[CSSSelector]): Iterator[String] = 51 | sels.iterator().asScala.flatMap(_.getAllMembers.iterator().asScala) 52 | .flatMap { 53 | case n: CSSSelectorMemberNot => getClassesFromSelectors(n.getAllSelectors) 54 | case s: CSSSelectorSimpleMember if s.isClass => Iterator(unescape(s.getValue.stripPrefix("."))) 55 | case a: CSSSelectorAttribute if a.getAttrName == "class" => 56 | val attrValue = a.getAttrValue 57 | if (attrValue == null) Iterator.empty 58 | else { 59 | val v = unquote(attrValue) 60 | if (v.endsWith("-")) Iterator.empty else Iterator(v) 61 | } 62 | case _ => Iterator.empty 63 | } 64 | 65 | def getClassesFromRules(rules: ICommonsList[ICSSTopLevelRule]): Iterator[String] = 66 | rules.iterator().asScala.flatMap { 67 | case r: CSSStyleRule => getClassesFromSelectors(r.getAllSelectors) 68 | case m: CSSMediaRule => getClassesFromRules(m.getAllRules) 69 | case _ => Iterator.empty 70 | } 71 | 72 | def getClassesFromSheet(sheet: CascadingStyleSheet): Iterator[String] = 73 | getClassesFromRules(sheet.getAllRules) 74 | 75 | def getClassesFromURL(url: URL): SortedSet[String] = { 76 | val reader: IHasReader = () => new java.io.InputStreamReader(url.openStream()) 77 | val sheet = CSSReader.readFromReader(reader, new CSSReaderSettings()) 78 | SortedSet(getClassesFromSheet(sheet).toSeq *) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /project/Generator.scala: -------------------------------------------------------------------------------- 1 | import scala.collection.immutable.SortedSet 2 | import scala.meta._ 3 | 4 | 5 | class Generator(packageName: String, 6 | moduleName: String, 7 | prefix: Option[String], 8 | classes: SortedSet[String], 9 | variant: TargetImpl) { 10 | private def camelize(s: String) = { 11 | val s2 = s.split(Array('-', ' ')).map(_.capitalize).mkString 12 | s2.take(1).toLowerCase + s2.drop(1) 13 | } 14 | 15 | private def ident(cls: String) = { 16 | val camelized = camelize(cls) 17 | prefix.fold(camelized)(_ + camelized.capitalize) 18 | } 19 | 20 | def defs: List[Stat] = 21 | classes.toList.map { cls => 22 | Defn.Val(List(Mod.Lazy()), List(Pat.Var(Term.Name(ident(cls)))), None, q"this.op($cls)") 23 | } 24 | 25 | def allDefs = defs :+ q"protected def op(clz: String): A" 26 | 27 | def tree: Tree = 28 | q""" 29 | package cssdsl.${Term.Name(packageName)} { 30 | 31 | import scala.language.implicitConversions 32 | ..${variant.imports} 33 | 34 | object ${Term.Name(moduleName)} { 35 | trait Classes[A] { 36 | ..$allDefs 37 | } 38 | 39 | ${variant.C} 40 | 41 | ${variant.implicitClass} 42 | } 43 | } 44 | """ 45 | 46 | def apply(): String = tree.syntax + "\n" 47 | } 48 | -------------------------------------------------------------------------------- /project/GeneratorPlugin.scala: -------------------------------------------------------------------------------- 1 | import sbt.Keys._ 2 | import sbt._ 3 | 4 | 5 | object GeneratorPlugin extends AutoPlugin { 6 | object autoImport { 7 | case class CssDslConfig(name: String, 8 | prefixes: Set[Option[String]], 9 | version: String, 10 | versionedUrl: String => String) { 11 | def scalaPackage = name.toLowerCase.filter(Character.isJavaIdentifierPart) 12 | } 13 | val cssDslConfig = settingKey[CssDslConfig]("The settings for generating the CSS DSL") 14 | val cssVariant = settingKey[TargetImpl]("The target") 15 | val cssGen = taskKey[Seq[File]]("Generate the DSL") 16 | } 17 | 18 | import autoImport._ 19 | 20 | 21 | override def projectSettings = Seq( 22 | cssGen := { 23 | val cfg = cssDslConfig.value 24 | val variant = cssVariant.value 25 | val outputDir = (Compile / sourceManaged).value 26 | val url = cfg.versionedUrl(cfg.version) 27 | streams.value.log.info(s"Processing $url...") 28 | val classes = CssExtractor.getClassesFromURL(new URL(url)) 29 | for (prefix <- cfg.prefixes.toSeq) yield { 30 | val name = prefix.getOrElse("").capitalize + "Dsl" 31 | val generator = new Generator(cfg.scalaPackage, name, prefix, classes, variant) 32 | val file = outputDir / "cssdsl" / cfg.scalaPackage.replace('.', '/') / s"$name.scala" 33 | IO.write(file, generator()) 34 | file 35 | } 36 | }, 37 | Compile / sourceGenerators += cssGen 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /project/TargetImpl.scala: -------------------------------------------------------------------------------- 1 | import scala.meta._ 2 | 3 | 4 | sealed trait TargetImpl { 5 | def imports: List[Import] 6 | def C: Defn.Object 7 | def implicitClass: Defn.Class 8 | } 9 | object TargetImpl { 10 | object ScalaJsReact extends TargetImpl { 11 | override def imports = List( 12 | q"import japgolly.scalajs.react.vdom.html_<^._", 13 | q"import japgolly.scalajs.react.vdom.{TagOf, TopNode}" 14 | ) 15 | 16 | override def C = 17 | q""" 18 | object C extends Classes[TagMod] { 19 | protected override def op(clz: String) = ^.cls := clz 20 | } 21 | """ 22 | 23 | override def implicitClass = 24 | q""" 25 | implicit class ConvertableToTagOfExtensionMethods[T, N <: TopNode](self: T)(implicit toTagOf: T => TagOf[N]) 26 | extends Classes[TagOf[N]] { 27 | protected override def op(clz: String): TagOf[N] = toTagOf(self).apply(^.cls := clz) 28 | } 29 | """ 30 | } 31 | 32 | object Scalatags extends TargetImpl { 33 | override def imports = List( 34 | q"import scalatags.Text.all._" 35 | ) 36 | 37 | override def C = 38 | q""" 39 | object C extends Classes[Modifier] { 40 | protected override def op(clz: String) = cls := clz 41 | } 42 | """ 43 | 44 | override def implicitClass = 45 | q""" 46 | implicit class ConvertableToTagOfExtensionMethods[O <: String](self: ConcreteHtmlTag[O]) 47 | extends Classes[ConcreteHtmlTag[O]] { 48 | protected override def op(clz: String): ConcreteHtmlTag[O] = 49 | self(cls := clz) 50 | } 51 | """ 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version = 1.11.2 2 | -------------------------------------------------------------------------------- /project/build.sbt: -------------------------------------------------------------------------------- 1 | libraryDependencies += "org.scalameta" %% "scalameta" % "4.13.6" 2 | libraryDependencies += "com.helger" % "ph-css" % "7.0.4" 3 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.19.0") 2 | addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.21.1") 3 | addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.11.1") 4 | addSbtPlugin("io.github.nafg.mergify" % "sbt-mergify-github-actions" % "0.9.0") 5 | libraryDependencies += "io.github.nafg.scalac-options" %% "scalac-options" % "0.3.0" 6 | --------------------------------------------------------------------------------