diff --git a/.changeset/odd-rockets-clap.md b/.changeset/odd-rockets-clap.md new file mode 100644 index 0000000..335601d --- /dev/null +++ b/.changeset/odd-rockets-clap.md @@ -0,0 +1,5 @@ +--- +"scalawind": minor +--- + +add class optimization for padding and margin utilities diff --git a/integration/package.json b/integration/package.json index 6df85de..aa499e3 100644 --- a/integration/package.json +++ b/integration/package.json @@ -2,7 +2,7 @@ "name": "integration-test", "type": "module", "scripts": { - "generate": "scalawind generate -pcr -o ./src/scalawind.scala", + "generate": "scalawind generate --classes-validation -pcr -o ./src/scalawind.scala", "test:scala": "scala-cli test src --test-only 'tests.only*'" }, "dependencies": { diff --git a/integration/src/scalawind.test.scala b/integration/src/scalawind.test.scala index 29efa15..59f69e7 100644 --- a/integration/src/scalawind.test.scala +++ b/integration/src/scalawind.test.scala @@ -279,3 +279,53 @@ class CreateNextAppHomePageTests extends munit.FunSuite { ) } } + +class ClassesValidationTests extends munit.FunSuite { + test("duplication") { + assertNoDiff( + compileErrors("sw(tw.flex.flex)"), + """|error: [Duplication] flex + | compileErrors("sw(tw.flex.flex)"), + | ^ + |""".stripMargin + ) + + assertNoDiff( + compileErrors("sw(tw.flex.hover(tw.items_center.items_center))"), + """|error: [Duplication] hover:items-center + | compileErrors("sw(tw.flex.hover(tw.items_center.items_center))"), + | ^ + |""".stripMargin + ) + } + + test("optimization one-direction class to two-directions class") { + assertNoDiff( + compileErrors("sw(tw.mr_2.ml_2)"), + """|error: [Optimization] Use mx-2 instead of ml-2 and mr-2 + | compileErrors("sw(tw.mr_2.ml_2)"), + | ^ + |""".stripMargin + ) + } + + test("optimization two-directions class to four-directions class") { + assertNoDiff( + compileErrors("sw(tw.mx_2.my_2)"), + """|error: [Optimization] Use m-2 instead of mx-2 and my-2 + | compileErrors("sw(tw.mx_2.my_2)"), + | ^ + |""".stripMargin + ) + } + + test("optimization one-direction class to four-directions class") { + assertNoDiff( + compileErrors("sw(tw.ml_2.mr_2.mt_2.mb_2)"), + """|error: [Optimization] Use m-2 instead of mt-2, mb-2, ml-2 and mr-2 + | compileErrors("sw(tw.ml_2.mr_2.mt_2.mb_2)"), + | ^ + |""".stripMargin + ) + } +} diff --git a/packages/scalawind/src/commands/generate.js b/packages/scalawind/src/commands/generate.js index 93cc989..f33ca4b 100644 --- a/packages/scalawind/src/commands/generate.js +++ b/packages/scalawind/src/commands/generate.js @@ -16,6 +16,7 @@ const initOptionsSchema = z.object({ previewCompliedResult: z.boolean(), laminar: z.boolean(), scalajsReact: z.boolean(), + classesValidation: z.boolean() }) export const generate = new Command() @@ -36,6 +37,11 @@ export const generate = new Command() "enable show preview compiled result", false ) + .option( + "-cv, --classes-validation", + "enable classes validation", + false + ) .option( "-l, --laminar", "generate some helper methods for using with Laminar", diff --git a/packages/scalawind/src/generate/index.js b/packages/scalawind/src/generate/index.js index 6f2f837..fe252a2 100644 --- a/packages/scalawind/src/generate/index.js +++ b/packages/scalawind/src/generate/index.js @@ -15,6 +15,7 @@ Handlebars.registerPartial({ tailwind: Handlebars.compile(readTemplate('tailwind')), laminar: Handlebars.compile(readTemplate('laminar')), scalajsReact: Handlebars.compile(readTemplate('scalajsReact')), + classesValidation: Handlebars.compile(readTemplate('classesValidation')), }); const template = Handlebars.compile(readTemplate('scalawind')); diff --git a/packages/scalawind/src/generate/templates/classesValidation.hbs b/packages/scalawind/src/generate/templates/classesValidation.hbs new file mode 100644 index 0000000..4aab568 --- /dev/null +++ b/packages/scalawind/src/generate/templates/classesValidation.hbs @@ -0,0 +1,68 @@ +{{#if classesValidation}} +def validate(classes: List[String]): Unit = { + checkDuplication(classes) + val optimizationSuggestions = checkOptimization(classes) + if optimizationSuggestions.nonEmpty then + report.errorAndAbort(s"[Optimization] ${optimizationSuggestions.mkString(", ")}") +} + +def checkDuplication(classes: List[String]): Unit = { + val duplicates = classes.groupBy(identity).collect { case (x, List(_, _, _*)) => x } + if duplicates.nonEmpty then + report.errorAndAbort(s"[Duplication] ${duplicates.mkString(", ")}") +} + +def checkOptimization(classes: List[String]): List[String] = { + val properties = List("p", "m") + + val suggestions = properties.flatMap { property => + val propertySuggestions = scala.collection.mutable.ListBuffer.empty[String] + + val classMap = classes.map { + case c if c.startsWith(s"${property}t-") => s"${property}t" -> c + case c if c.startsWith(s"${property}b-") => s"${property}b" -> c + case c if c.startsWith(s"${property}l-") => s"${property}l" -> c + case c if c.startsWith(s"${property}r-") => s"${property}r" -> c + case c if c.startsWith(s"${property}x-") => s"${property}x" -> c + case c if c.startsWith(s"${property}y-") => s"${property}y" -> c + case c if c.startsWith(s"${property}-") => property -> c + case c => c -> c + }.groupBy(_._1).view.mapValues(_.map(_._2)).toMap + + def checkAndSuggest(key1: String, key2: String, combined: String): Unit = { + (classMap.get(key1), classMap.get(key2)) match + case (Some(List(c1)), Some(List(c2))) if c1.substring(3) == c2.substring(3) => + propertySuggestions += s"Use $combined${c1.substring(3)} instead of $c1 and $c2" + case _ => () + } + + def checkFourWay(): Unit = { + (classMap.get(s"${property}t"), classMap.get(s"${property}b"), classMap.get(s"${property}l"), classMap.get(s"${property}r")) match + case (Some(List(pt)), Some(List(pb)), Some(List(pl)), Some(List(pr))) if pt.substring(3) == pb.substring(3) && pl.substring(3) == pr.substring(3) && pt.substring(3) == pl.substring(3) => + propertySuggestions += s"Use ${property}-${pt.substring(3)} instead of $pt, $pb, $pl and $pr" + case _ => () + } + + def checkPxPy(): Unit = { + (classMap.get(s"${property}x"), classMap.get(s"${property}y")) match + case (Some(List(px)), Some(List(py))) if px.substring(3) == py.substring(3) => + propertySuggestions += s"Use ${property}-${px.substring(3)} instead of $px and $py" + case _ => () + } + + // Check for four-way combination first + checkFourWay() + + // Only check for two-way combinations if no four-way combination is found + if propertySuggestions.isEmpty then + checkPxPy() + if propertySuggestions.isEmpty then + checkAndSuggest(s"${property}t", s"${property}b", s"${property}y-") + checkAndSuggest(s"${property}l", s"${property}r", s"${property}x-") + + propertySuggestions.toList + } + + suggestions +} +{{/if}} \ No newline at end of file diff --git a/packages/scalawind/src/generate/templates/laminar.hbs b/packages/scalawind/src/generate/templates/laminar.hbs index 214ea5a..4e89fc6 100644 --- a/packages/scalawind/src/generate/templates/laminar.hbs +++ b/packages/scalawind/src/generate/templates/laminar.hbs @@ -1,7 +1,8 @@ {{#if laminar}} import com.raquo.laminar.api.L.cls -object clx: +object clx { def apply(styles: String) = cls(styles) def :=(styles: String) = cls(styles) +} {{/if}} \ No newline at end of file diff --git a/packages/scalawind/src/generate/templates/scalajsReact.hbs b/packages/scalawind/src/generate/templates/scalajsReact.hbs index 8cb34bf..4605f76 100644 --- a/packages/scalawind/src/generate/templates/scalajsReact.hbs +++ b/packages/scalawind/src/generate/templates/scalajsReact.hbs @@ -1,7 +1,8 @@ {{#if scalajsReact}} import japgolly.scalajs.react.vdom.html_<^.* -object clx: +object clx { def apply(styles: String): TagMod = ^.cls := styles def :=(styles: String): TagMod = ^.cls := styles +} {{/if}} \ No newline at end of file diff --git a/packages/scalawind/src/generate/templates/swMacro.hbs b/packages/scalawind/src/generate/templates/swMacro.hbs index b21edfb..dda4bca 100644 --- a/packages/scalawind/src/generate/templates/swMacro.hbs +++ b/packages/scalawind/src/generate/templates/swMacro.hbs @@ -1,14 +1,16 @@ implicit inline def sw(inline tailwind: Tailwind): String = ${ swImpl('tailwind) } -def methodNameToTailwindClass(rawName: String) = +def methodNameToTailwindClass(rawName: String) = { val name = if rawName.startsWith("_") && rawName.charAt(1).isDigit then rawName.stripPrefix("_") else rawName name.replace("_", "-").replace("per", "/").replace("dot", ".") +} -def swImpl(tailwindExpr: Expr[Tailwind])(using Quotes): Expr[String] = +def swImpl(tailwindExpr: Expr[Tailwind])(using Quotes): Expr[String] = { import quotes.reflect.* - - def extractClassNames(term: Term, prefix: String = "", important: Boolean = false): List[String] = + {{> classesValidation this }} + + def extractClassNames(term: Term, prefix: String = "", important: Boolean = false): List[String] = { var stack = List((term, prefix, important)) var classNames = List.empty[String] @@ -18,7 +20,7 @@ def swImpl(tailwindExpr: Expr[Tailwind])(using Quotes): Expr[String] = val (currentTerm, currentPrefix, currentImportant) = stack.head stack = stack.tail - currentTerm match + currentTerm match { case Apply(Select(inner, "important"), List(styles)) => stack = (styles, currentPrefix, true) :: stack stack = (inner, currentPrefix, currentImportant) :: stack @@ -70,14 +72,19 @@ def swImpl(tailwindExpr: Expr[Tailwind])(using Quotes): Expr[String] = stack = (inner, currentPrefix, currentImportant) :: stack case unexpectedTerm => report.errorAndAbort(s"Unexpected term: $unexpectedTerm") - + } + classNames - end extractClassNames + } val term = tailwindExpr.asTerm - val classNames = extractClassNames(term) - val combinedClasses = classNames.reverse.mkString(" ") + val classList = extractClassNames(term).reverse + {{#if classesValidation}} + validate(classList) + {{/if}} + val combinedClasses = classList.mkString(" ") {{#if previewCompliedResult}} report.info(s"$combinedClasses") {{/if}} Expr(combinedClasses) +} \ No newline at end of file diff --git a/packages/scalawind/src/generate/templates/tailwind.hbs b/packages/scalawind/src/generate/templates/tailwind.hbs index 95079b5..d18bfd8 100644 --- a/packages/scalawind/src/generate/templates/tailwind.hbs +++ b/packages/scalawind/src/generate/templates/tailwind.hbs @@ -1,6 +1,6 @@ val tw = Tailwind() -case class Tailwind(): +case class Tailwind() { {{#each modifiers}} def {{this.name}}(@unused styles: Tailwind): Tailwind = this {{/each}} @@ -16,4 +16,5 @@ case class Tailwind(): {{/each}} def important(@unused styles: Tailwind): Tailwind = this def raw(@unused classString: String): Tailwind = this - def variant(selector: String, styles: Tailwind): Tailwind = this \ No newline at end of file + def variant(selector: String, styles: Tailwind): Tailwind = this +} \ No newline at end of file diff --git a/packages/scalawind/tests/cases/basic/basic.test.js b/packages/scalawind/tests/cases/basic/basic.test.js index eb7c87b..84ac46f 100644 --- a/packages/scalawind/tests/cases/basic/basic.test.js +++ b/packages/scalawind/tests/cases/basic/basic.test.js @@ -11,6 +11,7 @@ test('basic test', () => { previewCompliedResult: true, laminar: false, scalajsReact: true, + classesValidation: true }) const filepath = path.join(__dirname, "./expected.txt") // utils.writeFile(filepath, actual) diff --git a/packages/scalawind/tests/cases/basic/expected.txt b/packages/scalawind/tests/cases/basic/expected.txt index 6c0033e..3475466 100644 --- a/packages/scalawind/tests/cases/basic/expected.txt +++ b/packages/scalawind/tests/cases/basic/expected.txt @@ -5,20 +5,88 @@ import scala.annotation.unused import scala.language.implicitConversions import japgolly.scalajs.react.vdom.html_<^.* -object clx: +object clx { def apply(styles: String): TagMod = ^.cls := styles def :=(styles: String): TagMod = ^.cls := styles +} implicit inline def sw(inline tailwind: Tailwind): String = ${ swImpl('tailwind) } -def methodNameToTailwindClass(rawName: String) = +def methodNameToTailwindClass(rawName: String) = { val name = if rawName.startsWith("_") && rawName.charAt(1).isDigit then rawName.stripPrefix("_") else rawName name.replace("_", "-").replace("per", "/").replace("dot", ".") +} -def swImpl(tailwindExpr: Expr[Tailwind])(using Quotes): Expr[String] = +def swImpl(tailwindExpr: Expr[Tailwind])(using Quotes): Expr[String] = { import quotes.reflect.* - - def extractClassNames(term: Term, prefix: String = "", important: Boolean = false): List[String] = + def validate(classes: List[String]): Unit = { + checkDuplication(classes) + val optimizationSuggestions = checkOptimization(classes) + if optimizationSuggestions.nonEmpty then + report.errorAndAbort(s"[Optimization] ${optimizationSuggestions.mkString(", ")}") + } + + def checkDuplication(classes: List[String]): Unit = { + val duplicates = classes.groupBy(identity).collect { case (x, List(_, _, _*)) => x } + if duplicates.nonEmpty then + report.errorAndAbort(s"[Duplication] ${duplicates.mkString(", ")}") + } + + def checkOptimization(classes: List[String]): List[String] = { + val properties = List("p", "m") + + val suggestions = properties.flatMap { property => + val propertySuggestions = scala.collection.mutable.ListBuffer.empty[String] + + val classMap = classes.map { + case c if c.startsWith(s"${property}t-") => s"${property}t" -> c + case c if c.startsWith(s"${property}b-") => s"${property}b" -> c + case c if c.startsWith(s"${property}l-") => s"${property}l" -> c + case c if c.startsWith(s"${property}r-") => s"${property}r" -> c + case c if c.startsWith(s"${property}x-") => s"${property}x" -> c + case c if c.startsWith(s"${property}y-") => s"${property}y" -> c + case c if c.startsWith(s"${property}-") => property -> c + case c => c -> c + }.groupBy(_._1).view.mapValues(_.map(_._2)).toMap + + def checkAndSuggest(key1: String, key2: String, combined: String): Unit = { + (classMap.get(key1), classMap.get(key2)) match + case (Some(List(c1)), Some(List(c2))) if c1.substring(3) == c2.substring(3) => + propertySuggestions += s"Use $combined${c1.substring(3)} instead of $c1 and $c2" + case _ => () + } + + def checkFourWay(): Unit = { + (classMap.get(s"${property}t"), classMap.get(s"${property}b"), classMap.get(s"${property}l"), classMap.get(s"${property}r")) match + case (Some(List(pt)), Some(List(pb)), Some(List(pl)), Some(List(pr))) if pt.substring(3) == pb.substring(3) && pl.substring(3) == pr.substring(3) && pt.substring(3) == pl.substring(3) => + propertySuggestions += s"Use ${property}-${pt.substring(3)} instead of $pt, $pb, $pl and $pr" + case _ => () + } + + def checkPxPy(): Unit = { + (classMap.get(s"${property}x"), classMap.get(s"${property}y")) match + case (Some(List(px)), Some(List(py))) if px.substring(3) == py.substring(3) => + propertySuggestions += s"Use ${property}-${px.substring(3)} instead of $px and $py" + case _ => () + } + + // Check for four-way combination first + checkFourWay() + + // Only check for two-way combinations if no four-way combination is found + if propertySuggestions.isEmpty then + checkPxPy() + if propertySuggestions.isEmpty then + checkAndSuggest(s"${property}t", s"${property}b", s"${property}y-") + checkAndSuggest(s"${property}l", s"${property}r", s"${property}x-") + + propertySuggestions.toList + } + + suggestions + } + + def extractClassNames(term: Term, prefix: String = "", important: Boolean = false): List[String] = { var stack = List((term, prefix, important)) var classNames = List.empty[String] @@ -28,7 +96,7 @@ def swImpl(tailwindExpr: Expr[Tailwind])(using Quotes): Expr[String] = val (currentTerm, currentPrefix, currentImportant) = stack.head stack = stack.tail - currentTerm match + currentTerm match { case Apply(Select(inner, "important"), List(styles)) => stack = (styles, currentPrefix, true) :: stack stack = (inner, currentPrefix, currentImportant) :: stack @@ -80,19 +148,21 @@ def swImpl(tailwindExpr: Expr[Tailwind])(using Quotes): Expr[String] = stack = (inner, currentPrefix, currentImportant) :: stack case unexpectedTerm => report.errorAndAbort(s"Unexpected term: $unexpectedTerm") - + } + classNames - end extractClassNames + } val term = tailwindExpr.asTerm - val classNames = extractClassNames(term) - val combinedClasses = classNames.reverse.mkString(" ") + val classList = extractClassNames(term).reverse + validate(classList) + val combinedClasses = classList.mkString(" ") report.info(s"$combinedClasses") Expr(combinedClasses) - +} val tw = Tailwind() -case class Tailwind(): +case class Tailwind() { def first_letter(@unused styles: Tailwind): Tailwind = this def first_line(@unused styles: Tailwind): Tailwind = this def marker(@unused styles: Tailwind): Tailwind = this @@ -866,4 +936,5 @@ case class Tailwind(): def text_(value: String): Tailwind = this def important(@unused styles: Tailwind): Tailwind = this def raw(@unused classString: String): Tailwind = this - def variant(selector: String, styles: Tailwind): Tailwind = this \ No newline at end of file + def variant(selector: String, styles: Tailwind): Tailwind = this +} \ No newline at end of file diff --git a/packages/scalawind/tests/cases/full/expected.txt b/packages/scalawind/tests/cases/full/expected.txt index 275a0a3..5d14a8b 100644 --- a/packages/scalawind/tests/cases/full/expected.txt +++ b/packages/scalawind/tests/cases/full/expected.txt @@ -5,20 +5,22 @@ import scala.annotation.unused import scala.language.implicitConversions import com.raquo.laminar.api.L.cls -object clx: +object clx { def apply(styles: String) = cls(styles) def :=(styles: String) = cls(styles) +} implicit inline def sw(inline tailwind: Tailwind): String = ${ swImpl('tailwind) } -def methodNameToTailwindClass(rawName: String) = +def methodNameToTailwindClass(rawName: String) = { val name = if rawName.startsWith("_") && rawName.charAt(1).isDigit then rawName.stripPrefix("_") else rawName name.replace("_", "-").replace("per", "/").replace("dot", ".") +} -def swImpl(tailwindExpr: Expr[Tailwind])(using Quotes): Expr[String] = +def swImpl(tailwindExpr: Expr[Tailwind])(using Quotes): Expr[String] = { import quotes.reflect.* - - def extractClassNames(term: Term, prefix: String = "", important: Boolean = false): List[String] = + + def extractClassNames(term: Term, prefix: String = "", important: Boolean = false): List[String] = { var stack = List((term, prefix, important)) var classNames = List.empty[String] @@ -28,7 +30,7 @@ def swImpl(tailwindExpr: Expr[Tailwind])(using Quotes): Expr[String] = val (currentTerm, currentPrefix, currentImportant) = stack.head stack = stack.tail - currentTerm match + currentTerm match { case Apply(Select(inner, "important"), List(styles)) => stack = (styles, currentPrefix, true) :: stack stack = (inner, currentPrefix, currentImportant) :: stack @@ -80,19 +82,20 @@ def swImpl(tailwindExpr: Expr[Tailwind])(using Quotes): Expr[String] = stack = (inner, currentPrefix, currentImportant) :: stack case unexpectedTerm => report.errorAndAbort(s"Unexpected term: $unexpectedTerm") - + } + classNames - end extractClassNames + } val term = tailwindExpr.asTerm - val classNames = extractClassNames(term) - val combinedClasses = classNames.reverse.mkString(" ") + val classList = extractClassNames(term).reverse + val combinedClasses = classList.mkString(" ") report.info(s"$combinedClasses") Expr(combinedClasses) - +} val tw = Tailwind() -case class Tailwind(): +case class Tailwind() { def first_letter(@unused styles: Tailwind): Tailwind = this def first_line(@unused styles: Tailwind): Tailwind = this def marker(@unused styles: Tailwind): Tailwind = this @@ -96397,4 +96400,5 @@ case class Tailwind(): def forced_color_adjust_none_(value: String): Tailwind = this def important(@unused styles: Tailwind): Tailwind = this def raw(@unused classString: String): Tailwind = this - def variant(selector: String, styles: Tailwind): Tailwind = this \ No newline at end of file + def variant(selector: String, styles: Tailwind): Tailwind = this +} \ No newline at end of file