├── project ├── build.properties ├── build.sbt ├── plugins.sbt ├── Generator.scala ├── GeneratorPlugin.scala ├── TargetImpl.scala └── CssExtractor.scala ├── .gitignore ├── .mergify.yml ├── ci.sbt ├── .github ├── workflows │ ├── clean.yml │ └── ci.yml ├── copilot │ └── environment.yml └── copilot-instructions.md ├── generateInstructions.sbt └── README.md /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version = 1.11.7 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | target/ 3 | /.bsp/ 4 | /.metals/ 5 | /.bloop/ 6 | sbt/ 7 | -------------------------------------------------------------------------------- /project/build.sbt: -------------------------------------------------------------------------------- 1 | libraryDependencies += "org.scalameta" %% "scalameta" % "4.14.2" 2 | libraryDependencies += "com.helger" % "ph-css" % "8.1.1" 3 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.20.1") 2 | addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.21.1") 3 | addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.11.2") 4 | addSbtPlugin("io.github.nafg.mergify" % "sbt-mergify-github-actions" % "0.9.0") 5 | libraryDependencies += "io.github.nafg.scalac-options" %% "scalac-options" % "0.4.0" 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@17) 12 | - check-success=Build and Test (ubuntu-latest, 3.3.x, temurin@17) 13 | actions: 14 | queue: 15 | name: default 16 | -------------------------------------------------------------------------------- /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 | githubWorkflowEnv += ("SBT_OPTS" -> "-Xmx2g"), 10 | githubWorkflowScalaVersions := githubWorkflowScalaVersions.value.map(_.replaceFirst("\\d+$", "x")), 11 | githubWorkflowTargetTags ++= Seq("v*"), 12 | githubWorkflowPublishTargetBranches := Seq(RefPredicate.StartsWith(Ref.Tag("v"))), 13 | githubWorkflowJavaVersions := Seq(JavaSpec.temurin("17")), 14 | githubWorkflowPublish := Seq( 15 | WorkflowStep.Sbt( 16 | List("ci-release"), 17 | env = Map( 18 | "PGP_PASSPHRASE" -> "${{ secrets.PGP_PASSPHRASE }}", 19 | "PGP_SECRET" -> "${{ secrets.PGP_SECRET }}", 20 | "SONATYPE_PASSWORD" -> "${{ secrets.SONATYPE_PASSWORD }}", 21 | "SONATYPE_USERNAME" -> "${{ secrets.SONATYPE_USERNAME }}" 22 | ) 23 | ) 24 | ) 25 | )) 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/copilot/environment.yml: -------------------------------------------------------------------------------- 1 | # GitHub Copilot Agent Environment Configuration 2 | # Based on the existing CI workflow to ensure consistent development environment 3 | 4 | name: CSS DSL Development Environment 5 | 6 | # Use the same OS as CI workflow 7 | os: ubuntu-latest 8 | 9 | # Environment variables matching CI setup 10 | env: 11 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 12 | 13 | # Steps to set up the environment, mirroring the CI workflow 14 | setup: 15 | # Setup Java (temurin@11) - matches CI exactly 16 | - name: Setup Java (temurin@11) 17 | uses: actions/setup-java@v4 18 | with: 19 | distribution: temurin 20 | java-version: 11 21 | cache: sbt 22 | 23 | # Setup SBT - matches CI exactly 24 | - name: Setup sbt 25 | uses: sbt/setup-sbt@v1 26 | 27 | # Ensure network access to required CDNs for CSS download 28 | # These URLs are allowlisted for this repository 29 | - name: Verify CDN access 30 | run: | 31 | echo "Verifying access to required CDNs..." 32 | curl -I --connect-timeout 10 https://cdn.jsdelivr.net/ 33 | curl -I --connect-timeout 10 https://stackpath.bootstrapcdn.com/ 34 | 35 | # Working directory setup 36 | working_directory: /home/runner/work/css-dsl/css-dsl 37 | 38 | # Scala versions supported (matching CI matrix) 39 | scala_versions: 40 | - 2.13.x 41 | - 3.3.x 42 | 43 | # Java versions supported (matching CI matrix) 44 | java_versions: 45 | - temurin@11 46 | 47 | # Essential commands for validation 48 | validation_commands: 49 | # Check SBT is working 50 | - name: Verify SBT installation 51 | run: sbt --version 52 | 53 | # Verify project can be loaded 54 | - name: Load SBT project 55 | run: sbt projects 56 | timeout: 30 57 | 58 | # Basic compilation test (with network access) 59 | - name: Test compilation 60 | run: sbt compile 61 | timeout: 180 62 | 63 | # Network requirements documentation 64 | network: 65 | required: true 66 | description: "Project requires internet access to download CSS files from CDNs during build" 67 | allowlisted_domains: 68 | - cdn.jsdelivr.net 69 | - stackpath.bootstrapcdn.com 70 | failure_example: "java.net.UnknownHostException: cdn.jsdelivr.net" 71 | 72 | # Project-specific information 73 | project_info: 74 | type: "Scala code generator" 75 | build_tool: "SBT 1.11.3" 76 | target_frameworks: 77 | - "Bootstrap 3/4/5" 78 | - "Bulma" 79 | - "Semantic UI" 80 | - "Fomantic UI" 81 | - "Font Awesome" 82 | generated_projects: 14 83 | no_src_directories: true 84 | description: "Generates type-safe DSL wrappers for CSS frameworks" -------------------------------------------------------------------------------- /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.base.io.iface.IHasReader 7 | import com.helger.collection.commons.ICommonsList 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 | -------------------------------------------------------------------------------- /.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 | SBT_OPTS: '-Xmx2g' 20 | 21 | jobs: 22 | build: 23 | name: Build and Test 24 | strategy: 25 | matrix: 26 | os: [ubuntu-latest] 27 | scala: [2.13.x, 3.3.x] 28 | java: [temurin@17] 29 | runs-on: ${{ matrix.os }} 30 | steps: 31 | - name: Checkout current branch (full) 32 | uses: actions/checkout@v4 33 | with: 34 | fetch-depth: 0 35 | 36 | - name: Setup Java (temurin@17) 37 | if: matrix.java == 'temurin@17' 38 | uses: actions/setup-java@v4 39 | with: 40 | distribution: temurin 41 | java-version: 17 42 | cache: sbt 43 | 44 | - name: Setup sbt 45 | uses: sbt/setup-sbt@v1 46 | 47 | - name: Check that workflows are up to date 48 | run: sbt '++ ${{ matrix.scala }}' githubWorkflowCheck 49 | 50 | - name: Build project 51 | run: sbt '++ ${{ matrix.scala }}' test 52 | 53 | - name: Compress target directories 54 | 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 55 | 56 | - name: Upload target directories 57 | uses: actions/upload-artifact@v4 58 | with: 59 | name: target-${{ matrix.os }}-${{ matrix.scala }}-${{ matrix.java }} 60 | path: targets.tar 61 | 62 | publish: 63 | name: Publish Artifacts 64 | needs: [build] 65 | if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) 66 | strategy: 67 | matrix: 68 | os: [ubuntu-latest] 69 | scala: [3.3.7] 70 | java: [temurin@17] 71 | runs-on: ${{ matrix.os }} 72 | steps: 73 | - name: Checkout current branch (full) 74 | uses: actions/checkout@v4 75 | with: 76 | fetch-depth: 0 77 | 78 | - name: Setup Java (temurin@17) 79 | if: matrix.java == 'temurin@17' 80 | uses: actions/setup-java@v4 81 | with: 82 | distribution: temurin 83 | java-version: 17 84 | cache: sbt 85 | 86 | - name: Setup sbt 87 | uses: sbt/setup-sbt@v1 88 | 89 | - name: Download target directories (2.13.x) 90 | uses: actions/download-artifact@v4 91 | with: 92 | name: target-${{ matrix.os }}-2.13.x-${{ matrix.java }} 93 | 94 | - name: Inflate target directories (2.13.x) 95 | run: | 96 | tar xf targets.tar 97 | rm targets.tar 98 | 99 | - name: Download target directories (3.3.x) 100 | uses: actions/download-artifact@v4 101 | with: 102 | name: target-${{ matrix.os }}-3.3.x-${{ matrix.java }} 103 | 104 | - name: Inflate target directories (3.3.x) 105 | run: | 106 | tar xf targets.tar 107 | rm targets.tar 108 | 109 | - env: 110 | PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} 111 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 112 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 113 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 114 | run: sbt ci-release 115 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # CSS DSL Generator 2 | 3 | CSS DSL is a Scala code generator that creates type-safe DSL wrappers for CSS frameworks (Bootstrap, Bulma, Semantic UI, Font Awesome, etc.). It downloads CSS files from CDNs, parses them to extract class names, and generates Scala code for both JVM (scalatags) and Scala.js (scalajs-react) targets. 4 | 5 | Always reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here. 6 | 7 | ## Environment Configuration 8 | 9 | This repository includes a GitHub Copilot agent environment configuration (`.github/copilot/environment.yml`) that mirrors the CI workflow setup. The environment configuration ensures consistent development conditions with: 10 | 11 | - Ubuntu latest OS matching CI 12 | - Java 11 (temurin distribution) with SBT caching 13 | - SBT 1.11.3 setup identical to CI workflow 14 | - Verified CDN access to cdn.jsdelivr.net and stackpath.bootstrapcdn.com 15 | - Network validation commands for CSS framework downloads 16 | - Support for Scala 2.13.x and 3.3.x cross-compilation 17 | 18 | The environment setup replicates the exact CI conditions to ensure reliable code generation and testing. 19 | 20 | ## Working Effectively 21 | 22 | ### Prerequisites and Setup 23 | - Install Java 11+ (project tested with Java 11, works with Java 17+) 24 | - Install SBT 1.11.3: 25 | Follow the official SBT installation instructions for your platform: 26 | https://www.scala-sbt.org/download.html 27 | 28 | For Linux/macOS, you can use the installer script which verifies the download: 29 | ```bash 30 | curl -s https://raw.githubusercontent.com/sbt/sbt/develop/bin/install-sbt | bash 31 | export PATH="$HOME/bin:$PATH" 32 | sbt --version 33 | 34 | ### Network Requirements 35 | - **CRITICAL**: This project requires internet access to download CSS files from CDNs during build 36 | - Build downloads CSS from: cdn.jsdelivr.net, stackpath.bootstrapcdn.com 37 | - **IMPORTANT**: CDN URLs are allowlisted in GitHub Copilot environments for this repository 38 | - Build fails in environments without external network access with `java.net.UnknownHostException` 39 | - If network access is unavailable, code generation cannot proceed 40 | 41 | ### Building the Project 42 | - **NETWORK REQUIRED**: All build commands require internet access to CDNs 43 | - **ALLOWLISTED**: CDN access is configured for GitHub Copilot environments in this repository 44 | - Basic compilation: 45 | ```bash 46 | sbt compile 47 | ``` 48 | - **TIMING**: Compilation takes 15-30 seconds when network is available 49 | - **TIMING**: Initial SBT startup takes 10-15 seconds for dependency resolution 50 | - Build failure example when network unavailable: 51 | ``` 52 | [error] java.net.UnknownHostException: cdn.jsdelivr.net 53 | ``` 54 | 55 | ### Testing 56 | - Run tests (compilation tests): 57 | ```bash 58 | sbt test 59 | ``` 60 | - **TIMING**: Test suite takes 30-60 seconds. NEVER CANCEL. Set timeout to 90+ seconds. 61 | - **CRITICAL**: Tests are primarily compilation tests - if generated code compiles, tests pass 62 | 63 | ### Project Structure 64 | - **No src/ directories**: This is a generator project that creates subprojects dynamically 65 | - Generated subprojects (visible after successful build): 66 | - `bootstrap3_scalatags`, `bootstrap3_scalajsreact` 67 | - `bootstrap4_scalatags`, `bootstrap4_scalajsreact` 68 | - `bootstrap5_scalatags`, `bootstrap5_scalajsreact` 69 | - `bulma_scalatags`, `bulma_scalajsreact` 70 | - `semanticui_scalatags`, `semanticui_scalajsreact` 71 | - `fomanticui_scalatags`, `fomanticui_scalajsreact` 72 | - `fontawesome_scalatags`, `fontawesome_scalajsreact` 73 | 74 | ### Working with Subprojects 75 | - List all projects: 76 | ```bash 77 | sbt projects 78 | ``` 79 | - Switch to specific project: 80 | ```bash 81 | sbt 'project bootstrap4_scalatags' 82 | ``` 83 | - Check project dependencies: 84 | ```bash 85 | sbt 'project bootstrap4_scalatags' 'show libraryDependencies' 86 | ``` 87 | 88 | ### Cross-Compilation 89 | - Supported Scala versions: 2.13.16, 3.3.6 (default) 90 | - Cross-compile for all Scala versions: 91 | ```bash 92 | sbt +compile 93 | sbt +test 94 | ``` 95 | 96 | ## Limitations and Workarounds 97 | 98 | ### Network Access Limitation 99 | - **NETWORK DEPENDENCY**: Project requires external internet access to download CSS files from CDNs 100 | - **ALLOWLISTED**: CDN URLs (cdn.jsdelivr.net, stackpath.bootstrapcdn.com) are allowlisted for GitHub Copilot environments 101 | - **NO OFFLINE MODE**: Project has no offline fallback - CSS files must be downloaded fresh 102 | - Code generation happens at compile time, not runtime 103 | 104 | ### Generated Code Location 105 | - Generated sources are in: `[project]/target/scala-[version]/src_managed/main/cssdsl/[framework]/` 106 | - Target directories are created only after successful build 107 | - Clean removes all generated code: `sbt clean` 108 | 109 | ## Validation 110 | - **NETWORK CONNECTIVITY**: Verify network access to CDNs for builds (allowlisted in GitHub Copilot environments) 111 | - Test basic SBT functionality first: `sbt projects` 112 | - **Build validation requires network access** - cannot validate build offline 113 | - Generated DSL provides type-safe CSS class names as Scala methods 114 | - Example validation: 115 | ```bash 116 | sbt 'project bootstrap4_scalatags' compile 117 | ``` 118 | 119 | ## Common Tasks 120 | 121 | ### Development Workflow (with network access) 122 | 1. Clean previous build: `sbt clean` 123 | 2. Generate and compile all frameworks: `sbt compile` 124 | 3. Test compilation: `sbt test` 125 | 4. Work with specific framework: `sbt 'project bootstrap4_scalatags'` 126 | 127 | ### Updating Framework Versions 128 | - CSS framework versions are specified in build.sbt 129 | - Latest versions are fetched from npm automatically 130 | - Regeneration requires: `sbt clean compile` 131 | 132 | ### Custom Tasks 133 | - Update README.md tables: `sbt generateInstallInstructions` 134 | - Check GitHub workflow: `sbt githubWorkflowCheck` 135 | 136 | ### Troubleshooting 137 | rm -f ~/.sbt/boot/sbt.boot.lock 138 | ``` 139 | - **SBT startup time**: Initial SBT commands can take 10-15 seconds for dependency resolution 140 | - **Network timeouts**: If CSS downloads timeout, retry the build command 141 | 142 | ## Repository Structure 143 | ``` 144 | . 145 | ├── README.md # Main documentation 146 | ├── build.sbt # Main build configuration 147 | ├── ci.sbt # CI/CD configuration 148 | ├── generateInstructions.sbt # README generation task 149 | ├── project/ 150 | │ ├── build.properties # SBT version (1.11.3) 151 | │ ├── plugins.sbt # SBT plugins 152 | │ ├── GeneratorPlugin.scala # Code generation plugin 153 | │ ├── Generator.scala # DSL code generator 154 | │ ├── TargetImpl.scala # Target library implementations 155 | │ └── CssExtractor.scala # CSS parsing logic 156 | └── .github/workflows/ci.yml # GitHub Actions CI 157 | ``` 158 | 159 | ## Framework Support 160 | | Framework | Versions | JVM (scalatags) | Scala.js (scalajs-react) | 161 | |--------------|----------|-----------------|---------------------------| 162 | | Bootstrap 3 | 3.4.1 | ✓ | ✓ | 163 | | Bootstrap 4 | 4.6.2 | ✓ | ✓ | 164 | | Bootstrap 5 | 5.3.8 | ✓ | ✓ | 165 | | Bulma | 1.0.4 | ✓ | ✓ | 166 | | Semantic UI | 2.5.0 | ✓ | ✓ | 167 | | Fomantic UI | 2.9.0 | ✓ | ✓ | 168 | | Font Awesome | 7.0.0 | ✓ | ✓ | 169 | 170 | ## Dependencies 171 | - Scala 3.3.6 (primary), 2.13.16 (cross-compile) 172 | - SBT 1.11.3 173 | - scalatags 0.13.1 (JVM target) 174 | - scalajs-react 2.1.2 (Scala.js target) 175 | - ph-css (CSS parsing) 176 | - Scalameta (code generation) --------------------------------------------------------------------------------