SCSS modernization with sass-migrator (Dockerized Drupal theme)
Migrate a Drupal theme's SCSS off the deprecated bits of Dart
Sass (@import, / division, ambiguous unary minus, mixed-decls) and clear
every warning without changing a single byte of the computed CSS. Reference
run: akena.com (theme frontend, 120 .scss files, ~500 rules).
This skill pairs with gulp-to-vite — do the Gulp→Vite migration first (it gives
you make npm-build), then this to modernize the SCSS the new build compiles.
Golden rule: everything goes through the container
Dockerized + Makefile-driven. Compile inside the node service, never on the
host — the host sass CLI often pulls a broken @parcel/watcher on Apple
Silicon (No prebuild ... darwin-arm64), which is not a code bug.
| Need | Command |
|---|---|
| Production build (warning count) | make npm-build |
| One entrypoint, ALL warnings (verbose) | docker compose … exec -T node npx sass --verbose --no-source-map <in>.scss <out>.css |
| Run a migrator | npx sass-migrator <migrator> --migrate-deps <entry>.scss … (host is fine — it only rewrites source) |
make npm-build only prints the first 5 warnings per deprecation then
WARNING: N repetitive deprecation warnings omitted. To see the full list use a
verbose npx sass compile in the container.
Order of operations
- module —
@import→@use/@forward+ namespacing. - division —
/→math.div()/ multiplication. - strict-unary — disambiguate
$a -$b. - mixed-decls — no migrator exists; fix at the source (see below).
Always --migrate-deps and always pass every entrypoint at once (e.g.
style.scss ckeditor.scss mail.scss) so shared partials migrate consistently.
Full migrator catalog
sass-migrator help lists all of them. Run --dry-run for each on the target;
apply only the ones that report changes. The three above are the usual core; the
rest are situational but worth a dry-run pass so nothing is missed:
| Migrator | Fixes | When to run |
|---|---|---|
module |
@import → @use/@forward + namespacing |
Core. Always first. |
division |
/ → math.div() / multiplication |
Core. After module. |
strict-unary |
ambiguous $a -$b → $a - $b |
Core. After division. |
calc-interpolation |
removes #{…} interpolation inside calc()/clamp()/min()/max() |
If the theme builds calc strings with interpolation (Dart Sass now parses calc natively). |
color |
legacy color functions (darken(), lighten(), red()…) → color.adjust/color.channel |
If a color-functions deprecation shows up (common in older themes / admin themes). |
if-function |
legacy if() → the new CSS-style if() |
Rare; only if the code uses the old if() function form. |
namespace |
rename @use namespaces (--rename) |
Cosmetic — only if you want shorter/renamed namespaces after module (e.g. mixins → mx). Not a deprecation fix. |
Dry-run sweep to see which actually apply:
for m in module division strict-unary calc-interpolation color if-function; do
echo "== $m =="; npx sass-migrator $m --migrate-deps --dry-run <entries…> 2>&1 | tail -n +2 | head
done
namespace is intentionally omitted from the sweep — it's opt-in and needs
--rename, it won't self-detect anything.
Step 0 — Dry-run + scope
npx sass-migrator module --migrate-deps --dry-run <entries…> # lists files it will touch, exit 0 = parses OK
- A partial that only defines members (a mixin/variable file) and references nothing else is left untouched — that is correct, not a miss.
- If the user says "ignore file X, it errors" — dry-run first anyway. The module
migrator usually parses files the user thinks are broken; only genuinely
unparseable files fail, and then you exclude them by temporarily commenting
their
@importline, migrating, then porting that one line by hand.
Step 1 — Run the three migrators
npx sass-migrator module --migrate-deps <entries…>
npx sass-migrator division --migrate-deps <entries…>
npx sass-migrator strict-unary --migrate-deps <entries…>
Note: piping migrator output through a filter can swallow it and leave the files
migrated with a later "Nothing to migrate!" — check git diff, not stdout.
Step 2 — Fix what the migrator gets WRONG or SKIPS
The build will still fail/warn after Step 1. These are the recurring ones:
2a. math.max() / math.min() on CSS functions — build ERROR
The module migrator namespaces every max(/min( to math.max/math.min,
which breaks CSS usages like max(44px, env(safe-area-inset-right))
(env() is not a number). The migrator also emits shim functions at the top
of the file:
@function max($numbers...) { @return m#{a}x(#{$numbers}) } // literal CSS max()
@function min($numbers...) { @return m#{i}n(#{$numbers}) }
Fix: revert the math.max(/math.min( call sites back to plain max(/min(
(they then resolve to the shims → literal CSS), and drop the now-unused
@use "sass:math" if nothing else needs it. Keep the shims — they preserve
@supports (padding: max(0px)) (a plain max(0px) would fold to 0px).
2b. Cross-file @extend .class — build ERROR ("target selector not found")
Under @import everything shared one global scope, so @extend .title1 found
.title1 anywhere. Under modules, an @extend only sees selectors in the
current file and the modules it @uses/@forwards. The migrator does NOT
add these (it can't map a bare class to a module).
Fix: in each file that @extends a class defined elsewhere, add a @use of the
defining module. The namespace is irrelevant for @extend (it targets a plain
selector), you just need the module loaded:
# find every active cross-file @extend, then locate each target's canonical def:
grep -rn '@extend \.' --include='*.scss' . | grep -v '//'
grep -rnE '^\s{0,2}\.<class>\b' --include='*.scss' . # per class
Add @use "<path-to-defining-partial>"; to the extending file. Watch for
circular @use (build tells you). @extend reaching a class that lives in the
same entrypoint chain still needs the file loaded by the extending file.
Step 3 — mixed-decls (no migrator; fix at the source)
Root cause
A declaration after a nested rule in the same style rule. In these themes it is almost always a mixin that emits a nested rule placed mid-declaration-list:
set-font-size()→rfs()emits a@mediafit-crop-element()→ emits an@supports- any
@include breakpoint(...) { … }block
Every plain declaration that follows such an include (in the caller) warns.
Two safe fixes — pick per collision
- Reorder the offending
@includeto the end of its bare-declaration run (before nested children). Output-identical because current Dart Sass hoists declarations above nested rules — but only when the include emits no property the caller re-declares afterward.fit-crop-elementsetsmax-width: none/width/height; a callermax-width: 118pxafter it would flip tononeif you move the include. Reordering is safe forset-font-sizein practice, dangerous forfit-crop-element. & {}wrap the trailing declarations (order-preserving, cascade-safe):
Use wherever a reorder would change a last-wins collision, and to fix declarations that trail a child selector (not an include).img { @include mixins.fit-crop-element(); & { max-width: 118px; } }
Also fix mixin internals: e.g. move set-font-size's trailing
line-height/letter-spacing above its @include rfs() so the mixin itself
is clean. Wrap the leading decls of make-btn-with-bg-* mixins in & {} so they
survive being @included after a @media.
Scale
There are typically 100+ sites. Automate the reorder (a line-based script
that moves @include set-font-size/fit-crop-element past the following
same-indent declaration run) — see references/verification-harness.md, which
ships the reorder + verification scripts. Never trust the reorder blind —
verify with Step 4.
Step 4 — Prove zero regression (do NOT skip)
Reordering can silently flip a cascade collision. Prove the computed CSS is unchanged, per selector:
- Golden = compile the pre-migration source (the index/
git show :version, still on@import) in the container. - Final = compile your migrated source.
- Compare computed style per individual selector: strip comments, parse
each rule, expand comma-groups into individual selectors, merge same-context
blocks last-wins, ignore selector-list order. Zero value differences =
safe. The full parser/differ is in
references/verification-harness.md.
Interpretation:
- A
SEMANTIC DIFFon a property value = a real regression → fix (usually afit-crop/collision case → switch that site to& {}). MISSING RULEfor long multi-context selectors =@extendcross-product redundancy that the module system dropped; benign as long as the short covering selector is still present with the same declarations (grep to confirm). This is expected when moving off@import.
Then confirm the warning counts are zero:
docker compose … exec -T node npx sass --verbose … | grep -c DEPRECATION # per entrypoint → 0
Step 5 — Out of scope
- Unused/other entrypoints (e.g. a
backendadmin theme still on@import, or an entry the user says is unused): don't migrate. If the user disables it invite.config, revert any stray edits you made there so the diff stays scoped. - Residual
@import/color-functionsdeprecations outside the target theme are for a separate pass (thecolormigrator handles legacy color functions).
Step 6 — Git workflow
Ask the user which target branch the MR should go into (it often stacks on
in-flight work such as the gulp-to-vite or a global-refactor branch rather than
develop), then hand the whole branch/commit/push/MR flow to the
merge-request skill.
Namespacing note (for the human)
@use "…/mixins"; loads a module under a namespace (default = filename), so
members are only reachable prefixed: mixins.set-font-size(…),
variables.$color. That is why the migrator rewrites every call — bare
set-font-size(…) does not compile under @use. The migrator deliberately keeps
explicit namespaces (not as *) — that is the recommended, collision-proof style.