Kendi Sass Kütüphaneni Yazmak: @debug, @warn, @error ile Argüman Doğrulama, sass:meta, sass:selector ve True ile Test
Bir Sass kütüphanesinde public function'ları meta.type-of ile doğrulayıp meta.inspect'li @error ver, eskiyen API'yi @warn ile duyur ve function'ları True ile, hata ve uyarıları Vitest'te JavaScript'ten test et. Bookstore'un ortak Sass kodunu @bookstore/styles paketine taşıyor, Angular'ın sabit quietDeps ayarının @warn'ı susturmadığını gösteriyor ve 16 testlik bir paketle min-width regresyonunu yakalıyoruz.
Sürüm notu: Bu yazıdaki örnekler Dart Sass 1.108.2, True (sass-true) 10.1.0, Vitest 5.1 ve Angular 22.2 (TypeScript 6.0; Angular CLI'ın içindeki Sass 1.104.1) ile denenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) ortak Sass kodu bu yazıda @bookstore/styles adlı bir npm paketine taşınıyor. Paketteki eski from() mixin'i artık bir uyarı veriyor:
// eski ad: hâlâ çalışıyor ama uyarı veriyor, çağıranlar up()'a geçmeli
@mixin from($name) {
@warn "from() is deprecated and will be removed in @bookstore/styles 1.0. Use up(#{meta.inspect($name)}) instead.";
@include up($name) {
@content;
}
}
Paket node_modules'tan yükleniyor. Serinin yedinci yazısında Angular'ın build kodunun quietDeps: true'yu sabit verdiğini ve load path ya da importer üzerinden gelen dosyalardaki deprecation'ları hiç göstermediğini görmüştük. Bir component @include from("sm") yazarsa ng build bu uyarıyı gösterir mi?
Önceki üç yazıda rem(), space(), renk tonları, breakpoint mixin'leri ve bir utility generator yazdık. Hepsi bookstore-web'in abstracts klasöründe duruyordu. Bu yazıda onları bir kütüphaneye çeviriyoruz: argümanları doğrulamak, kullanıcıya düzgün hata ve uyarı vermek, public yüzeyi çizmek, sass:meta ve sass:selector ile çalışmak ve en önemlisi test yazmak.
Kütüphanenin Yüzeyi: Paket, @forward ve Private Member
Paket packages/styles klasöründe, Angular uygulamasının yanında duruyor. package.json'daki exports alanının sass koşulu, paketi @use "@bookstore/styles" ile yükleyen araçlara hangi dosyanın giriş noktası olduğunu söylüyor:
{
"name": "@bookstore/styles",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": {
"sass": "./_index.scss"
}
},
"files": [
"_index.scss",
"src"
],
"scripts": {
"test": "vitest run"
},
"devDependencies": {
"sass": "1.108.2",
"sass-true": "10.1.0",
"vitest": "5.1.0"
}
}
// @bookstore/styles: yalnız function ve mixin, CSS üretmez
@forward "src/units";
@forward "src/colors";
@forward "src/breakpoints";
@forward "src/utilities";
Public API, _index.scss'in @forward ettiği şeylerden ibaret. Serinin dördüncü yazısındaki iki kural burada kütüphane ölçeğinde işe yarıyor: -size(), -next() ve -query() gibi - ile başlayan üyeler dışarıdan görünmüyor; src/_validate.scss ise hiç forward edilmiyor, yani paketin içinde kullanılan ama dışarı açılmayan bir module. $root-font-size, $space-scale ve $breakpoints !default ile tanımlı; kullanıcı onları with() ile değiştirebiliyor ve konfigürasyon @forward zincirinden geçip asıl module'e ulaşıyor. Public yüzeyi derleyiciye sordum:
.meta-demo {
functions: map.keys(meta.module-functions("bs"));
mixins: map.keys(meta.module-mixins("bs"));
exists: meta.function-exists("rem", "bs") meta.function-exists("expect", "bs");
}
.meta-demo {
functions: "rem", "space", "font-stack", "tone", "with-states";
mixins: "up", "down", "between", "only", "each-breakpoint", "respond", "from", "generate-utilities";
exists: true false;
}
Beş function ve sekiz mixin; private üyeler ve expect() listede yok. Bir kütüphanenin "bunu değiştirirsem kim etkilenir?" sorusunun cevabı bu liste.
@error ile Argüman Doğrulama: meta.type-of ve meta.inspect
Serinin sekizinci yazısında rem("18px")'in hata mesajının bizim değil built-in function'ın olduğunu ve function'ın içini gösterdiğini görmüştük. Kütüphanede bütün tip kontrollerini tek bir function'a topladım:
// paket içi: _index.scss'ten forward edilmez
@use "sass:meta";
// $value'nun tipi $type ise onu döndürür, değilse build'i okunabilir bir mesajla durdurur
@function expect($value, $type, $caller) {
@if meta.type-of($value) != $type {
@error "#{$caller} expects a #{$type}, got #{meta.inspect($value)} (#{meta.type-of($value)})";
}
@return $value;
}
@function rem($value) {
$value: v.expect($value, number, "rem()");
// ...
}
@function tone($color, $lightness: 0%, $chroma: 0%) {
$color: v.expect($color, color, "tone()");
// ...
}
Error: 'tone() expects a color, got "#b45309" (string)'
╷
7 │ $color: v.expect($color, color, "tone()");
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
╵
node_modules/@bookstore/styles/src/_colors.scss 7:13 tone()
11/wrong-color.scss 4:12 root stylesheet
Bu hata gerçek hayatta sık görülüyor: token'lar bir JSON dosyasından ya da tasarım aracından geldiğinde renkler tırnaklı string olarak gelebiliyor ve "#b45309" ekranda bir renge benziyor. meta.inspect() burada önemli. Interpolation string'in tırnaklarını atıyor ve mesajda got #b45309 yazardı; kimse sorunun tip olduğunu anlamazdı. meta.inspect() değeri Sass'ta yazıldığı gibi gösteriyor:
@use "sass:meta";
$values: ("18px", 18px, null, (sm: 36rem), (Inter, system-ui), "#b45309", #b45309);
@each $value in $values {
@debug "#{meta.type-of($value)}: #{meta.inspect($value)}";
}
11/inspect.scss:6 DEBUG: string: "18px"
11/inspect.scss:6 DEBUG: number: 18px
11/inspect.scss:6 DEBUG: null: null
11/inspect.scss:6 DEBUG: map: (sm: 36rem)
11/inspect.scss:6 DEBUG: list: Inter, system-ui
11/inspect.scss:6 DEBUG: string: "#b45309"
11/inspect.scss:6 DEBUG: color: #b45309
Bir map'i doğrudan interpolation'a koymak ise hata: (sm: 36rem) isn't a valid CSS value. Hata mesajlarında değeri her zaman meta.inspect()'ten geçirmenin ikinci sebebi bu; yoksa hata mesajını üretmeye çalışırken başka bir hata alıyorsun.
Doğrulamanın asıl kazancı sessiz hataları kaldırması. Doğrulamasız bir function yanlış girdide null döndürürse, serinin dokuzuncu yazısında gördüğümüz gibi declaration çıktıdan uyarısız atılıyor. Paylaşılan bir kütüphanede bu, sorunun kodu yazan kişiden çok uzakta, bir başka ekibin sayfasında fark edilmesi demek.
@warn ile Deprecation, @debug ile Değer Yazdırmak
@warn derlemeyi durdurmuyor; mesajı ve stack trace'i yazıp devam ediyor. CLI çıktısı:
npx sass --load-path=node_modules 11/deprecated.scss
WARNING: from() is deprecated and will be removed in @bookstore/styles 1.0. Use up("md") instead.
node_modules/@bookstore/styles/src/_breakpoints.scss 101:5 from()
11/deprecated.scss 4:5 root stylesheet
Stack trace'in ikinci satırı önemli: uyarının sebebi paketin içi değil, from()'u çağıran satır. --quiet-deps ile aynı komut aynı uyarıyı yine gösteriyor. Tahminin cevabı da bu: Angular build'i @warn'ı gösteriyor.
▲ [WARNING] from() is deprecated and will be removed in @bookstore/styles 1.0. Use up("sm") instead. [plugin angular-sass]
../../../../../packages/styles/src/_breakpoints.scss 101:5 from()
../../../../src/app/app.scss 18:5 root stylesheet
Dart Sass CHANGELOG'u quietDeps'i "compiler warnings", yani derleyicinin kendi uyarılarını susturan bir ayar olarak tanımlıyor. @warn derleyicinin değil kütüphane yazarının mesajı; Angular'ın sabit quietDeps: true'su ona dokunmuyor. Bu yüzden bir kütüphanede kullanıcıyı bir değişikliğe hazırlamanın en güvenilir yolu @warn: slash-div gibi derleyici deprecation'ları Angular'da görünmezken bizim mesajımız build çıktısına düşüyor.
@debug geliştirirken değer görmek için. Aynı build'e component dosyasına yazılmış bir satırla baktım:
@debug "space(2) = #{space(2)}";
❯ Building...
../../../../src/app/app.scss:3 DEBUG: space(2) = 8px
✔ Building...
Angular @debug'ı uyarı formatına sokmadan, build ilerlerken araya yazıyor. Production build'i de durdurmuyor; yani unutulan bir @debug her build'in log'unda kalıyor.
sass:meta ile Function ve Mixin Seçmek
Sass 1.69.0'dan beri mixin'ler de function'lar gibi değer olarak taşınabiliyor. meta.get-mixin() bir mixin'i değer olarak alıyor, meta.apply() onu argümanlar ve içerik bloğuyla çağırıyor. Pakete, hangi breakpoint mixin'inin kullanılacağını parametreyle seçen bir mixin ekledim:
// sorgu mixin'ini adıyla seçer: respond(up, md), respond(between, sm, lg)
@mixin respond($kind, $args...) {
@if not list.index((up, down, between, only), $kind) {
@error "respond() expects up, down, between or only, got #{meta.inspect($kind)}";
}
@include meta.apply(meta.get-mixin($kind), $args...) {
@content;
}
}
Function tarafında karşılıkları meta.get-function() ve meta.call(). Utility generator'a değerleri tasarım birimiyle verip bir dönüşüm function'ını adıyla seçmek için kullandım:
// utility değerleri tasarım biriminde verilir, adıyla seçilen bir function ile çevrilir
$transforms: (spacing: "rem", none: null);
@function transform($value, $name) {
$fn-name: map.get($transforms, $name);
@if not $fn-name {
@return $value;
}
@return meta.call(meta.get-function($fn-name, $module: "bs"), $value);
}
.book-card {
padding: transform(12, spacing);
border-width: transform(1px, none);
@include bs.respond(between, "sm", "lg") {
padding: transform(16, spacing);
}
@include bs.respond(only, "xl") {
padding: transform(24, spacing);
}
}
.book-card {
padding: 0.75rem;
border-width: 1px;
}
@media (36rem <= width < 64rem) {
.book-card {
padding: 1rem;
}
}
@media (width >= 80rem) {
.book-card {
padding: 1.5rem;
}
}
Bu araçlar konfigürasyonla davranış seçen kütüphaneler için. Uygulama kodunda bs.respond(up, "md") yerine doğrudan bs.up("md") yazmak hem daha okunur hem de yanlış bir adı (above gibi) bizim @error'umuza gerek kalmadan Undefined mixin ile yakalıyor. Serinin altıncı yazısında kullandığımız meta.load-css() de aynı module'de; bir module'ün CSS'ini istediğin yere, örneğin bir @layer'ın içine yüklüyor.
sass:selector Ne Zaman Gerekir?
Çoğu zaman gerekmez: &, nesting ve .catalog & gibi yazımlar selector işlerinin büyük kısmını çözüyor. sass:selector, selector'ü bir değer olarak hesaplaman gerektiğinde işe yarıyor:
.selector-demo {
append: selector.append(".book-card", "--featured", ":hover");
nest: selector.nest(".catalog", "& .book-card", ".price");
unify: selector.unify("button.book-card__add", ".is-loading");
unify-none: selector.unify("button", "a");
replace: selector.replace(".cart .price", ".price", ".price--sale");
extend: selector.extend(".cart .price", ".price", ".total");
parse: selector.parse(".a, .b .c");
}
.selector-demo {
append: .book-card--featured:hover;
nest: .catalog .book-card .price;
unify: button.book-card__add.is-loading;
replace: .cart .price--sale;
extend: .cart .price, .cart .total;
parse: .a, .b .c;
}
unify-none satırı yok: bir element hem button hem a olamayacağı için selector.unify() null döndürüyor. Pratik bir örnek: BEM (Block, Element, Modifier; class isimlendirme yöntemi) element'inin içinden "block şu modifier'a sahipken" kuralı yazmak. .book-card--featured & elle yazılabilir ama block adını tekrar ediyor; mixin onu &'tan çıkarıyor:
// BEM block'unun bir element'i içinde: block bir modifier'a sahipken stil ver
@mixin when-block($modifier) {
// & örneğin ".book-card__title"; block "__"'dan önceki kısım
$element: string.slice(list.nth(list.nth(&, 1), -1) + "", 2);
$block: string.slice($element, 1, string.index($element, "__") - 1);
@at-root #{selector.nest(".#{$block}--#{$modifier}", &)} {
@content;
}
}
.book-card {
&__title {
color: var(--color-text);
@include when-block(featured) {
color: var(--color-brand);
}
}
}
.book-card__title {
color: var(--color-text);
}
.book-card--featured .book-card__title {
color: var(--color-brand);
}
& Sass'ta bir list'ler list'i (her selector bir list, her selector'ün parçaları da bir list); list.nth(list.nth(&, 1), -1) ilk selector'ün son parçasını alıyor. Bu kadar dolambaçlı bir kodu kütüphaneye koymadan önce "ekip bunu okuyabilir mi?" sorusunu sormak gerekiyor; Bookstore'da bu mixin'i pakete almadım.
True ve Vitest ile Test
True, OddBird'ün Sass test kütüphanesi. İki tür assertion var: assert-equal Sass değerlerini karşılaştırıyor (function testleri), assert/output/expect ise bir mixin'in ürettiği CSS'i beklenen CSS'le karşılaştırıyor. Testler bir JavaScript test runner'ı içinde çalışıyor; Bookstore'da Angular'ın da kullandığı Vitest:
cd packages/styles
npm install --save-dev --save-exact sass@1.108.2 sass-true@10.1.0 vitest@5.1.0
@use "true" as * with ($terminal-output: false);
@use "../index" as bs;
@include describe("rem()") {
@include it("converts px to rem") {
@include assert-equal(bs.rem(18px), 1.125rem);
}
@include it("treats a unitless number as px") {
@include assert-equal(bs.rem(6), 0.375rem);
}
@include it("converts units that are compatible with px") {
@include assert-equal(bs.rem(12pt), 1rem);
}
}
@include describe("up() / down() / between()") {
@include it("writes range syntax media queries") {
@include assert {
@include output {
.book-list {
@include bs.up("md") {
gap: 24px;
}
@include bs.between("sm", "lg") {
padding-inline: 16px;
}
}
}
@include expect {
@media (width >= 48rem) {
.book-list {
gap: 24px;
}
}
@media (36rem <= width < 64rem) {
.book-list {
padding-inline: 16px;
}
}
}
}
}
}
$terminal-output: false True'nun kendi raporunu kapatıyor; sonuçları Vitest raporluyor. Konfigürasyonu da test ediyorum: config.test.scss paketi @use "../index" as bs with ($root-font-size: 10px, $breakpoints: ("tablet": 40rem)) ile yüklüyor ve rem(18px)'in 1.8rem olduğunu kontrol ediyor. Her derleme bir module'ü tek bir konfigürasyonla yüklediği için farklı konfigürasyonlar ayrı test dosyalarında.
@error derlemeyi durdurduğu için True onu bir Sass testi içinde yakalayamıyor. True'nun kendi error() function'ı bunu çözüyor ama kütüphanenin True'ya bağımlı olmasını gerektiriyor. Ben hata ve uyarı testlerini JavaScript tarafında, aynı Vitest dosyasında yazdım:
import { fileURLToPath } from "node:url";
import * as sass from "sass";
import sassTrue from "sass-true";
import { describe, expect, it } from "vitest";
const root = fileURLToPath(new URL("..", import.meta.url));
// her *.test.scss dosyası Vitest'te bir describe/it ağacına dönüşür
for (const name of ["units", "colors", "breakpoints", "config"]) {
sassTrue.runSass({ describe, it, sass }, fileURLToPath(new URL(`./${name}.test.scss`, import.meta.url)));
}
// @error derlemeyi durdurur, bu yüzden hata ve uyarılar JavaScript'ten test edilir
function compile(source) {
const warnings = [];
const result = sass.compileString(`@use "index" as bs;\n${source}`, {
loadPaths: [root],
logger: { warn: (message) => warnings.push(message) },
});
return { css: result.css, warnings };
}
describe("errors and warnings", () => {
it("rejects a space() step outside the scale", () => {
expect(() => compile(".x { margin: bs.space(6); }")).toThrow("space() expects a step from 0 to 5, got 6");
});
it("rejects a string where rem() expects a number", () => {
expect(() => compile('.x { font-size: bs.rem("18px"); }')).toThrow('rem() expects a number, got "18px" (string)');
});
it("warns when the deprecated from() is used", () => {
const { css, warnings } = compile('.x { @include bs.from("md") { order: 1; } }');
expect(warnings).toEqual(['from() is deprecated and will be removed in @bookstore/styles 1.0. Use up("md") instead.']);
expect(css).toContain("@media (width >= 48rem)");
});
});
npm test -- --reporter=verbose
✓ test/styles.test.js > rem() > converts px to rem 0ms
✓ test/styles.test.js > rem() > treats a unitless number as px 0ms
✓ test/styles.test.js > rem() > converts units that are compatible with px 0ms
✓ test/styles.test.js > space() > starts the scale at 0 0ms
...
✓ test/styles.test.js > up() / down() / between() > writes range syntax media queries 0ms
...
✓ test/styles.test.js > errors and warnings > warns when the deprecated from() is used 2ms
Test Files 1 passed (1)
Tests 16 passed (16)
Testlerin değerini görmek için bir regresyon yaptım: up()'ı eski min-width yazımına çevirdim. Beş test kırıldı ve Vitest farkı satır satır gösterdi:
FAIL test/styles.test.js > up() / down() / between() > writes range syntax media queries
AssertionError: [type: equal]
- Expected
+ Received
- @media (width >= 48rem) {
+ @media (min-width: 48rem) {
.test-output .book-list {
gap: 24px;
}
}
Başarısız bir test koşusunda vitest run 1 exit code'uyla çıkıyor; CI'da paketin klasöründe npm ci ve npm test çalıştırmak yeterli. Vitest'in watch modunda .scss değişikliklerinde testlerin yeniden çalışması için vitest.config.js'e forceRerunTriggers: ["**/*.scss"] ekledim; True'nun README'si de bunu öneriyor.
Bookstore'da: @bookstore/styles'ı Uygulamaya Bağlamak
bookstore-web paketi npm'in file: protokolüyle kullanıyor; npm node_modules/@bookstore/styles'a bir symlink koyuyor:
npm install --save "@bookstore/styles@file:../packages/styles"
abstracts klasöründe yalnız uygulamaya özel olanlar kaldı: tema token'ları ve color-tokens() mixin'i. Diğer dört dosya silindi, index paketi forward ediyor:
// abstracts CSS üretmez: yalnız değişken, function ve mixin
@forward "@bookstore/styles";
@forward "tokens";
@forward "theme";
Component'lerin hiçbiri değişmedi; hepsi hâlâ @use "abstracts" as *; ile başlıyor. Taşımadan sonra ng build'in ürettiği global stil dosyasının adı aynı kaldı (styles-37AI4YFI.css); Angular dosya adına içeriğin hash'ini koyduğu için global CSS byte byte aynı. npm run check:sass2 da paketi node_modules load path'i üzerinden buluyor ve 3 files, 0 failed diyor.
Üretimde Dikkat
Paketin sürümünü ve @warn'larını bir arada yönet. Bir mixin'i kaldırmadan önce en az bir sürüm boyunca @warn ver, mesajda yerine neyin geleceğini yaz ve kaldırma sürümünü belirt. Bookstore'daki mesaj bu üç bilgiyi de içeriyor.
Test dosyalarını yayınlanan pakete koyma. package.json'daki files alanı yalnız _index.scss ve src'yi içeriyor; testler ve vitest.config.js repoda kalıyor.
Paketin Sass sürümüyle uygulamanınkini karıştırma. Paket testleri kendi sass@1.108.2'siyle çalışıyor, Angular build'i ise @angular/build'in getirdiği 1.104.1 ile. Paket yeni bir Sass özelliği kullanırsa testler geçip uygulama build'i kırılabilir; CI'da uygulamanın build'ini de çalıştırmak bu farkı yakalıyor.
Sık Düşülen Tuzaklar
Hatalı girdide sessizce null döndürmek. Bir function yanlış bir argümanla null döndürürse declaration çıktıdan atılıyor ve hiçbir yerde uyarı yok. Kütüphanenin public function'larında tip ve aralık kontrolü yap, @error ile dur.
@debug satırlarını unutmak. @debug CSS'i değiştirmiyor ama her build'in log'unda kalıyor ve gerçek uyarıları gölgeliyor. Kütüphanede @debug yerine testleri kullan; geçici @debug'ları commit'ten önce grep -rn "@debug" src ile ara.
Testte CSS'in yazılışına takılmak. True, CSS'i karşılaştırmadan önce biçimlendiriyor; girinti ve boşluk farkları sorun değil. Ama değerin yazılışı karşılaştırılıyor. Test dosyasına geçici olarak eklediğim bir testte tone(#ffffff, 0%)'in çıktısını color: #ffffff ile karşılaştırdım:
- Expected
+ Received
.test-output .a {
- color: #ffffff;
+ color: white;
}
Renk aynı, yazılışı farklı. Renk ya da sayı karşılaştırıyorsan assert-equal ile değeri test et (assert-equal(bs.tone(#ffffff, 0%), #ffffff) geçiyor); assert/output ile yalnız selector, media query ve declaration yapısını test et.
Her yardımcıyı kütüphaneye koymak. when-block() gibi zekice ama okunması zor bir mixin, paketin public API'sine girdiği an başka ekiplerin bağımlılığı oluyor. Paketin yüzeyini küçük tut; bir şeyi eklemek kolay, kaldırmak bir deprecation süreci.
Küçük Bir Deneme
★☆☆ packages/styles'ta space() için "negatif adım hata verir" testini JavaScript tarafına ekle ve hata mesajının tamamını toThrow() ile kontrol et.
★★☆ font-stack()'e bir doğrulama ekle: verilen her font adı string olmalı. font-stack(Georgia) (tırnaksız) geçerli mi olmalı, yoksa hata mı vermeli? Kararını meta.type-of(Georgia)'nın ne döndürdüğüne bakarak ver ve testini yaz.
★★★ from()'u paketten kaldıran bir 1.0 sürümüne hazırlan: bookstore-web'de from( geçen her yeri bulan ve up('a çeviren küçük bir Node script'i yaz. Script'i çalıştırdıktan sonra ng build çıktısında uyarı kalmadığını ve global CSS dosyasının adının değişmediğini doğrula.
Serinin son yazısında Sass'ın build'e nasıl bağlandığına bakıyoruz: CLI'da ve Angular'da source map'ler, DevTools'ta .scss satırına gitmek, Vite'ta sass-embedded ve preprocessorOptions, ASP.NET Core'da --watch ve --style=compressed ve kendi derleme script'inde pkg: importer'ıyla bu yazıdaki paketi yüklemek.