Skip to content

Importers

An importer decides what @use "…" and @import "…" actually load. jsass 6 follows the dart-sass model, which splits the job in two:

  1. canonicalize(url, context) turns whatever the stylesheet wrote into a canonical URI, or returns null to say "not mine, ask the next one".
  2. load(canonicalUrl) returns the stylesheet contents for a URI that this importer canonicalized.

The split is what makes caching and deduplication possible: two stylesheets that canonicalize to the same URI are the same module and are loaded once.

A minimal importer

Serve SCSS from a Map
public final class MapBackedImporter implements Importer {

  private static final String SCHEME = "virtual";
  private final Map<String, String> modules;

  public MapBackedImporter(Map<String, String> modules) {
    this.modules = Map.copyOf(modules);
  }

  @Override
  public @Nullable URI canonicalize(String url, CanonicalizeContext context) {
    String name = url.startsWith(SCHEME + ":") ? url.substring(SCHEME.length() + 1) : url;
    return modules.containsKey(name) ? URI.create(SCHEME + ":" + name) : null;
  }

  @Override
  public @Nullable ImporterResult load(URI canonicalUrl) {
    if (!SCHEME.equals(canonicalUrl.getScheme())) {
      return null;
    }
    String contents = modules.get(canonicalUrl.getSchemeSpecificPart());
    return contents == null ? null : DefaultImporterResult.builder()
        .contents(contents)
        .syntax(Syntax.SCSS)
        .build();
  }
}
Register it
var options = StringOptions.builder()
    .url(URI.create("virtual:root.scss"))
    .importers(List.of(new MapBackedImporter(Map.of("theme", "$brand: red;"))))
    .build();
Use it
@use "virtual:theme" as t;

.logo { color: t.$brand; }

CanonicalizeContext tells you why you are being asked: isFromImport() distinguishes a legacy @import from a @use, and getContainingUrl() is the stylesheet the request came from — the hook for relative resolution.

An ImporterResult carries the contents, the syntax (SCSS by default) and an optional sourceMapUrl. Build one with ImporterResult.builder() or DefaultImporterResult.builder().

Resolution order

For a single compile, dart-sass asks in this order:

  1. the importer set with importer(…), which also owns relative loads from the entry stylesheet — when you set none and the entry has a file: URL (every compilePath), jsass's built-in file importer takes this place,
  2. each importer from importers(…), in list order,
  3. the loadPaths, which jsass appends as one last built-in importer.

The first importer returning a non-null canonical URL wins. Returning null is the normal way to decline.

Load paths

loadPaths is the plain-filesystem case. jsass implements it in Java on every engine rather than delegating to dart-sass — dart-sass's own file lookup needs an API that only exists in the Node runtime, and its native load paths do not stop ... Doing it ourselves keeps load paths working on V8 and GraalJS and makes them behave the same on all four engines.

Files are found with Sass's own rules:

  • @use "theme" tries _theme.scss and theme.scss (and .sass), then .css, then the index file theme/_index.scss;
  • inside an @import, an import-only file such as _theme.import.scss wins over _theme.scss;
  • if one step finds two files — _theme.scss and theme.scss — the compile fails with "It's not clear which file to import" instead of picking one;
  • a relative load inside a file from a load path resolves against that file's own directory.

A load path is a boundary. A URL that escapes it through .. makes that load path decline, and the next one is tried; multiple load paths are the norm and one tainted entry should not abort the compile. Inside the load path .. works as usual.

Relative loads from the entry

compilePath(…), and compileString(…) with a file: URL, resolve the entry's relative loads against its directory on disk, with the same file rules — on every engine. These loads are not confined: @use "../shared/colors" from src/pages/main.scss works, as in dart-sass, because the entry is a file you chose. Set your own importer(…) to take over relative loads from the entry.

WebJars

jsass.webjar-importer resolves imports against WebJars on the classpath, which is how you pull Bootstrap or Foundation into a build without checking their sources in:

import io.bit3.jsass.webjar.WebjarImporter;

var options = StringOptions.builder()
    .url(URI.create("webjar:style.scss"))
    .importer(WebjarImporter.builder()
        .allowedWebjars(Set.of("bootstrap"))
        .build())
    .build();
@import "bootstrap/scss/bootstrap";

WebjarImporter lives in the package io.bit3.jsass.webjar (artifact jsass.webjar-importer, an automatic module). It is independent of the module resolver that loads dart-sass, and works with every engine, the embedded one included.

Builder property Default Purpose
allowedWebjars empty artifact names this importer may serve, case-insensitive
allowClasspathScan false true allows any WebJar on the classpath
classLoader the importer's own scopes the WebJar scan and reads the stylesheets; for container setups with an isolated classpath
charset UTF-8 encoding of the stylesheet sources

The allowlist is empty by default

An importer with no allowedWebjars resolves nothing. That is deliberate: without it, any WebJar that happens to be on the classpath — including one pulled in transitively — could be reached from a stylesheet. Name the artifacts you mean, or set allowClasspathScan(true) if you accept the wider surface.

Failure modes

Situation What happens
Importer declines canonicalize returns null, the next importer is asked
Artifact not in allowedWebjars declines, so another importer still gets its turn
URL contains .. / . segments, or an opaque webjar: URI has no path SassPathTraversalException — unchecked, aborts the compile
One import matches several stylesheets AmbiguousImportException naming every candidate (thrown as AmbiguousImportRuntimeException, which carries it)
Your importer throws anything else wrapped as SassImporterExecutionException, reported as a Sass error at the import site

SassPathTraversalException is unchecked because the SPI signature has no throws clause for it. It carries getRequestedPath() and getAllowedRoot() for logging.

Whatever an importer throws — these two included — ends the compile: the future fails with a SassImporterExecutionException whose getCause() is the original exception.