telerik / kendo-themes

Monorepo for SASS-based Kendo UI themes
149 stars 71 forks source link

Compiling kendo-themes in .net environment #3889

Open joneff opened 2 years ago

joneff commented 2 years ago

To correctly and fully answer how kendo themes can be compiled in .net environment, I must first explain what the themes are, what sass is, talk about existing solutions then get to answer the question.

It's a good place to introduce the concept of "framework native" -- the native way of doing something depending on the framework of choice. A few examples: npm is the framework native package manager of node. nuget is the framework native package manager for .net and so on and so forth.

It's not like I can't bring .net libraries / packages into a node project, but I would have to use nuget for fetching and dotnet executable for compiling and make sure it integrates well with my workflow.

This is going to be long, but the context is important.

What are Kendo UI themes (kendo-themes)?

kendo-themes are themes for Kendo UI (for angular, jquery, react, angular, vue) and Telerik UI for Blazor, written in sass and are currently built (compiled) and distributed around node and npm.

What is sass?

TLDR: sass is a stylesheet language and a superset of css that compiles to css. Sass has extensions over the standard css syntax like function, mixins, loops, that enables nesting selectors, code reuse or code generation.

There are two main versions of the syntax: sass, which relies on indentation and scss which resembles css a lot more.

Sass compilers, as the name suggests, compile sass to css.

The state of sass

The current state of sass is fairly complicated:

There are numerous other projects like rsass for rust; a nouvelle dart-sass implementation for ruby; a libsass implementation for ruby and so on and so forth.

On top, you have a new modern sass syntax with module system that does away with @import and switches to @use and @forward.

There are two js APIs to interact with the compiler: v1, that hasn't really changed since the introduction of node-sass and v2, that looks much more mature. The former being called legacyand the latter called modern. Needless to say, the API's are not compatible.

In short, there are two major implementations, two syntaxes and two js APIs, each with their own quirk and compatibility issues.

A quick note on the compiler API

The API allows to set certain options for the compiler that affect the output (css), like line ending, indentation etc.

There are also "plugin" points that allow for providing custom functions or even special behaviour when encountering certain file types on import.

A great example of such extension is the compass ruby gem, which had the ability to import images, to use for sprites, and create class names for each corresponding image.

Another great use, this time for node, is to use tilde (~) for pointing to node_modules and nudging the compiler in the right direction to load the files correctly.

Existing solutions for compiling sass in the .net world

Here are some of the most popular existing solutions, with each having advantages and shortcomings.

node

Probably the least popular among .net developers. I have no idea why, because this will yield the best results. That's the "framework native" way of the themes, yet an alien in the .net world.

In all fairness, using node does require that initial package.json file, but then again, what is a package.json file compared to a proper MSBuild file, like .csproj?

On the upside, there is Task Runner Explorer in Visual Studio that detects gulp tasks and npm scripts, so there is even UI to run them.

On the downside, .net projects are organized a bit differently and while it's possible to have a root (as in where the .sln is, and all the .csproj are children) and execute from that folder, the reality is that a developer could have a solution that spans multiple volumes, multiple repos even...

WebCompiler Visual Studio extension(s)

Probably, the most well know solution is WebCompiler by Mads Kristensen (@madskristensen), which enables compiling sass (and not only) directly in Visual Studio. The extension does so by bundling node with it and using the CLI.

While technically the CLI for node-sass does allow for custom functions and importers to be passed, the extension does not. So no ~ imports, no caching... Not that tilde imports would work or make sense if there isn't a node_modules folder in the right location.

Working with the extension on a daily basis with a huge codebase, one of the most useful features I found was having the files listed in a json format. Hell, I could even write my own json, and then right click and compile all!

Unfortunately, time has not been kind to this extension and it hasn't aged well...

But on the other had, that aging has spawn WebCompiler 2022+ by Jason Moore (@failwyn). WebCompiler 2022+ uses pretty much the same code base (it's forked), but is updated to work with both node-sass and dart-sass. It can also have different options for different files.

Still, the original WebCompiler was great piece of software, without a doubt, and a game changer!

Everything from Andrey Taritsyn

To me, one of the most prolific developers in the sass .net scene is Andrey Taritsyn (@Taritsyn). He has created multiple libraries that deal with wrapping sass -- libsasshost and dartsasshost, as well as libraries that consume them like BundleTransformer.SassAndScss.

In fact, one more entry here depends on dartsasshost: LigerShark.WebOptimizer.Sass.

Both libsasshost and dartsasshost are hosts (duh), but take different approaches. libsasshost is wrapper around the native libsass. dartsasshost, on the other hand, executes the transpiled javascript in a js engine host.

Both of them are brilliant and a probably the next best thing.

The only downside I see is that neither support passing custom functions or importers.

As for BundleTransformer.SassAndScss -- it supports only .net 4, but apart from that is pretty much on par, or better with almost everything else. And yes, still no custom function or importers.

Sidenote: I would very much like to see a proper dartsasshost, the way libsasshost is, with support for function and importers.

A note on js hosts

Running javascript in a host ... sounds promising at first, but then there are plenty of non-javascript aspects in a npm package. For starters, built in modules like fs, path etc; then you have npm itself with package resolution, deduping ...; and finally you have packages that are wrapped around native bindings like esbuild and even sass-embedded.

AspNetCore.SassCompiler nuget

A very good, and cross-platform, solution is AspNetCore.SassCompiler by @koenvzeijl.

The library author took a different approach and provides OS specific builds of dart-sass and compiles sass trough child processes.

On the upside, it integrates especially well with blazor. In addition, because the project employs tasks, one should probably be able to include it in a MSBuild target.

On the downside, there is limited configurability; no option to pass functions or importers and it works only with dart-sass (the native bindings, not the node module).

LigerShark.WebOptimizer.Sass nuget

A star infused team (LigerShark) consisting of Mads Kristensen and Scott Hanselman, among others.

To me it feels like WebOptimizer picked up from where BundleTransformer stopped -- it supports newer versions of .net and has everything that dartsasshost has.

Which means, it is again not able to receive custom functions and imports.

Microsoft.AspNetCore.NodeServices

That's a sort of "gateway" to executing javascript in actual node, but unfortunately it's obsolete and Microsoft.AspNetCore.SpaServices.Extensions should be used instead.

It would have been the perfect solution, because it would allow to interact with node in a civilized manner that's native to .net developers.

MSBuild targets

Targets are native to MSBuild (read .csproj), so it offers the best integration with CLI and IDE's like Visual Studio.

There are plenty of examples with targets that either run node or use any of the aforementioned tools.

On the downside, targets are not immediately visible, obvious or easy to comprehend. The MSBuild syntax is surprisingly powerful and convoluted with a learning curve to match.

It's hard, for instance, to decide in which part of the lifecycle a target should run. How often, for instance, does one want to do initial npm install or npm update or actually compile sass files?


It's evident that there are plenty of libraries and extensions that try to solve sass compilation and most of them do fine or good enough. But none matches the versatility a properly configured build for node-sass or sass.

To illustrate why I am so hell bent on being able to pass custom functions and importers to the sass compiler, lets run and measure each of the following, with node-sass, sass and sass-embedded:

// package.json
{
  "name": "sass-speed-test",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "dist-all": "gulp dist-all",
    "src-all": "gulp src-all",
    "src-grid": "gulp src-grid"
  },
  "dependencies": {
    "@progress/kendo-theme-default": "^5.6.0",
    "gulp": "^4.0.2",
    "gulp-sass": "^5.1.0",
    "node-sass": "^7.0.1",
    "sass": "^1.54.5",
    "sass-embedded": "^1.54.4"
  }
}
// gulpfile.js
const gulp = require('gulp');
const sass = require('gulp-sass')(require('sass'));
//const sass = require('gulp-sass')(require('node-sass'));
//const sass = require('gulp-sass')(require('sass-embedded'));

gulp.task('dist-all', function () {
    return gulp.src('./sass/dist-all.scss')
        .pipe(sass().on('error', sass.logError))
        .pipe(gulp.dest('./dist'));
});

gulp.task('src-grid', function () {
    return gulp.src('./sass/src-grid.scss')
        .pipe(sass().on('error', sass.logError))
        .pipe(gulp.dest('./dist'));
});

gulp.task('src-all', function () {
    return gulp.src('./sass/src-all.scss')
        .pipe(sass().on('error', sass.logError))
        .pipe(gulp.dest('./dist'));
});
// ./sass/dist-all.scss
@import "../node_modules/@progress/kendo-theme-default/dist/all.scss";

// ./sass/src-grid.scss
@import "../node_modules/@progress/kendo-theme-default/src/grid/_index.scss";

// ./sass/src-all.scss
@import "../node_modules/@progress/kendo-theme-default/src/all.scss";

The results will differ, based on actual machine load, but here are my results on MacBook Pro 2021 w/ M1 Pro:

node-sass

File gulp real user system
dist-all 270 ms 0.819s 0.553s 0.132s
src-grid 224 ms 0.598s 0.510s 0.117s
src-all 2810 ms 3.183s 3.000s 0.189s

dart-sass

File gulp real user system
dist-all 949 ms 1.534s 2.461s 0.172s
src-grid 3940 ms 4.574s 5.342s 0.502s
src-all 50000 ms 50.035s 51.196s 4.927s

sass-embedded

File gulp real user system
dist-all 252 ms 0.657s 0.572s 0.133s
src-grid 221 ms 0.631s 0.525s 0.146s
src-all 860 ms 1.264s 1.082s 0.202s

To get a better understanding of the difference between the time it took node-sass, sass and sass-embedded to compile the files, we must first look at the files:

So why the difference between in compile time? I am guessing, and this is just guessing, that it comes to the difference in implementation: node-sass is a wrapper around native libsass, where as sass is this huge chunk of dart to js. And I am speculating that I/O issass's arch-nemesis. Kryptonite, in a way. So it should come as no surprise when sass-embedded, which is also a wrapper (around native dart-sass), just blows past sass and is on par or better than node-sass.

How is this an argument for custom importers or functions? It's simple: not all problems can be solved by using faster implementation. Sometimes using a different implementation is not possible. For those cases we can use custom functions and importers.

For instance, we could have an importer that keep track of all processed files and suggest to the compiler that the file it requests (if already processed) is empty, which will just skip the entire processing of the file, along with any child imports and their child imports... True, the sass compiler is sort of not ok with said nudging in certain scenarios, but the node-sass is quite happy.

Mentioned quite early in this piece, compass gem enables to import images and generate sprite class names.

How about beieng able to import json files and generate variables. There could even be a strongly typed object that serializes to said json. Wouldn't that be something?

So that's why I am so hell bent on having the ability to supply custom functions and importers.


So how do you compile kendo-themes (or any sass) in .net environment?

Well that's the million dollar question, innit?

In many projects and organizations, there is standardization. If there is an established procedure for compilation, developers should adhere to it. If the procedure feels clunky, perhaps it can be improved.

Bottom line is that every developer should be able use the method of compilation that feels best (as long as that method of compilation provides the same output), but should follow practices, if present.

Any sass file

The answer is "it depends".

If it's a locally written file, then any of the above should suffice and perhaps WebCompiler or WebCompiler 2022+ will provide the best possible experience.

However, if we are talking about compiling a file that contains external dependency, then its best to see the origin (read eco system) of that dependency and use a pipeline that feels native to it.

For instance, bootstrap provide both a node package and a nuget. Considering we are talking about .net the nuget should be used. However, as of this writing the node package is v5.2.0 and the nuget is v5.1.3, so even this approach is not without it's limitations.

kendo themes

Speaking for kendo-themes, if the desired result is a full theme, the best way is to use the dist/all.scss. As mentioned above, it's a single file that contains the entire theme. This file will work in any scenario, regardless of the method used.

If the the desired result is part of the theme, then the above example about speed should be taken into consideration.

I cannot stress this enough: kendo-themes we use ~ imports for dependencies. In other words, currently, it's next to impossible to compile kendo-themes in certain scenarios outside of node.

(And though we provide documentation how to compile themes, we do sometimes forget to update it. But thanks to vigilant users like @smchargue who spotted the discrepancies, we corrected the mistake and updated the article.)

A few closing words

Obviously, node packages are not the perfect medium for .net projects. Yes, it's an acceptable medium, but not the perfect. That's why we'll begin shipping nugets of our themes, so it fits better within the .net eco system.

That's not going to be 1:1 port, but rather tailored to .net. For instance, ~ imports don't seem to be a huge thing in .net, so we'll strip it and instead rely on supplying include_paths or load_paths option (depending on the compiler).

We are working additional tooling that should make compiling easier and friendlier not only to .net developers, but developers as a whole.

One part is that we are experimenting with a json format that describes the files for building and settings for them, a lot like webcompilerconfig.json does. The difference being that we use a set of common settings to abstract the differences between compilers.

Here is a quick example: the legacy API has an outputStyle setting with 4 possible values -- nested, expanded, compact and compressed; the modern API has a style setting with 2 possible values -- expanded and compressed. Our abstraction is called minify and is a boolean setting which switches between expanded and compressed.

That about sums it up.

Happy coding!

failwyn commented 2 years ago

What about simplifying it and using relative paths for the imports instead? I had it working with includePaths and loadPaths with WebCompiler 2022, but the. Visual Studio 2022 doesn’t understand includePaths or loadPaths, so it gives undeclared variable errors and does not give intellisense for bariable names; but if you replace all the ~ imports with relative paths, it compiles perfectly with WebCompiler 2022, Visual Studio 2022 can follow the imports, so no errors are reported, and Intellisense autocomplete variable names.

sala91 commented 1 year ago

Wanting to use Telerik Blazor UI, and costumize it, seems that Kendo + boostrap with node-sass is what was used before to acchive this together with gulp. Would be really nice, if for example, https://demos.telerik.com/blazor-dashboard-app would be updated to showcase the proper modern theme development workflow.